diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 102074be..ca8c8a50 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -30,7 +30,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, + "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index 0d61eacc..bb269d8a 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, @@ -16,13 +17,18 @@ "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, + "UNIT_FIELD_STAT0": 138, + "UNIT_FIELD_STAT1": 139, + "UNIT_FIELD_STAT2": 140, + "UNIT_FIELD_STAT3": 141, + "UNIT_FIELD_STAT4": 142, "UNIT_END": 188, "PLAYER_FLAGS": 190, "PLAYER_BYTES": 191, "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, - "PLAYER_REST_STATE_EXPERIENCE": 718, + "PLAYER_REST_STATE_EXPERIENCE": 1175, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index 5bca8165..fdc9e07d 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -30,7 +30,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, + "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index c6d77c76..05e37180 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, @@ -16,13 +17,18 @@ "UNIT_NPC_FLAGS": 168, "UNIT_DYNAMIC_FLAGS": 164, "UNIT_FIELD_RESISTANCES": 185, + "UNIT_FIELD_STAT0": 159, + "UNIT_FIELD_STAT1": 160, + "UNIT_FIELD_STAT2": 161, + "UNIT_FIELD_STAT3": 162, + "UNIT_FIELD_STAT4": 163, "UNIT_END": 234, "PLAYER_FLAGS": 236, "PLAYER_BYTES": 237, "PLAYER_BYTES_2": 238, "PLAYER_XP": 926, "PLAYER_NEXT_LEVEL_XP": 927, - "PLAYER_REST_STATE_EXPERIENCE": 928, + "PLAYER_REST_STATE_EXPERIENCE": 1440, "PLAYER_FIELD_COINAGE": 1441, "PLAYER_QUEST_LOG_START": 244, "PLAYER_FIELD_INV_SLOT_HEAD": 650, diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index e31634e4..a2482e0d 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -30,7 +30,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, + "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index a91a314b..74b873ae 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 16, "UNIT_FIELD_TARGET_HI": 17, "UNIT_FIELD_BYTES_0": 36, @@ -16,13 +17,18 @@ "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, + "UNIT_FIELD_STAT0": 138, + "UNIT_FIELD_STAT1": 139, + "UNIT_FIELD_STAT2": 140, + "UNIT_FIELD_STAT3": 141, + "UNIT_FIELD_STAT4": 142, "UNIT_END": 188, "PLAYER_FLAGS": 190, "PLAYER_BYTES": 191, "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, - "PLAYER_REST_STATE_EXPERIENCE": 718, + "PLAYER_REST_STATE_EXPERIENCE": 1175, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 82252391..0d1667a1 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -31,7 +31,7 @@ "ReputationBase2": 12, "ReputationBase3": 13 }, "Achievement": { "ID": 0, "Title": 4, "Description": 21 }, - "AreaTable": { "ID": 0, "ExploreFlag": 3 }, + "AreaTable": { "ID": 0, "MapID": 1, "ParentAreaNum": 2, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, "HairStyleID": 5, "HairColorID": 6, "FacialHairID": 7, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 67019c80..1532f628 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -1,5 +1,6 @@ { "OBJECT_FIELD_ENTRY": 3, + "OBJECT_FIELD_SCALE_X": 4, "UNIT_FIELD_TARGET_LO": 6, "UNIT_FIELD_TARGET_HI": 7, "UNIT_FIELD_BYTES_0": 23, @@ -16,13 +17,18 @@ "UNIT_NPC_FLAGS": 82, "UNIT_DYNAMIC_FLAGS": 147, "UNIT_FIELD_RESISTANCES": 99, + "UNIT_FIELD_STAT0": 84, + "UNIT_FIELD_STAT1": 85, + "UNIT_FIELD_STAT2": 86, + "UNIT_FIELD_STAT3": 87, + "UNIT_FIELD_STAT4": 88, "UNIT_END": 148, "PLAYER_FLAGS": 150, "PLAYER_BYTES": 153, "PLAYER_BYTES_2": 154, "PLAYER_XP": 634, "PLAYER_NEXT_LEVEL_XP": 635, - "PLAYER_REST_STATE_EXPERIENCE": 636, + "PLAYER_REST_STATE_EXPERIENCE": 1169, "PLAYER_FIELD_COINAGE": 1170, "PLAYER_QUEST_LOG_START": 158, "PLAYER_FIELD_INV_SLOT_HEAD": 324, diff --git a/assets/shaders/quest_marker.frag.glsl b/assets/shaders/quest_marker.frag.glsl index 020b625d..0e209d8f 100644 --- a/assets/shaders/quest_marker.frag.glsl +++ b/assets/shaders/quest_marker.frag.glsl @@ -5,6 +5,7 @@ layout(set = 1, binding = 0) uniform sampler2D markerTexture; layout(push_constant) uniform Push { mat4 model; float alpha; + float grayscale; // 0 = full colour, 1 = fully desaturated (trivial quests) } push; layout(location = 0) in vec2 TexCoord; @@ -14,5 +15,7 @@ layout(location = 0) out vec4 outColor; void main() { vec4 texColor = texture(markerTexture, TexCoord); if (texColor.a < 0.1) discard; - outColor = vec4(texColor.rgb, texColor.a * push.alpha); + float lum = dot(texColor.rgb, vec3(0.299, 0.587, 0.114)); + vec3 rgb = mix(texColor.rgb, vec3(lum), push.grayscale); + outColor = vec4(rgb, texColor.a * push.alpha); } diff --git a/assets/shaders/quest_marker.frag.spv b/assets/shaders/quest_marker.frag.spv index e947d04c..90814c30 100644 Binary files a/assets/shaders/quest_marker.frag.spv and b/assets/shaders/quest_marker.frag.spv differ diff --git a/include/core/application.hpp b/include/core/application.hpp index 570f0658..7da1469b 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -98,7 +98,7 @@ private: void loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float z); void buildFactionHostilityMap(uint8_t playerRace); pipeline::M2Model loadCreatureM2Sync(const std::string& m2Path); - void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation); + void spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f); void despawnOnlineCreature(uint64_t guid); bool tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instanceId); void spawnOnlinePlayer(uint64_t guid, @@ -113,7 +113,7 @@ private: void despawnOnlinePlayer(uint64_t guid); void buildCreatureDisplayLookups(); std::string getModelPathForDisplayId(uint32_t displayId) const; - void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation); + void spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale = 1.0f); void despawnOnlineGameObject(uint64_t guid); void buildGameObjectDisplayLookups(); std::string getGameObjectModelPathForDisplayId(uint32_t displayId) const; @@ -214,6 +214,7 @@ private: uint32_t displayId; uint32_t modelId; float x, y, z, orientation; + float scale = 1.0f; std::shared_ptr model; // parsed on background thread std::unordered_map predecodedTextures; // decoded on bg thread bool valid = false; @@ -300,6 +301,7 @@ private: uint64_t guid; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; }; std::deque pendingCreatureSpawns_; static constexpr int MAX_SPAWNS_PER_FRAME = 3; @@ -393,6 +395,7 @@ private: uint32_t entry; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; }; std::vector pendingGameObjectSpawns_; void processGameObjectSpawnQueue(); @@ -403,6 +406,7 @@ private: uint32_t entry; uint32_t displayId; float x, y, z, orientation; + float scale = 1.0f; std::shared_ptr wmoModel; std::unordered_map predecodedTextures; // decoded on bg thread bool valid = false; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 2307f849..25b6b8de 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -295,6 +295,13 @@ public: // Server-authoritative armor (UNIT_FIELD_RESISTANCES[0]) int32_t getArmorRating() const { return playerArmorRating_; } + // Server-authoritative primary stats (UNIT_FIELD_STAT0-4: STR, AGI, STA, INT, SPI). + // Returns -1 if the server hasn't sent the value yet. + int32_t getPlayerStat(int idx) const { + if (idx < 0 || idx > 4) return -1; + return playerStats_[idx]; + } + // Inventory Inventory& getInventory() { return inventory; } const Inventory& getInventory() const { return inventory; } @@ -340,9 +347,24 @@ public: // Random roll void randomRoll(uint32_t minRoll = 1, uint32_t maxRoll = 100); + // Battleground queue slot (public so UI can read invite details) + struct BgQueueSlot { + uint32_t queueSlot = 0; + uint32_t bgTypeId = 0; + uint8_t arenaType = 0; + uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress + uint32_t inviteTimeout = 80; + std::chrono::steady_clock::time_point inviteReceivedTime{}; + }; + // Battleground bool hasPendingBgInvite() const; void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF); + void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); + const std::array& getBgQueues() const { return bgQueues_; } + + // Network latency (milliseconds, updated each PONG response) + uint32_t getLatencyMs() const { return lastLatency; } // Logout commands void requestLogout(); @@ -720,8 +742,8 @@ public: void setHearthstonePreloadCallback(HearthstonePreloadCallback cb) { hearthstonePreloadCallback_ = std::move(cb); } // Creature spawn callback (online mode - triggered when creature enters view) - // Parameters: guid, displayId, x, y, z (canonical), orientation - using CreatureSpawnCallback = std::function; + // Parameters: guid, displayId, x, y, z (canonical), orientation, scale (OBJECT_FIELD_SCALE_X) + using CreatureSpawnCallback = std::function; void setCreatureSpawnCallback(CreatureSpawnCallback cb) { creatureSpawnCallback_ = std::move(cb); } // Creature despawn callback (online mode - triggered when creature leaves view) @@ -751,8 +773,8 @@ public: void setPlayerEquipmentCallback(PlayerEquipmentCallback cb) { playerEquipmentCallback_ = std::move(cb); } // GameObject spawn callback (online mode - triggered when gameobject enters view) - // Parameters: guid, entry, displayId, x, y, z (canonical), orientation - using GameObjectSpawnCallback = std::function; + // Parameters: guid, entry, displayId, x, y, z (canonical), orientation, scale (OBJECT_FIELD_SCALE_X) + using GameObjectSpawnCallback = std::function; void setGameObjectSpawnCallback(GameObjectSpawnCallback cb) { gameObjectSpawnCallback_ = std::move(cb); } // GameObject move callback (online mode - triggered when gameobject position updates) @@ -911,13 +933,38 @@ public: enum class TradeStatus : uint8_t { None = 0, PendingIncoming, Open, Accepted, Complete }; + + static constexpr int TRADE_SLOT_COUNT = 6; // WoW has 6 normal trade slots + slot 6 for non-trade item + + struct TradeSlot { + uint32_t itemId = 0; + uint32_t displayId = 0; + uint32_t stackCount = 0; + uint64_t itemGuid = 0; + uint8_t bag = 0xFF; // 0xFF = not set + uint8_t bagSlot = 0xFF; + bool occupied = false; + }; + TradeStatus getTradeStatus() const { return tradeStatus_; } bool hasPendingTradeRequest() const { return tradeStatus_ == TradeStatus::PendingIncoming; } + bool isTradeOpen() const { return tradeStatus_ == TradeStatus::Open || tradeStatus_ == TradeStatus::Accepted; } const std::string& getTradePeerName() const { return tradePeerName_; } + + // My trade slots (what I'm offering) + const std::array& getMyTradeSlots() const { return myTradeSlots_; } + // Peer's trade slots (what they're offering) + const std::array& getPeerTradeSlots() const { return peerTradeSlots_; } + uint64_t getMyTradeGold() const { return myTradeGold_; } + uint64_t getPeerTradeGold() const { return peerTradeGold_; } + void acceptTradeRequest(); // respond to incoming SMSG_TRADE_STATUS(1) with CMSG_BEGIN_TRADE void declineTradeRequest(); // respond with CMSG_CANCEL_TRADE void acceptTrade(); // lock in offer: CMSG_ACCEPT_TRADE void cancelTrade(); // CMSG_CANCEL_TRADE + void setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot); + void clearTradeItem(uint8_t tradeSlot); + void setTradeGold(uint64_t copper); // ---- Duel ---- bool hasPendingDuelRequest() const { return pendingDuelRequest_; } @@ -1047,12 +1094,27 @@ public: std::string title; std::string objectives; bool complete = false; - // Objective kill counts: objectiveIndex -> (current, required) + // Objective kill counts: npcOrGoEntry -> (current, required) std::unordered_map> killCounts; // Quest item progress: itemId -> current count std::unordered_map itemCounts; // Server-authoritative quest item requirements from REQUEST_ITEMS std::unordered_map requiredItemCounts; + // Structured kill objectives parsed from SMSG_QUEST_QUERY_RESPONSE. + // Index 0-3 map to the server's objective slot order (packed into update fields). + // npcOrGoId != 0 => entity objective (kill NPC or interact with GO). + struct KillObjective { + int32_t npcOrGoId = 0; // negative = game-object entry + uint32_t required = 0; + }; + std::array killObjectives{}; // zeroed by default + // Required item objectives parsed from SMSG_QUEST_QUERY_RESPONSE. + // itemId != 0 => collect items of that type. + struct ItemObjective { + uint32_t itemId = 0; + uint32_t required = 0; + }; + std::array itemObjectives{}; // zeroed by default }; const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); @@ -1134,8 +1196,9 @@ public: void setOtherPlayerLevelUpCallback(OtherPlayerLevelUpCallback cb) { otherPlayerLevelUpCallback_ = std::move(cb); } // Achievement earned callback — fires when SMSG_ACHIEVEMENT_EARNED is received - using AchievementEarnedCallback = std::function; + using AchievementEarnedCallback = std::function; void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } + const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -1580,6 +1643,7 @@ private: void handleGossipMessage(network::Packet& packet); void handleQuestgiverQuestList(network::Packet& packet); void handleGossipComplete(network::Packet& packet); + void handleQuestPoiQueryResponse(network::Packet& packet); void handleQuestDetails(network::Packet& packet); void handleQuestRequestItems(network::Packet& packet); void handleQuestOfferReward(network::Packet& packet); @@ -1614,6 +1678,8 @@ private: void handleQuestConfirmAccept(network::Packet& packet); void handleSummonRequest(network::Packet& packet); void handleTradeStatus(network::Packet& packet); + void handleTradeStatusExtended(network::Packet& packet); + void resetTradeState(); void handleDuelRequested(network::Packet& packet); void handleDuelComplete(network::Packet& packet); void handleDuelWinner(network::Packet& packet); @@ -1969,12 +2035,6 @@ private: std::unordered_set petAutocastSpells_; // spells with autocast on // ---- Battleground queue state ---- - struct BgQueueSlot { - uint32_t queueSlot = 0; - uint32_t bgTypeId = 0; - uint8_t arenaType = 0; - uint32_t statusId = 0; // 0=none, 1=wait_queue, 2=wait_join, 3=in_progress - }; std::array bgQueues_{}; // Instance difficulty @@ -2044,6 +2104,10 @@ private: TradeStatus tradeStatus_ = TradeStatus::None; uint64_t tradePeerGuid_= 0; std::string tradePeerName_; + std::array myTradeSlots_{}; + std::array peerTradeSlots_{}; + uint64_t myTradeGold_ = 0; + uint64_t peerTradeGold_ = 0; // Duel state bool pendingDuelRequest_ = false; @@ -2099,6 +2163,8 @@ private: std::unordered_map recentLootMoneyAnnounceCooldowns_; uint64_t playerMoneyCopper_ = 0; int32_t playerArmorRating_ = 0; + // Server-authoritative primary stats: [0]=STR [1]=AGI [2]=STA [3]=INT [4]=SPI; -1 = not received yet + int32_t playerStats_[5] = {-1, -1, -1, -1, -1}; // Some servers/custom clients shift update field indices. We can auto-detect coinage by correlating // money-notify deltas with update-field diffs and then overriding UF::PLAYER_FIELD_COINAGE at runtime. uint32_t pendingMoneyDelta_ = 0; @@ -2246,6 +2312,9 @@ private: std::unordered_map achievementNameCache_; bool achievementNameCacheLoaded_ = false; void loadAchievementNameCache(); + // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) + std::unordered_set earnedAchievements_; + void handleAllAchievementData(network::Packet& packet); // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) std::unordered_map areaNameCache_; @@ -2340,6 +2409,10 @@ private: void loadSkillLineAbilityDbc(); void extractSkillFields(const std::map& fields); void extractExploredZoneFields(const std::map& fields); + void applyQuestStateFromFields(const std::map& fields); + // Apply packed kill counts from player update fields to a quest entry that has + // already had its killObjectives populated from SMSG_QUEST_QUERY_RESPONSE. + void applyPackedKillCountsFromFields(QuestLogEntry& quest); NpcDeathCallback npcDeathCallback_; NpcAggroCallback npcAggroCallback_; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index d2556e7b..2cb17fdb 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -207,6 +207,11 @@ public: * WotLK: 5 fields per slot, Classic/Vanilla: 3. */ virtual uint8_t questLogStride() const { return 5; } + /** Number of PLAYER_EXPLORED_ZONES uint32 fields in update-object blocks. + * Classic/Vanilla/Turtle: 64 (bit-packs up to zone ID 2047). + * TBC/WotLK: 128 (covers Outland/Northrend zone IDs up to 4095). */ + virtual uint8_t exploredZonesCount() const { return 128; } + // --- Quest Giver Status --- /** Read quest giver status from packet. @@ -407,6 +412,9 @@ public: network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override; // parseQuestDetails inherited from TbcPacketParsers (same format as TBC 2.4.3) uint8_t questLogStride() const override { return 3; } + // Classic 1.12 has 64 explored-zone uint32 fields (zone IDs fit in 2048 bits). + // TBC/WotLK use 128 (needed for Outland/Northrend zone IDs up to 4095). + uint8_t exploredZonesCount() const override { return 64; } bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override { return MonsterMoveParser::parseVanilla(packet, data); } diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 041b44f6..c4d70380 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -51,7 +51,7 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN, IMMUNE + ENERGIZE, XP_GAIN, IMMUNE, ABSORB, RESIST }; Type type; int32_t amount = 0; diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index 67651b00..07c735fd 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -14,6 +14,7 @@ namespace game { enum class UF : uint16_t { // Object fields OBJECT_FIELD_ENTRY, + OBJECT_FIELD_SCALE_X, // Unit fields UNIT_FIELD_TARGET_LO, @@ -33,6 +34,11 @@ enum class UF : uint16_t { UNIT_NPC_FLAGS, UNIT_DYNAMIC_FLAGS, UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array) + UNIT_FIELD_STAT0, // Strength (effective base, includes items) + UNIT_FIELD_STAT1, // Agility + UNIT_FIELD_STAT2, // Stamina + UNIT_FIELD_STAT3, // Intellect + UNIT_FIELD_STAT4, // Spirit UNIT_END, // Player fields diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 5d75e887..61d36ebf 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1356,6 +1356,33 @@ public: static network::Packet build(); }; +/** CMSG_SET_TRADE_ITEM packet builder (tradeSlot, bag, bagSlot) */ +class SetTradeItemPacket { +public: + // tradeSlot: 0-5 (normal) or 6 (backpack money-only slot) + // bag: 255 = main backpack, 19-22 = bag slots + // bagSlot: slot within bag + static network::Packet build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot); +}; + +/** CMSG_CLEAR_TRADE_ITEM packet builder (remove item from trade slot) */ +class ClearTradeItemPacket { +public: + static network::Packet build(uint8_t tradeSlot); +}; + +/** CMSG_SET_TRADE_GOLD packet builder (gold offered, in copper) */ +class SetTradeGoldPacket { +public: + static network::Packet build(uint64_t copper); +}; + +/** CMSG_UNACCEPT_TRADE packet builder (unaccept without cancelling) */ +class UnacceptTradePacket { +public: + static network::Packet build(); +}; + /** CMSG_ATTACKSWING packet builder */ class AttackSwingPacket { public: diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 3337a755..7401ffdd 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -90,6 +90,11 @@ public: // Movement callback for sending opcodes to server using MovementCallback = std::function; void setMovementCallback(MovementCallback cb) { movementCallback = std::move(cb); } + + // Callback invoked when the player stands up via local input (space/X/movement key + // while server-sitting), so the caller can send CMSG_STAND_STATE_CHANGE(0). + using StandUpCallback = std::function; + void setStandUpCallback(StandUpCallback cb) { standUpCallback_ = std::move(cb); } void setUseWoWSpeed(bool use) { useWoWSpeed = use; } void setRunSpeedOverride(float speed) { runSpeedOverride_ = speed; } void setWalkSpeedOverride(float speed) { walkSpeedOverride_ = speed; } @@ -265,6 +270,7 @@ private: // Movement callback MovementCallback movementCallback; + StandUpCallback standUpCallback_; // Movement speeds bool useWoWSpeed = false; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index e26583b5..3d79379f 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -127,7 +127,8 @@ struct M2ModelGPU { // Particle emitter data (kept from M2Model) std::vector particleEmitters; - std::vector particleTextures; // Resolved Vulkan textures per emitter + std::vector particleTextures; // Resolved Vulkan textures per emitter + std::vector particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc) // Texture transform data for UV animation std::vector textureTransforms; diff --git a/include/rendering/quest_marker_renderer.hpp b/include/rendering/quest_marker_renderer.hpp index 2d6a73d3..a0d18776 100644 --- a/include/rendering/quest_marker_renderer.hpp +++ b/include/rendering/quest_marker_renderer.hpp @@ -35,8 +35,10 @@ public: * @param position World position (NPC base position) * @param markerType 0=available(!), 1=turnin(?), 2=incomplete(?) * @param boundingHeight NPC bounding height (optional, default 2.0f) + * @param grayscale 0 = full colour, 1 = desaturated grey (trivial/low-level quests) */ - void setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight = 2.0f); + void setMarker(uint64_t guid, const glm::vec3& position, int markerType, + float boundingHeight = 2.0f, float grayscale = 0.0f); /** * Remove a quest marker @@ -61,6 +63,7 @@ private: glm::vec3 position; int type; // 0=available, 1=turnin, 2=incomplete float boundingHeight = 2.0f; + float grayscale = 0.0f; // 0 = colour, 1 = desaturated (trivial quests) }; std::unordered_map markers_; diff --git a/include/rendering/world_map.hpp b/include/rendering/world_map.hpp index 89568209..47956b42 100644 --- a/include/rendering/world_map.hpp +++ b/include/rendering/world_map.hpp @@ -117,6 +117,8 @@ private: std::vector serverExplorationMask; bool hasServerExplorationMask = false; std::unordered_set exploredZones; + // Locally accumulated exploration (used as fallback when server mask is unavailable) + std::unordered_set locallyExploredZones_; }; } // namespace rendering diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 5dc0aa6d..655b20cb 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -189,6 +189,7 @@ private: * Render target frame */ void renderTargetFrame(game::GameHandler& gameHandler); + void renderFocusFrame(game::GameHandler& gameHandler); /** * Render pet frame (below player frame when player has an active pet) @@ -223,6 +224,7 @@ private: void renderDuelRequestPopup(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); void renderTradeRequestPopup(game::GameHandler& gameHandler); + void renderTradeWindow(game::GameHandler& gameHandler); void renderSummonRequestPopup(game::GameHandler& gameHandler); void renderSharedQuestPopup(game::GameHandler& gameHandler); void renderItemTextWindow(game::GameHandler& gameHandler); @@ -248,6 +250,8 @@ private: void renderGuildRoster(game::GameHandler& gameHandler); void renderGuildInvitePopup(game::GameHandler& gameHandler); void renderReadyCheckPopup(game::GameHandler& gameHandler); + void renderBgInvitePopup(game::GameHandler& gameHandler); + void renderLfgProposalPopup(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); void renderMailComposeWindow(game::GameHandler& gameHandler); @@ -366,6 +370,7 @@ private: static constexpr float ACHIEVEMENT_TOAST_DURATION = 5.0f; float achievementToastTimer_ = 0.0f; uint32_t achievementToastId_ = 0; + std::string achievementToastName_; void renderAchievementToast(); // Zone discovery text ("Entering: ") @@ -377,7 +382,7 @@ private: public: void triggerDing(uint32_t newLevel); - void triggerAchievementToast(uint32_t achievementId); + void triggerAchievementToast(uint32_t achievementId, std::string name = {}); }; } // namespace ui diff --git a/include/ui/inventory_screen.hpp b/include/ui/inventory_screen.hpp index a0a19386..bfca779f 100644 --- a/include/ui/inventory_screen.hpp +++ b/include/ui/inventory_screen.hpp @@ -96,6 +96,7 @@ private: std::unordered_map iconCache_; public: VkDescriptorSet getItemIcon(uint32_t displayInfoId); + void renderItemTooltip(const game::ItemQueryResponseData& info); private: // Character model preview @@ -147,7 +148,8 @@ private: int bagIndex, float defaultX, float defaultY, uint64_t moneyCopper); void renderEquipmentPanel(game::Inventory& inventory); void renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections = false); - void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0); + void renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor = 0, + const int32_t* serverStats = nullptr); void renderReputationPanel(game::GameHandler& gameHandler); void renderItemSlot(game::Inventory& inventory, const game::ItemSlot& slot, diff --git a/include/ui/spellbook_screen.hpp b/include/ui/spellbook_screen.hpp index 77f1c2d6..470cb233 100644 --- a/include/ui/spellbook_screen.hpp +++ b/include/ui/spellbook_screen.hpp @@ -94,8 +94,8 @@ private: VkDescriptorSet getSpellIcon(uint32_t iconId, pipeline::AssetManager* assetManager); const SpellInfo* getSpellInfo(uint32_t spellId) const; - // Tooltip rendering helper - void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler); + // Tooltip rendering helper (showUsageHints=false when called from action bar) + void renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints = true); }; } // namespace ui diff --git a/src/core/application.cpp b/src/core/application.cpp index 35ebca5f..b04a5269 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -628,6 +628,11 @@ void Application::setState(AppState newState) { gameHandler->sendMovement(static_cast(opcode)); } }); + cc->setStandUpCallback([this]() { + if (gameHandler) { + gameHandler->setStandState(0); // CMSG_STAND_STATE_CHANGE(STAND) + } + }); cc->setUseWoWSpeed(true); } if (gameHandler) { @@ -980,6 +985,18 @@ void Application::update(float deltaTime) { retrySpawn.y = unit->getY(); retrySpawn.z = unit->getZ(); retrySpawn.orientation = unit->getOrientation(); + { + using game::fieldIndex; using game::UF; + uint16_t si = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (si != 0xFFFF) { + uint32_t raw = unit->getField(si); + if (raw != 0) { + float s2 = 1.0f; + std::memcpy(&s2, &raw, sizeof(float)); + if (s2 > 0.01f && s2 < 100.0f) retrySpawn.scale = s2; + } + } + } pendingCreatureSpawns_.push_back(retrySpawn); pendingCreatureSpawnGuids_.insert(guid); } @@ -2193,12 +2210,12 @@ void Application::setupUICallbacks() { // Faction hostility map is built in buildFactionHostilityMap() when character enters world // Creature spawn callback (online mode) - spawn creature models - gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { + gameHandler->setCreatureSpawnCallback([this](uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { // Queue spawns to avoid hanging when many creatures appear at once. // Deduplicate so repeated updates don't flood pending queue. if (creatureInstances_.count(guid)) return; if (pendingCreatureSpawnGuids_.count(guid)) return; - pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation}); + pendingCreatureSpawns_.push_back({guid, displayId, x, y, z, orientation, scale}); pendingCreatureSpawnGuids_.insert(guid); }); @@ -2244,8 +2261,8 @@ void Application::setupUICallbacks() { }); // GameObject spawn callback (online mode) - spawn static models (mailboxes, etc.) - gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { - pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation}); + gameHandler->setGameObjectSpawnCallback([this](uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { + pendingGameObjectSpawns_.push_back({guid, entry, displayId, x, y, z, orientation, scale}); }); // GameObject despawn callback (online mode) - remove static models @@ -2330,9 +2347,9 @@ void Application::setupUICallbacks() { }); // Achievement earned callback — show toast banner - gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId) { + gameHandler->setAchievementEarnedCallback([this](uint32_t achievementId, const std::string& name) { if (uiManager) { - uiManager->getGameScreen().triggerAchievementToast(achievementId); + uiManager->getGameScreen().triggerAchievementToast(achievementId, name); } }); @@ -2548,13 +2565,19 @@ void Application::setupUICallbacks() { // Don't override Death animation (1). The per-frame sync loop will return to // Stand when movement stops. if (durationMs > 0) { - uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; - auto* cr = renderer->getCharacterRenderer(); - bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); - if (!gotState || curAnimId != 1 /*Death*/) { - cr->playAnimation(instanceId, 5u, /*loop=*/true); + // Player animation is managed by the local renderer state machine — + // don't reset it here or every server movement packet restarts the + // run cycle from frame 0, causing visible stutter. + if (!isPlayer) { + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + auto* cr = renderer->getCharacterRenderer(); + bool gotState = cr->getAnimationState(instanceId, curAnimId, curT, curDur); + // Only start Run if not already running and not in Death animation. + if (!gotState || (curAnimId != 1 /*Death*/ && curAnimId != 5u /*Run*/)) { + cr->playAnimation(instanceId, 5u, /*loop=*/true); + } + creatureWasMoving_[guid] = true; } - if (!isPlayer) creatureWasMoving_[guid] = true; } } }); @@ -4743,7 +4766,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // Process ALL pending game object spawns. while (!pendingGameObjectSpawns_.empty()) { auto& s = pendingGameObjectSpawns_.front(); - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation); + spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); } @@ -5274,7 +5297,7 @@ pipeline::M2Model Application::loadCreatureM2Sync(const std::string& m2Path) { return model; } -void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation) { +void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!renderer || !renderer->getCharacterRenderer() || !assetManager) return; // Skip if lookups not yet built (asset manager not ready) @@ -5711,9 +5734,9 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x // Convert canonical WoW orientation (0=north) -> render yaw (0=west) float renderYaw = orientation + glm::radians(90.0f); - // Create instance + // Create instance (apply server-provided scale from OBJECT_FIELD_SCALE_X) uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); + glm::vec3(0.0f, 0.0f, renderYaw), scale); if (instanceId == 0) { LOG_WARNING("Failed to create creature instance for guid 0x", std::hex, guid, std::dec); @@ -7024,7 +7047,7 @@ void Application::despawnOnlinePlayer(uint64_t guid) { creatureWasWalking_.erase(guid); } -void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { +void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { if (!renderer || !assetManager) return; if (!gameObjectLookupsBuilt_) { @@ -7181,7 +7204,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t if (loadedAsWmo) { uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawWmo), 1.0f); + glm::vec3(0.0f, 0.0f, renderYawWmo), scale); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec); return; @@ -7289,7 +7312,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t } uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawM2go), 1.0f); + glm::vec3(0.0f, 0.0f, renderYawM2go), scale); if (instanceId == 0) { LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); return; @@ -7407,6 +7430,7 @@ void Application::processAsyncCreatureResults(bool unlimited) { s.y = result.y; s.z = result.z; s.orientation = result.orientation; + s.scale = result.scale; pendingCreatureSpawns_.push_back(s); pendingCreatureSpawnGuids_.insert(result.guid); } @@ -7721,6 +7745,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { result.y = s.y; result.z = s.z; result.orientation = s.orientation; + result.scale = s.scale; auto m2Data = am->readFile(m2Path); if (m2Data.empty()) { @@ -7799,7 +7824,7 @@ void Application::processCreatureSpawnQueue(bool unlimited) { // Cached model — spawn is fast (no file I/O, just instance creation + texture setup) { auto spawnStart = std::chrono::steady_clock::now(); - spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation); + spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); auto spawnEnd = std::chrono::steady_clock::now(); float spawnMs = std::chrono::duration(spawnEnd - spawnStart).count(); if (spawnMs > 100.0f) { @@ -8004,7 +8029,7 @@ void Application::processAsyncGameObjectResults() { if (!result.valid || !result.isWmo || !result.wmoModel) { // Fallback: spawn via sync path (likely an M2 or failed WMO) spawnOnlineGameObject(result.guid, result.entry, result.displayId, - result.x, result.y, result.z, result.orientation); + result.x, result.y, result.z, result.orientation, result.scale); continue; } @@ -8031,7 +8056,7 @@ void Application::processAsyncGameObjectResults() { glm::vec3 renderPos = core::coords::canonicalToRender( glm::vec3(result.x, result.y, result.z)); uint32_t instanceId = wmoRenderer->createInstance( - modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), 1.0f); + modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale); if (instanceId == 0) continue; gameObjectInstances_[result.guid] = {modelId, instanceId, true}; @@ -8118,6 +8143,7 @@ void Application::processGameObjectSpawnQueue() { result.y = capture.y; result.z = capture.z; result.orientation = capture.orientation; + result.scale = capture.scale; result.modelPath = capturePath; result.isWmo = true; @@ -8183,7 +8209,7 @@ void Application::processGameObjectSpawnQueue() { } // Cached WMO or M2 — spawn synchronously (cheap) - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation); + spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); } } @@ -8696,17 +8722,21 @@ void Application::updateQuestMarkers() { int markerType = -1; // -1 = no marker using game::QuestGiverStatus; + float markerGrayscale = 0.0f; // 0 = colour, 1 = grey (trivial quests) switch (status) { case QuestGiverStatus::AVAILABLE: + markerType = 0; // Yellow ! + break; case QuestGiverStatus::AVAILABLE_LOW: - markerType = 0; // Available (yellow !) + markerType = 0; // Grey ! (same texture, desaturated in shader) + markerGrayscale = 1.0f; break; case QuestGiverStatus::REWARD: case QuestGiverStatus::REWARD_REP: - markerType = 1; // Turn-in (yellow ?) + markerType = 1; // Yellow ? break; case QuestGiverStatus::INCOMPLETE: - markerType = 2; // Incomplete (grey ?) + markerType = 2; // Grey ? break; default: break; @@ -8740,7 +8770,7 @@ void Application::updateQuestMarkers() { } // Set the marker (renderer will handle positioning, bob, glow, etc.) - questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight); + questMarkerRenderer->setMarker(guid, renderPos, markerType, boundingHeight, markerGrayscale); markersAdded++; } diff --git a/src/core/memory_monitor.cpp b/src/core/memory_monitor.cpp index 913240fd..080a1ef6 100644 --- a/src/core/memory_monitor.cpp +++ b/src/core/memory_monitor.cpp @@ -109,16 +109,16 @@ size_t MemoryMonitor::getAvailableRAM() const { size_t MemoryMonitor::getRecommendedCacheBudget() const { size_t available = getAvailableRAM(); - // Use 80% of available RAM for caches (very aggressive), but cap at 90% of total - size_t budget = available * 80 / 100; - size_t maxBudget = totalRAM_ * 90 / 100; - return budget < maxBudget ? budget : maxBudget; + // Use 50% of available RAM for caches, hard-capped at 16 GB. + static constexpr size_t kHardCapBytes = 16ull * 1024 * 1024 * 1024; // 16 GB + size_t budget = available * 50 / 100; + return budget < kHardCapBytes ? budget : kHardCapBytes; } bool MemoryMonitor::isMemoryPressure() const { size_t available = getAvailableRAM(); - // Memory pressure if < 20% RAM available - return available < (totalRAM_ * 20 / 100); + // Memory pressure if < 10% RAM available + return available < (totalRAM_ * 10 / 100); } } // namespace core diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index e8ecdf72..e9452785 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -357,6 +357,87 @@ QuestQueryTextCandidate pickBestQuestQueryTexts(const std::vector& data return best; } + +// Parse kill/item objectives from SMSG_QUEST_QUERY_RESPONSE raw data. +// Returns true if the objective block was found and at least one entry read. +// +// Format after the fixed integer header (40*4 Classic or 55*4 WotLK bytes post questId+questMethod): +// N strings (title, objectives, details, endText; + completedText for WotLK) +// 4x { int32 npcOrGoId, uint32 count } -- entity (kill/interact) objectives +// 6x { uint32 itemId, uint32 count } -- item collect objectives +// 4x cstring -- per-objective display text +// +// We use the same fixed-offset heuristic as pickBestQuestQueryTexts and then scan past +// the string section to reach the objective data. +struct QuestQueryObjectives { + struct Kill { int32_t npcOrGoId; uint32_t required; }; + struct Item { uint32_t itemId; uint32_t required; }; + std::array kills{}; + std::array items{}; + bool valid = false; +}; + +static uint32_t readU32At(const std::vector& d, size_t pos) { + return static_cast(d[pos]) + | (static_cast(d[pos + 1]) << 8) + | (static_cast(d[pos + 2]) << 16) + | (static_cast(d[pos + 3]) << 24); +} + +// Try to parse objective block starting at `startPos` with `nStrings` strings before it. +// Returns a valid QuestQueryObjectives if the data looks plausible, otherwise invalid. +static QuestQueryObjectives tryParseQuestObjectivesAt(const std::vector& data, + size_t startPos, int nStrings) { + QuestQueryObjectives out; + size_t pos = startPos; + + // Scan past each string (null-terminated). + for (int si = 0; si < nStrings; ++si) { + while (pos < data.size() && data[pos] != 0) ++pos; + if (pos >= data.size()) return out; // truncated + ++pos; // consume null terminator + } + + // Read 4 entity objectives: int32 npcOrGoId + uint32 count each. + for (int i = 0; i < 4; ++i) { + if (pos + 8 > data.size()) return out; + out.kills[i].npcOrGoId = static_cast(readU32At(data, pos)); pos += 4; + out.kills[i].required = readU32At(data, pos); pos += 4; + } + + // Read 6 item objectives: uint32 itemId + uint32 count each. + for (int i = 0; i < 6; ++i) { + if (pos + 8 > data.size()) break; + out.items[i].itemId = readU32At(data, pos); pos += 4; + out.items[i].required = readU32At(data, pos); pos += 4; + } + + out.valid = true; + return out; +} + +QuestQueryObjectives extractQuestQueryObjectives(const std::vector& data, bool classicHint) { + if (data.size() < 16) return {}; + + // questId(4) + questMethod(4) prefix before the fixed integer header. + const size_t base = 8; + // Classic/TBC: 40 fixed uint32 fields + 4 strings before objectives. + // WotLK: 55 fixed uint32 fields + 5 strings before objectives. + const size_t classicStart = base + 40u * 4u; + const size_t wotlkStart = base + 55u * 4u; + + // Try the expected layout first, then fall back to the other. + if (classicHint) { + auto r = tryParseQuestObjectivesAt(data, classicStart, 4); + if (r.valid) return r; + return tryParseQuestObjectivesAt(data, wotlkStart, 5); + } else { + auto r = tryParseQuestObjectivesAt(data, wotlkStart, 5); + if (r.valid) return r; + return tryParseQuestObjectivesAt(data, classicStart, 4); + } +} + } // namespace @@ -1825,7 +1906,13 @@ void GameHandler::handlePacket(network::Packet& packet) { casting = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; - const char* reason = getSpellCastResultString(castResult, -1); + // Pass player's power type so result 85 says "Not enough rage/energy/etc." + int playerPowerType = -1; + if (auto pe = entityManager.getEntity(playerGuid)) { + if (auto pu = std::dynamic_pointer_cast(pe)) + playerPowerType = static_cast(pu->getPowerType()); + } + const char* reason = getSpellCastResultString(castResult, playerPowerType); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; @@ -1854,14 +1941,23 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell proc resist log ---- case Opcode::SMSG_PROCRESIST: { - // casterGuid(8) + victimGuid(8) + uint32 spellId + uint8 logSchoolMask - if (packet.getSize() - packet.getReadPos() >= 17) { - /*uint64_t caster =*/ packet.readUInt64(); - uint64_t victim = packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); - if (victim == playerGuid) - addCombatText(CombatTextEntry::MISS, 0, spellId, false); - } + // WotLK: packed_guid caster + packed_guid victim + uint32 spellId + ... + // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + ... + const bool prTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto readPrGuid = [&]() -> uint64_t { + if (prTbcLike) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; + /*uint64_t caster =*/ readPrGuid(); + if (packet.getSize() - packet.getReadPos() < (prTbcLike ? 8u : 1u)) break; + uint64_t victim = readPrGuid(); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t spellId = packet.readUInt32(); + if (victim == playerGuid) + addCombatText(CombatTextEntry::RESIST, 0, spellId, false); + packet.setReadPos(packet.getSize()); break; } @@ -1925,6 +2021,11 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } + case Opcode::SMSG_LEARNED_DANCE_MOVES: + // Contains bitmask of learned dance moves — cosmetic only, no gameplay effect. + LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")"); + break; + // ---- Hearthstone binding ---- case Opcode::SMSG_PLAYERBOUND: { // uint64 binderGuid + uint32 mapId + uint32 zoneId @@ -2292,26 +2393,42 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { - // WotLK: packed_guid caster + packed_guid target + uint8 isCrit + uint32 count - // TBC/Classic: full uint64 caster + full uint64 target + uint8 isCrit + uint32 count - // + count × (uint64 victimGuid + uint8 missInfo) + // All expansions: uint32 spellId first. + // WotLK: spellId(4) + packed_guid caster + uint8 unk + uint32 count + // + count × (packed_guid victim + uint8 missInfo) + // [missInfo==11(REFLECT): + uint32 reflectSpellId + uint8 reflectResult] + // TBC/Classic: spellId(4) + uint64 caster + uint8 unk + uint32 count + // + count × (uint64 victim + uint8 missInfo) const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); auto readSpellMissGuid = [&]() -> uint64_t { if (spellMissTbcLike) return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; return UpdateObjectParser::readPackedGuid(packet); }; + // spellId prefix present in all expansions + if (packet.getSize() - packet.getReadPos() < 4) break; + /*uint32_t spellId =*/ packet.readUInt32(); if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; uint64_t casterGuid = readSpellMissGuid(); - if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; - /*uint64_t targetGuidLog =*/ readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; - /*uint8_t isCrit =*/ packet.readUInt8(); + /*uint8_t unk =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); count = std::min(count, 32u); - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 9; ++i) { - /*uint64_t victimGuid =*/ packet.readUInt64(); + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 9u : 2u)) break; + /*uint64_t victimGuid =*/ readSpellMissGuid(); + if (packet.getSize() - packet.getReadPos() < 1) break; uint8_t missInfo = packet.readUInt8(); + // REFLECT (11): extra uint32 reflectSpellId + uint8 reflectResult + if (missInfo == 11 && !spellMissTbcLike) { + if (packet.getSize() - packet.getReadPos() >= 5) { + /*uint32_t reflectSpellId =*/ packet.readUInt32(); + /*uint8_t reflectResult =*/ packet.readUInt8(); + } else { + packet.setReadPos(packet.getSize()); + break; + } + } // Show combat text only for local player's spell misses if (casterGuid == playerGuid) { static const CombatTextEntry::Type missTypes[] = { @@ -2319,11 +2436,11 @@ void GameHandler::handlePacket(network::Packet& packet) { CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE → show as MISS - CombatTextEntry::MISS, // 5=IMMUNE → show as MISS + CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::MISS, // 6=DEFLECT - CombatTextEntry::MISS, // 7=ABSORB - CombatTextEntry::MISS, // 8=RESIST + CombatTextEntry::ABSORB, // 7=ABSORB + CombatTextEntry::RESIST, // 8=RESIST }; CombatTextEntry::Type ct = (missInfo < 9) ? missTypes[missInfo] : CombatTextEntry::MISS; addCombatText(ct, 0, 0, true); @@ -2339,10 +2456,15 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victimGuid = packet.readUInt64(); /*uint8_t envType =*/ packet.readUInt8(); uint32_t damage = packet.readUInt32(); - /*uint32_t absorb =*/ packet.readUInt32(); - /*uint32_t resist =*/ packet.readUInt32(); - if (victimGuid == playerGuid && damage > 0) { - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); + uint32_t absorb = packet.readUInt32(); + uint32_t resist = packet.readUInt32(); + if (victimGuid == playerGuid) { + if (damage > 0) + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(damage), 0, false); + if (absorb > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(absorb), 0, false); + if (resist > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(resist), 0, false); } break; } @@ -2630,6 +2752,27 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t failGuid = tbcOrClassic ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) : UpdateObjectParser::readPackedGuid(packet); + // Read castCount + spellId + failReason + if (packet.getSize() - packet.getReadPos() >= 6) { + /*uint8_t castCount =*/ packet.readUInt8(); + /*uint32_t spellId =*/ packet.readUInt32(); + uint8_t failReason = packet.readUInt8(); + if (failGuid == playerGuid && failReason != 0) { + // Show interruption/failure reason in chat for player + int pt = -1; + if (auto pe = entityManager.getEntity(playerGuid)) + if (auto pu = std::dynamic_pointer_cast(pe)) + pt = static_cast(pu->getPowerType()); + const char* reason = getSpellCastResultString(failReason, pt); + if (reason) { + MessageChatData emsg; + emsg.type = ChatType::SYSTEM; + emsg.language = ChatLanguage::UNIVERSAL; + emsg.message = reason; + addLocalChatMessage(emsg); + } + } + } if (failGuid == playerGuid || failGuid == 0) { // Player's own cast failed casting = false; @@ -2695,7 +2838,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAchievementEarned(packet); break; case Opcode::SMSG_ALL_ACHIEVEMENT_DATA: - // Initial data burst on login — ignored for now (no achievement tracker UI). + handleAllAchievementData(packet); break; case Opcode::SMSG_ITEM_COOLDOWN: { // uint64 itemGuid + uint32 spellId + uint32 cooldownMs @@ -2745,10 +2888,11 @@ void GameHandler::handlePacket(network::Packet& packet) { // uint32 areaId if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t areaId = packet.readUInt32(); - char buf[128]; - std::snprintf(buf, sizeof(buf), - "A zone is under attack! (area %u)", areaId); - addSystemChatMessage(buf); + std::string areaName = getAreaName(areaId); + std::string msg = areaName.empty() + ? std::string("A zone is under attack!") + : (areaName + " is under attack!"); + addSystemChatMessage(msg); } break; } @@ -2761,27 +2905,52 @@ void GameHandler::handlePacket(network::Packet& packet) { handleAuraUpdate(packet, true); break; case Opcode::SMSG_DISPEL_FAILED: { - // casterGuid(8) + victimGuid(8) + spellId(4) [+ failing spellId(4)...] - if (packet.getSize() - packet.getReadPos() >= 20) { - /*uint64_t casterGuid =*/ packet.readUInt64(); - /*uint64_t victimGuid =*/ packet.readUInt64(); - uint32_t spellId = packet.readUInt32(); + // WotLK: uint32 dispelSpellId + packed_guid caster + packed_guid victim + // [+ count × uint32 failedSpellId] + // TBC/Classic: uint64 caster + uint64 victim + uint32 spellId + // [+ count × uint32 failedSpellId] + const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint32_t dispelSpellId = 0; + if (dispelTbcLike) { + if (packet.getSize() - packet.getReadPos() < 20) break; + /*uint64_t caster =*/ packet.readUInt64(); + /*uint64_t victim =*/ packet.readUInt64(); + dispelSpellId = packet.readUInt32(); + } else { + if (packet.getSize() - packet.getReadPos() < 4) break; + dispelSpellId = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < 1) break; + /*uint64_t caster =*/ UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + /*uint64_t victim =*/ UpdateObjectParser::readPackedGuid(packet); + } + { + loadSpellNameCache(); + auto it = spellNameCache_.find(dispelSpellId); char buf[128]; - std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", spellId); + if (it != spellNameCache_.end() && !it->second.name.empty()) + std::snprintf(buf, sizeof(buf), "%s failed to dispel.", it->second.name.c_str()); + else + std::snprintf(buf, sizeof(buf), "Dispel failed! (spell %u)", dispelSpellId); addSystemChatMessage(buf); } break; } case Opcode::SMSG_TOTEM_CREATED: { - // uint8 slot + uint64 guid + uint32 duration + uint32 spellId - if (packet.getSize() - packet.getReadPos() >= 17) { - uint8_t slot = packet.readUInt8(); + // WotLK: uint8 slot + packed_guid + uint32 duration + uint32 spellId + // TBC/Classic: uint8 slot + uint64 guid + uint32 duration + uint32 spellId + const bool totemTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (totemTbcLike ? 17u : 9u)) break; + uint8_t slot = packet.readUInt8(); + if (totemTbcLike) /*uint64_t guid =*/ packet.readUInt64(); - uint32_t duration = packet.readUInt32(); - uint32_t spellId = packet.readUInt32(); - LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, - " spellId=", spellId, " duration=", duration, "ms"); - } + else + /*uint64_t guid =*/ UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t duration = packet.readUInt32(); + uint32_t spellId = packet.readUInt32(); + LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", (int)slot, + " spellId=", spellId, " duration=", duration, "ms"); break; } case Opcode::SMSG_AREA_SPIRIT_HEALER_TIME: { @@ -3020,9 +3189,11 @@ void GameHandler::handlePacket(network::Packet& packet) { addSystemChatMessage("Summon cancelled."); break; case Opcode::SMSG_TRADE_STATUS: - case Opcode::SMSG_TRADE_STATUS_EXTENDED: handleTradeStatus(packet); break; + case Opcode::SMSG_TRADE_STATUS_EXTENDED: + handleTradeStatusExtended(packet); + break; case Opcode::SMSG_LOOT_ROLL: handleLootRoll(packet); break; @@ -3175,13 +3346,20 @@ void GameHandler::handlePacket(network::Packet& packet) { // Silently ignore common packets we don't handle yet case Opcode::SMSG_INIT_WORLD_STATES: { - // Minimal parse: uint32 mapId, uint32 zoneId, uint16 count, repeated (uint32 key, uint32 val) + // WotLK format: uint32 mapId, uint32 zoneId, uint32 areaId, uint16 count, N*(uint32 key, uint32 val) + // Classic/TBC format: uint32 mapId, uint32 zoneId, uint16 count, N*(uint32 key, uint32 val) if (packet.getSize() - packet.getReadPos() < 10) { LOG_WARNING("SMSG_INIT_WORLD_STATES too short: ", packet.getSize(), " bytes"); break; } worldStateMapId_ = packet.readUInt32(); worldStateZoneId_ = packet.readUInt32(); + // WotLK adds areaId (uint32) before count; detect by checking if payload would be consistent + size_t remaining = packet.getSize() - packet.getReadPos(); + bool isWotLKFormat = isActiveExpansion("wotlk") || isActiveExpansion("turtle"); + if (isWotLKFormat && remaining >= 6) { + packet.readUInt32(); // areaId (WotLK only) + } uint16_t count = packet.readUInt16(); size_t needed = static_cast(count) * 8; size_t available = packet.getSize() - packet.getReadPos(); @@ -3326,22 +3504,63 @@ void GameHandler::handlePacket(network::Packet& packet) { for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 1; ++i) { uint8_t auraType = packet.readUInt8(); if (auraType == 3 || auraType == 89) { - // PERIODIC_DAMAGE / PERIODIC_DAMAGE_PERCENT: damage+school+absorbed+resisted - if (packet.getSize() - packet.getReadPos() < 16) break; + // Classic/TBC: damage(4)+school(4)+absorbed(4)+resisted(4) = 16 bytes + // WotLK 3.3.5a: damage(4)+overkill(4)+school(4)+absorbed(4)+resisted(4) = 20 bytes + const bool periodicWotlk = isActiveExpansion("wotlk"); + const size_t dotSz = periodicWotlk ? 20u : 16u; + if (packet.getSize() - packet.getReadPos() < dotSz) break; uint32_t dmg = packet.readUInt32(); + if (periodicWotlk) /*uint32_t overkill=*/ packet.readUInt32(); /*uint32_t school=*/ packet.readUInt32(); - /*uint32_t abs=*/ packet.readUInt32(); - /*uint32_t res=*/ packet.readUInt32(); - addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), - spellId, isPlayerCaster); + uint32_t abs = packet.readUInt32(); + uint32_t res = packet.readUInt32(); + if (dmg > 0) + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(dmg), + spellId, isPlayerCaster); + if (abs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(abs), + spellId, isPlayerCaster); + if (res > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(res), + spellId, isPlayerCaster); } else if (auraType == 8 || auraType == 124 || auraType == 45) { - // PERIODIC_HEAL / PERIODIC_HEAL_PCT / OBS_MOD_HEALTH: heal+maxHeal+overHeal - if (packet.getSize() - packet.getReadPos() < 12) break; + // Classic/TBC: heal(4)+maxHeal(4)+overHeal(4) = 12 bytes + // WotLK 3.3.5a: heal(4)+maxHeal(4)+overHeal(4)+absorbed(4)+isCrit(1) = 17 bytes + const bool healWotlk = isActiveExpansion("wotlk"); + const size_t hotSz = healWotlk ? 17u : 12u; + if (packet.getSize() - packet.getReadPos() < hotSz) break; uint32_t heal = packet.readUInt32(); /*uint32_t max=*/ packet.readUInt32(); /*uint32_t over=*/ packet.readUInt32(); + uint32_t hotAbs = 0; + if (healWotlk) { + hotAbs = packet.readUInt32(); + /*uint8_t isCrit=*/ packet.readUInt8(); + } addCombatText(CombatTextEntry::PERIODIC_HEAL, static_cast(heal), spellId, isPlayerCaster); + if (hotAbs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(hotAbs), + spellId, isPlayerCaster); + } else if (auraType == 46 || auraType == 91) { + // OBS_MOD_POWER / PERIODIC_ENERGIZE: miscValue(powerType) + amount + // Common in WotLK: Replenishment, Mana Spring Totem, Divine Plea, etc. + if (packet.getSize() - packet.getReadPos() < 8) break; + /*uint32_t powerType =*/ packet.readUInt32(); + uint32_t amount = packet.readUInt32(); + if ((isPlayerVictim || isPlayerCaster) && amount > 0) + addCombatText(CombatTextEntry::ENERGIZE, static_cast(amount), + spellId, isPlayerCaster); + } else if (auraType == 98) { + // PERIODIC_MANA_LEECH: miscValue(powerType) + amount + float multiplier + if (packet.getSize() - packet.getReadPos() < 12) break; + /*uint32_t powerType =*/ packet.readUInt32(); + uint32_t amount = packet.readUInt32(); + /*float multiplier =*/ packet.readUInt32(); // read as raw uint32 (float bits) + // Show as periodic damage from victim's perspective (mana drained) + if (isPlayerVictim && amount > 0) + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(amount), + spellId, false); } else { // Unknown/untracked aura type — stop parsing this event safely packet.setReadPos(packet.getSize()); @@ -3381,9 +3600,16 @@ void GameHandler::handlePacket(network::Packet& packet) { uint64_t victimGuid = packet.readUInt64(); /*uint8_t envType =*/ packet.readUInt8(); uint32_t dmg = packet.readUInt32(); - /*uint32_t abs =*/ packet.readUInt32(); - if (victimGuid == playerGuid && dmg > 0) - addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); + uint32_t envAbs = packet.readUInt32(); + uint32_t envRes = packet.readUInt32(); + if (victimGuid == playerGuid) { + if (dmg > 0) + addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false); + if (envAbs > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(envAbs), 0, false); + if (envRes > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false); + } packet.setReadPos(packet.getSize()); break; } @@ -4076,8 +4302,22 @@ void GameHandler::handlePacket(network::Packet& packet) { if (reqCount == 0) { auto it = quest.killCounts.find(entry); if (it != quest.killCounts.end()) reqCount = it->second.second; - if (reqCount == 0) reqCount = count; } + // Fall back to killObjectives (parsed from SMSG_QUEST_QUERY_RESPONSE). + // Note: npcOrGoId < 0 means game object; server always sends entry as uint32 + // in QUESTUPDATE_ADD_KILL regardless of type, so match by absolute value. + if (reqCount == 0) { + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId == 0 || obj.required == 0) continue; + uint32_t objEntry = static_cast( + obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId); + if (objEntry == entry) { + reqCount = obj.required; + break; + } + } + } + if (reqCount == 0) reqCount = count; // last-resort: avoid 0/0 display quest.killCounts[entry] = {count, reqCount}; std::string creatureName = getCachedCreatureName(entry); @@ -4123,9 +4363,20 @@ void GameHandler::handlePacket(network::Packet& packet) { bool updatedAny = false; for (auto& quest : questLog_) { if (quest.complete) continue; - const bool tracksItem = + bool tracksItem = quest.requiredItemCounts.count(itemId) > 0 || quest.itemCounts.count(itemId) > 0; + // Also check itemObjectives parsed from SMSG_QUEST_QUERY_RESPONSE in case + // requiredItemCounts hasn't been populated yet (race during quest accept). + if (!tracksItem) { + for (const auto& obj : quest.itemObjectives) { + if (obj.itemId == itemId && obj.required > 0) { + quest.requiredItemCounts.emplace(itemId, obj.required); + tracksItem = true; + break; + } + } + } if (!tracksItem) continue; quest.itemCounts[itemId] = count; updatedAny = true; @@ -4175,12 +4426,31 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_QUEST_FORCE_REMOVE: { - // Minimal parse: uint32 questId + // This opcode is aliased to SMSG_SET_REST_START in the opcode table + // because both share opcode 0x21E in WotLK 3.3.5a. + // In WotLK: payload = uint32 areaId (entering rest) or 0 (leaving rest). + // In Classic/TBC: payload = uint32 questId (force-remove a quest). if (packet.getSize() - packet.getReadPos() < 4) { - LOG_WARNING("SMSG_QUEST_FORCE_REMOVE too short"); + LOG_WARNING("SMSG_QUEST_FORCE_REMOVE/SET_REST_START too short"); break; } - uint32_t questId = packet.readUInt32(); + uint32_t value = packet.readUInt32(); + + // WotLK uses this opcode as SMSG_SET_REST_START: non-zero = entering + // a rest area (inn/city), zero = leaving. Classic/TBC use it for quest removal. + if (!isClassicLikeExpansion() && !isActiveExpansion("tbc")) { + // WotLK: treat as SET_REST_START + bool nowResting = (value != 0); + if (nowResting != isResting_) { + isResting_ = nowResting; + addSystemChatMessage(isResting_ ? "You are now resting." + : "You are no longer resting."); + } + break; + } + + // Classic/TBC: treat as QUEST_FORCE_REMOVE (uint32 questId) + uint32_t questId = value; clearPendingQuestAccept(questId); pendingQuestQueryIds_.erase(questId); if (questId == 0) { @@ -4232,8 +4502,11 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t questId = packet.readUInt32(); packet.readUInt32(); // questMethod - const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() == 3; + // Classic/Turtle = stride 3, TBC = stride 4 — all use 40 fixed fields + 4 strings. + // WotLK = stride 5, uses 55 fixed fields + 5 strings. + const bool isClassicLayout = packetParsers_ && packetParsers_->questLogStride() <= 4; const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); + const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout); for (auto& q : questLog_) { if (q.questId != questId) continue; @@ -4257,6 +4530,38 @@ void GameHandler::handlePacket(network::Packet& packet) { (q.objectives.empty() || q.objectives.size() < 16)) { q.objectives = parsed.objectives; } + + // Store structured kill/item objectives for later kill-count restoration. + if (objs.valid) { + for (int i = 0; i < 4; ++i) { + q.killObjectives[i].npcOrGoId = objs.kills[i].npcOrGoId; + q.killObjectives[i].required = objs.kills[i].required; + } + for (int i = 0; i < 6; ++i) { + q.itemObjectives[i].itemId = objs.items[i].itemId; + q.itemObjectives[i].required = objs.items[i].required; + } + // Now that we have the objective creature IDs, apply any packed kill + // counts from the player update fields that arrived at login. + applyPackedKillCountsFromFields(q); + // Pre-fetch creature/GO names and item info so objective display is + // populated by the time the player opens the quest log. + for (int i = 0; i < 4; ++i) { + int32_t id = objs.kills[i].npcOrGoId; + if (id == 0 || objs.kills[i].required == 0) continue; + if (id > 0) queryCreatureInfo(static_cast(id), 0); + else queryGameObjectInfo(static_cast(-id), 0); + } + for (int i = 0; i < 6; ++i) { + if (objs.items[i].itemId != 0 && objs.items[i].required != 0) + queryItemInfo(objs.items[i].itemId, 0); + } + LOG_DEBUG("Quest ", questId, " objectives parsed: kills=[", + objs.kills[0].npcOrGoId, "/", objs.kills[0].required, ", ", + objs.kills[1].npcOrGoId, "/", objs.kills[1].required, ", ", + objs.kills[2].npcOrGoId, "/", objs.kills[2].required, ", ", + objs.kills[3].npcOrGoId, "/", objs.kills[3].required, "]"); + } break; } @@ -4301,7 +4606,21 @@ void GameHandler::handlePacket(network::Packet& packet) { uint32_t mapId = packet.readUInt32(); uint8_t reason = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0; LOG_WARNING("SMSG_TRANSFER_ABORTED: mapId=", mapId, " reason=", (int)reason); - addSystemChatMessage("Transfer aborted."); + // Provide reason-specific feedback (WotLK TRANSFER_ABORT_* codes) + const char* abortMsg = nullptr; + switch (reason) { + case 0x01: abortMsg = "Transfer aborted: difficulty unavailable."; break; + case 0x02: abortMsg = "Transfer aborted: expansion required."; break; + case 0x03: abortMsg = "Transfer aborted: instance not found."; break; + case 0x04: abortMsg = "Transfer aborted: too many instances. Please wait before entering a new instance."; break; + case 0x06: abortMsg = "Transfer aborted: instance is full."; break; + case 0x07: abortMsg = "Transfer aborted: zone is in combat."; break; + case 0x08: abortMsg = "Transfer aborted: you are already in this instance."; break; + case 0x09: abortMsg = "Transfer aborted: not enough players."; break; + case 0x0C: abortMsg = "Transfer aborted."; break; + default: abortMsg = "Transfer aborted."; break; + } + addSystemChatMessage(abortMsg); break; } @@ -4360,6 +4679,7 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("Battleground player left"); break; case Opcode::SMSG_INSTANCE_DIFFICULTY: + case Opcode::MSG_SET_DUNGEON_DIFFICULTY: handleInstanceDifficulty(packet); break; case Opcode::SMSG_INSTANCE_SAVE_CREATED: @@ -4699,13 +5019,13 @@ void GameHandler::handlePacket(network::Packet& packet) { std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); - for (uint8_t i = 0; i < count && remaining() >= 13; i++) { - uint8_t slot = packet.readUInt8(); - uint32_t spellId = packet.readUInt32(); - (void) packet.readUInt8(); // effectIndex (unused for slot display) - uint8_t flags = packet.readUInt8(); - uint32_t durationMs = packet.readUInt32(); - uint32_t maxDurMs = packet.readUInt32(); + for (uint8_t i = 0; i < count && remaining() >= 15; i++) { + uint8_t slot = packet.readUInt8(); // 1 byte + uint32_t spellId = packet.readUInt32(); // 4 bytes + (void) packet.readUInt8(); // effectIndex: 1 byte (unused for slot display) + uint8_t flags = packet.readUInt8(); // 1 byte + uint32_t durationMs = packet.readUInt32(); // 4 bytes + uint32_t maxDurMs = packet.readUInt32(); // 4 bytes — total 15 bytes per entry if (auraList) { while (auraList->size() <= slot) auraList->push_back(AuraSlot{}); @@ -4934,14 +5254,24 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_AURACASTLOG: case Opcode::SMSG_SPELLBREAKLOG: case Opcode::SMSG_SPELLDAMAGESHIELD: { - // victimGuid(8) + casterGuid(8) + spellId(4) + damage(4) + schoolMask(4) - if (packet.getSize() - packet.getReadPos() < 24) { + // Classic/TBC: uint64 victim + uint64 caster + spellId(4) + damage(4) + schoolMask(4) + // WotLK: packed_guid victim + packed_guid caster + spellId(4) + damage(4) + absorbed(4) + schoolMask(4) + const bool shieldClassicLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const size_t shieldMinSz = shieldClassicLike ? 24u : 2u; + if (packet.getSize() - packet.getReadPos() < shieldMinSz) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = shieldClassicLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + uint64_t casterGuid = shieldClassicLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = packet.readUInt64(); - uint64_t casterGuid = packet.readUInt64(); /*uint32_t spellId =*/ packet.readUInt32(); uint32_t damage = packet.readUInt32(); + if (!shieldClassicLike && packet.getSize() - packet.getReadPos() >= 4) + /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect if (casterGuid == playerGuid) { @@ -4998,10 +5328,11 @@ void GameHandler::handlePacket(network::Packet& packet) { if (victimGuid == playerGuid || casterGuid == playerGuid) { const char* verb = isStolen ? "stolen" : "dispelled"; // Collect first dispelled spell name for the message + // Each entry: uint32 spellId + uint8 isPositive (5 bytes in WotLK/TBC/Classic) std::string firstSpellName; - for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 8; ++i) { + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 5; ++i) { uint32_t dispelledId = packet.readUInt32(); - /*uint32_t unk =*/ packet.readUInt32(); + /*uint8_t isPositive =*/ packet.readUInt8(); if (i == 0) { const std::string& nm = getSpellName(dispelledId); firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; @@ -5022,8 +5353,45 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLSTEALLOG: { - // Similar to SPELLDISPELLOG but always isStolen=true; same wire format - // Just consume — SPELLDISPELLOG handles the player-facing case above + // Sent to the CASTER (Mage) when Spellsteal succeeds. + // Wire format mirrors SPELLDISPELLOG: + // WotLK: packed victim + packed caster + uint32 spellId + uint8 isStolen + uint32 count + // + count × (uint32 stolenSpellId + uint8 isPositive) + // TBC/Classic: full uint64 victim + full uint64 caster + same tail + const bool stealTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t stealVictim =*/ stealTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < (stealTbcLike ? 8u : 2u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t stealCaster = stealTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } + /*uint32_t stealSpellId =*/ packet.readUInt32(); + /*uint8_t isStolen =*/ packet.readUInt8(); + uint32_t stealCount = packet.readUInt32(); + // Show feedback only when we are the caster (we stole something) + if (stealCaster == playerGuid) { + std::string stolenName; + for (uint32_t i = 0; i < stealCount && packet.getSize() - packet.getReadPos() >= 5; ++i) { + uint32_t stolenId = packet.readUInt32(); + /*uint8_t isPos =*/ packet.readUInt8(); + if (i == 0) { + const std::string& nm = getSpellName(stolenId); + stolenName = nm.empty() ? ("spell " + std::to_string(stolenId)) : nm; + } + } + if (!stolenName.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "You stole %s.", stolenName.c_str()); + addSystemChatMessage(buf); + } + } packet.setReadPos(packet.getSize()); break; } @@ -5446,15 +5814,44 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- PVP quest kill update ---- case Opcode::SMSG_QUESTUPDATE_ADD_PVP_KILL: { - // uint64 guid + uint32 questId + uint32 killCount + // WotLK 3.3.5a format: uint64 guid + uint32 questId + uint32 count + uint32 reqCount + // Classic format: uint64 guid + uint32 questId + uint32 count (no reqCount) if (packet.getSize() - packet.getReadPos() >= 16) { /*uint64_t guid =*/ packet.readUInt64(); uint32_t questId = packet.readUInt32(); uint32_t count = packet.readUInt32(); - char buf[64]; - std::snprintf(buf, sizeof(buf), "PVP kill counted for quest #%u (%u).", - questId, count); - addSystemChatMessage(buf); + uint32_t reqCount = 0; + if (packet.getSize() - packet.getReadPos() >= 4) { + reqCount = packet.readUInt32(); + } + + // Update quest log kill counts (PvP kills use entry=0 as the key + // since there's no specific creature entry — one slot per quest). + constexpr uint32_t PVP_KILL_ENTRY = 0u; + for (auto& quest : questLog_) { + if (quest.questId != questId) continue; + + if (reqCount == 0) { + auto it = quest.killCounts.find(PVP_KILL_ENTRY); + if (it != quest.killCounts.end()) reqCount = it->second.second; + } + if (reqCount == 0) { + // Pull required count from kill objectives (npcOrGoId == 0 slot, if any) + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId == 0 && obj.required > 0) { + reqCount = obj.required; + break; + } + } + } + if (reqCount == 0) reqCount = count; + quest.killCounts[PVP_KILL_ENTRY] = {count, reqCount}; + + std::string progressMsg = quest.title + ": PvP kills " + + std::to_string(count) + "/" + std::to_string(reqCount); + addSystemChatMessage(progressMsg); + break; + } } break; } @@ -5607,6 +6004,8 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: case Opcode::SMSG_PLAYER_SKINNED: case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: + handleQuestPoiQueryResponse(packet); + break; case Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA: case Opcode::SMSG_RESET_RANGED_COMBAT_TIMER: case Opcode::SMSG_PROFILEDATA_RESPONSE: @@ -6079,6 +6478,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { onlineEquipDirty_ = false; playerMoneyCopper_ = 0; playerArmorRating_ = 0; + std::fill(std::begin(playerStats_), std::end(playerStats_), -1); knownSpells.clear(); spellCooldowns.clear(); actionBar = {}; @@ -7990,8 +8390,19 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, block.guid, std::dec, " displayId=", unit->getDisplayId(), " at (", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); + float unitScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale, &raw, sizeof(float)); + if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; + } + } + } creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); if (unitInitiallyDead && npcDeathCallback_) { npcDeathCallback_(block.guid); } @@ -8041,8 +8452,19 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created } if (go->getDisplayId() != 0 && gameObjectSpawnCallback_) { + float goScale = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&goScale, &raw, sizeof(float)); + if (goScale <= 0.01f || goScale > 100.0f) goScale = 1.0f; + } + } + } gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), - go->getX(), go->getY(), go->getZ(), go->getOrientation()); + go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); } // Fire transport move callback for transports (position update on re-creation) if (transportGuids_.count(block.guid) && transportMoveCallback_) { @@ -8101,6 +8523,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufStats[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } @@ -8124,9 +8551,18 @@ 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 + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte & 0x01) != 0; + isResting_ = (restStateByte != 0); + } + else { + for (int si = 0; si < 5; ++si) { + if (ufStats[si] != 0xFFFF && key == ufStats[si]) { + playerStats_[si] = static_cast(val); + break; + } + } } // Do not synthesize quest-log entries from raw update-field slots. // Slot layouts differ on some classic-family realms and can produce @@ -8137,6 +8573,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { maybeDetectVisibleItemLayout(); extractSkillFields(lastPlayerFields_); extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); } break; } @@ -8346,8 +8783,19 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } } else if (creatureSpawnCallback_) { + float unitScale2 = 1.0f; + { + uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); + if (scaleIdx != 0xFFFF) { + uint32_t raw = entity->getField(scaleIdx); + if (raw != 0) { + std::memcpy(&unitScale2, &raw, sizeof(float)); + if (unitScale2 <= 0.01f || unitScale2 > 100.0f) unitScale2 = 1.0f; + } + } + } creatureSpawnCallback_(block.guid, unit->getDisplayId(), - unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); + unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale2); bool isDeadNow = (unit->getHealth() == 0) || ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); if (isDeadNow && !npcDeathNotified && npcDeathCallback_) { @@ -8401,6 +8849,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufStatsV[5] = { + fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), + fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), + fieldIndex(UF::UNIT_FIELD_STAT4) + }; for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; @@ -8435,9 +8888,10 @@ 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 + // Byte 3 (bits 24-31): REST_STATE + // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY uint8_t restStateByte = static_cast((val >> 24) & 0xFF); - isResting_ = (restStateByte & 0x01) != 0; + isResting_ = (restStateByte != 0); } else if (key == ufPlayerFlags) { constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; @@ -8456,6 +8910,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(false); } } + else { + for (int si = 0; si < 5; ++si) { + if (ufStatsV[si] != 0xFFFF && key == ufStatsV[si]) { + playerStats_[si] = static_cast(val); + break; + } + } + } } // Do not auto-create quests from VALUES quest-log slot fields for the // same reason as CREATE_OBJECT2 above (can be misaligned per realm). @@ -8463,6 +8925,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (slotsChanged) rebuildOnlineInventory(); extractSkillFields(lastPlayerFields_); extractExploredZoneFields(lastPlayerFields_); + applyQuestStateFromFields(lastPlayerFields_); } // Update item stack count / durability for online items @@ -11811,12 +12274,41 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { bgName = std::to_string(arenaType) + "v" + std::to_string(arenaType) + " Arena"; } + // Parse status-specific fields + uint32_t inviteTimeout = 80; // default WoW BG invite window (seconds) + if (statusId == 1) { + // STATUS_WAIT_QUEUE: avgWaitTime(4) + timeInQueue(4) + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t avgWait =*/ packet.readUInt32(); + /*uint32_t inQueue =*/ packet.readUInt32(); + } + } else if (statusId == 2) { + // STATUS_WAIT_JOIN: timeout(4) + mapId(4) + if (packet.getSize() - packet.getReadPos() >= 4) { + inviteTimeout = packet.readUInt32(); + } + if (packet.getSize() - packet.getReadPos() >= 4) { + /*uint32_t mapId =*/ packet.readUInt32(); + } + } else if (statusId == 3) { + // STATUS_IN_PROGRESS: mapId(4) + timeSinceStart(4) + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t mapId =*/ packet.readUInt32(); + /*uint32_t elapsed =*/ packet.readUInt32(); + } + } + // Store queue state if (queueSlot < bgQueues_.size()) { + bool wasInvite = (bgQueues_[queueSlot].statusId == 2); bgQueues_[queueSlot].queueSlot = queueSlot; bgQueues_[queueSlot].bgTypeId = bgTypeId; bgQueues_[queueSlot].arenaType = arenaType; bgQueues_[queueSlot].statusId = statusId; + if (statusId == 2 && !wasInvite) { + bgQueues_[queueSlot].inviteTimeout = inviteTimeout; + bgQueues_[queueSlot].inviteReceivedTime = std::chrono::steady_clock::now(); + } } switch (statusId) { @@ -11828,8 +12320,10 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { LOG_INFO("Battlefield status: WAIT_QUEUE for ", bgName); break; case 2: // STATUS_WAIT_JOIN - addSystemChatMessage(bgName + " is ready! Type /join to enter."); - LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName); + // Popup shown by the UI; add chat notification too. + addSystemChatMessage(bgName + " is ready!"); + LOG_INFO("Battlefield status: WAIT_JOIN for ", bgName, + " timeout=", inviteTimeout, "s"); break; case 3: // STATUS_IN_PROGRESS addSystemChatMessage("Entered " + bgName + "."); @@ -11844,6 +12338,44 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { } } +void GameHandler::declineBattlefield(uint32_t queueSlot) { + if (state != WorldState::IN_WORLD) return; + if (!socket) return; + + const BgQueueSlot* slot = nullptr; + if (queueSlot == 0xFFFFFFFF) { + for (const auto& s : bgQueues_) { + if (s.statusId == 2) { slot = &s; break; } + } + } else if (queueSlot < bgQueues_.size() && bgQueues_[queueSlot].statusId == 2) { + slot = &bgQueues_[queueSlot]; + } + + if (!slot) { + addSystemChatMessage("No battleground invitation pending."); + return; + } + + // CMSG_BATTLEFIELD_PORT with action=0 (decline) + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT)); + pkt.writeUInt8(slot->arenaType); + pkt.writeUInt8(0x00); + pkt.writeUInt32(slot->bgTypeId); + pkt.writeUInt16(0x0000); + pkt.writeUInt8(0); // 0 = decline + + socket->send(pkt); + + // Clear queue slot + uint32_t clearSlot = slot->queueSlot; + if (clearSlot < bgQueues_.size()) { + bgQueues_[clearSlot] = BgQueueSlot{}; + } + + addSystemChatMessage("Battleground invitation declined."); + LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: decline"); +} + bool GameHandler::hasPendingBgInvite() const { for (const auto& slot : bgQueues_) { if (slot.statusId == 2) return true; // STATUS_WAIT_JOIN @@ -11880,6 +12412,12 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) { socket->send(pkt); + // Optimistically clear the invite so the popup disappears immediately. + uint32_t clearSlot = slot->queueSlot; + if (clearSlot < bgQueues_.size()) { + bgQueues_[clearSlot].statusId = 3; // STATUS_IN_PROGRESS (server will confirm) + } + addSystemChatMessage("Accepting battleground invitation..."); LOG_INFO("Sent CMSG_BATTLEFIELD_PORT: accept bgTypeId=", slot->bgTypeId); } @@ -11920,10 +12458,27 @@ void GameHandler::handleRaidInstanceInfo(network::Packet& packet) { } void GameHandler::handleInstanceDifficulty(network::Packet& packet) { - if (packet.getSize() - packet.getReadPos() < 8) return; + // SMSG_INSTANCE_DIFFICULTY: uint32 difficulty, uint32 heroic (8 bytes) + // MSG_SET_DUNGEON_DIFFICULTY: uint32 difficulty[, uint32 isInGroup, uint32 savedBool] (4 or 12 bytes) + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 4) return; instanceDifficulty_ = packet.readUInt32(); - uint32_t isHeroic = packet.readUInt32(); - instanceIsHeroic_ = (isHeroic != 0); + if (rem() >= 4) { + uint32_t secondField = packet.readUInt32(); + // SMSG_INSTANCE_DIFFICULTY: second field is heroic flag (0 or 1) + // MSG_SET_DUNGEON_DIFFICULTY: second field is isInGroup (not heroic) + // Heroic = difficulty value 1 for 5-man, so use the field value for SMSG and + // infer from difficulty for MSG variant (which has larger payloads). + if (rem() >= 4) { + // Three+ fields: this is MSG_SET_DUNGEON_DIFFICULTY; heroic = (difficulty == 1) + instanceIsHeroic_ = (instanceDifficulty_ == 1); + } else { + // Two fields: SMSG_INSTANCE_DIFFICULTY format + instanceIsHeroic_ = (secondField != 0); + } + } else { + instanceIsHeroic_ = (instanceDifficulty_ == 1); + } LOG_INFO("Instance difficulty: ", instanceDifficulty_, " heroic=", instanceIsHeroic_); } @@ -13022,10 +13577,32 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { } else if (data.victimState == 2) { addCombatText(CombatTextEntry::PARRY, 0, 0, isPlayerAttacker); } else if (data.victimState == 4) { - addCombatText(CombatTextEntry::BLOCK, 0, 0, isPlayerAttacker); + // VICTIMSTATE_BLOCKS: show reduced damage and the blocked amount + if (data.totalDamage > 0) + addCombatText(CombatTextEntry::MELEE_DAMAGE, data.totalDamage, 0, isPlayerAttacker); + addCombatText(CombatTextEntry::BLOCK, static_cast(data.blocked), 0, isPlayerAttacker); + } else if (data.victimState == 5) { + // VICTIMSTATE_EVADE: NPC evaded (out of combat zone). Show as miss. + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); + } else if (data.victimState == 6) { + // VICTIMSTATE_IS_IMMUNE: Target is immune to this attack. + addCombatText(CombatTextEntry::IMMUNE, 0, 0, isPlayerAttacker); + } else if (data.victimState == 7) { + // VICTIMSTATE_DEFLECT: Attack was deflected (e.g. shield slam reflect). + addCombatText(CombatTextEntry::MISS, 0, 0, isPlayerAttacker); } else { auto type = data.isCrit() ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::MELEE_DAMAGE; addCombatText(type, data.totalDamage, 0, isPlayerAttacker); + // Show partial absorb/resist from sub-damage entries + uint32_t totalAbsorbed = 0, totalResisted = 0; + for (const auto& sub : data.subDamages) { + totalAbsorbed += sub.absorbed; + totalResisted += sub.resisted; + } + if (totalAbsorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(totalAbsorbed), 0, isPlayerAttacker); + if (totalResisted > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(totalResisted), 0, isPlayerAttacker); } (void)isPlayerTarget; @@ -13045,7 +13622,12 @@ void GameHandler::handleSpellDamageLog(network::Packet& packet) { } auto type = data.isCrit ? CombatTextEntry::CRIT_DAMAGE : CombatTextEntry::SPELL_DAMAGE; - addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); + if (data.damage > 0) + addCombatText(type, static_cast(data.damage), data.spellId, isPlayerSource); + if (data.absorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); + if (data.resisted > 0) + addCombatText(CombatTextEntry::RESIST, static_cast(data.resisted), data.spellId, isPlayerSource); } void GameHandler::handleSpellHealLog(network::Packet& packet) { @@ -13058,6 +13640,8 @@ void GameHandler::handleSpellHealLog(network::Packet& packet) { auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL; addCombatText(type, static_cast(data.heal), data.spellId, isPlayerSource); + if (data.absorbed > 0) + addCombatText(CombatTextEntry::ABSORB, static_cast(data.absorbed), data.spellId, isPlayerSource); } // ============================================================ @@ -13130,22 +13714,16 @@ void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { } // Instant melee abilities: client-side range + facing check to avoid server "not in front" errors + // Detected via physical school mask (1) from DBC cache — covers warrior, rogue, DK, paladin, + // feral druid, and hunter melee abilities generically. { - uint32_t sid = spellId; - bool isMeleeAbility = - sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike - sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 || - sid == 25286 || sid == 29707 || sid == 30324 || - sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend - sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 || - sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge - sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 || - sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave - sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 || - sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike - sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 || - sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam - sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488; + loadSpellNameCache(); + bool isMeleeAbility = false; + auto cacheIt = spellNameCache_.find(spellId); + if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) { + // Physical school and no cast time (instant) — treat as melee ability + isMeleeAbility = true; + } if (isMeleeAbility && target != 0) { auto entity = entityManager.getEntity(target); if (entity) { @@ -13425,21 +14003,21 @@ void GameHandler::handleSpellGo(network::Packet& packet) { } // Instant melee abilities → trigger attack animation + // Detect via physical school mask (1 = Physical) from the spell DBC cache. + // This covers warrior, rogue, DK, paladin, feral druid, and hunter melee + // abilities generically instead of maintaining a brittle per-spell-ID list. uint32_t sid = data.spellId; - bool isMeleeAbility = - sid == 78 || sid == 284 || sid == 285 || sid == 1608 || // Heroic Strike ranks - sid == 11564 || sid == 11565 || sid == 11566 || sid == 11567 || - sid == 25286 || sid == 29707 || sid == 30324 || - sid == 772 || sid == 6546 || sid == 6547 || sid == 6548 || // Rend ranks - sid == 11572 || sid == 11573 || sid == 11574 || sid == 25208 || - sid == 6572 || sid == 6574 || sid == 7379 || sid == 11600 || // Revenge ranks - sid == 11601 || sid == 25288 || sid == 25269 || sid == 30357 || - sid == 845 || sid == 7369 || sid == 11608 || sid == 11609 || // Cleave ranks - sid == 20569 || sid == 25231 || sid == 47519 || sid == 47520 || - sid == 12294 || sid == 21551 || sid == 21552 || sid == 21553 || // Mortal Strike ranks - sid == 25248 || sid == 30330 || sid == 47485 || sid == 47486 || - sid == 23922 || sid == 23923 || sid == 23924 || sid == 23925 || // Shield Slam ranks - sid == 25258 || sid == 30356 || sid == 47487 || sid == 47488; + bool isMeleeAbility = false; + { + loadSpellNameCache(); + auto cacheIt = spellNameCache_.find(sid); + if (cacheIt != spellNameCache_.end() && cacheIt->second.schoolMask == 1) { + // Physical school — treat as instant melee ability if cast time is zero. + // We don't store cast time in the cache; use the fact that if we were not + // in a cast (casting == true with this spellId) then it was instant. + isMeleeAbility = (currentCastSpellId != sid); + } + } if (isMeleeAbility && meleeSwingCallback_) { meleeSwingCallback_(); } @@ -13467,11 +14045,11 @@ void GameHandler::handleSpellGo(network::Packet& packet) { CombatTextEntry::DODGE, // 1=DODGE CombatTextEntry::PARRY, // 2=PARRY CombatTextEntry::BLOCK, // 3=BLOCK - CombatTextEntry::MISS, // 4=EVADE → show as MISS - CombatTextEntry::MISS, // 5=IMMUNE → show as MISS + CombatTextEntry::MISS, // 4=EVADE + CombatTextEntry::IMMUNE, // 5=IMMUNE CombatTextEntry::MISS, // 6=DEFLECT - CombatTextEntry::MISS, // 7=ABSORB - CombatTextEntry::MISS, // 8=RESIST + CombatTextEntry::ABSORB, // 7=ABSORB + CombatTextEntry::RESIST, // 8=RESIST }; // Show text for each miss (usually just 1 target per spell go) for (const auto& m : data.missTargets) { @@ -13532,7 +14110,11 @@ void GameHandler::handleSpellCooldown(network::Packet& packet) { } void GameHandler::handleCooldownEvent(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellId = packet.readUInt32(); + // WotLK appends the target unit guid (8 bytes) — skip it + if (packet.getSize() - packet.getReadPos() >= 8) + packet.readUInt64(); // Cooldown finished spellCooldowns.erase(spellId); for (auto& slot : actionBar) { @@ -13587,6 +14169,7 @@ void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { } void GameHandler::handleLearnedSpell(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellId = packet.readUInt32(); knownSpells.insert(spellId); LOG_INFO("Learned spell: ", spellId); @@ -13615,6 +14198,7 @@ void GameHandler::handleLearnedSpell(network::Packet& packet) { } void GameHandler::handleRemovedSpell(network::Packet& packet) { + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellId = packet.readUInt32(); knownSpells.erase(spellId); LOG_INFO("Removed spell: ", spellId); @@ -13622,6 +14206,7 @@ void GameHandler::handleRemovedSpell(network::Packet& packet) { void GameHandler::handleSupercededSpell(network::Packet& packet) { // Old spell replaced by new rank (e.g., Fireball Rank 1 -> Fireball Rank 2) + if (packet.getSize() - packet.getReadPos() < 8) return; uint32_t oldSpellId = packet.readUInt32(); uint32_t newSpellId = packet.readUInt32(); @@ -13641,6 +14226,7 @@ void GameHandler::handleSupercededSpell(network::Packet& packet) { void GameHandler::handleUnlearnSpells(network::Packet& packet) { // Sent when unlearning multiple spells (e.g., spec change, respec) + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t spellCount = packet.readUInt32(); LOG_INFO("Unlearning ", spellCount, " spells"); @@ -14584,9 +15170,96 @@ bool GameHandler::requestQuestQuery(uint32_t questId, bool force) { pkt.writeUInt32(questId); socket->send(pkt); pendingQuestQueryIds_.insert(questId); + + // WotLK supports CMSG_QUEST_POI_QUERY to get objective map locations. + // Only send if the opcode is mapped (stride==5 means WotLK). + if (packetParsers_ && packetParsers_->questLogStride() == 5) { + const uint32_t wirePoiQuery = wireOpcode(Opcode::CMSG_QUEST_POI_QUERY); + if (wirePoiQuery != 0xFFFF) { + network::Packet poiPkt(static_cast(wirePoiQuery)); + poiPkt.writeUInt32(1); // count = 1 + poiPkt.writeUInt32(questId); + socket->send(poiPkt); + } + } return true; } +void GameHandler::handleQuestPoiQueryResponse(network::Packet& packet) { + // WotLK 3.3.5a SMSG_QUEST_POI_QUERY_RESPONSE format: + // uint32 questCount + // per quest: + // uint32 questId + // uint32 poiCount + // per poi: + // uint32 poiId + // int32 objIndex (-1 = no specific objective) + // uint32 mapId + // uint32 areaId + // uint32 floorId + // uint32 unk1 + // uint32 unk2 + // uint32 pointCount + // per point: int32 x, int32 y + if (packet.getSize() - packet.getReadPos() < 4) return; + const uint32_t questCount = packet.readUInt32(); + for (uint32_t qi = 0; qi < questCount; ++qi) { + if (packet.getSize() - packet.getReadPos() < 8) return; + const uint32_t questId = packet.readUInt32(); + const uint32_t poiCount = packet.readUInt32(); + + // Remove any previously added POI markers for this quest to avoid duplicates + // on repeated queries (e.g. zone change or force-refresh). + gossipPois_.erase( + std::remove_if(gossipPois_.begin(), gossipPois_.end(), + [questId, this](const GossipPoi& p) { + // Match by questId embedded in data field (set below). + return p.data == questId; + }), + gossipPois_.end()); + + // Find the quest title for the marker label. + std::string questTitle; + for (const auto& q : questLog_) { + if (q.questId == questId) { questTitle = q.title; break; } + } + + for (uint32_t pi = 0; pi < poiCount; ++pi) { + if (packet.getSize() - packet.getReadPos() < 28) return; + packet.readUInt32(); // poiId + packet.readUInt32(); // objIndex (int32) + const uint32_t mapId = packet.readUInt32(); + packet.readUInt32(); // areaId + packet.readUInt32(); // floorId + packet.readUInt32(); // unk1 + packet.readUInt32(); // unk2 + const uint32_t pointCount = packet.readUInt32(); + if (pointCount == 0) continue; + if (packet.getSize() - packet.getReadPos() < pointCount * 8) return; + // Compute centroid of the poi region to place a minimap marker. + float sumX = 0.0f, sumY = 0.0f; + for (uint32_t pt = 0; pt < pointCount; ++pt) { + const int32_t px = static_cast(packet.readUInt32()); + const int32_t py = static_cast(packet.readUInt32()); + sumX += static_cast(px); + sumY += static_cast(py); + } + // Skip POIs for maps other than the player's current map. + if (mapId != currentMapId_) continue; + // Add as a GossipPoi; use data field to carry questId for later dedup. + GossipPoi poi; + poi.x = sumX / static_cast(pointCount); // WoW canonical X + poi.y = sumY / static_cast(pointCount); // WoW canonical Y + poi.icon = 6; // generic quest POI icon + poi.data = questId; // used for dedup on subsequent queries + poi.name = questTitle.empty() ? "Quest objective" : questTitle; + LOG_DEBUG("Quest POI: questId=", questId, " mapId=", mapId, + " centroid=(", poi.x, ",", poi.y, ") title=", poi.name); + gossipPois_.push_back(std::move(poi)); + } + } +} + void GameHandler::handleQuestDetails(network::Packet& packet) { QuestDetailsData data; bool ok = packetParsers_ ? packetParsers_->parseQuestDetails(packet, data) @@ -14606,6 +15279,10 @@ void GameHandler::handleQuestDetails(network::Packet& packet) { } break; } + // Pre-fetch item info for all reward items so icons and names are ready + // by the time the offer-reward dialog opens (after the player turns in). + for (const auto& item : data.rewardChoiceItems) queryItemInfo(item.itemId, 0); + for (const auto& item : data.rewardItems) queryItemInfo(item.itemId, 0); questDetailsOpen = true; gossipWindowOpen = false; } @@ -14645,15 +15322,38 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; - std::unordered_set serverQuestIds; - serverQuestIds.reserve(25); + + // Collect quest IDs and their completion state from update fields. + // State field (slot*stride+1) uses the same QuestStatus enum across all expansions: + // 0 = none, 1 = complete (ready to turn in), 3 = incomplete/active, etc. + static constexpr uint32_t kQuestStatusComplete = 1; + + std::unordered_map serverQuestComplete; // questId → complete + serverQuestComplete.reserve(25); for (uint16_t slot = 0; slot < 25; ++slot) { - const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t stateField = ufQuestStart + slot * qStride + 1; auto it = lastPlayerFields_.find(idField); if (it == lastPlayerFields_.end()) continue; - if (it->second != 0) serverQuestIds.insert(it->second); + uint32_t questId = it->second; + if (questId == 0) continue; + + bool complete = false; + if (qStride >= 2) { + auto stateIt = lastPlayerFields_.find(stateField); + if (stateIt != lastPlayerFields_.end()) { + // Lower byte is the quest state; treat any variant of "complete" as done. + uint32_t state = stateIt->second & 0xFF; + complete = (state == kQuestStatusComplete); + } + } + serverQuestComplete[questId] = complete; } + std::unordered_set serverQuestIds; + serverQuestIds.reserve(serverQuestComplete.size()); + for (const auto& [qid, _] : serverQuestComplete) serverQuestIds.insert(qid); + const size_t localBefore = questLog_.size(); std::erase_if(questLog_, [&](const QuestLogEntry& q) { return q.questId == 0 || serverQuestIds.count(q.questId) == 0; @@ -14667,6 +15367,20 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { ++added; } + // Apply server-authoritative completion state to all tracked quests. + // This initialises quest.complete correctly on login for quests that were + // already complete before the current session started. + size_t marked = 0; + for (auto& quest : questLog_) { + auto it = serverQuestComplete.find(quest.questId); + if (it == serverQuestComplete.end()) continue; + if (it->second && !quest.complete) { + quest.complete = true; + ++marked; + LOG_DEBUG("Quest ", quest.questId, " marked complete from update fields"); + } + } + if (forceQueryMetadata) { for (uint32_t questId : serverQuestIds) { requestQuestQuery(questId, false); @@ -14674,10 +15388,119 @@ bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { } LOG_INFO("Quest log resync from server slots: server=", serverQuestIds.size(), - " localBefore=", localBefore, " removed=", removed, " added=", added); + " localBefore=", localBefore, " removed=", removed, " added=", added, + " markedComplete=", marked); return true; } +// Apply quest completion state from player update fields to already-tracked local quests. +// Called from VALUES update handler so quests that complete mid-session (or that were +// complete on login) get quest.complete=true without waiting for SMSG_QUESTUPDATE_COMPLETE. +void GameHandler::applyQuestStateFromFields(const std::map& fields) { + const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); + if (ufQuestStart == 0xFFFF || questLog_.empty()) return; + + const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; + if (qStride < 2) return; // Need at least 2 fields per slot (id + state) + + static constexpr uint32_t kQuestStatusComplete = 1; + + for (uint16_t slot = 0; slot < 25; ++slot) { + const uint16_t idField = ufQuestStart + slot * qStride; + const uint16_t stateField = idField + 1; + auto idIt = fields.find(idField); + if (idIt == fields.end()) continue; + uint32_t questId = idIt->second; + if (questId == 0) continue; + + auto stateIt = fields.find(stateField); + if (stateIt == fields.end()) continue; + bool serverComplete = ((stateIt->second & 0xFF) == kQuestStatusComplete); + if (!serverComplete) continue; + + for (auto& quest : questLog_) { + if (quest.questId == questId && !quest.complete) { + quest.complete = true; + LOG_INFO("Quest ", questId, " marked complete from VALUES update field state"); + break; + } + } + } +} + +// Extract packed 6-bit kill/objective counts from WotLK/TBC/Classic quest-log update fields +// and populate quest.killCounts + quest.itemCounts using the structured objectives obtained +// from a prior SMSG_QUEST_QUERY_RESPONSE. Silently does nothing if objectives are absent. +void GameHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { + if (lastPlayerFields_.empty()) return; + + const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); + if (ufQuestStart == 0xFFFF) return; + + const uint8_t qStride = packetParsers_ ? packetParsers_->questLogStride() : 5; + if (qStride < 3) return; // Need at least id + state + packed-counts field + + // Find which server slot this quest occupies. + int slot = findQuestLogSlotIndexFromServer(quest.questId); + if (slot < 0) return; + + // Packed count fields: stride+2 (all expansions), stride+3 (WotLK only, stride==5) + const uint16_t countField1 = ufQuestStart + static_cast(slot) * qStride + 2; + const uint16_t countField2 = (qStride >= 5) + ? static_cast(countField1 + 1) + : static_cast(0xFFFF); + + auto f1It = lastPlayerFields_.find(countField1); + if (f1It == lastPlayerFields_.end()) return; + const uint32_t packed1 = f1It->second; + + uint32_t packed2 = 0; + if (countField2 != 0xFFFF) { + auto f2It = lastPlayerFields_.find(countField2); + if (f2It != lastPlayerFields_.end()) packed2 = f2It->second; + } + + // Unpack six 6-bit counts (bit fields 0-5, 6-11, 12-17, 18-23 in packed1; + // bits 0-5, 6-11 in packed2 for objectives 4 and 5). + auto unpack6 = [](uint32_t word, int idx) -> uint8_t { + return static_cast((word >> (idx * 6)) & 0x3F); + }; + const uint8_t counts[6] = { + unpack6(packed1, 0), unpack6(packed1, 1), + unpack6(packed1, 2), unpack6(packed1, 3), + unpack6(packed2, 0), unpack6(packed2, 1), + }; + + // Apply kill objective counts (indices 0-3). + for (int i = 0; i < 4; ++i) { + const auto& obj = quest.killObjectives[i]; + if (obj.npcOrGoId == 0 || obj.required == 0) continue; + // Negative npcOrGoId means game object; use absolute value as the map key + // (SMSG_QUESTUPDATE_ADD_KILL always sends a positive entry regardless of type). + const uint32_t entryKey = static_cast( + obj.npcOrGoId > 0 ? obj.npcOrGoId : -obj.npcOrGoId); + // Don't overwrite live kill count with stale packed data if already non-zero. + if (counts[i] == 0 && quest.killCounts.count(entryKey)) continue; + quest.killCounts[entryKey] = {counts[i], obj.required}; + LOG_DEBUG("Quest ", quest.questId, " objective[", i, "]: npcOrGo=", + obj.npcOrGoId, " count=", (int)counts[i], "/", obj.required); + } + + // Apply item objective counts (only available in WotLK stride+3 positions 4-5). + // Item counts also arrive live via SMSG_QUESTUPDATE_ADD_ITEM; just initialise here. + for (int i = 0; i < 6; ++i) { + const auto& obj = quest.itemObjectives[i]; + if (obj.itemId == 0 || obj.required == 0) continue; + if (i < 2 && qStride >= 5) { + uint8_t cnt = counts[4 + i]; + if (cnt > 0) { + quest.itemCounts[obj.itemId] = std::max(quest.itemCounts[obj.itemId], static_cast(cnt)); + } + } + quest.requiredItemCounts.emplace(obj.itemId, obj.required); + } +} + void GameHandler::clearPendingQuestAccept(uint32_t questId) { pendingQuestAcceptTimeouts_.erase(questId); pendingQuestAcceptNpcGuids_.erase(questId); @@ -14780,6 +15603,12 @@ void GameHandler::abandonQuest(uint32_t questId) { if (localIndex >= 0) { questLog_.erase(questLog_.begin() + static_cast(localIndex)); } + + // Remove any quest POI minimap markers for this quest. + gossipPois_.erase( + std::remove_if(gossipPois_.begin(), gossipPois_.end(), + [questId](const GossipPoi& p) { return p.data == questId; }), + gossipPois_.end()); } void GameHandler::handleQuestRequestItems(network::Packet& packet) { @@ -16158,6 +16987,10 @@ void GameHandler::handleNewWorld(network::Packet& packet) { entityManager.clear(); hostileAttackers_.clear(); worldStates_.clear(); + // Quest POI markers are map-specific; remove those that don't apply to the new map. + // Markers without a questId tag (data==0) are gossip-window POIs — keep them cleared + // here since gossipWindowOpen is reset on teleport anyway. + gossipPois_.clear(); worldStateMapId_ = mapId; worldStateZoneId_ = 0; activeAreaTriggers_.clear(); @@ -17396,18 +18229,31 @@ void GameHandler::extractSkillFields(const std::map& fields) } void GameHandler::extractExploredZoneFields(const std::map& fields) { + // Number of explored-zone uint32 fields varies by expansion: + // Classic/Turtle = 64, TBC/WotLK = 128. Always allocate 128 for world-map + // bit lookups, but only read the expansion-specific count to avoid reading + // player money or rest-XP fields as zone flags. + const size_t zoneCount = packetParsers_ + ? static_cast(packetParsers_->exploredZonesCount()) + : PLAYER_EXPLORED_ZONES_COUNT; + if (playerExploredZones_.size() != PLAYER_EXPLORED_ZONES_COUNT) { playerExploredZones_.assign(PLAYER_EXPLORED_ZONES_COUNT, 0u); } bool foundAny = false; - for (size_t i = 0; i < PLAYER_EXPLORED_ZONES_COUNT; i++) { + for (size_t i = 0; i < zoneCount; i++) { const uint16_t fieldIdx = static_cast(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i); auto it = fields.find(fieldIdx); if (it == fields.end()) continue; playerExploredZones_[i] = it->second; foundAny = true; } + // Zero out slots beyond the expansion's zone count to prevent stale data + // from polluting the fog-of-war display. + for (size_t i = zoneCount; i < PLAYER_EXPLORED_ZONES_COUNT; i++) { + playerExploredZones_[i] = 0u; + } if (foundAny) { hasPlayerExploredZones_ = true; @@ -18456,13 +19302,17 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { break; } case 2: // OPEN_WINDOW + myTradeSlots_.fill(TradeSlot{}); + peerTradeSlots_.fill(TradeSlot{}); + myTradeGold_ = 0; + peerTradeGold_ = 0; tradeStatus_ = TradeStatus::Open; addSystemChatMessage("Trade window opened."); break; case 3: // CANCELLED case 9: // REJECTED case 12: // CLOSE_WINDOW - tradeStatus_ = TradeStatus::None; + resetTradeState(); addSystemChatMessage("Trade cancelled."); break; case 4: // ACCEPTED (partner accepted) @@ -18470,9 +19320,8 @@ void GameHandler::handleTradeStatus(network::Packet& packet) { addSystemChatMessage("Trade accepted. Awaiting other player..."); break; case 8: // COMPLETE - tradeStatus_ = TradeStatus::Complete; addSystemChatMessage("Trade complete!"); - tradeStatus_ = TradeStatus::None; // reset after notification + resetTradeState(); break; case 7: // BACK_TO_TRADE (unaccepted after a change) tradeStatus_ = TradeStatus::Open; @@ -18511,10 +19360,109 @@ void GameHandler::acceptTrade() { void GameHandler::cancelTrade() { if (!socket) return; - tradeStatus_ = TradeStatus::None; + resetTradeState(); socket->send(CancelTradePacket::build()); } +void GameHandler::setTradeItem(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { + if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return; + socket->send(SetTradeItemPacket::build(tradeSlot, bag, bagSlot)); +} + +void GameHandler::clearTradeItem(uint8_t tradeSlot) { + if (!isTradeOpen() || !socket || tradeSlot >= TRADE_SLOT_COUNT) return; + myTradeSlots_[tradeSlot] = TradeSlot{}; + socket->send(ClearTradeItemPacket::build(tradeSlot)); +} + +void GameHandler::setTradeGold(uint64_t copper) { + if (!isTradeOpen() || !socket) return; + myTradeGold_ = copper; + socket->send(SetTradeGoldPacket::build(copper)); +} + +void GameHandler::resetTradeState() { + tradeStatus_ = TradeStatus::None; + myTradeGold_ = 0; + peerTradeGold_ = 0; + myTradeSlots_.fill(TradeSlot{}); + peerTradeSlots_.fill(TradeSlot{}); +} + +void GameHandler::handleTradeStatusExtended(network::Packet& packet) { + // WotLK 3.3.5a SMSG_TRADE_STATUS_EXTENDED format: + // uint8 isSelfState (1 = my trade window, 0 = peer's) + // uint32 tradeId + // uint32 slotCount (7: 6 normal + 1 extra for enchanting) + // Per slot (up to slotCount): + // uint8 slotIndex + // uint32 itemId + // uint32 displayId + // uint32 stackCount + // uint8 isWrapped + // uint64 giftCreatorGuid + // uint32 enchantId (and several more enchant/stat fields) + // ... (complex; we parse only the essential fields) + // uint64 coins (gold offered by the sender of this message) + + size_t rem = packet.getSize() - packet.getReadPos(); + if (rem < 9) return; + + uint8_t isSelf = packet.readUInt8(); + uint32_t tradeId = packet.readUInt32(); (void)tradeId; + uint32_t slotCount= packet.readUInt32(); + + auto& slots = isSelf ? myTradeSlots_ : peerTradeSlots_; + + for (uint32_t i = 0; i < slotCount && (packet.getSize() - packet.getReadPos()) >= 14; ++i) { + uint8_t slotIdx = packet.readUInt8(); + uint32_t itemId = packet.readUInt32(); + uint32_t displayId = packet.readUInt32(); + uint32_t stackCount = packet.readUInt32(); + + bool isWrapped = false; + if (packet.getSize() - packet.getReadPos() >= 1) { + isWrapped = (packet.readUInt8() != 0); + } + // AzerothCore 3.3.5a SendUpdateTrade() field order after isWrapped: + // giftCreatorGuid (8) + PERM enchant (4) + SOCK enchants×3 (12) + // + BONUS enchant (4) + TEMP enchant (4) [total enchants: 24] + // + randomPropertyId (4) + suffixFactor (4) + // + durability (4) + maxDurability (4) + createPlayedTime (4) = 52 bytes + constexpr size_t SLOT_TRAIL = 52; + if (packet.getSize() - packet.getReadPos() >= SLOT_TRAIL) { + packet.setReadPos(packet.getReadPos() + SLOT_TRAIL); + } else { + packet.setReadPos(packet.getSize()); + return; + } + (void)isWrapped; + + if (slotIdx < TRADE_SLOT_COUNT) { + TradeSlot& s = slots[slotIdx]; + s.itemId = itemId; + s.displayId = displayId; + s.stackCount = stackCount; + s.occupied = (itemId != 0); + } + } + + // Gold offered (uint64 copper) + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t coins = packet.readUInt64(); + if (isSelf) myTradeGold_ = coins; + else peerTradeGold_ = coins; + } + + // Prefetch item info for all occupied trade slots so names show immediately + for (const auto& s : slots) { + if (s.occupied && s.itemId != 0) queryItemInfo(s.itemId, 0); + } + + LOG_DEBUG("SMSG_TRADE_STATUS_EXTENDED: isSelf=", (int)isSelf, + " myGold=", myTradeGold_, " peerGold=", peerTradeGold_); +} + // --------------------------------------------------------------------------- // Group loot roll (SMSG_LOOT_ROLL / SMSG_LOOT_ROLL_WON / CMSG_LOOT_ROLL) // --------------------------------------------------------------------------- @@ -18542,6 +19490,8 @@ void GameHandler::handleLootRoll(network::Packet& packet) { pendingLootRoll_.objectGuid = objectGuid; pendingLootRoll_.slot = slot; pendingLootRoll_.itemId = itemId; + // Ensure item info is in cache; query if not + queryItemInfo(itemId, 0); // Look up item name from cache auto* info = getItemInfo(itemId); pendingLootRoll_.itemName = info ? info->name : std::to_string(itemId); @@ -18686,8 +19636,9 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { } addSystemChatMessage(buf); + earnedAchievements_.insert(achievementId); if (achievementEarnedCallback_) { - achievementEarnedCallback_(achievementId); + achievementEarnedCallback_(achievementId, achName); } } else { // Another player in the zone earned an achievement @@ -18718,6 +19669,38 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { achName.empty() ? "" : " name=", achName); } +// --------------------------------------------------------------------------- +// SMSG_ALL_ACHIEVEMENT_DATA (WotLK 3.3.5a) +// Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel +// Criteria records: repeated { uint32 id, uint64 counter, uint32 packedDate, ... } until 0xFFFFFFFF +// --------------------------------------------------------------------------- +void GameHandler::handleAllAchievementData(network::Packet& packet) { + loadAchievementNameCache(); + earnedAchievements_.clear(); + + // Parse achievement entries (id + packedDate pairs, sentinel 0xFFFFFFFF) + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (packet.getSize() - packet.getReadPos() < 4) break; + /*uint32_t date =*/ packet.readUInt32(); + earnedAchievements_.insert(id); + } + + // Skip criteria block (id + uint64 counter + uint32 date + uint32 flags until 0xFFFFFFFF) + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + // counter(8) + date(4) + unknown(4) = 16 bytes + if (packet.getSize() - packet.getReadPos() < 16) break; + packet.readUInt64(); // counter + packet.readUInt32(); // date + packet.readUInt32(); // unknown / flags + } + + LOG_INFO("SMSG_ALL_ACHIEVEMENT_DATA: loaded ", earnedAchievements_.size(), " earned achievements"); +} + // --------------------------------------------------------------------------- // Faction name cache (lazily loaded from Faction.dbc) // --------------------------------------------------------------------------- diff --git a/src/game/update_field_table.cpp b/src/game/update_field_table.cpp index 505b86e6..41473539 100644 --- a/src/game/update_field_table.cpp +++ b/src/game/update_field_table.cpp @@ -19,6 +19,7 @@ struct UFNameEntry { static const UFNameEntry kUFNames[] = { {"OBJECT_FIELD_ENTRY", UF::OBJECT_FIELD_ENTRY}, + {"OBJECT_FIELD_SCALE_X", UF::OBJECT_FIELD_SCALE_X}, {"UNIT_FIELD_TARGET_LO", UF::UNIT_FIELD_TARGET_LO}, {"UNIT_FIELD_TARGET_HI", UF::UNIT_FIELD_TARGET_HI}, {"UNIT_FIELD_BYTES_0", UF::UNIT_FIELD_BYTES_0}, @@ -36,6 +37,11 @@ static const UFNameEntry kUFNames[] = { {"UNIT_NPC_FLAGS", UF::UNIT_NPC_FLAGS}, {"UNIT_DYNAMIC_FLAGS", UF::UNIT_DYNAMIC_FLAGS}, {"UNIT_FIELD_RESISTANCES", UF::UNIT_FIELD_RESISTANCES}, + {"UNIT_FIELD_STAT0", UF::UNIT_FIELD_STAT0}, + {"UNIT_FIELD_STAT1", UF::UNIT_FIELD_STAT1}, + {"UNIT_FIELD_STAT2", UF::UNIT_FIELD_STAT2}, + {"UNIT_FIELD_STAT3", UF::UNIT_FIELD_STAT3}, + {"UNIT_FIELD_STAT4", UF::UNIT_FIELD_STAT4}, {"UNIT_END", UF::UNIT_END}, {"PLAYER_FLAGS", UF::PLAYER_FLAGS}, {"PLAYER_BYTES", UF::PLAYER_BYTES}, @@ -52,6 +58,9 @@ static const UFNameEntry kUFNames[] = { {"PLAYER_EXPLORED_ZONES_START", UF::PLAYER_EXPLORED_ZONES_START}, {"GAMEOBJECT_DISPLAYID", UF::GAMEOBJECT_DISPLAYID}, {"ITEM_FIELD_STACK_COUNT", UF::ITEM_FIELD_STACK_COUNT}, + {"ITEM_FIELD_DURABILITY", UF::ITEM_FIELD_DURABILITY}, + {"ITEM_FIELD_MAXDURABILITY", UF::ITEM_FIELD_MAXDURABILITY}, + {"PLAYER_REST_STATE_EXPERIENCE", UF::PLAYER_REST_STATE_EXPERIENCE}, {"CONTAINER_FIELD_NUM_SLOTS", UF::CONTAINER_FIELD_NUM_SLOTS}, {"CONTAINER_FIELD_SLOT_1", UF::CONTAINER_FIELD_SLOT_1}, }; diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index c3adbcb0..545f2f70 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -2177,6 +2177,35 @@ network::Packet AcceptTradePacket::build() { return packet; } +network::Packet SetTradeItemPacket::build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_ITEM)); + packet.writeUInt8(tradeSlot); + packet.writeUInt8(bag); + packet.writeUInt8(bagSlot); + LOG_DEBUG("Built CMSG_SET_TRADE_ITEM slot=", (int)tradeSlot, " bag=", (int)bag, " bagSlot=", (int)bagSlot); + return packet; +} + +network::Packet ClearTradeItemPacket::build(uint8_t tradeSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_CLEAR_TRADE_ITEM)); + packet.writeUInt8(tradeSlot); + LOG_DEBUG("Built CMSG_CLEAR_TRADE_ITEM slot=", (int)tradeSlot); + return packet; +} + +network::Packet SetTradeGoldPacket::build(uint64_t copper) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_GOLD)); + packet.writeUInt64(copper); + LOG_DEBUG("Built CMSG_SET_TRADE_GOLD copper=", copper); + return packet; +} + +network::Packet UnacceptTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_UNACCEPT_TRADE)); + LOG_DEBUG("Built CMSG_UNACCEPT_TRADE"); + return packet; +} + network::Packet InitiateTradePacket::build(uint64_t targetGuid) { network::Packet packet(wireOpcode(Opcode::CMSG_INITIATE_TRADE)); packet.writeUInt64(targetGuid); @@ -3637,11 +3666,19 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData data.title = normalizeWowTextTokens(packet.readString()); data.rewardText = normalizeWowTextTokens(packet.readString()); - if (packet.getReadPos() + 10 > packet.getSize()) { + if (packet.getReadPos() + 8 > packet.getSize()) { LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); return true; } + // After the two strings the packet contains a variable prefix (autoFinish + optional fields) + // before the emoteCount. Different expansions and server emulator versions differ: + // Classic 1.12 : uint8 autoFinish + uint32 suggestedPlayers = 5 bytes + // TBC 2.4.3 : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (variable arrays) + // WotLK 3.3.5a : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (fixed 6/4 arrays) + // Some vanilla-family servers omit autoFinish entirely (0 bytes of prefix). + // We scan prefix sizes 0..16 bytes with both fixed and variable array layouts, scoring each. + struct ParsedTail { uint32_t rewardMoney = 0; uint32_t rewardXp = 0; @@ -3649,28 +3686,27 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData std::vector fixedRewards; bool ok = false; int score = -1000; + size_t prefixSkip = 0; + bool fixedArrays = false; }; - auto parseTail = [&](size_t startPos, bool hasFlags, bool fixedArrays) -> ParsedTail { + auto parseTail = [&](size_t startPos, size_t prefixSkip, bool fixedArrays) -> ParsedTail { ParsedTail out; + out.prefixSkip = prefixSkip; + out.fixedArrays = fixedArrays; packet.setReadPos(startPos); - if (packet.getReadPos() + 1 > packet.getSize()) return out; - /*autoFinish*/ packet.readUInt8(); - if (hasFlags) { - if (packet.getReadPos() + 4 > packet.getSize()) return out; - /*flags*/ packet.readUInt32(); - } - if (packet.getReadPos() + 4 > packet.getSize()) return out; - /*suggestedPlayers*/ packet.readUInt32(); + // Skip the prefix bytes (autoFinish + optional suggestedPlayers before emoteCount) + if (packet.getReadPos() + prefixSkip > packet.getSize()) return out; + packet.setReadPos(packet.getReadPos() + prefixSkip); if (packet.getReadPos() + 4 > packet.getSize()) return out; uint32_t emoteCount = packet.readUInt32(); - if (emoteCount > 64) return out; // guard against misalignment + if (emoteCount > 32) return out; // guard against misalignment for (uint32_t i = 0; i < emoteCount; ++i) { if (packet.getReadPos() + 8 > packet.getSize()) return out; packet.readUInt32(); // delay - packet.readUInt32(); // emote + packet.readUInt32(); // emote type } if (packet.getReadPos() + 4 > packet.getSize()) return out; @@ -3688,7 +3724,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData item.choiceSlot = i; if (item.itemId > 0) { out.choiceRewards.push_back(item); - nonZeroChoice++; + ++nonZeroChoice; } } @@ -3706,7 +3742,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData item.displayInfoId = packet.readUInt32(); if (item.itemId > 0) { out.fixedRewards.push_back(item); - nonZeroFixed++; + ++nonZeroFixed; } } @@ -3717,43 +3753,56 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData out.ok = true; out.score = 0; - if (hasFlags) out.score += 1; - if (fixedArrays) out.score += 1; + // Prefer the standard WotLK/TBC 8-byte prefix (uint32 autoFinish + uint32 suggestedPlayers) + if (prefixSkip == 8) out.score += 3; + else if (prefixSkip == 5) out.score += 1; // Classic uint8 autoFinish + uint32 suggestedPlayers + // Prefer fixed arrays (WotLK/TBC servers always send 6+4 slots) + if (fixedArrays) out.score += 2; + // Valid counts if (choiceCount <= 6) out.score += 3; if (rewardCount <= 4) out.score += 3; - if (fixedArrays) { - if (nonZeroChoice <= choiceCount) out.score += 3; - if (nonZeroFixed <= rewardCount) out.score += 3; - } else { - out.score += 3; // variable arrays align naturally with count - } - if (packet.getReadPos() <= packet.getSize()) out.score += 2; + // All non-zero items are within declared counts + if (nonZeroChoice <= choiceCount) out.score += 2; + if (nonZeroFixed <= rewardCount) out.score += 2; + // No bytes left over (or only a few) size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining <= 32) out.score += 2; + if (remaining == 0) out.score += 5; + else if (remaining <= 4) out.score += 3; + else if (remaining <= 8) out.score += 2; + else if (remaining <= 16) out.score += 1; + else out.score -= static_cast(remaining / 4); + // Plausible money/XP values + if (out.rewardMoney < 5000000u) out.score += 1; // < 500g + if (out.rewardXp < 200000u) out.score += 1; // < 200k XP return out; }; size_t tailStart = packet.getReadPos(); - ParsedTail a = parseTail(tailStart, true, true); // WotLK-like (flags + fixed 6/4 arrays) - ParsedTail b = parseTail(tailStart, false, true); // no flags + fixed 6/4 arrays - ParsedTail c = parseTail(tailStart, true, false); // flags + variable arrays - ParsedTail d = parseTail(tailStart, false, false); // classic-like variable arrays + // Try prefix sizes 0..16 bytes with both fixed and variable array layouts + std::vector candidates; + candidates.reserve(34); + for (size_t skip = 0; skip <= 16; ++skip) { + candidates.push_back(parseTail(tailStart, skip, true)); // fixed arrays + candidates.push_back(parseTail(tailStart, skip, false)); // variable arrays + } const ParsedTail* best = nullptr; - for (const ParsedTail* cand : {&a, &b, &c, &d}) { - if (!cand->ok) continue; - if (!best || cand->score > best->score) best = cand; + for (const auto& cand : candidates) { + if (!cand.ok) continue; + if (!best || cand.score > best->score) best = &cand; } if (best) { data.choiceRewards = best->choiceRewards; - data.fixedRewards = best->fixedRewards; - data.rewardMoney = best->rewardMoney; - data.rewardXp = best->rewardXp; + data.fixedRewards = best->fixedRewards; + data.rewardMoney = best->rewardMoney; + data.rewardXp = best->rewardXp; } LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title, - "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size()); + "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size(), + " prefix=", (best ? best->prefixSkip : size_t(0)), + (best && best->fixedArrays ? " fixed" : " var")); return true; } diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 89b063c5..469df669 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -92,7 +92,7 @@ void AssetManager::setupFileCacheBudget() { const size_t envMaxMB = parseEnvSizeMB("WOWEE_FILE_CACHE_MAX_MB"); const size_t minBudgetBytes = 256ull * 1024ull * 1024ull; - const size_t defaultMaxBudgetBytes = 32768ull * 1024ull * 1024ull; + const size_t defaultMaxBudgetBytes = 12288ull * 1024ull * 1024ull; // 12 GB max for file cache const size_t maxBudgetBytes = (envMaxMB > 0) ? (envMaxMB * 1024ull * 1024ull) : defaultMaxBudgetBytes; diff --git a/src/rendering/amd_fsr3_runtime.cpp b/src/rendering/amd_fsr3_runtime.cpp index e7606fb6..26fc5ce1 100644 --- a/src/rendering/amd_fsr3_runtime.cpp +++ b/src/rendering/amd_fsr3_runtime.cpp @@ -64,7 +64,7 @@ std::string narrowWString(const wchar_t* msg) { std::string out; for (const wchar_t* p = msg; *p; ++p) { const wchar_t wc = *p; - if (wc >= 0 && wc <= 0x7f) { + if (wc <= 0x7f) { out.push_back(static_cast(wc)); } else { out.push_back('?'); diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index cfa6120a..77908f3a 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -369,6 +369,7 @@ void CameraController::update(float deltaTime) { // Toggle sit/crouch with X key (edge-triggered) — only when UI doesn't want keyboard // Blocked while mounted + bool prevSitting = sitting; bool xDown = !uiWantsKeyboard && input.isKeyPressed(SDL_SCANCODE_X); if (xDown && !xKeyWasDown && !mounted_) { sitting = !sitting; @@ -376,6 +377,21 @@ void CameraController::update(float deltaTime) { if (mounted_) sitting = false; xKeyWasDown = xDown; + // Stand up on any movement key or jump while sitting (WoW behaviour) + if (!uiWantsKeyboard && sitting && !movementSuppressed) { + bool anyMoveKey = + input.isKeyPressed(SDL_SCANCODE_W) || input.isKeyPressed(SDL_SCANCODE_S) || + input.isKeyPressed(SDL_SCANCODE_A) || input.isKeyPressed(SDL_SCANCODE_D) || + input.isKeyPressed(SDL_SCANCODE_Q) || input.isKeyPressed(SDL_SCANCODE_E) || + input.isKeyPressed(SDL_SCANCODE_SPACE); + if (anyMoveKey) sitting = false; + } + + // Notify server when the player stands up via local input + if (prevSitting && !sitting && standUpCallback_) { + standUpCallback_(); + } + // Update eye height based on crouch state (smooth transition) float targetEyeHeight = sitting ? CROUCH_EYE_HEIGHT : STAND_EYE_HEIGHT; float heightLerpSpeed = 10.0f * deltaTime; @@ -389,11 +405,6 @@ void CameraController::update(float deltaTime) { if (nowStrafeLeft) movement += right; if (nowStrafeRight) movement -= right; - // Stand up if jumping while crouched - if (!uiWantsKeyboard && sitting && input.isKeyPressed(SDL_SCANCODE_SPACE)) { - sitting = false; - } - // Third-person orbit camera mode if (thirdPerson && followTarget) { // Move the follow target (character position) instead of the camera diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 5683af91..82e4ff89 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1327,12 +1327,12 @@ VkTexture* CharacterRenderer::compositeWithRegions(const std::string& basePath, blitOverlay(composite, width, height, overlay, dstX, dstY); } } else { + // Size mismatch — blit at natural size (may clip or leave gap) + core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx, + " at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height, + " expected=", expectedW, "x", expectedH, " from ", rl.second); blitOverlay(composite, width, height, overlay, dstX, dstY); } - - core::Logger::getInstance().warning("compositeWithRegions: region ", regionIdx, - " at (", dstX, ",", dstY, ") overlay=", overlay.width, "x", overlay.height, - " expected=", expectedW, "x", expectedH, " from ", rl.second); } // Upload to GPU via VkTexture @@ -1580,12 +1580,20 @@ void CharacterRenderer::playAnimation(uint32_t instanceId, uint32_t animationId, instance.animationTime = 0.0f; instance.animationLoop = loop; + // Prefer variationIndex==0 (primary animation); fall back to first match + int firstMatch = -1; for (size_t i = 0; i < model.sequences.size(); i++) { if (model.sequences[i].id == animationId) { - instance.currentSequenceIndex = static_cast(i); - break; + if (firstMatch < 0) firstMatch = static_cast(i); + if (model.sequences[i].variationIndex == 0) { + instance.currentSequenceIndex = static_cast(i); + break; + } } } + if (instance.currentSequenceIndex < 0 && firstMatch >= 0) { + instance.currentSequenceIndex = firstMatch; + } if (instance.currentSequenceIndex < 0) { // Fall back to first sequence diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index b079f50a..b9a52c3e 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -743,10 +743,16 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) { VmaAllocator alloc = vkCtx_->getAllocator(); if (model.vertexBuffer) { vmaDestroyBuffer(alloc, model.vertexBuffer, model.vertexAlloc); model.vertexBuffer = VK_NULL_HANDLE; } if (model.indexBuffer) { vmaDestroyBuffer(alloc, model.indexBuffer, model.indexAlloc); model.indexBuffer = VK_NULL_HANDLE; } + VkDevice device = vkCtx_->getDevice(); for (auto& batch : model.batches) { + if (batch.materialSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &batch.materialSet); batch.materialSet = VK_NULL_HANDLE; } if (batch.materialUBO) { vmaDestroyBuffer(alloc, batch.materialUBO, batch.materialUBOAlloc); batch.materialUBO = VK_NULL_HANDLE; } - // materialSet freed when pool is reset/destroyed } + // Free pre-allocated particle texture descriptor sets + for (auto& pSet : model.particleTexSets) { + if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; } + } + model.particleTexSets.clear(); } void M2Renderer::destroyInstanceBones(M2Instance& inst) { @@ -979,8 +985,16 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("monument") != std::string::npos) || (lowerName.find("sculpture") != std::string::npos); gpuModel.collisionStatue = statueName; + // Sittable furniture: chairs/benches/stools cause players to get stuck against + // invisible bounding boxes; WMOs already handle room collision. + bool sittableFurnitureName = + (lowerName.find("chair") != std::string::npos) || + (lowerName.find("bench") != std::string::npos) || + (lowerName.find("stool") != std::string::npos) || + (lowerName.find("seat") != std::string::npos) || + (lowerName.find("throne") != std::string::npos); bool smallSolidPropName = - statueName || + (statueName && !sittableFurnitureName) || (lowerName.find("crate") != std::string::npos) || (lowerName.find("box") != std::string::npos) || (lowerName.find("chest") != std::string::npos) || @@ -1023,6 +1037,10 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("bamboo") != std::string::npos) || (lowerName.find("banana") != std::string::npos) || (lowerName.find("coconut") != std::string::npos) || + (lowerName.find("watermelon") != std::string::npos) || + (lowerName.find("melon") != std::string::npos) || + (lowerName.find("squash") != std::string::npos) || + (lowerName.find("gourd") != std::string::npos) || (lowerName.find("canopy") != std::string::npos) || (lowerName.find("hedge") != std::string::npos) || (lowerName.find("cactus") != std::string::npos) || @@ -1148,7 +1166,8 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { (lowerName.find("lavasplash") != std::string::npos) || (lowerName.find("lavabubble") != std::string::npos) || (lowerName.find("lavasteam") != std::string::npos) || - (lowerName.find("wisps") != std::string::npos); + (lowerName.find("wisps") != std::string::npos) || + (lowerName.find("levelup") != std::string::npos); gpuModel.isSpellEffect = effectByName || (hasParticles && model.vertices.size() <= 200 && model.particleEmitters.size() >= 3); @@ -1335,6 +1354,31 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Pre-allocate one stable descriptor set per particle emitter to avoid per-frame allocation. + // This prevents materialDescPool_ exhaustion when many emitters are active each frame. + if (particleTexLayout_ && materialDescPool_ && !model.particleEmitters.empty()) { + VkDevice device = vkCtx_->getDevice(); + gpuModel.particleTexSets.resize(model.particleEmitters.size(), VK_NULL_HANDLE); + for (size_t ei = 0; ei < model.particleEmitters.size(); ei++) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(device, &ai, &gpuModel.particleTexSets[ei]) == VK_SUCCESS) { + VkTexture* tex = gpuModel.particleTextures[ei]; + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = gpuModel.particleTexSets[ei]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + // Copy texture transform data for UV animation gpuModel.textureTransforms = model.textureTransforms; gpuModel.textureTransformLookup = model.textureTransformLookup; @@ -3401,6 +3445,7 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame uint8_t blendType; uint16_t tilesX; uint16_t tilesY; + VkDescriptorSet preAllocSet = VK_NULL_HANDLE; // Pre-allocated stable set, avoids per-frame alloc std::vector vertexData; // 9 floats per particle }; std::unordered_map groups; @@ -3442,6 +3487,11 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame group.blendType = em.blendingType; group.tilesX = tilesX; group.tilesY = tilesY; + // Capture pre-allocated descriptor set on first insertion for this key + if (group.preAllocSet == VK_NULL_HANDLE && + p.emitterIndex < static_cast(gpu.particleTexSets.size())) { + group.preAllocSet = gpu.particleTexSets[p.emitterIndex]; + } group.vertexData.push_back(p.position.x); group.vertexData.push_back(p.position.y); @@ -3485,23 +3535,27 @@ void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrame currentPipeline = desiredPipeline; } - // Allocate descriptor set for this group's texture - VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; - ai.descriptorPool = materialDescPool_; - ai.descriptorSetCount = 1; - ai.pSetLayouts = &particleTexLayout_; - VkDescriptorSet texSet = VK_NULL_HANDLE; - if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { - VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); - VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); - VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; - write.dstSet = texSet; - write.dstBinding = 0; - write.descriptorCount = 1; - write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - write.pImageInfo = &imgInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); - + // Use pre-allocated stable descriptor set; fall back to per-frame alloc only if unavailable + VkDescriptorSet texSet = group.preAllocSet; + if (texSet == VK_NULL_HANDLE) { + // Fallback: allocate per-frame (pool exhaustion risk — should not happen in practice) + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { + VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = texSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); + } + } + if (texSet != VK_NULL_HANDLE) { vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particlePipelineLayout_, 1, 1, &texSet, 0, nullptr); } diff --git a/src/rendering/quest_marker_renderer.cpp b/src/rendering/quest_marker_renderer.cpp index bc481d5a..d9aa3886 100644 --- a/src/rendering/quest_marker_renderer.cpp +++ b/src/rendering/quest_marker_renderer.cpp @@ -17,8 +17,9 @@ namespace wowee { namespace rendering { // Push constant layout matching quest_marker.vert.glsl / quest_marker.frag.glsl struct QuestMarkerPushConstants { - glm::mat4 model; // 64 bytes, used by vertex shader - float alpha; // 4 bytes, used by fragment shader + glm::mat4 model; // 64 bytes, used by vertex shader + float alpha; // 4 bytes, used by fragment shader + float grayscale; // 4 bytes: 0=colour, 1=desaturated (trivial quests) }; QuestMarkerRenderer::QuestMarkerRenderer() { @@ -340,8 +341,9 @@ void QuestMarkerRenderer::loadTextures(pipeline::AssetManager* assetManager) { } } -void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, float boundingHeight) { - markers_[guid] = {position, markerType, boundingHeight}; +void QuestMarkerRenderer::setMarker(uint64_t guid, const glm::vec3& position, int markerType, + float boundingHeight, float grayscale) { + markers_[guid] = {position, markerType, boundingHeight, grayscale}; } void QuestMarkerRenderer::removeMarker(uint64_t guid) { @@ -436,10 +438,11 @@ void QuestMarkerRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSe vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, pipelineLayout_, 1, 1, &texDescSets_[marker.type], 0, nullptr); - // Push constants: model matrix + alpha + // Push constants: model matrix + alpha + grayscale tint QuestMarkerPushConstants push{}; push.model = model; push.alpha = fadeAlpha; + push.grayscale = marker.grayscale; vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 71cb2a7c..c06e5bed 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -710,6 +710,8 @@ bool Renderer::initialize(core::Window* win) { levelUpEffect = std::make_unique(); + questMarkerRenderer = std::make_unique(); + LOG_INFO("Vulkan sub-renderers initialized (Phase 3)"); // LightingManager doesn't use GL — initialize for data-only use @@ -2222,6 +2224,14 @@ void Renderer::updateCharacterAnimation() { } else if (sitting) { cancelEmote(); newState = CharAnimState::SIT_DOWN; + } else if (!emoteLoop && characterRenderer && characterInstanceId > 0) { + // Auto-cancel non-looping emotes once animation completes + uint32_t curId = 0; float curT = 0.0f, curDur = 0.0f; + if (characterRenderer->getAnimationState(characterInstanceId, curId, curT, curDur) + && curDur > 0.1f && curT >= curDur - 0.05f) { + cancelEmote(); + newState = CharAnimState::IDLE; + } } break; @@ -4845,7 +4855,11 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + // Server orientation is in WoW space: π/2 = North, 0 = East. + // Minimap arrow expects render space: 0 = North, π/2 = East. + // Convert: minimap_angle = server_orientation - π/2 + minimapPlayerOrientation = gameHandler->getMovementInfo().orientation + - static_cast(M_PI_2); hasMinimapPlayerOrientation = true; } minimap->render(cmd, *camera, minimapCenter, @@ -4973,7 +4987,11 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation; + // Server orientation is in WoW space: π/2 = North, 0 = East. + // Minimap arrow expects render space: 0 = North, π/2 = East. + // Convert: minimap_angle = server_orientation - π/2 + minimapPlayerOrientation = gameHandler->getMovementInfo().orientation + - static_cast(M_PI_2); hasMinimapPlayerOrientation = true; } minimap->render(currentCmd, *camera, minimapCenter, diff --git a/src/rendering/terrain_renderer.cpp b/src/rendering/terrain_renderer.cpp index 4e8593f5..3af644cf 100644 --- a/src/rendering/terrain_renderer.cpp +++ b/src/rendering/terrain_renderer.cpp @@ -89,6 +89,7 @@ bool TerrainRenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameL VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; poolInfo.maxSets = MAX_MATERIAL_SETS; poolInfo.poolSizeCount = 2; poolInfo.pPoolSizes = poolSizes; @@ -1034,6 +1035,10 @@ void TerrainRenderer::destroyChunkGPU(TerrainChunkGPU& chunk) { destroyBuffer(allocator, ab); chunk.paramsUBO = VK_NULL_HANDLE; } + // Return material descriptor set to the pool so it can be reused by new chunks + if (chunk.materialSet && materialDescPool) { + vkFreeDescriptorSets(vkCtx->getDevice(), materialDescPool, 1, &chunk.materialSet); + } chunk.materialSet = VK_NULL_HANDLE; // Destroy owned alpha textures (VkTexture::~VkTexture is a no-op, must call destroy() explicitly) diff --git a/src/rendering/vk_context.cpp b/src/rendering/vk_context.cpp index dc4144fa..fdd07d8e 100644 --- a/src/rendering/vk_context.cpp +++ b/src/rendering/vk_context.cpp @@ -1051,14 +1051,21 @@ bool VkContext::recreateSwapchain(int width, int height) { auto swapRet = builder.build(); - if (oldSwapchain) { - vkDestroySwapchainKHR(device, oldSwapchain, nullptr); + if (!swapRet) { + // Destroy old swapchain now that we failed (it can't be used either) + if (oldSwapchain) { + vkDestroySwapchainKHR(device, oldSwapchain, nullptr); + swapchain = VK_NULL_HANDLE; + } + LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message()); + // Keep swapchainDirty=true so the next frame retries + swapchainDirty = true; + return false; } - if (!swapRet) { - LOG_ERROR("Failed to recreate swapchain: ", swapRet.error().message()); - swapchain = VK_NULL_HANDLE; - return false; + // Success — safe to retire the old swapchain + if (oldSwapchain) { + vkDestroySwapchainKHR(device, oldSwapchain, nullptr); } auto vkbSwap = swapRet.value(); @@ -1322,6 +1329,7 @@ bool VkContext::recreateSwapchain(int width, int height) { VkCommandBuffer VkContext::beginFrame(uint32_t& imageIndex) { if (deviceLost_) return VK_NULL_HANDLE; + if (swapchain == VK_NULL_HANDLE) return VK_NULL_HANDLE; // Swapchain lost; recreate pending auto& frame = frames[currentFrame]; diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 85f56431..84c7f956 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -124,6 +124,7 @@ bool WMORenderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayou VkDescriptorPoolCreateInfo poolInfo{}; poolInfo.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolInfo.flags = VK_DESCRIPTOR_POOL_CREATE_FREE_DESCRIPTOR_SET_BIT; poolInfo.maxSets = MAX_MATERIAL_SETS; poolInfo.poolSizeCount = 2; poolInfo.pPoolSizes = poolSizes; @@ -1946,8 +1947,13 @@ void WMORenderer::destroyGroupGPU(GroupResources& group) { group.indexAlloc = VK_NULL_HANDLE; } - // Destroy material UBOs (descriptor sets are freed when pool is reset/destroyed) + // Destroy material UBOs and free descriptor sets back to pool + VkDevice device = vkCtx_->getDevice(); for (auto& mb : group.mergedBatches) { + if (mb.materialSet) { + vkFreeDescriptorSets(device, materialDescPool_, 1, &mb.materialSet); + mb.materialSet = VK_NULL_HANDLE; + } if (mb.materialUBO) { vmaDestroyBuffer(allocator, mb.materialUBO, mb.materialUBOAlloc); mb.materialUBO = VK_NULL_HANDLE; diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index a1debba9..cf4c70fd 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -233,6 +233,10 @@ void WorldMap::setMapName(const std::string& name) { void WorldMap::setServerExplorationMask(const std::vector& masks, bool hasData) { if (!hasData || masks.empty()) { + // New session or no data yet — reset both server mask and local accumulation + if (hasServerExplorationMask) { + locallyExploredZones_.clear(); + } hasServerExplorationMask = false; serverExplorationMask.clear(); return; @@ -765,9 +769,12 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { } if (markedAny) return; + // Server mask unavailable or empty — fall back to locally-accumulated position tracking. + // Add the zone the player is currently in to the local set and display that. float wowX = playerRenderPos.y; float wowY = playerRenderPos.x; + bool foundPos = false; for (int i = 0; i < static_cast(zones.size()); i++) { const auto& z = zones[i]; if (z.areaID == 0) continue; @@ -775,15 +782,18 @@ void WorldMap::updateExploration(const glm::vec3& playerRenderPos) { float minY = std::min(z.locTop, z.locBottom), maxY = std::max(z.locTop, z.locBottom); if (maxX - minX < 0.001f || maxY - minY < 0.001f) continue; if (wowX >= minX && wowX <= maxX && wowY >= minY && wowY <= maxY) { - exploredZones.insert(i); - markedAny = true; + locallyExploredZones_.insert(i); + foundPos = true; } } - if (!markedAny) { + if (!foundPos) { int zoneIdx = findZoneForPlayer(playerRenderPos); - if (zoneIdx >= 0) exploredZones.insert(zoneIdx); + if (zoneIdx >= 0) locallyExploredZones_.insert(zoneIdx); } + + // Display the accumulated local set + exploredZones = locallyExploredZones_; } void WorldMap::zoomIn(const glm::vec3& playerRenderPos) { diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 3b3c7216..52e056fb 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -380,6 +380,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderTargetFrame(gameHandler); } + // Focus target frame (only when we have a focus) + if (gameHandler.hasFocus()) { + renderFocusFrame(gameHandler); + } + // Render windows if (showPlayerInfo) { renderPlayerInfo(gameHandler); @@ -409,11 +414,14 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderDuelRequestPopup(gameHandler); renderLootRollPopup(gameHandler); renderTradeRequestPopup(gameHandler); + renderTradeWindow(gameHandler); renderSummonRequestPopup(gameHandler); renderSharedQuestPopup(gameHandler); renderItemTextWindow(gameHandler); renderGuildInvitePopup(gameHandler); renderReadyCheckPopup(gameHandler); + renderBgInvitePopup(gameHandler); + renderLfgProposalPopup(gameHandler); renderGuildRoster(gameHandler); renderBuffBar(gameHandler); renderLootWindow(gameHandler); @@ -1683,11 +1691,11 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { heightOffset = 0.3f; } } else if (t == game::ObjectType::GAMEOBJECT) { - // Do not hard-filter by GO type here. Some realms/content - // classify usable objects (including some chests) with types - // that look decorative in cache data. - hitRadius = 2.5f; - heightOffset = 1.2f; + // For GOs with no renderer instance yet, use a tight fallback + // sphere (not 2.5f) so invisible/unloaded GOs (chairs, doodads) + // are not accidentally clicked during camera right-drag. + hitRadius = 1.2f; + heightOffset = 1.0f; } hitCenter = core::coords::canonicalToRender( glm::vec3(entity->getX(), entity->getY(), entity->getZ())); @@ -2458,6 +2466,10 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { if (totEntity->getType() == game::ObjectType::UNIT || totEntity->getType() == game::ObjectType::PLAYER) { auto totUnit = std::static_pointer_cast(totEntity); + if (totUnit->getLevel() > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("Lv%u", totUnit->getLevel()); + } uint32_t hp = totUnit->getHealth(); uint32_t maxHp = totUnit->getMaxHealth(); if (maxHp > 0) { @@ -2470,6 +2482,10 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } } + // Click to target the target-of-target + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { + gameHandler.setTarget(totGuid); + } } ImGui::End(); ImGui::PopStyleColor(2); @@ -2479,6 +2495,134 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } +void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { + auto focus = gameHandler.getFocus(); + if (!focus) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + // Position: right side of screen, mirroring the target frame on the opposite side + float frameW = 200.0f; + float frameX = screenW - frameW - 10.0f; + + ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + // Determine color based on relation (same logic as target frame) + ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f); + if (focus->getType() == game::ObjectType::PLAYER) { + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else if (focus->getType() == game::ObjectType::UNIT) { + auto u = std::static_pointer_cast(focus); + if (u->getHealth() == 0 && u->getMaxHealth() > 0) { + focusColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + } else if (u->isHostile()) { + uint32_t playerLv = gameHandler.getPlayerLevel(); + uint32_t mobLv = u->getLevel(); + int32_t diff = static_cast(mobLv) - static_cast(playerLv); + if (game::GameHandler::killXp(playerLv, mobLv) == 0) + focusColor = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); + else if (diff >= 10) + focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); + else if (diff >= 5) + focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); + else if (diff >= -2) + focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); + else + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } else { + focusColor = ImVec4(0.3f, 1.0f, 0.3f, 1.0f); + } + } + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.15f, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.9f, 0.8f)); // Blue tint = focus + + if (ImGui::Begin("##FocusFrame", nullptr, flags)) { + // "Focus" label + ImGui::TextDisabled("[Focus]"); + ImGui::SameLine(); + + std::string focusName = getEntityName(focus); + ImGui::TextColored(focusColor, "%s", focusName.c_str()); + + if (focus->getType() == game::ObjectType::UNIT || + focus->getType() == game::ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(focus); + + // Level + health on same row + ImGui::SameLine(); + ImGui::TextDisabled("Lv %u", unit->getLevel()); + + uint32_t hp = unit->getHealth(); + uint32_t maxHp = unit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? ImVec4(0.2f, 0.7f, 0.2f, 1.0f) : + pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) : + ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + char overlay[32]; + snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), overlay); + ImGui::PopStyleColor(); + + // Power bar + uint8_t pType = unit->getPowerType(); + uint32_t pwr = unit->getPower(); + uint32_t maxPwr = unit->getMaxPower(); + if (maxPwr == 0 && (pType == 1 || pType == 3)) maxPwr = 100; + if (maxPwr > 0) { + float mpPct = static_cast(pwr) / static_cast(maxPwr); + ImVec4 pwrColor; + switch (pType) { + case 0: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; + case 1: pwrColor = ImVec4(0.9f, 0.2f, 0.2f, 1.0f); break; + case 3: pwrColor = ImVec4(0.9f, 0.9f, 0.2f, 1.0f); break; + case 6: pwrColor = ImVec4(0.8f, 0.1f, 0.2f, 1.0f); break; + default: pwrColor = ImVec4(0.2f, 0.2f, 0.9f, 1.0f); break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pwrColor); + ImGui::ProgressBar(mpPct, ImVec2(-1, 10), ""); + ImGui::PopStyleColor(); + } + } + + // Focus cast bar + const auto* focusCast = gameHandler.getUnitCastState(focus->getGuid()); + if (focusCast) { + float total = focusCast->timeTotal > 0.f ? focusCast->timeTotal : 1.f; + float rem = focusCast->timeRemaining; + float prog = std::clamp(1.0f - rem / total, 0.f, 1.f); + const std::string& spName = gameHandler.getSpellName(focusCast->spellId); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + char castBuf[64]; + if (!spName.empty()) + snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem); + else + snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem); + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + ImGui::PopStyleColor(); + } + } + + // Clicking the focus frame targets it + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { + gameHandler.setTarget(focus->getGuid()); + } + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + void GameScreen::sendChatMessage(game::GameHandler& gameHandler) { if (strlen(chatInputBuffer) > 0) { std::string input(chatInputBuffer); @@ -4867,7 +5011,7 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { if (toShow.empty()) return; float x = screenW - TRACKER_W - RIGHT_MARGIN; - float y = 200.0f; // below minimap area + float y = 320.0f; // below minimap (210) + buff bar space (up to 3 rows ≈ 114px) ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); @@ -4905,10 +5049,15 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } else { // Kill counts for (const auto& [entry, progress] : q.killCounts) { - std::string creatureName = gameHandler.getCachedCreatureName(entry); - if (!creatureName.empty()) { + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + // May be a game object objective; fall back to GO name cache. + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo && !goInfo->name.empty()) name = goInfo->name; + } + if (!name.empty()) { ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), - " %s: %u/%u", creatureName.c_str(), + " %s: %u/%u", name.c_str(), progress.first, progress.second); } else { ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), @@ -5024,7 +5173,10 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { : ImVec4(0.4f, 0.9f, 1.0f, alpha); break; case game::CombatTextEntry::BLOCK: - snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); + if (entry.amount > 0) + snprintf(text, sizeof(text), outgoing ? "Block %d" : "You Block %d", entry.amount); + else + snprintf(text, sizeof(text), outgoing ? "Block" : "You Block"); color = outgoing ? ImVec4(0.6f, 0.6f, 0.6f, alpha) : ImVec4(0.4f, 0.9f, 1.0f, alpha); break; @@ -5054,6 +5206,20 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "Immune!"); color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune break; + case game::CombatTextEntry::ABSORB: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Absorbed %d", entry.amount); + else + snprintf(text, sizeof(text), "Absorbed"); + color = ImVec4(0.5f, 0.8f, 1.0f, alpha); // Light blue for absorb + break; + case game::CombatTextEntry::RESIST: + if (entry.amount > 0) + snprintf(text, sizeof(text), "Resisted %d", entry.amount); + else + snprintf(text, sizeof(text), "Resisted"); + color = ImVec4(0.7f, 0.7f, 0.7f, alpha); // Grey for resist + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -5094,6 +5260,27 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { const uint64_t playerGuid = gameHandler.getPlayerGuid(); const uint64_t targetGuid = gameHandler.getTargetGuid(); + // Build set of creature entries that are kill objectives in active (incomplete) quests. + std::unordered_set questKillEntries; + { + const auto& questLog = gameHandler.getQuestLog(); + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + for (const auto& q : questLog) { + if (q.complete || q.questId == 0) continue; + // Only highlight for tracked quests (or all if nothing tracked). + if (!trackedIds.empty() && !trackedIds.count(q.questId)) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId > 0 && obj.required > 0) { + // Check if not already completed. + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) { + questKillEntries.insert(static_cast(obj.npcOrGoId)); + } + } + } + } + } + ImDrawList* drawList = ImGui::GetBackgroundDrawList(); for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) { @@ -5108,9 +5295,13 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { // Player nameplates are always shown; NPC nameplates respect the V-key toggle if (!isPlayer && !showNameplates_) continue; - // Convert canonical WoW position → render space, raise to head height - glm::vec3 renderPos = core::coords::canonicalToRender( - glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + // Prefer the renderer's actual instance position so the nameplate tracks the + // rendered model exactly (avoids drift from the parallel entity interpolator). + glm::vec3 renderPos; + if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + } renderPos.z += 2.3f; // Cull distance: target or other players up to 40 units; NPC others up to 20 units @@ -5215,6 +5406,14 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { drawList->AddText(ImVec2(markX + 1.0f, nameY + 1.0f), IM_COL32(0,0,0,120), kNPMarks[raidMark].sym); drawList->AddText(ImVec2(markX, nameY), kNPMarks[raidMark].col, kNPMarks[raidMark].sym); } + + // Quest kill objective indicator: small yellow sword icon to the right of the name + if (!isPlayer && questKillEntries.count(unit->getEntry())) { + const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8) + float objX = nameX + textSize.x + 4.0f; + drawList->AddText(ImVec2(objX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); + drawList->AddText(ImVec2(objX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); + } } // Click to target: detect left-click inside the combined nameplate region @@ -5313,13 +5512,27 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { bool isDead = (m.onlineStatus & 0x0020) != 0; bool isGhost = (m.onlineStatus & 0x0010) != 0; - // Name text (truncated) + // Name text (truncated); leader name is gold char truncName[16]; snprintf(truncName, sizeof(truncName), "%.12s", m.name.c_str()); - ImU32 nameCol = (!isOnline || isDead || isGhost) - ? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255); + bool isMemberLeader = (m.guid == partyData.leaderGuid); + ImU32 nameCol = isMemberLeader ? IM_COL32(255, 215, 0, 255) : + (!isOnline || isDead || isGhost) + ? IM_COL32(140, 140, 140, 200) : IM_COL32(220, 220, 220, 255); draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); + // Leader crown star in top-right of cell + if (isMemberLeader) + draw->AddText(ImVec2(cellMax.x - 10.0f, cellMin.y + 2.0f), IM_COL32(255, 215, 0, 255), "*"); + + // LFG role badge in bottom-right corner of cell + if (m.roles & 0x02) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(80, 130, 255, 230), "T"); + else if (m.roles & 0x04) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(60, 220, 80, 230), "H"); + else if (m.roles & 0x08) + draw->AddText(ImVec2(cellMax.x - 11.0f, cellMax.y - 11.0f), IM_COL32(220, 80, 80, 230), "D"); + // Health bar uint32_t hp = m.hasPartyStats ? m.curHealth : 0; uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0; @@ -5397,11 +5610,14 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.8f)); if (ImGui::Begin("##PartyFrames", nullptr, flags)) { + const uint64_t leaderGuid = partyData.leaderGuid; for (const auto& member : partyData.members) { ImGui::PushID(static_cast(member.guid)); - // Name with level and status info - std::string label = member.name; + bool isLeader = (member.guid == leaderGuid); + + // Name with level and status info — leader gets a gold star prefix + std::string label = (isLeader ? "* " : " ") + member.name; if (member.hasPartyStats && member.level > 0) { label += " [" + std::to_string(member.level) + "]"; } @@ -5413,10 +5629,20 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { else if (isDead || isGhost) label += " (dead)"; } - // Clickable name to target + // Clickable name to target; leader name is gold + if (isLeader) ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); if (ImGui::Selectable(label.c_str(), gameHandler.getTargetGuid() == member.guid)) { gameHandler.setTarget(member.guid); } + if (isLeader) ImGui::PopStyleColor(); + + // LFG role badge (Tank/Healer/DPS) — shown on same line as name when set + if (member.roles != 0) { + ImGui::SameLine(); + if (member.roles & 0x02) ImGui::TextColored(ImVec4(0.3f, 0.5f, 1.0f, 1.0f), "[T]"); + if (member.roles & 0x04) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.2f, 0.9f, 0.3f, 1.0f), "[H]"); } + if (member.roles & 0x08) { ImGui::SameLine(); ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "[D]"); } + } // Health bar: prefer party stats, fall back to entity uint32_t hp = 0, maxHp = 0; @@ -5475,6 +5701,32 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Right-click context menu for party member actions + if (ImGui::BeginPopupContextItem("PartyMemberCtx")) { + ImGui::TextDisabled("%s", member.name.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) { + gameHandler.setTarget(member.guid); + } + if (ImGui::MenuItem("Set Focus")) { + gameHandler.setFocus(member.guid); + } + if (ImGui::MenuItem("Whisper")) { + selectedChatType = 4; // WHISPER + strncpy(whisperTargetBuffer, member.name.c_str(), sizeof(whisperTargetBuffer) - 1); + whisperTargetBuffer[sizeof(whisperTargetBuffer) - 1] = '\0'; + refocusChatInput = true; + } + if (ImGui::MenuItem("Trade")) { + gameHandler.initiateTrade(member.guid); + } + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(member.guid); + gameHandler.inspectTarget(); + } + ImGui::EndPopup(); + } + ImGui::Separator(); ImGui::PopID(); } @@ -5746,6 +5998,150 @@ void GameScreen::renderTradeRequestPopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderTradeWindow(game::GameHandler& gameHandler) { + if (!gameHandler.isTradeOpen()) return; + + const auto& mySlots = gameHandler.getMyTradeSlots(); + const auto& peerSlots = gameHandler.getPeerTradeSlots(); + const uint64_t myGold = gameHandler.getMyTradeGold(); + const uint64_t peerGold = gameHandler.getPeerTradeGold(); + const auto& peerName = gameHandler.getTradePeerName(); + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 240.0f, screenH / 2.0f - 180.0f), ImGuiCond_Once); + ImGui::SetNextWindowSize(ImVec2(480.0f, 360.0f), ImGuiCond_Once); + + bool open = true; + if (ImGui::Begin(("Trade with " + peerName).c_str(), &open, + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize)) { + + auto formatGold = [](uint64_t copper, char* buf, size_t bufsz) { + uint64_t g = copper / 10000; + uint64_t s = (copper % 10000) / 100; + uint64_t c = copper % 100; + if (g > 0) std::snprintf(buf, bufsz, "%llug %llus %lluc", + (unsigned long long)g, (unsigned long long)s, (unsigned long long)c); + else if (s > 0) std::snprintf(buf, bufsz, "%llus %lluc", + (unsigned long long)s, (unsigned long long)c); + else std::snprintf(buf, bufsz, "%lluc", (unsigned long long)c); + }; + + auto renderSlotColumn = [&](const char* label, + const std::array& slots, + uint64_t gold, bool isMine) { + ImGui::Text("%s", label); + ImGui::Separator(); + + for (int i = 0; i < game::GameHandler::TRADE_SLOT_COUNT; ++i) { + const auto& slot = slots[i]; + ImGui::PushID(i * (isMine ? 1 : -1) - (isMine ? 0 : 100)); + + if (slot.occupied && slot.itemId != 0) { + const auto* info = gameHandler.getItemInfo(slot.itemId); + std::string name = (info && info->valid && !info->name.empty()) + ? info->name + : ("Item " + std::to_string(slot.itemId)); + if (slot.stackCount > 1) + name += " x" + std::to_string(slot.stackCount); + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), " %d. %s", i + 1, name.c_str()); + + if (isMine && ImGui::IsItemHovered() && ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) { + gameHandler.clearTradeItem(static_cast(i)); + } + if (isMine && ImGui::IsItemHovered()) { + ImGui::SetTooltip("Double-click to remove"); + } + } else { + ImGui::TextDisabled(" %d. (empty)", i + 1); + + // Allow dragging inventory items into trade slots via right-click context menu + if (isMine && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup(("##additem" + std::to_string(i)).c_str()); + } + } + + if (isMine) { + // Drag-from-inventory: show small popup listing bag items + if (ImGui::BeginPopup(("##additem" + std::to_string(i)).c_str())) { + ImGui::TextDisabled("Add from inventory:"); + const auto& inv = gameHandler.getInventory(); + // Backpack slots 0-15 (bag=255) + for (int si = 0; si < game::Inventory::BACKPACK_SLOTS; ++si) { + const auto& slot = inv.getBackpackSlot(si); + if (slot.empty()) continue; + const auto* ii = gameHandler.getItemInfo(slot.item.itemId); + std::string iname = (ii && ii->valid && !ii->name.empty()) + ? ii->name + : (!slot.item.name.empty() ? slot.item.name + : ("Item " + std::to_string(slot.item.itemId))); + if (ImGui::Selectable(iname.c_str())) { + // bag=255 = main backpack + gameHandler.setTradeItem(static_cast(i), 255u, + static_cast(si)); + ImGui::CloseCurrentPopup(); + } + } + ImGui::EndPopup(); + } + } + ImGui::PopID(); + } + + // Gold row + char gbuf[48]; + formatGold(gold, gbuf, sizeof(gbuf)); + ImGui::Spacing(); + if (isMine) { + ImGui::Text("Gold offered: %s", gbuf); + static char goldInput[32] = "0"; + ImGui::SetNextItemWidth(120.0f); + if (ImGui::InputText("##goldset", goldInput, sizeof(goldInput), + ImGuiInputTextFlags_CharsDecimal | ImGuiInputTextFlags_EnterReturnsTrue)) { + uint64_t copper = std::strtoull(goldInput, nullptr, 10); + gameHandler.setTradeGold(copper); + } + ImGui::SameLine(); + ImGui::TextDisabled("(copper, Enter to set)"); + } else { + ImGui::Text("Gold offered: %s", gbuf); + } + }; + + // Two-column layout: my offer | peer offer + float colW = ImGui::GetContentRegionAvail().x * 0.5f - 4.0f; + ImGui::BeginChild("##myoffer", ImVec2(colW, 240.0f), true); + renderSlotColumn("Your offer", mySlots, myGold, true); + ImGui::EndChild(); + + ImGui::SameLine(); + + ImGui::BeginChild("##peroffer", ImVec2(colW, 240.0f), true); + renderSlotColumn((peerName + "'s offer").c_str(), peerSlots, peerGold, false); + ImGui::EndChild(); + + // Buttons + ImGui::Spacing(); + ImGui::Separator(); + float bw = (ImGui::GetContentRegionAvail().x - ImGui::GetStyle().ItemSpacing.x) * 0.5f; + if (ImGui::Button("Accept Trade", ImVec2(bw, 0))) { + gameHandler.acceptTrade(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(bw, 0))) { + gameHandler.cancelTrade(); + } + } + ImGui::End(); + + if (!open) { + gameHandler.cancelTrade(); + } +} + void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { if (!gameHandler.hasPendingLootRoll()) return; @@ -5771,7 +6167,19 @@ void GameScreen::renderLootRollPopup(game::GameHandler& gameHandler) { ImVec4 col = (q < 6) ? kQualityColors[q] : kQualityColors[1]; ImGui::Text("An item is up for rolls:"); + + // Show item icon if available + const auto* rollInfo = gameHandler.getItemInfo(roll.itemId); + uint32_t rollDisplayId = rollInfo ? rollInfo->displayInfoId : 0; + VkDescriptorSet rollIcon = rollDisplayId ? inventoryScreen.getItemIcon(rollDisplayId) : VK_NULL_HANDLE; + if (rollIcon) { + ImGui::Image((ImTextureID)(uintptr_t)rollIcon, ImVec2(24, 24)); + ImGui::SameLine(); + } ImGui::TextColored(col, "[%s]", roll.itemName.c_str()); + if (ImGui::IsItemHovered() && rollInfo && rollInfo->valid) { + inventoryScreen.renderItemTooltip(*rollInfo); + } ImGui::Spacing(); if (ImGui::Button("Need", ImVec2(80, 30))) { @@ -5851,6 +6259,148 @@ void GameScreen::renderReadyCheckPopup(game::GameHandler& gameHandler) { ImGui::End(); } +void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { + if (!gameHandler.hasPendingBgInvite()) return; + + const auto& queues = gameHandler.getBgQueues(); + // Find the first WAIT_JOIN slot + const game::GameHandler::BgQueueSlot* slot = nullptr; + for (const auto& s : queues) { + if (s.statusId == 2) { slot = &s; break; } + } + if (!slot) return; + + // Compute time remaining + auto now = std::chrono::steady_clock::now(); + double elapsed = std::chrono::duration(now - slot->inviteReceivedTime).count(); + double remaining = static_cast(slot->inviteTimeout) - elapsed; + + // If invite has expired, clear it silently (server will handle the queue) + if (remaining <= 0.0) { + gameHandler.declineBattlefield(slot->queueSlot); + 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; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 190, screenH / 2 - 70), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(380, 0), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.18f, 0.95f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.4f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags popupFlags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Battleground Ready!", nullptr, popupFlags)) { + // BG name + std::string bgName; + if (slot->arenaType > 0) { + bgName = std::to_string(slot->arenaType) + "v" + std::to_string(slot->arenaType) + " Arena"; + } else { + switch (slot->bgTypeId) { + case 1: bgName = "Alterac Valley"; break; + case 2: bgName = "Warsong Gulch"; break; + case 3: bgName = "Arathi Basin"; break; + case 7: bgName = "Eye of the Storm"; break; + case 9: bgName = "Strand of the Ancients"; break; + case 11: bgName = "Isle of Conquest"; break; + default: bgName = "Battleground"; break; + } + } + + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", bgName.c_str()); + ImGui::TextWrapped("A spot has opened! You have %d seconds to enter.", static_cast(remaining)); + ImGui::Spacing(); + + // Countdown progress bar + float frac = static_cast(remaining / static_cast(slot->inviteTimeout)); + frac = std::clamp(frac, 0.0f, 1.0f); + ImVec4 barColor = frac > 0.5f ? ImVec4(0.2f, 0.8f, 0.2f, 1.0f) + : frac > 0.25f ? ImVec4(0.9f, 0.7f, 0.1f, 1.0f) + : ImVec4(0.9f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barColor); + char countdownLabel[32]; + snprintf(countdownLabel, sizeof(countdownLabel), "%ds", static_cast(remaining)); + ImGui::ProgressBar(frac, ImVec2(-1, 16), countdownLabel); + ImGui::PopStyleColor(); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Enter Battleground", ImVec2(180, 30))) { + gameHandler.acceptBattlefield(slot->queueSlot); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Leave Queue", ImVec2(175, 30))) { + gameHandler.declineBattlefield(slot->queueSlot); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + +void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { + using LfgState = game::GameHandler::LfgState; + if (gameHandler.getLfgState() != LfgState::Proposal) 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; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 175.0f, screenH / 2.0f - 65.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(350.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.14f, 0.08f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.3f, 0.8f, 0.3f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.1f, 0.3f, 0.1f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Dungeon Finder", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "A group has been found!"); + ImGui::Spacing(); + ImGui::TextWrapped("Please accept or decline to join the dungeon."); + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Accept", ImVec2(155.0f, 30.0f))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), true); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Decline", ImVec2(155.0f, 30.0f))) { + gameHandler.lfgAcceptProposal(gameHandler.getLfgProposalId(), false); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { // O key toggle (WoW default Social/Guild keybind) if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { @@ -6298,12 +6848,15 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { auto* assetMgr = core::Application::getInstance().getAssetManager(); - // Position below the player frame in top-left + // Position below the minimap (minimap: 200x200 at top-right, bottom edge at Y≈210) + // Anchored to the right side to stay away from party frames on the left constexpr float ICON_SIZE = 32.0f; constexpr int ICONS_PER_ROW = 8; float barW = ICONS_PER_ROW * (ICON_SIZE + 4.0f) + 8.0f; - // Dock under player frame in top-left (player frame is at 10, 30 with ~110px height) - ImGui::SetNextWindowPos(ImVec2(10.0f, 145.0f), ImGuiCond_Always); + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + // Y=215 puts us just below the minimap's bottom edge (minimap bottom ≈ 210) + ImGui::SetNextWindowPos(ImVec2(screenW - barW - 10.0f, 215.0f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(barW, 0), ImGuiCond_Always); ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | @@ -6314,16 +6867,22 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); if (ImGui::Begin("##BuffBar", nullptr, flags)) { - int shown = 0; - for (size_t i = 0; i < auras.size() && shown < 16; ++i) { + // Separate buffs and debuffs; show buffs first, then debuffs with a visual gap + // Render one pass for buffs, one for debuffs + for (int pass = 0; pass < 2; ++pass) { + bool wantBuff = (pass == 0); + int shown = 0; + for (size_t i = 0; i < auras.size() && shown < 40; ++i) { const auto& aura = auras[i]; if (aura.isEmpty()) continue; + bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag + if (isBuff != wantBuff) continue; // only render matching pass + if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); - ImGui::PushID(static_cast(i)); + ImGui::PushID(static_cast(i) + (pass * 256)); - bool isBuff = (aura.flags & 0x80) == 0; // 0x80 = negative/debuff flag ImVec4 borderColor = isBuff ? ImVec4(0.2f, 0.8f, 0.2f, 0.9f) : ImVec4(0.8f, 0.2f, 0.2f, 0.9f); // Try to get spell icon @@ -6418,10 +6977,14 @@ void GameScreen::renderBuffBar(game::GameHandler& gameHandler) { ImGui::PopID(); shown++; - } + } // end aura loop + // Add visual gap between buffs and debuffs + if (pass == 0 && shown > 0) ImGui::Spacing(); + } // end pass loop + // Dismiss Pet button if (gameHandler.hasPet()) { - if (shown > 0) ImGui::Spacing(); + ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.6f, 0.2f, 0.2f, 0.9f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.8f, 0.3f, 0.3f, 1.0f)); if (ImGui::Button("Dismiss Pet", ImVec2(-1, 0))) { @@ -6495,6 +7058,13 @@ void GameScreen::renderLootWindow(game::GameHandler& gameHandler) { } bool hovered = ImGui::IsItemHovered(); + // Show item tooltip on hover + if (hovered && info && info->valid) { + inventoryScreen.renderItemTooltip(*info); + } else if (hovered && !itemName.empty() && itemName[0] != 'I') { + ImGui::SetTooltip("%s", itemName.c_str()); + } + ImDrawList* drawList = ImGui::GetWindowDrawList(); // Draw hover highlight @@ -6977,15 +7547,16 @@ void GameScreen::renderQuestOfferRewardWindow(game::GameHandler& gameHandler) { return {iconTex, col}; }; - // Helper: show item tooltip - auto rewardItemTooltip = [&](const game::QuestRewardItem& ri, ImVec4 nameCol) { + // Helper: show full item tooltip (reuses InventoryScreen's rich 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 (!info || !info->valid) { + ImGui::BeginTooltip(); + ImGui::TextDisabled("Loading item data..."); + ImGui::EndTooltip(); + return; + } + inventoryScreen.renderItemTooltip(*info); }; if (!quest.choiceRewards.empty()) { @@ -7218,24 +7789,7 @@ void GameScreen::renderVendorWindow(game::GameHandler& gameHandler) { ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); // Tooltip with stats on hover if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - ImGui::TextColored(qualityColors[q], "%s", info->name.c_str()); - if (info->damageMax > 0.0f) { - ImGui::Text("%.0f - %.0f Damage", info->damageMin, info->damageMax); - if (info->delayMs > 0) { - float speed = static_cast(info->delayMs) / 1000.0f; - float dps = ((info->damageMin + info->damageMax) * 0.5f) / speed; - ImGui::Text("Speed %.2f", speed); - ImGui::Text("%.1f damage per second", dps); - } - } - if (info->armor > 0) ImGui::Text("Armor: %d", info->armor); - if (info->stamina > 0) ImGui::Text("+%d Stamina", info->stamina); - if (info->strength > 0) ImGui::Text("+%d Strength", info->strength); - if (info->agility > 0) ImGui::Text("+%d Agility", info->agility); - if (info->intellect > 0) ImGui::Text("+%d Intellect", info->intellect); - if (info->spirit > 0) ImGui::Text("+%d Spirit", info->spirit); - ImGui::EndTooltip(); + inventoryScreen.renderItemTooltip(*info); } } else { ImGui::Text("Item %u", item.itemId); @@ -8869,10 +9423,13 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float dx = worldRenderPos.x - playerRender.x; float dy = worldRenderPos.y - playerRender.y; - // Match minimap shader transform exactly. - // Render axes: +X=west, +Y=north. Minimap screen axes: +X=right(east), +Y=down(south). - float rx = -dx * cosB + dy * sinB; - float ry = -dx * sinB - dy * cosB; + // Exact inverse of minimap display shader: + // shader: mapUV = playerUV + vec2(-rotated.x, rotated.y) * zoom * 2 + // where rotated = R(bearing) * center, center in [-0.5, 0.5] + // Inverse: center = R^-1(bearing) * (-deltaUV.x, deltaUV.y) / (zoom*2) + // With deltaUV.x ∝ +dx (render +X=west=larger U) and deltaUV.y ∝ -dy (V increases south): + float rx = -(dx * cosB + dy * sinB); + float ry = dx * sinB - dy * cosB; // Scale to minimap pixels float px = rx / viewRadius * mapRadius; @@ -9135,21 +9692,73 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } ImGui::End(); - // "New Mail" indicator below the minimap + // Indicators below the minimap (stacked: new mail, then BG queue, then latency) + float indicatorX = centerX - mapRadius; + float nextIndicatorY = centerY + mapRadius + 4.0f; + const float indicatorW = mapRadius * 2.0f; + constexpr float kIndicatorH = 22.0f; + ImGuiWindowFlags indicatorFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs; + + // "New Mail" indicator if (gameHandler.hasNewMail()) { - float indicatorX = centerX - mapRadius; - float indicatorY = centerY + mapRadius + 4.0f; - ImGui::SetNextWindowPos(ImVec2(indicatorX, indicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(mapRadius * 2.0f, 22), ImGuiCond_Always); - ImGuiWindowFlags mailFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs; - if (ImGui::Begin("##NewMailIndicator", nullptr, mailFlags)) { - // Pulsing effect + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##NewMailIndicator", nullptr, indicatorFlags)) { float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!"); } ImGui::End(); + nextIndicatorY += kIndicatorH; + } + + // BG queue status indicator (when in queue but not yet invited) + for (const auto& slot : gameHandler.getBgQueues()) { + if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only + + std::string bgName; + if (slot.arenaType > 0) { + bgName = std::to_string(slot.arenaType) + "v" + std::to_string(slot.arenaType) + " Arena"; + } else { + switch (slot.bgTypeId) { + case 1: bgName = "AV"; break; + case 2: bgName = "WSG"; break; + case 3: bgName = "AB"; break; + case 7: bgName = "EotS"; break; + case 9: bgName = "SotA"; break; + case 11: bgName = "IoC"; break; + default: bgName = "BG"; break; + } + } + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.5f); + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "In Queue: %s", bgName.c_str()); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + break; // Show at most one queue slot indicator + } + + // Latency indicator (shown when in world and last latency is known) + uint32_t latMs = gameHandler.getLatencyMs(); + if (latMs > 0 && gameHandler.getState() == game::WorldState::IN_WORLD) { + ImVec4 latColor; + if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.8f); // Green < 100ms + else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.8f); // Yellow < 250ms + else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.8f); // Orange < 500ms + else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.8f); // Red >= 500ms + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { + ImGui::TextColored(latColor, "%u ms", latMs); + } + ImGui::End(); } } @@ -10795,8 +11404,9 @@ void GameScreen::renderDingEffect() { IM_COL32(255, 210, 0, (int)(alpha * 255)), buf); } -void GameScreen::triggerAchievementToast(uint32_t achievementId) { +void GameScreen::triggerAchievementToast(uint32_t achievementId, std::string name) { achievementToastId_ = achievementId; + achievementToastName_ = std::move(name); achievementToastTimer_ = ACHIEVEMENT_TOAST_DURATION; // Play a UI sound if available @@ -10855,9 +11465,15 @@ void GameScreen::renderAchievementToast() { draw->AddText(font, titleSize, ImVec2(titleX, toastY + 8), IM_COL32(255, 215, 0, (int)(alpha * 255)), title); - // Achievement ID line (until we have Achievement.dbc name lookup) - char idBuf[64]; - std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + // Achievement name (falls back to ID if name not available) + char idBuf[256]; + const char* achText = achievementToastName_.empty() + ? nullptr : achievementToastName_.c_str(); + if (achText) { + std::snprintf(idBuf, sizeof(idBuf), "%s", achText); + } else { + std::snprintf(idBuf, sizeof(idBuf), "Achievement #%u", achievementToastId_); + } float idW = font->CalcTextSizeA(bodySize, FLT_MAX, 0.0f, idBuf).x; float idX = toastX + (TOAST_W - idW) * 0.5f; draw->AddText(font, bodySize, ImVec2(idX, toastY + 28), diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 8567c3ce..0bb2c8c3 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1086,7 +1086,10 @@ void InventoryScreen::renderCharacterScreen(game::GameHandler& gameHandler) { if (ImGui::BeginTabItem("Stats")) { ImGui::Spacing(); - renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating()); + int32_t stats[5]; + for (int i = 0; i < 5; ++i) stats[i] = gameHandler.getPlayerStat(i); + const int32_t* serverStats = (stats[0] >= 0) ? stats : nullptr; + renderStatsPanel(inventory, gameHandler.getPlayerLevel(), gameHandler.getArmorRating(), serverStats); ImGui::EndTabItem(); } @@ -1376,18 +1379,18 @@ void InventoryScreen::renderEquipmentPanel(game::Inventory& inventory) { // Stats Panel // ============================================================ -void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, int32_t serverArmor) { - // Sum equipment stats - int32_t totalStr = 0, totalAgi = 0, totalSta = 0, totalInt = 0, totalSpi = 0; - +void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t playerLevel, + int32_t serverArmor, const int32_t* serverStats) { + // Sum equipment stats for item-query bonus display + int32_t itemStr = 0, itemAgi = 0, itemSta = 0, itemInt = 0, itemSpi = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty()) continue; - totalStr += slot.item.strength; - totalAgi += slot.item.agility; - totalSta += slot.item.stamina; - totalInt += slot.item.intellect; - totalSpi += slot.item.spirit; + itemStr += slot.item.strength; + itemAgi += slot.item.agility; + itemSta += slot.item.stamina; + itemInt += slot.item.intellect; + itemSpi += slot.item.spirit; } // Use server-authoritative armor from UNIT_FIELD_RESISTANCES when available. @@ -1399,9 +1402,6 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play } int32_t totalArmor = (serverArmor > 0) ? serverArmor : itemQueryArmor; - // Base stats: 20 + level - int32_t baseStat = 20 + static_cast(playerLevel); - ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); ImVec4 white(1.0f, 1.0f, 1.0f, 1.0f); ImVec4 gold(1.0f, 0.84f, 0.0f, 1.0f); @@ -1414,23 +1414,41 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play ImGui::TextColored(gray, "Armor: 0"); } - // Helper to render a stat line - auto renderStat = [&](const char* name, int32_t equipBonus) { - int32_t total = baseStat + equipBonus; - if (equipBonus > 0) { - ImGui::TextColored(white, "%s: %d", name, total); - ImGui::SameLine(); - ImGui::TextColored(green, "(+%d)", equipBonus); - } else { - ImGui::TextColored(gray, "%s: %d", name, total); + if (serverStats) { + // Server-authoritative stats from UNIT_FIELD_STAT0-4: show total and item bonus. + // serverStats[i] is the server's effective base stat (items included, buffs excluded). + const char* statNames[5] = {"Strength", "Agility", "Stamina", "Intellect", "Spirit"}; + const int32_t itemBonuses[5] = {itemStr, itemAgi, itemSta, itemInt, itemSpi}; + for (int i = 0; i < 5; ++i) { + int32_t total = serverStats[i]; + int32_t bonus = itemBonuses[i]; + if (bonus > 0) { + ImGui::TextColored(white, "%s: %d", statNames[i], total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", bonus); + } else { + ImGui::TextColored(gray, "%s: %d", statNames[i], total); + } } - }; - - renderStat("Strength", totalStr); - renderStat("Agility", totalAgi); - renderStat("Stamina", totalSta); - renderStat("Intellect", totalInt); - renderStat("Spirit", totalSpi); + } else { + // Fallback: estimated base (20 + level) plus item query bonuses. + int32_t baseStat = 20 + static_cast(playerLevel); + auto renderStat = [&](const char* name, int32_t equipBonus) { + int32_t total = baseStat + equipBonus; + if (equipBonus > 0) { + ImGui::TextColored(white, "%s: %d", name, total); + ImGui::SameLine(); + ImGui::TextColored(green, "(+%d)", equipBonus); + } else { + ImGui::TextColored(gray, "%s: %d", name, total); + } + }; + renderStat("Strength", itemStr); + renderStat("Agility", itemAgi); + renderStat("Stamina", itemSta); + renderStat("Intellect", itemInt); + renderStat("Spirit", itemSpi); + } } void InventoryScreen::renderBackpackPanel(game::Inventory& inventory, bool collapseEmptySections) { @@ -1704,7 +1722,9 @@ void InventoryScreen::renderItemSlot(game::Inventory& inventory, const game::Ite } if (ImGui::IsItemHovered() && !holdingItem) { - renderItemTooltip(item, &inventory); + // Pass inventory for backpack/bag items only; equipped items compare against themselves otherwise + const game::Inventory* tooltipInv = (kind == SlotKind::EQUIPMENT) ? nullptr : &inventory; + renderItemTooltip(item, tooltipInv); } } } @@ -1880,7 +1900,10 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } if (item.requiredLevel > 1) { - ImGui::TextColored(ImVec4(1.0f, 0.5f, 0.5f, 1.0f), "Requires Level %u", item.requiredLevel); + uint32_t playerLvl = gameHandler_ ? gameHandler_->getPlayerLevel() : 0; + bool meetsReq = (playerLvl >= item.requiredLevel); + ImVec4 reqColor = meetsReq ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(reqColor, "Requires Level %u", item.requiredLevel); } if (item.maxDurability > 0) { float durPct = static_cast(item.curDurability) / static_cast(item.maxDurability); @@ -1947,6 +1970,22 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I } ImGui::TextColored(getQualityColor(eq->item.quality), "%s", eq->item.name.c_str()); + // Item level comparison (always shown when different) + if (eq->item.itemLevel > 0 || item.itemLevel > 0) { + char ilvlBuf[64]; + float diff = static_cast(item.itemLevel) - static_cast(eq->item.itemLevel); + if (diff > 0.0f) + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▲%.0f)", item.itemLevel, diff); + else if (diff < 0.0f) + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (▼%.0f)", item.itemLevel, -diff); + else + std::snprintf(ilvlBuf, sizeof(ilvlBuf), "Item Level: %u (=)", item.itemLevel); + ImVec4 ilvlColor = (diff > 0.0f) ? ImVec4(0.0f, 1.0f, 0.0f, 1.0f) + : (diff < 0.0f) ? ImVec4(1.0f, 0.3f, 0.3f, 1.0f) + : ImVec4(0.7f, 0.7f, 0.7f, 1.0f); + ImGui::TextColored(ilvlColor, "%s", ilvlBuf); + } + // Helper: render a numeric stat diff line auto showDiff = [](const char* label, float newVal, float eqVal) { if (newVal == 0.0f && eqVal == 0.0f) return; @@ -1959,7 +1998,7 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I 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); + std::snprintf(buf, sizeof(buf), "%s: %.0f (=)", label, newVal); ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", buf); } }; @@ -2027,5 +2066,170 @@ void InventoryScreen::renderItemTooltip(const game::ItemDef& item, const game::I ImGui::EndTooltip(); } +// --------------------------------------------------------------------------- +// Tooltip overload for ItemQueryResponseData (used by loot window, etc.) +// --------------------------------------------------------------------------- +void InventoryScreen::renderItemTooltip(const game::ItemQueryResponseData& info) { + ImGui::BeginTooltip(); + + ImVec4 qColor = getQualityColor(static_cast(info.quality)); + ImGui::TextColored(qColor, "%s", info.name.c_str()); + if (info.itemLevel > 0) { + ImGui::TextColored(ImVec4(1.0f, 1.0f, 1.0f, 0.7f), "Item Level %u", info.itemLevel); + } + + // Binding type + switch (info.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; + } + + // Slot / subclass + if (info.inventoryType > 0) { + const char* slotName = ""; + switch (info.inventoryType) { + case 1: slotName = "Head"; break; + case 2: slotName = "Neck"; break; + case 3: slotName = "Shoulder"; break; + case 4: slotName = "Shirt"; break; + case 5: slotName = "Chest"; break; + case 6: slotName = "Waist"; break; + case 7: slotName = "Legs"; break; + case 8: slotName = "Feet"; break; + case 9: slotName = "Wrist"; break; + case 10: slotName = "Hands"; break; + case 11: slotName = "Finger"; break; + case 12: slotName = "Trinket"; break; + case 13: slotName = "One-Hand"; break; + case 14: slotName = "Shield"; break; + case 15: slotName = "Ranged"; break; + case 16: slotName = "Back"; break; + case 17: slotName = "Two-Hand"; break; + case 18: slotName = "Bag"; break; + case 19: slotName = "Tabard"; break; + case 20: slotName = "Robe"; break; + case 21: slotName = "Main Hand"; break; + case 22: slotName = "Off Hand"; break; + case 23: slotName = "Held In Off-hand"; break; + case 25: slotName = "Thrown"; break; + case 26: slotName = "Ranged"; break; + default: break; + } + if (slotName[0]) { + if (!info.subclassName.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s %s", slotName, info.subclassName.c_str()); + else + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 1.0f), "%s", slotName); + } + } + + // Weapon stats + auto isWeaponInvType = [](uint32_t t) { + return t == 13 || t == 15 || t == 17 || t == 21 || t == 25 || t == 26; + }; + ImVec4 green(0.0f, 1.0f, 0.0f, 1.0f); + if (isWeaponInvType(info.inventoryType) && info.damageMax > 0.0f && info.delayMs > 0) { + float speed = static_cast(info.delayMs) / 1000.0f; + float dps = ((info.damageMin + info.damageMax) * 0.5f) / speed; + ImGui::Text("%.0f - %.0f Damage", info.damageMin, info.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); + } + + if (info.armor > 0) ImGui::Text("%d Armor", info.armor); + + auto appendBonus = [](std::string& out, int32_t val, const char* name) { + if (val <= 0) return; + if (!out.empty()) out += " "; + out += "+" + std::to_string(val) + " " + name; + }; + std::string bonusLine; + appendBonus(bonusLine, info.strength, "Str"); + appendBonus(bonusLine, info.agility, "Agi"); + appendBonus(bonusLine, info.stamina, "Sta"); + appendBonus(bonusLine, info.intellect, "Int"); + appendBonus(bonusLine, info.spirit, "Spi"); + if (!bonusLine.empty()) ImGui::TextColored(green, "%s", bonusLine.c_str()); + + // Extra stats + for (const auto& es : info.extraStats) { + const char* statName = nullptr; + switch (es.statType) { + case 12: statName = "Defense Rating"; break; + case 13: statName = "Dodge Rating"; break; + case 14: statName = "Parry Rating"; break; + case 16: case 17: case 18: case 31: statName = "Hit Rating"; break; + case 19: case 20: case 21: case 32: statName = "Crit Rating"; break; + case 28: case 29: case 30: case 36: statName = "Haste Rating"; break; + case 35: statName = "Resilience"; 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 (info.requiredLevel > 1) { + uint32_t playerLvl = gameHandler_ ? gameHandler_->getPlayerLevel() : 0; + bool meetsReq = (playerLvl >= info.requiredLevel); + ImVec4 reqColor = meetsReq ? ImVec4(1.0f, 1.0f, 1.0f, 0.75f) : ImVec4(1.0f, 0.5f, 0.5f, 1.0f); + ImGui::TextColored(reqColor, "Requires Level %u", info.requiredLevel); + } + + // Spell effects + 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; + default: break; + } + if (!trigger) continue; + if (gameHandler_) { + 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); + } + } + + if (info.startQuestId != 0) { + ImGui::TextColored(ImVec4(1.0f, 0.82f, 0.0f, 1.0f), "Begins a Quest"); + } + if (!info.description.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 0.9f), "\"%s\"", info.description.c_str()); + } + + if (info.sellPrice > 0) { + uint32_t g = info.sellPrice / 10000; + uint32_t s = (info.sellPrice / 100) % 100; + uint32_t c = info.sellPrice % 100; + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 1.0f), "Sell: %ug %us %uc", g, s, c); + } + + ImGui::EndTooltip(); +} + } // namespace ui } // namespace wowee diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index 00fbd173..8a9ddd55 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -379,14 +379,24 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { ImGui::Separator(); ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Tracked Progress"); for (const auto& [entry, progress] : sel.killCounts) { - ImGui::BulletText("Kill %u: %u/%u", entry, progress.first, progress.second); + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + // Game object objective: fall back to GO name cache. + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo && !goInfo->name.empty()) name = goInfo->name; + } + if (name.empty()) name = "Unknown (" + std::to_string(entry) + ")"; + ImGui::BulletText("%s: %u/%u", name.c_str(), progress.first, progress.second); } for (const auto& [itemId, count] : sel.itemCounts) { std::string itemLabel = "Item " + std::to_string(itemId); if (const auto* info = gameHandler.getItemInfo(itemId)) { if (!info->name.empty()) itemLabel = info->name; } - ImGui::BulletText("%s: %u", itemLabel.c_str(), count); + uint32_t required = 1; + auto reqIt = sel.requiredItemCounts.find(itemId); + if (reqIt != sel.requiredItemCounts.end()) required = reqIt->second; + ImGui::BulletText("%s: %u/%u", itemLabel.c_str(), count, required); } } diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index f90090f7..0a355ff3 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -189,7 +189,7 @@ bool SpellbookScreen::renderSpellInfoTooltip(uint32_t spellId, game::GameHandler if (!dbcLoadAttempted) loadSpellDBC(assetManager); const SpellInfo* info = getSpellInfo(spellId); if (!info) return false; - renderSpellTooltip(info, gameHandler); + renderSpellTooltip(info, gameHandler, /*showUsageHints=*/false); return true; } @@ -446,7 +446,7 @@ const SpellInfo* SpellbookScreen::getSpellInfo(uint32_t spellId) const { return (it != spellData.end()) ? &it->second : nullptr; } -void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler) { +void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandler& gameHandler, bool showUsageHints) { ImGui::BeginTooltip(); ImGui::PushTextWrapPos(320.0f); @@ -551,8 +551,8 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle ImGui::TextWrapped("%s", info->description.c_str()); } - // Usage hints - if (!info->isPassive()) { + // Usage hints — only shown when browsing the spellbook, not on action bar hover + if (!info->isPassive() && showUsageHints) { ImGui::Spacing(); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Drag to action bar"); ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f, 1.0f), "Double-click to cast");