diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index 5f97f29f..4549a48c 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -22,6 +22,7 @@ "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, + "PLAYER_REST_STATE_EXPERIENCE": 718, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, diff --git a/Data/expansions/tbc/update_fields.json b/Data/expansions/tbc/update_fields.json index bbcedec5..bee972ca 100644 --- a/Data/expansions/tbc/update_fields.json +++ b/Data/expansions/tbc/update_fields.json @@ -22,6 +22,7 @@ "PLAYER_BYTES_2": 238, "PLAYER_XP": 926, "PLAYER_NEXT_LEVEL_XP": 927, + "PLAYER_REST_STATE_EXPERIENCE": 928, "PLAYER_FIELD_COINAGE": 1441, "PLAYER_QUEST_LOG_START": 244, "PLAYER_FIELD_INV_SLOT_HEAD": 650, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index 5f97f29f..393694a0 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -22,6 +22,7 @@ "PLAYER_BYTES_2": 192, "PLAYER_XP": 716, "PLAYER_NEXT_LEVEL_XP": 717, + "PLAYER_REST_STATE_EXPERIENCE": 718, "PLAYER_FIELD_COINAGE": 1176, "PLAYER_QUEST_LOG_START": 198, "PLAYER_FIELD_INV_SLOT_HEAD": 486, @@ -35,4 +36,4 @@ "ITEM_FIELD_STACK_COUNT": 14, "CONTAINER_FIELD_NUM_SLOTS": 48, "CONTAINER_FIELD_SLOT_1": 50 -} +} \ No newline at end of file diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index f308cf0d..fa4b9ada 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -22,6 +22,7 @@ "PLAYER_BYTES_2": 154, "PLAYER_XP": 634, "PLAYER_NEXT_LEVEL_XP": 635, + "PLAYER_REST_STATE_EXPERIENCE": 636, "PLAYER_FIELD_COINAGE": 1170, "PLAYER_QUEST_LOG_START": 158, "PLAYER_FIELD_INV_SLOT_HEAD": 324, diff --git a/README.md b/README.md index 54ae7eaa..7353ed15 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A native C++ World of Warcraft client with a custom Vulkan renderer. [![Sponsor](https://img.shields.io/github/sponsors/Kelsidavis?label=Sponsor&logo=GitHub)](https://github.com/sponsors/Kelsidavis) -[![Discord](https://img.shields.io/discord/1?label=Discord&logo=discord)](https://discord.gg/SDqjA79B) +[![Discord](https://img.shields.io/discord/1?label=Discord&logo=discord)](https://discord.gg/PSdMPS8uje) [![Watch the video](https://img.youtube.com/vi/B-jtpPmiXGM/maxresdefault.jpg)](https://youtu.be/B-jtpPmiXGM) diff --git a/include/core/application.hpp b/include/core/application.hpp index 4d10acc7..0c7ca61e 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -78,6 +78,7 @@ public: // Render bounds lookup (for click targeting / selection) bool getRenderBoundsForGuid(uint64_t guid, glm::vec3& outCenter, float& outRadius) const; bool getRenderFootZForGuid(uint64_t guid, float& outFootZ) const; + bool getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const; // Character skin composite state (saved at spawn for re-compositing on equipment change) const std::string& getBodySkinPath() const { return bodySkinPath_; } @@ -186,6 +187,9 @@ private: std::unordered_map creatureInstances_; // guid → render instanceId std::unordered_map creatureModelIds_; // guid → loaded modelId std::unordered_map creatureRenderPosCache_; // guid -> last synced render position + std::unordered_map creatureWasMoving_; // guid -> previous-frame movement state + std::unordered_map creatureSwimmingState_; // guid -> currently in swim mode (SWIMMING flag) + std::unordered_map creatureWalkingState_; // guid -> walking (WALKING flag, selects Walk(4) vs Run(5)) std::unordered_set creatureWeaponsAttached_; // guid set when NPC virtual weapons attached std::unordered_map creatureWeaponAttachAttempts_; // guid -> attach attempts std::unordered_map modelIdIsWolfLike_; // modelId → cached wolf/worg check @@ -360,7 +364,7 @@ private: std::future future; }; std::vector asyncNpcCompositeLoads_; - void processAsyncNpcCompositeResults(); + void processAsyncNpcCompositeResults(bool unlimited = false); // Cache base player model geometry by (raceId, genderId) std::unordered_map playerModelCache_; // key=(race<<8)|gender → modelId struct PlayerTextureSlots { int skin = -1; int hair = -1; int underwear = -1; }; diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 47dd523d..8e3420c5 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -55,6 +55,24 @@ enum class QuestGiverStatus : uint8_t { REWARD = 10 // ? (yellow) }; +/** + * A single contact list entry (friend, ignore, or mute). + */ +struct ContactEntry { + uint64_t guid = 0; + std::string name; + std::string note; + uint32_t flags = 0; // 0x1=friend, 0x2=ignore, 0x4=mute + uint8_t status = 0; // 0=offline, 1=online, 2=AFK, 3=DND + uint32_t areaId = 0; + uint32_t level = 0; + uint32_t classId = 0; + + bool isFriend() const { return (flags & 0x1) != 0; } + bool isIgnored() const { return (flags & 0x2) != 0; } + bool isOnline() const { return status != 0; } +}; + /** * World connection state */ @@ -570,8 +588,10 @@ public: const std::unordered_map& getAllTalentTabs() const { return talentTabCache_; } void loadTalentDbc(); - // Action bar - static constexpr int ACTION_BAR_SLOTS = 12; + // Action bar — 2 bars × 12 slots = 24 total + static constexpr int SLOTS_PER_BAR = 12; + static constexpr int ACTION_BARS = 2; + static constexpr int ACTION_BAR_SLOTS = SLOTS_PER_BAR * ACTION_BARS; // 24 std::array& getActionBar() { return actionBar; } const std::array& getActionBar() const { return actionBar; } void setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id); @@ -599,10 +619,33 @@ public: using NpcRespawnCallback = std::function; void setNpcRespawnCallback(NpcRespawnCallback cb) { npcRespawnCallback_ = std::move(cb); } + // Stand state animation callback — fired when SMSG_STANDSTATE_UPDATE confirms a new state + // standState: 0=stand, 1-6=sit variants, 7=dead, 8=kneel + using StandStateCallback = std::function; + void setStandStateCallback(StandStateCallback cb) { standStateCallback_ = std::move(cb); } + + // Ghost state callback — fired when player enters or leaves ghost (spirit) form + using GhostStateCallback = std::function; + void setGhostStateCallback(GhostStateCallback cb) { ghostStateCallback_ = std::move(cb); } + // Melee swing callback (for driving animation/SFX) using MeleeSwingCallback = std::function; void setMeleeSwingCallback(MeleeSwingCallback cb) { meleeSwingCallback_ = std::move(cb); } + // Spell cast animation callbacks — true=start cast/channel, false=finish/cancel + // guid: caster (may be player or another unit), isChannel: channel vs regular cast + using SpellCastAnimCallback = std::function; + void setSpellCastAnimCallback(SpellCastAnimCallback cb) { spellCastAnimCallback_ = std::move(cb); } + + // Unit animation hint: signal jump (animId=38) for other players/NPCs + using UnitAnimHintCallback = std::function; + void setUnitAnimHintCallback(UnitAnimHintCallback cb) { unitAnimHintCallback_ = std::move(cb); } + + // Unit move-flags callback: fired on every MSG_MOVE_* for other players with the raw flags field. + // Drives Walk(4) vs Run(5) selection and swim state initialization from heartbeat packets. + using UnitMoveFlagsCallback = std::function; + void setUnitMoveFlagsCallback(UnitMoveFlagsCallback cb) { unitMoveFlagsCallback_ = std::move(cb); } + // NPC swing callback (plays attack animation on NPC) using NpcSwingCallback = std::function; void setNpcSwingCallback(NpcSwingCallback cb) { npcSwingCallback_ = std::move(cb); } @@ -620,6 +663,8 @@ public: // XP tracking uint32_t getPlayerXp() const { return playerXp_; } uint32_t getPlayerNextLevelXp() const { return playerNextLevelXp_; } + uint32_t getPlayerRestedXp() const { return playerRestedXp_; } + bool isPlayerResting() const { return isResting_; } uint32_t getPlayerLevel() const { return serverPlayerLevel_; } const std::vector& getPlayerExploredZoneMasks() const { return playerExploredZones_; } bool hasPlayerExploredZoneMasks() const { return hasPlayerExploredZones_; } @@ -644,8 +689,8 @@ public: uint32_t getSkillCategory(uint32_t skillId) const; // World entry callback (online mode - triggered when entering world) - // Parameters: mapId, x, y, z (canonical WoW coordinates) - using WorldEntryCallback = std::function; + // Parameters: mapId, x, y, z (canonical WoW coords), isInitialEntry=true on first login or reconnect + using WorldEntryCallback = std::function; void setWorldEntryCallback(WorldEntryCallback cb) { worldEntryCallback_ = std::move(cb); } // Unstuck callback (resets player Z to floor height) @@ -800,6 +845,7 @@ public: void leaveGroup(); bool isInGroup() const { return !partyData.isEmpty(); } const GroupListData& getPartyData() const { return partyData; } + const std::vector& getContacts() const { return contacts_; } bool hasPendingGroupInvite() const { return pendingGroupInvite; } const std::string& getPendingInviterName() const { return pendingInviterName; } @@ -867,6 +913,21 @@ public: return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0; } + // Raid target markers (MSG_RAID_TARGET_UPDATE) + // Icon indices 0-7: Star, Circle, Diamond, Triangle, Moon, Square, Cross, Skull + static constexpr uint32_t kRaidMarkCount = 8; + // Returns the GUID marked with the given icon (0 = no mark) + uint64_t getRaidMarkGuid(uint32_t icon) const { + return (icon < kRaidMarkCount) ? raidTargetGuids_[icon] : 0; + } + // Returns the raid mark icon for a given guid (0xFF = no mark) + uint8_t getEntityRaidMark(uint64_t guid) const { + if (guid == 0) return 0xFF; + for (uint32_t i = 0; i < kRaidMarkCount; ++i) + if (raidTargetGuids_[i] == guid) return static_cast(i); + return 0xFF; + } + // ---- LFG / Dungeon Finder ---- enum class LfgState : uint8_t { None = 0, @@ -966,6 +1027,12 @@ public: const std::vector& getQuestLog() const { return questLog_; } void abandonQuest(uint32_t questId); bool requestQuestQuery(uint32_t questId, bool force = false); + bool isQuestTracked(uint32_t questId) const { return trackedQuestIds_.count(questId) > 0; } + void setQuestTracked(uint32_t questId, bool tracked) { + if (tracked) trackedQuestIds_.insert(questId); + else trackedQuestIds_.erase(questId); + } + const std::unordered_set& getTrackedQuestIds() const { return trackedQuestIds_; } bool isQuestQueryPending(uint32_t questId) const { return pendingQuestQueryIds_.count(questId) > 0; } @@ -1656,11 +1723,12 @@ private: std::unordered_map gameObjectInfoCache_; std::unordered_set pendingGameObjectQueries_; - // ---- Friend list cache ---- + // ---- Friend/contact list cache ---- std::unordered_map friendsCache; // name -> guid std::unordered_set friendGuids_; // all known friend GUIDs (for name backfill) uint32_t lastContactListMask_ = 0; uint32_t lastContactListCount_ = 0; + std::vector contacts_; // structured contact list (friends + ignores) // ---- World state and faction initialization snapshots ---- uint32_t worldStateMapId_ = 0; @@ -1796,6 +1864,7 @@ private: std::unordered_map talentCache_; // talentId -> entry std::unordered_map talentTabCache_; // tabId -> entry bool talentDbcLoaded_ = false; + bool talentsInitialized_ = false; // Reset on world entry; guards first-spec selection // ---- Area trigger detection ---- struct AreaTriggerEntry { @@ -1813,7 +1882,7 @@ private: bool areaTriggerSuppressFirst_ = false; // suppress first check after map transfer float castTimeTotal = 0.0f; - std::array actionBar{}; + std::array actionBar{}; std::vector playerAuras; std::vector targetAuras; uint64_t petGuid_ = 0; @@ -1836,6 +1905,9 @@ private: uint32_t instanceDifficulty_ = 0; bool instanceIsHeroic_ = false; + // Raid target markers (icon 0-7 -> guid; 0 = empty slot) + std::array raidTargetGuids_ = {}; + // Mirror timers (0=fatigue, 1=breath, 2=feigndeath) MirrorTimer mirrorTimers_[3]; @@ -1981,6 +2053,7 @@ private: // Quest log std::vector questLog_; std::unordered_set pendingQuestQueryIds_; + std::unordered_set trackedQuestIds_; bool pendingLoginQuestResync_ = false; float pendingLoginQuestResyncTimeout_ = 0.0f; @@ -2097,6 +2170,12 @@ private: std::unordered_map achievementNameCache_; bool achievementNameCacheLoaded_ = false; void loadAchievementNameCache(); + + // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) + std::unordered_map areaNameCache_; + bool areaNameCacheLoaded_ = false; + void loadAreaNameCache(); + std::string getAreaName(uint32_t areaId) const; std::vector trainerTabs_; void handleTrainerList(network::Packet& packet); void loadSpellNameCache(); @@ -2152,6 +2231,8 @@ private: // ---- XP tracking ---- uint32_t playerXp_ = 0; uint32_t playerNextLevelXp_ = 0; + uint32_t playerRestedXp_ = 0; + bool isResting_ = false; uint32_t serverPlayerLevel_ = 1; static uint32_t xpForLevel(uint32_t level); @@ -2187,7 +2268,12 @@ private: NpcDeathCallback npcDeathCallback_; NpcAggroCallback npcAggroCallback_; NpcRespawnCallback npcRespawnCallback_; + StandStateCallback standStateCallback_; + GhostStateCallback ghostStateCallback_; MeleeSwingCallback meleeSwingCallback_; + SpellCastAnimCallback spellCastAnimCallback_; + UnitAnimHintCallback unitAnimHintCallback_; + UnitMoveFlagsCallback unitMoveFlagsCallback_; NpcSwingCallback npcSwingCallback_; NpcGreetingCallback npcGreetingCallback_; NpcFarewellCallback npcFarewellCallback_; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index 03fc502e..d2556e7b 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -353,6 +353,9 @@ public: // TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST: guid(8) + questId(4) — no trailing // isDialogContinued byte that WotLK added network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override; + // TBC/Classic SMSG_QUESTGIVER_QUEST_DETAILS lacks informUnit(u64), flags(u32), + // isFinished(u8) that WotLK added; uses variable item counts + emote section. + bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override; }; /** @@ -402,7 +405,7 @@ public: uint8_t readQuestGiverStatus(network::Packet& packet) override; network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override; network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override; - bool parseQuestDetails(network::Packet& packet, QuestDetailsData& data) override; + // parseQuestDetails inherited from TbcPacketParsers (same format as TBC 2.4.3) uint8_t questLogStride() const override { return 3; } bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override { return MonsterMoveParser::parseVanilla(packet, data); diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index b841925e..fd208554 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -41,6 +41,7 @@ enum class UF : uint16_t { PLAYER_BYTES_2, PLAYER_XP, PLAYER_NEXT_LEVEL_XP, + PLAYER_REST_STATE_EXPERIENCE, PLAYER_FIELD_COINAGE, PLAYER_QUEST_LOG_START, PLAYER_FIELD_INV_SLOT_HEAD, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 7f62b622..c13659c3 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -481,6 +481,10 @@ struct UpdateBlock { // Update flags from movement block (for detecting transports, etc.) uint16_t updateFlags = 0; + // Raw movement flags from LIVING block (SWIMMING=0x200000, WALKING=0x100, CAN_FLY=0x800000, FLYING=0x1000000) + // Used to initialise swim/walk/fly state on entity spawn (cold-join). + uint32_t moveFlags = 0; + // Transport data from LIVING movement block (MOVEMENTFLAG_ONTRANSPORT) bool onTransport = false; uint64_t transportGuid = 0; diff --git a/include/pipeline/asset_manager.hpp b/include/pipeline/asset_manager.hpp index a32895d6..869b87a3 100644 --- a/include/pipeline/asset_manager.hpp +++ b/include/pipeline/asset_manager.hpp @@ -59,6 +59,15 @@ public: */ void setExpansionDataPath(const std::string& path); + /** + * Set a base data path to fall back to when the primary manifest + * does not contain a requested file. Call this when the primary + * dataPath is an expansion-specific subset (e.g. Data/expansions/vanilla/) + * that only holds DBC overrides, not the full world asset set. + * @param basePath Path to the base extraction (Data/) that has a manifest.json + */ + void setBaseFallbackPath(const std::string& basePath); + /** * Load a DBC file * @param name DBC file name (e.g., "Map.dbc") @@ -66,6 +75,14 @@ public: */ std::shared_ptr loadDBC(const std::string& name); + /** + * Load a DBC file that is optional (not all expansions ship it). + * Returns nullptr quietly (debug-level log only) when the file is absent. + * @param name DBC file name (e.g., "Item.dbc") + * @return Loaded DBC file, or nullptr if not available + */ + std::shared_ptr loadDBCOptional(const std::string& name); + /** * Get a cached DBC file * @param name DBC file name @@ -136,6 +153,11 @@ private: AssetManifest manifest_; LooseFileReader looseReader_; + // Optional base-path fallback: used when manifest_ doesn't contain a file. + // Populated by setBaseFallbackPath(); ignored if baseFallbackDataPath_ is empty. + std::string baseFallbackDataPath_; + AssetManifest baseFallbackManifest_; + /** * Resolve filesystem path: check override dir first, then base manifest. * Returns empty string if not found. diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index 34600b47..79a7d622 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -82,6 +82,7 @@ public: bool isSwimming() const { return swimming; } bool isInsideWMO() const { return cachedInsideWMO; } void setGrounded(bool g) { grounded = g; } + void setSitting(bool s) { sitting = s; } bool isOnTaxi() const { return externalFollow_; } const glm::vec3* getFollowTarget() const { return followTarget; } glm::vec3* getFollowTargetMutable() { return followTarget; } @@ -156,6 +157,7 @@ private: static constexpr float MAX_PITCH = 35.0f; // Limited upward look glm::vec3* followTarget = nullptr; glm::vec3 smoothedCamPos = glm::vec3(0.0f); // For smooth camera movement + float smoothedCollisionDist_ = -1.0f; // Asymmetrically-smoothed WMO collision limit (-1 = uninitialised) // Gravity / grounding float verticalVelocity = 0.0f; diff --git a/include/rendering/character_renderer.hpp b/include/rendering/character_renderer.hpp index 7a01c0d7..2b400998 100644 --- a/include/rendering/character_renderer.hpp +++ b/include/rendering/character_renderer.hpp @@ -78,6 +78,7 @@ public: void setInstanceRotation(uint32_t instanceId, const glm::vec3& rotation); void moveInstanceTo(uint32_t instanceId, const glm::vec3& destination, float durationSeconds); void startFadeIn(uint32_t instanceId, float durationSeconds); + void setInstanceOpacity(uint32_t instanceId, float opacity); const pipeline::M2Model* getModelData(uint32_t modelId) const; void setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets); void setGroupTextureOverride(uint32_t instanceId, uint16_t geosetGroup, VkTexture* texture); @@ -91,6 +92,7 @@ public: bool getInstanceModelName(uint32_t instanceId, std::string& modelName) const; bool getInstanceBounds(uint32_t instanceId, glm::vec3& outCenter, float& outRadius) const; bool getInstanceFootZ(uint32_t instanceId, float& outFootZ) const; + bool getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const; /** Debug: Log all available animations for an instance */ void dumpAnimations(uint32_t instanceId) const; diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 1496ea28..15e098e7 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -64,6 +64,7 @@ private: bool showChatWindow = true; bool showNameplates_ = true; // V key toggles nameplates bool showPlayerInfo = false; + bool showSocialFrame_ = false; // O key toggles social/friends list bool showGuildRoster_ = false; std::string selectedGuildMember_; bool showGuildNoteEdit_ = false; @@ -219,6 +220,7 @@ private: void renderSharedQuestPopup(game::GameHandler& gameHandler); void renderItemTextWindow(game::GameHandler& gameHandler); void renderBuffBar(game::GameHandler& gameHandler); + void renderSocialFrame(game::GameHandler& gameHandler); void renderLootWindow(game::GameHandler& gameHandler); void renderGossipWindow(game::GameHandler& gameHandler); void renderQuestDetailsWindow(game::GameHandler& gameHandler); diff --git a/include/ui/quest_log_screen.hpp b/include/ui/quest_log_screen.hpp index 7e289e92..d86abedc 100644 --- a/include/ui/quest_log_screen.hpp +++ b/include/ui/quest_log_screen.hpp @@ -13,11 +13,19 @@ public: bool isOpen() const { return open; } void toggle() { open = !open; } void setOpen(bool o) { open = o; } + // Open the log and scroll to the given quest (by questId) + void openAndSelectQuest(uint32_t questId) { + open = true; + pendingSelectQuestId_ = questId; + scrollToSelected_ = true; + } private: bool open = false; bool lKeyWasDown = false; int selectedIndex = -1; + uint32_t pendingSelectQuestId_ = 0; // non-zero: select this quest on next render + bool scrollToSelected_ = false; // true: call SetScrollHereY once after selection uint32_t lastDetailRequestQuestId_ = 0; double lastDetailRequestAt_ = 0.0; std::unordered_set questDetailQueryNoResponse_; diff --git a/src/core/application.cpp b/src/core/application.cpp index b3883e0c..ca692dd6 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -292,6 +292,11 @@ bool Application::initialize() { if (std::filesystem::exists(expansionManifest)) { assetPath = profile->dataPath; LOG_INFO("Using expansion-specific asset path: ", assetPath); + // Register base Data/ as fallback so world terrain files are found + // even when the expansion path only contains DBC overrides. + if (assetPath != dataPath) { + assetManager->setBaseFallbackPath(dataPath); + } } } } @@ -744,6 +749,9 @@ void Application::logoutToLogin() { creatureRenderPosCache_.clear(); creatureWeaponsAttached_.clear(); creatureWeaponAttachAttempts_.clear(); + creatureWasMoving_.clear(); + creatureSwimmingState_.clear(); + creatureWalkingState_.clear(); deadCreatureGuids_.clear(); nonRenderableCreatureDisplayIds_.clear(); creaturePermanentFailureGuids_.clear(); @@ -1461,14 +1469,36 @@ void Application::update(float deltaTime) { auto unitPtr = std::static_pointer_cast(entity); const bool deadOrCorpse = unitPtr->getHealth() == 0; const bool largeCorrection = (planarDist > 6.0f) || (dz > 3.0f); + const bool isMovingNow = !deadOrCorpse && (planarDist > 0.03f || dz > 0.08f); if (deadOrCorpse || largeCorrection) { charRenderer->setInstancePosition(instanceId, renderPos); - } else if (planarDist > 0.03f || dz > 0.08f) { - // Use movement interpolation so step/run animation can play. + } else if (isMovingNow) { float duration = std::clamp(planarDist / 5.5f, 0.05f, 0.22f); charRenderer->moveInstanceTo(instanceId, renderPos, duration); } posIt->second = renderPos; + + // Drive movement animation: Walk/Run/Swim (4/5/42) when moving, + // Stand/SwimIdle (0/41) when idle. Walk(4) selected when WALKING flag is set. + // WoW M2 animation IDs: 4=Walk, 5=Run, 41=SwimIdle, 42=Swim. + // Only switch on transitions to avoid resetting animation time. + // Don't override Death (1) animation. + const bool isSwimmingNow = creatureSwimmingState_.count(guid) > 0; + const bool isWalkingNow = creatureWalkingState_.count(guid) > 0; + bool prevMoving = creatureWasMoving_[guid]; + if (isMovingNow != prevMoving) { + creatureWasMoving_[guid] = isMovingNow; + uint32_t curAnimId = 0; float curT = 0.0f, curDur = 0.0f; + bool gotState = charRenderer->getAnimationState(instanceId, curAnimId, curT, curDur); + if (!gotState || curAnimId != 1 /*Death*/) { + uint32_t targetAnim; + if (isMovingNow) + targetAnim = isSwimmingNow ? 42u : (isWalkingNow ? 4u : 5u); // Swim/Walk/Run + else + targetAnim = isSwimmingNow ? 41u : 0u; // SwimIdle vs Stand + charRenderer->playAnimation(instanceId, targetAnim, /*loop=*/true); + } + } } float renderYaw = entity->getOrientation() + glm::radians(90.0f); charRenderer->setInstanceRotation(instanceId, glm::vec3(0.0f, 0.0f, renderYaw)); @@ -1687,8 +1717,72 @@ void Application::setupUICallbacks() { }); // World entry callback (online mode) - load terrain when entering world - gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z) { - LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"); + gameHandler->setWorldEntryCallback([this](uint32_t mapId, float x, float y, float z, bool isInitialEntry) { + LOG_INFO("Online world entry: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")" + " initial=", isInitialEntry); + + // Reconnect to the same map: terrain stays loaded but all online entities are stale. + // Despawn them properly so the server's fresh CREATE_OBJECTs will re-populate the world. + if (mapId == loadedMapId_ && renderer && renderer->getTerrainManager() && isInitialEntry) { + LOG_INFO("Reconnect to same map ", mapId, ": clearing stale online entities (terrain preserved)"); + + // Pending spawn queues and failure caches + pendingCreatureSpawns_.clear(); + pendingCreatureSpawnGuids_.clear(); + creatureSpawnRetryCounts_.clear(); + creaturePermanentFailureGuids_.clear(); // Clear so previously-failed GUIDs can retry + deadCreatureGuids_.clear(); // Will be re-populated from fresh server state + pendingPlayerSpawns_.clear(); + pendingPlayerSpawnGuids_.clear(); + pendingOnlinePlayerEquipment_.clear(); + deferredEquipmentQueue_.clear(); + pendingGameObjectSpawns_.clear(); + + // Properly despawn all tracked instances from the renderer + { + std::vector guids; + guids.reserve(creatureInstances_.size()); + for (const auto& [g, _] : creatureInstances_) guids.push_back(g); + for (auto g : guids) despawnOnlineCreature(g); + } + { + std::vector guids; + guids.reserve(playerInstances_.size()); + for (const auto& [g, _] : playerInstances_) guids.push_back(g); + for (auto g : guids) despawnOnlinePlayer(g); + } + { + std::vector guids; + guids.reserve(gameObjectInstances_.size()); + for (const auto& [g, _] : gameObjectInstances_) guids.push_back(g); + for (auto g : guids) despawnOnlineGameObject(g); + } + + // Update player position and re-queue nearby tiles (same logic as teleport) + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(x, y, z)); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + renderer->getCharacterPosition() = renderPos; + if (renderer->getCameraController()) { + auto* ft = renderer->getCameraController()->getFollowTargetMutable(); + if (ft) *ft = renderPos; + renderer->getCameraController()->clearMovementInputs(); + renderer->getCameraController()->suppressMovementFor(1.0f); + } + worldEntryMovementGraceTimer_ = 2.0f; + taxiLandingClampTimer_ = 0.0f; + lastTaxiFlight_ = false; + renderer->getTerrainManager()->processAllReadyTiles(); + { + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + std::vector> nearbyTiles; + nearbyTiles.reserve(289); + for (int dy = -8; dy <= 8; dy++) + for (int dx = -8; dx <= 8; dx++) + nearbyTiles.push_back({tileX + dx, tileY + dy}); + renderer->getTerrainManager()->precacheTiles(nearbyTiles); + } + return; + } // Same-map teleport (taxi landing, GM teleport on same continent): // just update position, let terrain streamer handle tile loading incrementally. @@ -1714,6 +1808,21 @@ void Application::setupUICallbacks() { // (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before // the first frame at the new position. renderer->getTerrainManager()->processAllReadyTiles(); + + // Queue all remaining tiles within the load radius (8 tiles = 17x17) + // at the new position. precacheTiles skips already-loaded/pending tiles, + // so this only enqueues tiles that aren't yet in the pipeline. + // This ensures background workers immediately start loading everything + // visible from the new position (hearthstone may land far from old location). + { + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + std::vector> nearbyTiles; + nearbyTiles.reserve(289); + for (int dy = -8; dy <= 8; dy++) + for (int dx = -8; dx <= 8; dx++) + nearbyTiles.push_back({tileX + dx, tileY + dy}); + renderer->getTerrainManager()->precacheTiles(nearbyTiles); + } return; } @@ -1976,13 +2085,15 @@ void Application::setupUICallbacks() { if (mapId == loadedMapId_) { // Same map: pre-enqueue tiles around the bind point so workers start // loading them now. Uses render-space coords (canonicalToRender). + // Use radius 4 (9x9=81 tiles) — hearthstone cast is ~10s, enough time + // for workers to parse most of these before the player arrives. glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); std::vector> tiles; - tiles.reserve(25); - for (int dy = -2; dy <= 2; dy++) - for (int dx = -2; dx <= 2; dx++) + tiles.reserve(81); + for (int dy = -4; dy <= 4; dy++) + for (int dx = -4; dx <= 4; dx++) tiles.push_back({tileX + dx, tileY + dy}); terrainMgr->precacheTiles(tiles); @@ -2341,8 +2452,9 @@ void Application::setupUICallbacks() { gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { if (!renderer || !renderer->getCharacterRenderer()) return; uint32_t instanceId = 0; + bool isPlayer = false; auto pit = playerInstances_.find(guid); - if (pit != playerInstances_.end()) instanceId = pit->second; + if (pit != playerInstances_.end()) { instanceId = pit->second; isPlayer = true; } else { auto it = creatureInstances_.find(guid); if (it != creatureInstances_.end()) instanceId = it->second; @@ -2351,6 +2463,19 @@ void Application::setupUICallbacks() { glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); float durationSec = static_cast(durationMs) / 1000.0f; renderer->getCharacterRenderer()->moveInstanceTo(instanceId, renderPos, durationSec); + // Play Run animation (anim 5) for the duration of the spline move. + // WoW M2 animation IDs: 4=Walk, 5=Run. + // 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); + } + if (!isPlayer) creatureWasMoving_[guid] = true; + } } }); @@ -2655,6 +2780,136 @@ void Application::setupUICallbacks() { } }); + // Unit animation hint callback — plays jump (38=JumpMid) animation on other players/NPCs. + // Swim/walking state is now authoritative from the move-flags callback below. + // animId=38 (JumpMid): airborne jump animation; land detection is via per-frame sync. + gameHandler->setUnitAnimHintCallback([this](uint64_t guid, uint32_t animId) { + if (!renderer) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + uint32_t instanceId = 0; + { + auto it = playerInstances_.find(guid); + if (it != playerInstances_.end()) instanceId = it->second; + } + if (instanceId == 0) { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) instanceId = it->second; + } + if (instanceId == 0) return; + // Don't override Death animation (1) + uint32_t curAnim = 0; float curT = 0.0f, curDur = 0.0f; + if (cr->getAnimationState(instanceId, curAnim, curT, curDur) && curAnim == 1) return; + cr->playAnimation(instanceId, animId, /*loop=*/true); + }); + + // Unit move-flags callback — updates swimming and walking state from every MSG_MOVE_* packet. + // This is more reliable than opcode-based hints for cold joins and heartbeats: + // a player already swimming when we join will have SWIMMING set on the first heartbeat. + // Walking(4) vs Running(5) is also driven here from the WALKING flag. + gameHandler->setUnitMoveFlagsCallback([this](uint64_t guid, uint32_t moveFlags) { + const bool isSwimming = (moveFlags & static_cast(game::MovementFlags::SWIMMING)) != 0; + const bool isWalking = (moveFlags & static_cast(game::MovementFlags::WALKING)) != 0; + if (isSwimming) creatureSwimmingState_[guid] = true; + else creatureSwimmingState_.erase(guid); + if (isWalking) creatureWalkingState_[guid] = true; + else creatureWalkingState_.erase(guid); + }); + + // Emote animation callback — play server-driven emote animations on NPCs and other players + gameHandler->setEmoteAnimCallback([this](uint64_t guid, uint32_t emoteAnim) { + if (!renderer || emoteAnim == 0) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + // Look up creature instance first, then online players + { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) { + cr->playAnimation(it->second, emoteAnim, false); + return; + } + } + { + auto it = playerInstances_.find(guid); + if (it != playerInstances_.end()) { + cr->playAnimation(it->second, emoteAnim, false); + } + } + }); + + // Spell cast animation callback — play cast animation on caster (player or NPC/other player) + gameHandler->setSpellCastAnimCallback([this](uint64_t guid, bool start, bool /*isChannel*/) { + if (!renderer) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + // Animation 3 = SpellCast (one-shot; return-to-idle handled by character_renderer) + const uint32_t castAnim = 3; + // Check player character + { + uint32_t charInstId = renderer->getCharacterInstanceId(); + if (charInstId != 0 && guid == gameHandler->getPlayerGuid()) { + if (start) cr->playAnimation(charInstId, castAnim, false); + // On finish: playAnimation(castAnim, loop=false) will auto-return to Stand + return; + } + } + // Check creatures and other online players + { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) { + if (start) cr->playAnimation(it->second, castAnim, false); + return; + } + } + { + auto it = playerInstances_.find(guid); + if (it != playerInstances_.end()) { + if (start) cr->playAnimation(it->second, castAnim, false); + } + } + }); + + // Ghost state callback — make player semi-transparent when in spirit form + gameHandler->setGhostStateCallback([this](bool isGhost) { + if (!renderer) return; + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + uint32_t charInstId = renderer->getCharacterInstanceId(); + if (charInstId == 0) return; + cr->setInstanceOpacity(charInstId, isGhost ? 0.5f : 1.0f); + }); + + // Stand state animation callback — map server stand state to M2 animation on player + // and sync camera sit flag so movement is blocked while sitting + gameHandler->setStandStateCallback([this](uint8_t standState) { + if (!renderer) return; + + // Sync camera controller sitting flag: block movement while sitting/kneeling + if (auto* cc = renderer->getCameraController()) { + cc->setSitting(standState >= 1 && standState <= 8 && standState != 7); + } + + auto* cr = renderer->getCharacterRenderer(); + if (!cr) return; + uint32_t charInstId = renderer->getCharacterInstanceId(); + if (charInstId == 0) return; + // WoW stand state → M2 animation ID mapping + // 0=Stand→0, 1-6=Sit variants→27 (SitGround), 7=Dead→1, 8=Kneel→72 + uint32_t animId = 0; + if (standState == 0) { + animId = 0; // Stand + } else if (standState >= 1 && standState <= 6) { + animId = 27; // SitGround (covers sit-chair too; correct visual differs by chair height) + } else if (standState == 7) { + animId = 1; // Death + } else if (standState == 8) { + animId = 72; // Kneel + } + // Loop sit/kneel (not death) so the held-pose frame stays visible + const bool loop = (animId != 1); + cr->playAnimation(charInstId, animId, loop); + }); + // NPC greeting callback - play voice line gameHandler->setNpcGreetingCallback([this](uint64_t guid, const glm::vec3& position) { if (renderer && renderer->getNpcVoiceManager()) { @@ -3348,7 +3603,9 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan auto itemDisplayDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); if (!itemDisplayDbc) return false; - auto itemDbc = assetManager->loadDBC("Item.dbc"); + // Item.dbc is not distributed to clients in Vanilla 1.12; on those expansions + // item display IDs are resolved via the server-sent item cache instead. + auto itemDbc = assetManager->loadDBCOptional("Item.dbc"); const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; const auto* itemL = pipeline::getActiveDBCLayout() @@ -3356,7 +3613,7 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan auto resolveDisplayInfoId = [&](uint32_t rawId) -> uint32_t { if (rawId == 0) return 0; - // AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID. + // Primary path: AzerothCore uses item entries in UNIT_VIRTUAL_ITEM_SLOT_ID. // Resolve strictly through Item.dbc entry -> DisplayID to avoid // accidental ItemDisplayInfo ID collisions (staff/hilt mismatches). if (itemDbc) { @@ -3369,6 +3626,17 @@ bool Application::tryAttachCreatureVirtualWeapons(uint64_t guid, uint32_t instan } } } + // Fallback: Vanilla 1.12 does not distribute Item.dbc to clients. + // Items arrive via SMSG_ITEM_QUERY_SINGLE_RESPONSE and are cached in + // itemInfoCache_. Use the server-sent displayInfoId when available. + if (!itemDbc && gameHandler) { + if (const auto* info = gameHandler->getItemInfo(rawId)) { + uint32_t displayIdB = info->displayInfoId; + if (displayIdB != 0 && itemDisplayDbc->findRecordById(displayIdB) >= 0) { + return displayIdB; + } + } + } return 0; }; @@ -4380,7 +4648,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float // During load screen warmup: lift per-frame budgets so GPU uploads // and spawns happen in bulk while the loading screen is still visible. processCreatureSpawnQueue(true); - processAsyncNpcCompositeResults(); + processAsyncNpcCompositeResults(true); // Process equipment queue more aggressively during warmup (multiple per iteration) for (int i = 0; i < 8 && (!deferredEquipmentQueue_.empty() || !asyncEquipmentLoads_.empty()); i++) { processDeferredEquipmentQueue(); @@ -4862,6 +5130,26 @@ bool Application::getRenderFootZForGuid(uint64_t guid, float& outFootZ) const { return renderer->getCharacterRenderer()->getInstanceFootZ(instanceId, outFootZ); } +bool Application::getRenderPositionForGuid(uint64_t guid, glm::vec3& outPos) const { + if (!renderer || !renderer->getCharacterRenderer()) return false; + uint32_t instanceId = 0; + + if (gameHandler && guid == gameHandler->getPlayerGuid()) { + instanceId = renderer->getCharacterInstanceId(); + } + if (instanceId == 0) { + auto pit = playerInstances_.find(guid); + if (pit != playerInstances_.end()) instanceId = pit->second; + } + if (instanceId == 0) { + auto it = creatureInstances_.find(guid); + if (it != creatureInstances_.end()) instanceId = it->second; + } + if (instanceId == 0) return false; + + return renderer->getCharacterRenderer()->getInstancePosition(instanceId, outPos); +} + pipeline::M2Model Application::loadCreatureM2Sync(const std::string& m2Path) { auto m2Data = assetManager->readFile(m2Path); if (m2Data.empty()) { @@ -5595,11 +5883,11 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x if (did == 0) return 0; int32_t idx = itemDisplayDbc->findRecordById(did); if (idx < 0) { - LOG_INFO("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc"); + LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " NOT FOUND in ItemDisplayInfo.dbc"); return 0; } uint32_t gg = itemDisplayDbc->getUInt32(static_cast(idx), fGG1); - LOG_INFO("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg, " (field=", fGG1, ")"); + LOG_DEBUG("NPC equip slot ", slotName, " displayId=", did, " GeosetGroup1=", gg); return gg; }; @@ -5729,23 +6017,6 @@ void Application::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float x activeGeosets.insert(101); // Default group 1 connector } - // Log model's actual submesh IDs for debugging geoset mismatches - if (auto* md = charRenderer->getModelData(modelId)) { - std::string batchIds; - for (const auto& b : md->batches) { - if (!batchIds.empty()) batchIds += ","; - batchIds += std::to_string(b.submeshId); - } - LOG_INFO("Model batches submeshIds: [", batchIds, "]"); - } - - // Log what geosets we're setting for debugging - std::string geosetList; - for (uint16_t g : activeGeosets) { - if (!geosetList.empty()) geosetList += ","; - geosetList += std::to_string(g); - } - LOG_INFO("NPC geosets for instance ", instanceId, ": [", geosetList, "]"); charRenderer->setActiveGeosets(instanceId, activeGeosets); if (geosetCape != 0 && npcCapeTextureId) { charRenderer->setGroupTextureOverride(instanceId, 15, npcCapeTextureId); @@ -6661,6 +6932,8 @@ void Application::despawnOnlinePlayer(uint64_t guid) { playerInstances_.erase(it); onlinePlayerAppearance_.erase(guid); pendingOnlinePlayerEquipment_.erase(guid); + creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); } void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation) { @@ -7052,11 +7325,21 @@ void Application::processAsyncCreatureResults(bool unlimited) { } } -void Application::processAsyncNpcCompositeResults() { +void Application::processAsyncNpcCompositeResults(bool unlimited) { auto* charRenderer = renderer ? renderer->getCharacterRenderer() : nullptr; if (!charRenderer) return; + // Budget: 2ms per frame to avoid stalling when many NPCs complete skin compositing + // simultaneously. In unlimited mode (load screen), process everything without cap. + static constexpr float kCompositeBudgetMs = 2.0f; + auto startTime = std::chrono::steady_clock::now(); + for (auto it = asyncNpcCompositeLoads_.begin(); it != asyncNpcCompositeLoads_.end(); ) { + if (!unlimited) { + float elapsed = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsed >= kCompositeBudgetMs) break; + } if (!it->future.valid() || it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { ++it; @@ -8243,6 +8526,9 @@ void Application::despawnOnlineCreature(uint64_t guid) { creatureRenderPosCache_.erase(guid); creatureWeaponsAttached_.erase(guid); creatureWeaponAttachAttempts_.erase(guid); + creatureWasMoving_.erase(guid); + creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 9d5e4587..747a557d 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -483,8 +483,13 @@ void GameHandler::disconnect() { playerNameCache.clear(); pendingNameQueries.clear(); friendGuids_.clear(); + contacts_.clear(); transportAttachments_.clear(); serverUpdatedTransportGuids_.clear(); + // Clear in-flight query sets so reconnect can re-issue queries for any + // entries whose responses were lost during the disconnect. + pendingCreatureQueries.clear(); + pendingGameObjectQueries_.clear(); requiresWarden_ = false; wardenGateSeen_ = false; wardenGateElapsed_ = 0.0f; @@ -498,6 +503,8 @@ void GameHandler::disconnect() { wardenModuleSize_ = 0; wardenModuleData_.clear(); wardenLoadedModule_.reset(); + // Clear entity state so reconnect sees fresh CREATE_OBJECT for all visible objects. + entityManager.clear(); setState(WorldState::DISCONNECTED); LOG_INFO("Disconnected from world server"); } @@ -520,6 +527,13 @@ void GameHandler::resetDbcCaches() { talentDbcLoaded_ = false; talentCache_.clear(); talentTabCache_.clear(); + // Clear the AssetManager DBC file cache so that expansion-specific DBCs + // (CharSections, ItemDisplayInfo, etc.) are reloaded from the new expansion's + // MPQ files instead of returning stale data from a previous session/expansion. + auto* am = core::Application::getInstance().getAssetManager(); + if (am) { + am->clearDBCCache(); + } LOG_INFO("GameHandler: DBC caches cleared for expansion switch"); } @@ -651,6 +665,30 @@ void GameHandler::update(float deltaTime) { } } + // Periodically re-query names for players whose initial CMSG_NAME_QUERY was + // lost (server didn't respond) or whose entity was recreated while the query + // was still pending. Runs every 5 seconds to keep overhead minimal. + if (state == WorldState::IN_WORLD && socket) { + static float nameResyncTimer = 0.0f; + nameResyncTimer += deltaTime; + if (nameResyncTimer >= 5.0f) { + nameResyncTimer = 0.0f; + for (const auto& [guid, entity] : entityManager.getEntities()) { + if (!entity || entity->getType() != ObjectType::PLAYER) continue; + if (guid == playerGuid) continue; + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) continue; + if (playerNameCache.count(guid)) continue; + if (pendingNameQueries.count(guid)) continue; + // Player entity exists with empty name and no pending query — resend. + LOG_DEBUG("Name resync: re-querying guid=0x", std::hex, guid, std::dec); + pendingNameQueries.insert(guid); + auto pkt = NameQueryPacket::build(guid); + socket->send(pkt); + } + } + } + if (pendingLootMoneyNotifyTimer_ > 0.0f) { pendingLootMoneyNotifyTimer_ -= deltaTime; if (pendingLootMoneyNotifyTimer_ <= 0.0f) { @@ -1573,13 +1611,21 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_EXPLORATION_EXPERIENCE: { // uint32 areaId + uint32 xpGained if (packet.getSize() - packet.getReadPos() >= 8) { - /*uint32_t areaId =*/ packet.readUInt32(); + uint32_t areaId = packet.readUInt32(); uint32_t xpGained = packet.readUInt32(); if (xpGained > 0) { - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Discovered new area! Gained %u experience.", xpGained); - addSystemChatMessage(buf); + std::string areaName = getAreaName(areaId); + std::string msg; + if (!areaName.empty()) { + msg = "Discovered " + areaName + "! Gained " + + std::to_string(xpGained) + " experience."; + } else { + char buf[128]; + std::snprintf(buf, sizeof(buf), + "Discovered new area! Gained %u experience.", xpGained); + msg = buf; + } + addSystemChatMessage(msg); // XP is updated via PLAYER_XP update fields from the server. } } @@ -2301,24 +2347,40 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_MONSTER_MOVE_TRANSPORT: handleMonsterMoveTransport(packet); break; - case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE: - case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE: case Opcode::SMSG_SPLINE_MOVE_FEATHER_FALL: case Opcode::SMSG_SPLINE_MOVE_GRAVITY_DISABLE: case Opcode::SMSG_SPLINE_MOVE_GRAVITY_ENABLE: case Opcode::SMSG_SPLINE_MOVE_LAND_WALK: case Opcode::SMSG_SPLINE_MOVE_NORMAL_FALL: case Opcode::SMSG_SPLINE_MOVE_ROOT: - case Opcode::SMSG_SPLINE_MOVE_SET_FLYING: - case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: - case Opcode::SMSG_SPLINE_MOVE_START_SWIM: - case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: { - // Minimal parse: PackedGuid only — entity state flag change. + case Opcode::SMSG_SPLINE_MOVE_SET_HOVER: { + // Minimal parse: PackedGuid only — no animation-relevant state change. if (packet.getSize() - packet.getReadPos() >= 1) { (void)UpdateObjectParser::readPackedGuid(packet); } break; } + case Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE: + case Opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE: + case Opcode::SMSG_SPLINE_MOVE_SET_FLYING: + case Opcode::SMSG_SPLINE_MOVE_START_SWIM: + case Opcode::SMSG_SPLINE_MOVE_STOP_SWIM: { + // PackedGuid + synthesised move-flags → drives animation state in application layer. + // SWIMMING=0x00200000, WALKING=0x00000100, CAN_FLY=0x00800000, FLYING=0x01000000 + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + if (guid == 0 || guid == playerGuid || !unitMoveFlagsCallback_) break; + uint32_t synthFlags = 0; + if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_START_SWIM) + synthFlags = 0x00200000u; // SWIMMING + else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE) + synthFlags = 0x00000100u; // WALKING + else if (*logicalOp == Opcode::SMSG_SPLINE_MOVE_SET_FLYING) + synthFlags = 0x01000000u | 0x00800000u; // FLYING | CAN_FLY + // STOP_SWIM and SET_RUN_MODE: synthFlags stays 0 → clears swim/walk + unitMoveFlagsCallback_(guid, synthFlags); + break; + } case Opcode::SMSG_SPLINE_SET_RUN_SPEED: case Opcode::SMSG_SPLINE_SET_RUN_BACK_SPEED: case Opcode::SMSG_SPLINE_SET_SWIM_SPEED: { @@ -2559,9 +2621,15 @@ void GameHandler::handlePacket(network::Packet& packet) { ssm->stopPrecast(); } } + if (spellCastAnimCallback_) { + spellCastAnimCallback_(playerGuid, false, false); + } } else { // Another unit's cast failed — clear their tracked cast bar unitCastStates_.erase(failGuid); + if (spellCastAnimCallback_) { + spellCastAnimCallback_(failGuid, false, false); + } } break; } @@ -3325,12 +3393,11 @@ void GameHandler::handlePacket(network::Packet& packet) { /*uint8_t mode =*/ packet.readUInt8(); rem--; constexpr int SERVER_BAR_SLOTS = 144; - constexpr int OUR_BAR_SLOTS = 12; // our actionBar array size for (int i = 0; i < SERVER_BAR_SLOTS; ++i) { if (rem < 4) break; uint32_t packed = packet.readUInt32(); rem -= 4; - if (i >= OUR_BAR_SLOTS) continue; // only load first bar + if (i >= ACTION_BAR_SLOTS) continue; // only load bars 1 and 2 if (packed == 0) { // Empty slot — only clear if not already set to Attack/Hearthstone defaults // so we don't wipe hardcoded fallbacks when the server sends zeros. @@ -3645,8 +3712,33 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::MSG_RAID_TARGET_UPDATE: + case Opcode::MSG_RAID_TARGET_UPDATE: { + // uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)), + // 1 = single update (uint8 icon + uint64 guid) + size_t remRTU = packet.getSize() - packet.getReadPos(); + if (remRTU < 1) break; + uint8_t rtuType = packet.readUInt8(); + if (rtuType == 0) { + // Full update: always 8 entries + for (uint32_t i = 0; i < kRaidMarkCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 9) break; + uint8_t icon = packet.readUInt8(); + uint64_t guid = packet.readUInt64(); + if (icon < kRaidMarkCount) + raidTargetGuids_[icon] = guid; + } + } else { + // Single update + if (packet.getSize() - packet.getReadPos() >= 9) { + uint8_t icon = packet.readUInt8(); + uint64_t guid = packet.readUInt64(); + if (icon < kRaidMarkCount) + raidTargetGuids_[icon] = guid; + } + } + LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast(rtuType)); break; + } case Opcode::SMSG_BUY_ITEM: { // uint64 vendorGuid + uint32 vendorSlot + int32 newCount + uint32 itemCount // Confirms a successful CMSG_BUY_ITEM. The inventory update arrives via SMSG_UPDATE_OBJECT. @@ -3938,9 +4030,12 @@ void GameHandler::handlePacket(network::Packet& packet) { } quest.killCounts[entry] = {count, reqCount}; - std::string progressMsg = quest.title + ": " + - std::to_string(count) + "/" + - std::to_string(reqCount); + std::string creatureName = getCachedCreatureName(entry); + std::string progressMsg = quest.title + ": "; + if (!creatureName.empty()) { + progressMsg += creatureName + " "; + } + progressMsg += std::to_string(count) + "/" + std::to_string(reqCount); addSystemChatMessage(progressMsg); LOG_INFO("Updated kill count for quest ", questId, ": ", @@ -4140,7 +4235,7 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_TRANSFER_PENDING: { // SMSG_TRANSFER_PENDING: uint32 mapId, then optional transport data uint32_t pendingMapId = packet.readUInt32(); - LOG_WARNING("SMSG_TRANSFER_PENDING: mapId=", pendingMapId); + LOG_INFO("SMSG_TRANSFER_PENDING: mapId=", pendingMapId); // Optional: if remaining data, there's a transport entry + mapId if (packet.getReadPos() + 8 <= packet.getSize()) { uint32_t transportEntry = packet.readUInt32(); @@ -4174,6 +4269,9 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("Stand state updated: ", static_cast(standState_), " (", standState_ == 0 ? "stand" : standState_ == 1 ? "sit" : standState_ == 7 ? "dead" : standState_ == 8 ? "kneel" : "other", ")"); + if (standStateCallback_) { + standStateCallback_(standState_); + } } break; case Opcode::SMSG_NEW_TAXI_PATH: @@ -4375,6 +4473,13 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_MOVE_HEARTBEAT: case Opcode::MSG_MOVE_START_SWIM: case Opcode::MSG_MOVE_STOP_SWIM: + case Opcode::MSG_MOVE_SET_WALK_MODE: + case Opcode::MSG_MOVE_SET_RUN_MODE: + case Opcode::MSG_MOVE_START_PITCH_UP: + case Opcode::MSG_MOVE_START_PITCH_DOWN: + case Opcode::MSG_MOVE_STOP_PITCH: + case Opcode::MSG_MOVE_START_ASCEND: + case Opcode::MSG_MOVE_STOP_ASCEND: if (state == WorldState::IN_WORLD) { handleOtherPlayerMovement(packet); } @@ -4588,8 +4693,9 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SET_REST_START: { if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t restTrigger = packet.readUInt32(); - addSystemChatMessage(restTrigger > 0 ? "You are now resting." - : "You are no longer resting."); + isResting_ = (restTrigger > 0); + addSystemChatMessage(isResting_ ? "You are now resting." + : "You are no longer resting."); } break; } @@ -5952,7 +6058,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { // Initialize movement info with world entry position (server → canonical) glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); - LOG_WARNING("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z, + LOG_DEBUG("LOGIN_VERIFY_WORLD: server=(", data.x, ", ", data.y, ", ", data.z, ") canonical=(", canonical.x, ", ", canonical.y, ", ", canonical.z, ") mapId=", data.mapId); movementInfo.x = canonical.x; movementInfo.y = canonical.y; @@ -5978,8 +6084,18 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { mountCallback_(0); } - // Clear boss encounter unit slots on world transfer + // Clear boss encounter unit slots and raid marks on world transfer encounterUnitGuids_.fill(0); + raidTargetGuids_.fill(0); + + // Reset talent initialization so the first SMSG_TALENTS_INFO after login + // correctly sets the active spec (static locals don't reset across logins) + talentsInitialized_ = false; + learnedTalents_[0].clear(); + learnedTalents_[1].clear(); + unspentTalentPoints_[0] = 0; + unspentTalentPoints_[1] = 0; + activeTalentSpec_ = 0; // Suppress area triggers on initial login — prevents exit portals from // immediately firing when spawning inside a dungeon/instance. @@ -5996,7 +6112,7 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { // Notify application to load terrain for this map/position (online mode) if (worldEntryCallback_) { - worldEntryCallback_(data.mapId, data.x, data.y, data.z); + worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); } // Auto-join default chat channels @@ -7247,7 +7363,8 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { static int updateObjErrors = 0; if (++updateObjErrors <= 5) LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT"); - return; + if (data.blocks.empty()) return; + // Fall through: process any blocks that were successfully parsed before the failure. } auto extractPlayerAppearance = [&](const std::map& fields, @@ -7403,6 +7520,9 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { otherPlayerMoveTimeMs_.erase(guid); inspectedPlayerItemEntries_.erase(guid); pendingAutoInspect_.erase(guid); + // Clear pending name query so the query is re-sent when this player + // comes back into range (entity is recreated as a new object). + pendingNameQueries.erase(guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { gameObjectDespawnCallback_(guid); } @@ -7652,6 +7772,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { releasedSpirit_ = true; playerDead_ = true; LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); } } // Determine hostility from faction template for online creatures @@ -7688,6 +7809,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { npcDeathCallback_(block.guid); } } + // Initialise swim/walk state from spawn-time movement flags (cold-join fix). + // Without this, an entity already swimming/walking when the client joins + // won't get its animation state set until the next MSG_MOVE_* heartbeat. + if (block.hasMovement && block.moveFlags != 0 && unitMoveFlagsCallback_ && + block.guid != playerGuid) { + unitMoveFlagsCallback_(block.guid, block.moveFlags); + } // Query quest giver status for NPCs with questgiver flag (0x02) if (block.objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && socket) { network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); @@ -7713,13 +7841,13 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { queryGameObjectInfo(itEntry->second, block.guid); } // Detect transport GameObjects via UPDATEFLAG_TRANSPORT (0x0002) - LOG_WARNING("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, + LOG_DEBUG("GameObject CREATE: guid=0x", std::hex, block.guid, std::dec, " entry=", go->getEntry(), " displayId=", go->getDisplayId(), " updateFlags=0x", std::hex, block.updateFlags, std::dec, " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); if (block.updateFlags & 0x0002) { transportGuids_.insert(block.guid); - LOG_WARNING("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, + LOG_INFO("Detected transport GameObject: 0x", std::hex, block.guid, std::dec, " entry=", go->getEntry(), " displayId=", go->getDisplayId(), " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); @@ -7775,6 +7903,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { bool slotsChanged = false; const uint16_t ufPlayerXp = fieldIndex(UF::PLAYER_XP); const uint16_t ufPlayerNextXp = fieldIndex(UF::PLAYER_NEXT_LEVEL_XP); + const uint16_t ufPlayerRestedXp = fieldIndex(UF::PLAYER_REST_STATE_EXPERIENCE); const uint16_t ufPlayerLevel = fieldIndex(UF::UNIT_FIELD_LEVEL); const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); @@ -7782,6 +7911,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { for (const auto& [key, val] : block.fields) { if (key == ufPlayerXp) { playerXp_ = val; } else if (key == ufPlayerNextXp) { playerNextLevelXp_ = val; } + else if (ufPlayerRestedXp != 0xFFFF && key == ufPlayerRestedXp) { playerRestedXp_ = val; } else if (key == ufPlayerLevel) { serverPlayerLevel_ = val; for (auto& ch : characters) { @@ -8113,12 +8243,14 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (!wasGhost && nowGhost) { releasedSpirit_ = true; LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); + if (ghostStateCallback_) ghostStateCallback_(true); } else if (wasGhost && !nowGhost) { releasedSpirit_ = false; playerDead_ = false; repopPending_ = false; resurrectPending_ = false; LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); + if (ghostStateCallback_) ghostStateCallback_(false); } } } @@ -8366,6 +8498,15 @@ void GameHandler::handleDestroyObject(network::Packet& packet) { if (entity) { if (entity->getType() == ObjectType::UNIT && creatureDespawnCallback_) { creatureDespawnCallback_(data.guid); + } else if (entity->getType() == ObjectType::PLAYER && playerDespawnCallback_) { + // Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range. + playerDespawnCallback_(data.guid); + otherPlayerVisibleItemEntries_.erase(data.guid); + otherPlayerVisibleDirty_.erase(data.guid); + otherPlayerMoveTimeMs_.erase(data.guid); + inspectedPlayerItemEntries_.erase(data.guid); + pendingAutoInspect_.erase(data.guid); + pendingNameQueries.erase(data.guid); } else if (entity->getType() == ObjectType::GAMEOBJECT && gameObjectDespawnCallback_) { gameObjectDespawnCallback_(data.guid); } @@ -8719,6 +8860,10 @@ void GameHandler::setTarget(uint64_t guid) { targetGuid = guid; + // Clear stale aura data from the previous target so the buff bar shows + // an empty state until the server sends SMSG_AURA_UPDATE_ALL for the new target. + for (auto& slot : targetAuras) slot = AuraSlot{}; + // Clear previous target's cast bar on target change // (the new target's cast state is naturally fetched from unitCastStates_ by GUID) @@ -9822,7 +9967,20 @@ void GameHandler::addLocalChatMessage(const MessageChatData& msg) { // ============================================================ void GameHandler::queryPlayerName(uint64_t guid) { - if (playerNameCache.count(guid) || pendingNameQueries.count(guid)) return; + // If already cached, apply the name to the entity (handles entity recreation after + // moving out/in range — the entity object is new but the cached name is valid). + auto cacheIt = playerNameCache.find(guid); + if (cacheIt != playerNameCache.end()) { + auto entity = entityManager.getEntity(guid); + if (entity && entity->getType() == ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (player->getName().empty()) { + player->setName(cacheIt->second); + } + } + return; + } + if (pendingNameQueries.count(guid)) return; if (state != WorldState::IN_WORLD || !socket) { LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec, " state=", worldStateName(state), " socket=", (socket ? "yes" : "no")); @@ -10037,8 +10195,8 @@ void GameHandler::queryItemInfo(uint32_t entry, uint64_t guid) { ? packetParsers_->buildItemQuery(entry, queryGuid) : ItemQueryPacket::build(entry, queryGuid); socket->send(packet); - LOG_INFO("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec, - " pending=", pendingItemQueries_.size()); + LOG_DEBUG("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec, + " pending=", pendingItemQueries_.size()); } void GameHandler::handleItemQueryResponse(network::Packet& packet) { @@ -10052,9 +10210,8 @@ void GameHandler::handleItemQueryResponse(network::Packet& packet) { } pendingItemQueries_.erase(data.entry); - LOG_INFO("handleItemQueryResponse: entry=", data.entry, " valid=", data.valid, - " name='", data.name, "' displayInfoId=", data.displayInfoId, - " pending=", pendingItemQueries_.size()); + LOG_DEBUG("handleItemQueryResponse: entry=", data.entry, " name='", data.name, + "' displayInfoId=", data.displayInfoId, " pending=", pendingItemQueries_.size()); if (data.valid) { itemInfoCache_[data.entry] = data; @@ -11056,7 +11213,8 @@ void GameHandler::handleForceSpeedChange(network::Packet& packet, const char* na if (guid != playerGuid) return; // Always ACK the speed change to prevent server stall. - if (socket && !isClassicLikeExpansion()) { + // Classic/TBC use full uint64 GUID; WotLK uses packed GUID. + if (socket) { network::Packet ack(wireOpcode(ackOpcode)); const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); @@ -11142,7 +11300,7 @@ void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) movementInfo.flags &= ~static_cast(MovementFlags::ROOT); } - if (!socket || isClassicLikeExpansion()) return; + if (!socket) return; uint16_t ackWire = wireOpcode(rooted ? Opcode::CMSG_FORCE_MOVE_ROOT_ACK : Opcode::CMSG_FORCE_MOVE_UNROOT_ACK); if (ackWire == 0xFFFF) return; @@ -11203,7 +11361,7 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* } } - if (!socket || isClassicLikeExpansion()) return; + if (!socket) return; uint16_t ackWire = wireOpcode(ackOpcode); if (ackWire == 0xFFFF) return; @@ -11258,7 +11416,7 @@ void GameHandler::handleMoveKnockBack(network::Packet& packet) { if (guid != playerGuid) return; - if (!socket || isClassicLikeExpansion()) return; + if (!socket) return; uint16_t ackWire = wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK); if (ackWire == 0xFFFF) return; @@ -12037,11 +12195,39 @@ void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { } otherPlayerMoveTimeMs_[moverGuid] = info.time; - entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, durationMs / 1000.0f); + // Classify the opcode so we can drive the correct entity update and animation. + const uint16_t wireOp = packet.getOpcode(); + const bool isStopOpcode = + (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP)) || + (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_STRAFE)) || + (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_TURN)) || + (wireOp == wireOpcode(Opcode::MSG_MOVE_STOP_SWIM)) || + (wireOp == wireOpcode(Opcode::MSG_MOVE_FALL_LAND)); + const bool isJumpOpcode = (wireOp == wireOpcode(Opcode::MSG_MOVE_JUMP)); - // Notify renderer + // For stop opcodes snap the entity position (duration=0) so it doesn't keep interpolating, + // and pass durationMs=0 to the renderer so the Run-anim flash is suppressed. + // The per-frame sync will detect no movement and play Stand on the next frame. + const float entityDuration = isStopOpcode ? 0.0f : (durationMs / 1000.0f); + entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, entityDuration); + + // Notify renderer of position change if (creatureMoveCallback_) { - creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, durationMs); + const uint32_t notifyDuration = isStopOpcode ? 0u : durationMs; + creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, notifyDuration); + } + + // Signal specific animation transitions that the per-frame sync can't detect reliably. + // WoW M2 animation ID 38=JumpMid (loops during airborne). + // Swim/walking state is now authoritative from the movement flags field via unitMoveFlagsCallback_. + if (unitAnimHintCallback_ && isJumpOpcode) { + unitAnimHintCallback_(moverGuid, 38u); + } + + // Fire move-flags callback so application.cpp can update swimming/walking state + // from the flags field embedded in every movement packet (covers heartbeats and cold joins). + if (unitMoveFlagsCallback_) { + unitMoveFlagsCallback_(moverGuid, info.flags); } } @@ -12059,7 +12245,7 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) // Not static — wireOpcode() depends on runtime active opcode table. - const std::array kMoveOpcodes = { + const std::array kMoveOpcodes = { wireOpcode(Opcode::MSG_MOVE_START_FORWARD), wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), wireOpcode(Opcode::MSG_MOVE_STOP), @@ -12075,6 +12261,13 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { wireOpcode(Opcode::MSG_MOVE_HEARTBEAT), wireOpcode(Opcode::MSG_MOVE_START_SWIM), wireOpcode(Opcode::MSG_MOVE_STOP_SWIM), + wireOpcode(Opcode::MSG_MOVE_SET_WALK_MODE), + wireOpcode(Opcode::MSG_MOVE_SET_RUN_MODE), + wireOpcode(Opcode::MSG_MOVE_START_PITCH_UP), + wireOpcode(Opcode::MSG_MOVE_START_PITCH_DOWN), + wireOpcode(Opcode::MSG_MOVE_STOP_PITCH), + wireOpcode(Opcode::MSG_MOVE_START_ASCEND), + wireOpcode(Opcode::MSG_MOVE_STOP_ASCEND), }; // Track unhandled sub-opcodes once per compressed packet (avoid log spam) @@ -12164,16 +12357,6 @@ void GameHandler::handleMonsterMove(network::Packet& packet) { return; } decompressed.resize(destLen); - // Dump ALL bytes for format diagnosis (remove once confirmed) - static int dumpCount = 0; - if (dumpCount < 10) { - ++dumpCount; - std::string hex; - for (size_t i = 0; i < destLen; ++i) { - char buf[4]; snprintf(buf, sizeof(buf), "%02X ", decompressed[i]); hex += buf; - } - LOG_INFO("MonsterMove decomp[", destLen, "]: ", hex); - } std::vector stripped; bool hasWrappedForm = stripWrappedSubpacket(decompressed, stripped); @@ -12763,11 +12946,8 @@ void GameHandler::handleInitialSpells(network::Packet& packet) { knownSpells = {data.spellIds.begin(), data.spellIds.end()}; - // Debug: check if specific spells are in initial spells - bool has527 = knownSpells.count(527u); - bool has988 = knownSpells.count(988u); - bool has1180 = knownSpells.count(1180u); - LOG_INFO("Initial spells include: 527=", has527, " 988=", has988, " 1180=", has1180); + LOG_DEBUG("Initial spells include: 527=", knownSpells.count(527u), + " 988=", knownSpells.count(988u), " 1180=", knownSpells.count(1180u)); // Ensure Attack (6603) and Hearthstone (8690) are always present knownSpells.insert(6603u); @@ -12846,6 +13026,10 @@ void GameHandler::handleSpellStart(network::Packet& packet) { s.spellId = data.spellId; s.timeTotal = data.castTime / 1000.0f; s.timeRemaining = s.timeTotal; + // Trigger cast animation on the casting unit + if (spellCastAnimCallback_) { + spellCastAnimCallback_(data.casterUnit, true, false); + } } // If this is the player's own cast, start cast bar @@ -12867,6 +13051,11 @@ void GameHandler::handleSpellStart(network::Packet& packet) { } } + // Trigger cast animation on player character + if (spellCastAnimCallback_) { + spellCastAnimCallback_(playerGuid, true, false); + } + // Hearthstone cast: begin pre-loading terrain at bind point during cast time // so tiles are ready when the teleport fires (avoids falling through un-loaded terrain). // Spell IDs: 6948 = Vanilla Hearthstone (rank 1), 8690 = TBC/WotLK Hearthstone @@ -12918,6 +13107,14 @@ void GameHandler::handleSpellGo(network::Packet& packet) { casting = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; + + // End cast animation on player character + if (spellCastAnimCallback_) { + spellCastAnimCallback_(playerGuid, false, false); + } + } else if (spellCastAnimCallback_) { + // End cast animation on other unit + spellCastAnimCallback_(data.casterUnit, false, false); } // Clear unit cast bar when the spell lands (for any tracked unit) @@ -13134,10 +13331,9 @@ void GameHandler::handleTalentsInfo(network::Packet& packet) { " unspent=", (int)unspentTalentPoints_[data.talentSpec], " learned=", learnedTalents_[data.talentSpec].size()); - // If this is the first spec received, set it as active - static bool firstSpecReceived = false; - if (!firstSpecReceived) { - firstSpecReceived = true; + // If this is the first spec received after login, set it as the active spec + if (!talentsInitialized_) { + talentsInitialized_ = true; activeTalentSpec_ = data.talentSpec; // Show message to player about active spec @@ -13261,6 +13457,9 @@ void GameHandler::handleGroupList(network::Packet& packet) { // WotLK 3.3.5a added a roles byte (group level + per-member) for the dungeon finder. // Classic 1.12 and TBC 2.4.3 do not send the roles byte. const bool hasRoles = isActiveExpansion("wotlk"); + // Reset before parsing — SMSG_GROUP_LIST is a full replacement, not a delta. + // Without this, repeated GROUP_LIST packets push duplicate members. + partyData = GroupListData{}; if (!GroupListParser::parse(packet, partyData, hasRoles)) return; if (partyData.isEmpty()) { @@ -14688,12 +14887,12 @@ void GameHandler::useItemInBag(int bagIndex, int slotIndex) { void GameHandler::useItemById(uint32_t itemId) { if (itemId == 0) return; - LOG_INFO("useItemById: searching for itemId=", itemId, " in backpack (", inventory.getBackpackSize(), " slots)"); + LOG_DEBUG("useItemById: searching for itemId=", itemId); // Search backpack first for (int i = 0; i < inventory.getBackpackSize(); i++) { const auto& slot = inventory.getBackpackSlot(i); if (!slot.empty() && slot.item.itemId == itemId) { - LOG_INFO("useItemById: found itemId=", itemId, " at backpack slot ", i); + LOG_DEBUG("useItemById: found itemId=", itemId, " at backpack slot ", i); useItemBySlot(i); return; } @@ -14704,7 +14903,7 @@ void GameHandler::useItemById(uint32_t itemId) { for (int slot = 0; slot < bagSize; slot++) { const auto& bagSlot = inventory.getBagSlot(bag, slot); if (!bagSlot.empty() && bagSlot.item.itemId == itemId) { - LOG_INFO("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot); + LOG_DEBUG("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot); useItemInBag(bag, slot); return; } @@ -15003,29 +15202,24 @@ void GameHandler::handleTrainerList(network::Packet& packet) { trainerWindowOpen_ = true; gossipWindowOpen = false; - // Debug: log known spells - LOG_INFO("Known spells count: ", knownSpells.size()); + LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells"); + LOG_DEBUG("Known spells count: ", knownSpells.size()); if (knownSpells.size() <= 50) { std::string spellList; for (uint32_t id : knownSpells) { if (!spellList.empty()) spellList += ", "; spellList += std::to_string(id); } - LOG_INFO("Known spells: ", spellList); + LOG_DEBUG("Known spells: ", spellList); } - // Check if specific prerequisite spells are known - bool has527 = knownSpells.count(527u); - bool has25312 = knownSpells.count(25312u); - LOG_INFO("Prerequisite check: 527=", has527, " 25312=", has25312); - - // Debug: log first few trainer spells to see their state - LOG_INFO("Trainer spells received: ", currentTrainerList_.spells.size(), " spells"); + LOG_DEBUG("Prerequisite check: 527=", knownSpells.count(527u), + " 25312=", knownSpells.count(25312u)); for (size_t i = 0; i < std::min(size_t(5), currentTrainerList_.spells.size()); ++i) { const auto& s = currentTrainerList_.spells[i]; - LOG_INFO(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state, - " cost=", s.spellCost, " reqLvl=", (int)s.reqLevel, - " chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")"); + LOG_DEBUG(" Spell[", i, "]: id=", s.spellId, " state=", (int)s.state, + " cost=", s.spellCost, " reqLvl=", (int)s.reqLevel, + " chain=(", s.chainNode1, ",", s.chainNode2, ",", s.chainNode3, ")"); } @@ -15460,12 +15654,13 @@ void GameHandler::handleTeleportAck(network::Packet& packet) { // Send the ack back to the server // Client→server MSG_MOVE_TELEPORT_ACK: u64 guid + u32 counter + u32 time - if (socket && !isClassicLikeExpansion()) { + // Classic/TBC use full uint64 GUID; WotLK uses packed GUID. + if (socket) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK)); const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); if (legacyGuidAck) { - ack.writeUInt64(playerGuid); // CMaNGOS expects full GUID for teleport ACK + ack.writeUInt64(playerGuid); // CMaNGOS/VMaNGOS expects full GUID for Classic/TBC } else { MovementPacket::writePackedGuid(ack, playerGuid); } @@ -15478,7 +15673,7 @@ void GameHandler::handleTeleportAck(network::Packet& packet) { // Notify application of teleport — the callback decides whether to do // a full world reload (map change) or just update position (same map). if (worldEntryCallback_) { - worldEntryCallback_(currentMapId_, serverX, serverY, serverZ); + worldEntryCallback_(currentMapId_, serverX, serverY, serverZ, false); } } @@ -15495,7 +15690,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { float serverZ = packet.readFloat(); float orientation = packet.readFloat(); - LOG_WARNING("SMSG_NEW_WORLD: mapId=", mapId, + LOG_INFO("SMSG_NEW_WORLD: mapId=", mapId, " pos=(", serverX, ", ", serverY, ", ", serverZ, ")", " orient=", orientation); @@ -15585,7 +15780,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { // Reload terrain at new position if (worldEntryCallback_) { - worldEntryCallback_(mapId, serverX, serverY, serverZ); + worldEntryCallback_(mapId, serverX, serverY, serverZ, false); } } @@ -16398,16 +16593,23 @@ void GameHandler::handleWho(network::Packet& packet) { uint32_t raceId = packet.readUInt32(); if (hasGender && packet.getSize() - packet.getReadPos() >= 1) packet.readUInt8(); // gender (WotLK only, unused) + uint32_t zoneId = 0; if (packet.getSize() - packet.getReadPos() >= 4) - packet.readUInt32(); // zoneId (unused) + zoneId = packet.readUInt32(); std::string msg = " " + playerName; if (!guildName.empty()) msg += " <" + guildName + ">"; msg += " - Level " + std::to_string(level); + if (zoneId != 0) { + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + msg += " [" + zoneName + "]"; + } addSystemChatMessage(msg); - LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, " Race:", raceId); + LOG_INFO(" ", playerName, " (", guildName, ") Lv", level, " Class:", classId, + " Race:", raceId, " Zone:", zoneId); } } @@ -16425,6 +16627,11 @@ void GameHandler::handleFriendList(network::Packet& packet) { if (rem() < 1) return; uint8_t count = packet.readUInt8(); LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries"); + + // Rebuild friend contacts (keep ignores from previous contact_ entries) + contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(), + [](const ContactEntry& e){ return e.isFriend(); }), contacts_.end()); + for (uint8_t i = 0; i < count && rem() >= 9; ++i) { uint64_t guid = packet.readUInt64(); uint8_t status = packet.readUInt8(); @@ -16434,18 +16641,28 @@ void GameHandler::handleFriendList(network::Packet& packet) { level = packet.readUInt32(); classId = packet.readUInt32(); } - (void)area; (void)level; (void)classId; // Track as a friend GUID; resolve name via name query friendGuids_.insert(guid); auto nit = playerNameCache.find(guid); + std::string name; if (nit != playerNameCache.end()) { - friendsCache[nit->second] = guid; - LOG_INFO(" Friend: ", nit->second, " status=", (int)status); + name = nit->second; + friendsCache[name] = guid; + LOG_INFO(" Friend: ", name, " status=", (int)status); } else { LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec, " status=", (int)status, " (name pending)"); queryPlayerName(guid); } + ContactEntry entry; + entry.guid = guid; + entry.name = name; + entry.flags = 0x1; // friend + entry.status = status; + entry.areaId = area; + entry.level = level; + entry.classId = classId; + contacts_.push_back(std::move(entry)); } } @@ -16469,19 +16686,23 @@ void GameHandler::handleContactList(network::Packet& packet) { } lastContactListMask_ = packet.readUInt32(); lastContactListCount_ = packet.readUInt32(); + contacts_.clear(); for (uint32_t i = 0; i < lastContactListCount_ && rem() >= 8; ++i) { uint64_t guid = packet.readUInt64(); if (rem() < 4) break; uint32_t flags = packet.readUInt32(); std::string note = packet.readString(); // may be empty - (void)note; + uint8_t status = 0; + uint32_t areaId = 0; + uint32_t level = 0; + uint32_t classId = 0; if (flags & 0x1) { // SOCIAL_FLAG_FRIEND if (rem() < 1) break; - uint8_t status = packet.readUInt8(); + status = packet.readUInt8(); if (status != 0 && rem() >= 12) { - packet.readUInt32(); // area - packet.readUInt32(); // level - packet.readUInt32(); // class + areaId = packet.readUInt32(); + level = packet.readUInt32(); + classId = packet.readUInt32(); } friendGuids_.insert(guid); auto nit = playerNameCache.find(guid); @@ -16492,6 +16713,17 @@ void GameHandler::handleContactList(network::Packet& packet) { } } // ignore / mute entries: no additional fields beyond guid+flags+note + ContactEntry entry; + entry.guid = guid; + entry.flags = flags; + entry.note = std::move(note); + entry.status = status; + entry.areaId = areaId; + entry.level = level; + entry.classId = classId; + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) entry.name = nit->second; + contacts_.push_back(std::move(entry)); } LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_, " count=", lastContactListCount_); @@ -16520,6 +16752,28 @@ void GameHandler::handleFriendStatus(network::Packet& packet) { friendsCache.erase(playerName); } + // Mirror into contacts_: update existing entry or add/remove as needed + if (data.status == 0) { // Removed from friends list + contacts_.erase(std::remove_if(contacts_.begin(), contacts_.end(), + [&](const ContactEntry& e){ return e.guid == data.guid; }), contacts_.end()); + } else { + auto cit = std::find_if(contacts_.begin(), contacts_.end(), + [&](const ContactEntry& e){ return e.guid == data.guid; }); + if (cit != contacts_.end()) { + if (!playerName.empty() && playerName != "Unknown") cit->name = playerName; + // status: 2=online→1, 3=offline→0, 1=added→1 (online on add) + if (data.status == 2) cit->status = 1; + else if (data.status == 3) cit->status = 0; + } else { + ContactEntry entry; + entry.guid = data.guid; + entry.name = playerName; + entry.flags = 0x1; // friend + entry.status = (data.status == 2) ? 1 : 0; + contacts_.push_back(std::move(entry)); + } + } + // Status messages switch (data.status) { case 0: @@ -17361,7 +17615,7 @@ void GameHandler::handleShowBank(network::Packet& packet) { for (int i = 0; i < effectiveBankBagSlots_; i++) { if (inventory.getBankBagSize(i) > 0) filledBags++; } - LOG_WARNING("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec, + LOG_INFO("SMSG_SHOW_BANK: banker=0x", std::hex, bankerGuid_, std::dec, " purchased=", static_cast(inventory.getPurchasedBankBagSlots()), " filledBags=", filledBags, " effectiveBankBagSlots=", effectiveBankBagSlots_); @@ -17370,7 +17624,7 @@ void GameHandler::handleShowBank(network::Packet& packet) { void GameHandler::handleBuyBankSlotResult(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t result = packet.readUInt32(); - LOG_WARNING("SMSG_BUY_BANK_SLOT_RESULT: result=", result); + LOG_INFO("SMSG_BUY_BANK_SLOT_RESULT: result=", result); // AzerothCore/TrinityCore: 0=TOO_MANY, 1=INSUFFICIENT_FUNDS, 2=NOT_BANKER, 3=OK if (result == 3) { addSystemChatMessage("Bank slot purchased."); @@ -18124,6 +18378,45 @@ const std::string& GameHandler::getFactionNamePublic(uint32_t factionId) const { return empty; } +// --------------------------------------------------------------------------- +// Area name cache (lazy-loaded from WorldMapArea.dbc) +// --------------------------------------------------------------------------- + +void GameHandler::loadAreaNameCache() { + if (areaNameCacheLoaded_) return; + areaNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("WorldMapArea.dbc"); + if (!dbc || !dbc->isLoaded()) return; + + const auto* layout = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("WorldMapArea") : nullptr; + const uint32_t areaIdField = layout ? (*layout)["AreaID"] : 2; + const uint32_t areaNameField = layout ? (*layout)["AreaName"] : 3; + + if (dbc->getFieldCount() <= areaNameField) return; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t areaId = dbc->getUInt32(i, areaIdField); + if (areaId == 0) continue; + std::string name = dbc->getString(i, areaNameField); + if (!name.empty() && !areaNameCache_.count(areaId)) { + areaNameCache_[areaId] = std::move(name); + } + } + LOG_INFO("WorldMapArea.dbc: loaded ", areaNameCache_.size(), " area names"); +} + +std::string GameHandler::getAreaName(uint32_t areaId) const { + if (areaId == 0) return {}; + const_cast(this)->loadAreaNameCache(); + auto it = areaNameCache_.find(areaId); + return (it != areaNameCache_.end()) ? it->second : std::string{}; +} + // --------------------------------------------------------------------------- // Aura duration update // --------------------------------------------------------------------------- diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index c0ab0c88..33d39b77 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -103,6 +103,7 @@ bool ClassicPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlo /*float turnRate =*/ packet.readFloat(); block.runSpeed = runSpeed; + block.moveFlags = moveFlags; // Spline data (Classic: SPLINE_ENABLED=0x00400000) if (moveFlags & ClassicMoveFlags::SPLINE_ENABLED) { @@ -447,9 +448,9 @@ bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Att data.blocked = packet.readUInt32(); } - LOG_INFO("[Classic] Melee hit: ", data.totalDamage, " damage", - data.isCrit() ? " (CRIT)" : "", - data.isMiss() ? " (MISS)" : ""); + LOG_DEBUG("[Classic] Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); return true; } @@ -484,8 +485,8 @@ bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDam data.isCrit = (flags & 0x02) != 0; data.overkill = 0; // no overkill field in Vanilla (same as TBC) - LOG_INFO("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); return true; } @@ -510,8 +511,8 @@ bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealL data.overheal = packet.readUInt32(); data.isCrit = (packet.readUInt8() != 0); - LOG_INFO("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); return true; } @@ -700,13 +701,9 @@ bool ClassicPacketParsers::parseCharEnum(network::Packet& packet, CharEnumRespon character.equipment.push_back(item); } - LOG_INFO(" Character ", (int)(i + 1), ": ", character.name); - LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec); - LOG_INFO(" ", getRaceName(character.race), " ", - getClassName(character.characterClass), " (", - getGenderName(character.gender), ")"); - LOG_INFO(" Level: ", (int)character.level); - LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId); + LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name, + " (", getRaceName(character.race), " ", getClassName(character.characterClass), + " level ", (int)character.level, " zone ", character.zoneId, ")"); response.characters.push_back(character); } @@ -1016,7 +1013,7 @@ bool ClassicPacketParsers::parseGossipMessage(network::Packet& packet, GossipMes data.quests.push_back(quest); } - LOG_INFO("Classic Gossip: ", optionCount, " options, ", questCount, " quests"); + LOG_DEBUG("Classic Gossip: ", optionCount, " options, ", questCount, " quests"); return true; } @@ -1507,7 +1504,7 @@ bool TurtlePacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveD packet.setReadPos(start); if (MonsterMoveParser::parse(packet, data)) { - LOG_WARNING("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout"); + LOG_DEBUG("[Turtle] SMSG_MONSTER_MOVE parsed via WotLK fallback layout"); return true; } @@ -1561,66 +1558,6 @@ network::Packet ClassicPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, u return packet; } -// ============================================================================ -// Classic SMSG_QUESTGIVER_QUEST_DETAILS — Vanilla 1.12 format -// WotLK inserts an informUnit GUID (8 bytes) between npcGuid and questId. -// Vanilla has: npcGuid(8) + questId(4) + title + details + objectives + ... -// ============================================================================ -bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsData& data) { - if (packet.getSize() < 16) return false; - - data.npcGuid = packet.readUInt64(); - // Vanilla: questId follows immediately — no informUnit GUID - data.questId = packet.readUInt32(); - data.title = normalizeWowTextTokens(packet.readString()); - data.details = normalizeWowTextTokens(packet.readString()); - data.objectives = normalizeWowTextTokens(packet.readString()); - - if (packet.getReadPos() + 5 > packet.getSize()) { - LOG_INFO("Quest details classic (short): id=", data.questId, " title='", data.title, "'"); - return !data.title.empty() || data.questId != 0; - } - - /*activateAccept*/ packet.readUInt8(); - data.suggestedPlayers = packet.readUInt32(); - - // Vanilla 1.12: emote section before reward items - // Format: emoteCount(u32) + [delay(u32) + type(u32)] × emoteCount - if (packet.getReadPos() + 4 <= packet.getSize()) { - uint32_t emoteCount = packet.readUInt32(); - for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { - packet.readUInt32(); // delay - packet.readUInt32(); // type - } - } - - // Choice reward items: variable count + 3 uint32s each - if (packet.getReadPos() + 4 <= packet.getSize()) { - uint32_t choiceCount = packet.readUInt32(); - for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo - } - } - - // Fixed reward items: variable count + 3 uint32s each - if (packet.getReadPos() + 4 <= packet.getSize()) { - uint32_t rewardCount = packet.readUInt32(); - for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { - packet.readUInt32(); // itemId - packet.readUInt32(); // count - packet.readUInt32(); // displayInfo - } - } - - if (packet.getReadPos() + 4 <= packet.getSize()) - data.rewardMoney = packet.readUInt32(); - - LOG_INFO("Quest details classic: id=", data.questId, " title='", data.title, "'"); - return true; -} - // ============================================================================ // ClassicPacketParsers::parseCreatureQueryResponse // diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index 5cef0290..d4cad578 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -116,6 +116,7 @@ bool TbcPacketParsers::parseMovementBlock(network::Packet& packet, UpdateBlock& /*float turnRate =*/ packet.readFloat(); block.runSpeed = runSpeed; + block.moveFlags = moveFlags; // Spline data (TBC/WotLK: SPLINE_ENABLED = 0x08000000) if (moveFlags & TbcMoveFlags::SPLINE_ENABLED) { @@ -355,13 +356,9 @@ bool TbcPacketParsers::parseCharEnum(network::Packet& packet, CharEnumResponse& character.equipment.push_back(item); } - LOG_INFO(" Character ", (int)(i + 1), ": ", character.name); - LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec); - LOG_INFO(" ", getRaceName(character.race), " ", - getClassName(character.characterClass), " (", - getGenderName(character.gender), ")"); - LOG_INFO(" Level: ", (int)character.level); - LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId); + LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name, + " (", getRaceName(character.race), " ", getClassName(character.characterClass), + " level ", (int)character.level, " zone ", character.zoneId, ")"); response.characters.push_back(character); } @@ -488,7 +485,7 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa packet.setReadPos(startPos); if (parseWithLayout(false, parsed)) { - LOG_WARNING("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); + LOG_DEBUG("[TBC] SMSG_UPDATE_OBJECT parsed without has_transport byte fallback"); data = std::move(parsed); return true; } @@ -540,7 +537,7 @@ bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessage data.quests.push_back(quest); } - LOG_INFO("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests"); + LOG_DEBUG("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests"); return true; } @@ -698,6 +695,75 @@ network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint3 return packet; } +// ============================================================================ +// TBC 2.4.3 SMSG_QUESTGIVER_QUEST_DETAILS +// +// TBC and Classic share the same format — neither has the WotLK-specific fields +// (informUnit GUID, flags uint32, isFinished uint8) that were added in 3.x. +// +// Format: +// npcGuid(8) + questId(4) + title + details + objectives +// + activateAccept(1) + suggestedPlayers(4) +// + emoteCount(4) + [delay(4)+type(4)] × emoteCount +// + choiceCount(4) + [itemId(4)+count(4)+displayInfo(4)] × choiceCount +// + rewardCount(4) + [itemId(4)+count(4)+displayInfo(4)] × rewardCount +// + rewardMoney(4) + rewardXp(4) +// ============================================================================ +bool TbcPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetailsData& data) { + if (packet.getSize() < 16) return false; + + data.npcGuid = packet.readUInt64(); + data.questId = packet.readUInt32(); + data.title = normalizeWowTextTokens(packet.readString()); + data.details = normalizeWowTextTokens(packet.readString()); + data.objectives = normalizeWowTextTokens(packet.readString()); + + if (packet.getReadPos() + 5 > packet.getSize()) { + LOG_DEBUG("Quest details tbc/classic (short): id=", data.questId, " title='", data.title, "'"); + return !data.title.empty() || data.questId != 0; + } + + /*activateAccept*/ packet.readUInt8(); + data.suggestedPlayers = packet.readUInt32(); + + // TBC/Classic: emote section before reward items + if (packet.getReadPos() + 4 <= packet.getSize()) { + uint32_t emoteCount = packet.readUInt32(); + for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { + packet.readUInt32(); // delay + packet.readUInt32(); // type + } + } + + // Choice reward items (variable count, up to QUEST_REWARD_CHOICES_COUNT) + if (packet.getReadPos() + 4 <= packet.getSize()) { + uint32_t choiceCount = packet.readUInt32(); + for (uint32_t i = 0; i < choiceCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { + packet.readUInt32(); // itemId + packet.readUInt32(); // count + packet.readUInt32(); // displayInfo + } + } + + // Fixed reward items (variable count, up to QUEST_REWARDS_COUNT) + if (packet.getReadPos() + 4 <= packet.getSize()) { + uint32_t rewardCount = packet.readUInt32(); + for (uint32_t i = 0; i < rewardCount && packet.getReadPos() + 12 <= packet.getSize(); ++i) { + packet.readUInt32(); // itemId + packet.readUInt32(); // count + packet.readUInt32(); // displayInfo + } + } + + if (packet.getReadPos() + 4 <= packet.getSize()) + data.rewardMoney = packet.readUInt32(); + if (packet.getReadPos() + 4 <= packet.getSize()) + data.rewardXp = packet.readUInt32(); + + LOG_DEBUG("Quest details tbc/classic: id=", data.questId, " title='", data.title, "'"); + return true; +} + // ============================================================================ // TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST // @@ -718,7 +784,7 @@ network::Packet TbcPacketParsers::buildQueryQuestPacket(uint64_t npcGuid, uint32 // SMSG_SET_EXTRA_AURA_INFO_OBSOLETE (0x3A4) instead // ============================================================================ bool TbcPacketParsers::parseAuraUpdate(network::Packet& /*packet*/, AuraUpdateData& /*data*/, bool /*isAll*/) { - LOG_WARNING("[TBC] parseAuraUpdate called but SMSG_AURA_UPDATE does not exist in TBC 2.4.3"); + LOG_DEBUG("[TBC] parseAuraUpdate called but SMSG_AURA_UPDATE does not exist in TBC 2.4.3"); return false; } @@ -1131,9 +1197,9 @@ bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, Attacke data.blocked = packet.readUInt32(); } - LOG_INFO("[TBC] Melee hit: ", data.totalDamage, " damage", - data.isCrit() ? " (CRIT)" : "", - data.isMiss() ? " (MISS)" : ""); + LOG_DEBUG("[TBC] Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); return true; } @@ -1163,8 +1229,8 @@ bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageL // TBC does not have an overkill field here data.overkill = 0; - LOG_INFO("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); return true; } @@ -1187,8 +1253,8 @@ bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogDa data.isCrit = (critFlag != 0); } - LOG_INFO("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); return true; } diff --git a/src/game/warden_emulator.cpp b/src/game/warden_emulator.cpp index 1d43768b..5fadc408 100644 --- a/src/game/warden_emulator.cpp +++ b/src/game/warden_emulator.cpp @@ -14,9 +14,11 @@ namespace game { #ifdef HAVE_UNICORN // Memory layout for emulated environment +// Note: heap must not overlap the module region (typically loaded at 0x400000) +// or the stack. Keep heap above 0x02000000 (32MB) to leave space for module + padding. constexpr uint32_t STACK_BASE = 0x00100000; // 1MB constexpr uint32_t STACK_SIZE = 0x00100000; // 1MB stack -constexpr uint32_t HEAP_BASE = 0x00200000; // 2MB +constexpr uint32_t HEAP_BASE = 0x02000000; // 32MB — well above typical module base (0x400000) constexpr uint32_t HEAP_SIZE = 0x01000000; // 16MB heap constexpr uint32_t API_STUB_BASE = 0x70000000; // API stub area (high memory) @@ -58,6 +60,17 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 moduleBase_ = baseAddress; moduleSize_ = (moduleSize + 0xFFF) & ~0xFFF; // Align to 4KB + // Detect overlap between module and heap/stack regions early. + uint32_t modEnd = moduleBase_ + moduleSize_; + if (modEnd > heapBase_ && moduleBase_ < heapBase_ + heapSize_) { + std::cerr << "[WardenEmulator] Module [0x" << std::hex << moduleBase_ + << ", 0x" << modEnd << ") overlaps heap [0x" << heapBase_ + << ", 0x" << (heapBase_ + heapSize_) << ") — adjust HEAP_BASE\n" << std::dec; + uc_close(uc_); + uc_ = nullptr; + return false; + } + // Map module memory (code + data) err = uc_mem_map(uc_, moduleBase_, moduleSize_, UC_PROT_ALL); if (err != UC_ERR_OK) { @@ -108,6 +121,15 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 return false; } + // Map a null guard page at address 0 (read-only, zeroed) so that NULL-pointer + // dereferences in the module don't crash the emulator with UC_ERR_MAP. + // This allows execution to continue past NULL reads, making diagnostics easier. + err = uc_mem_map(uc_, 0x0, 0x1000, UC_PROT_READ); + if (err != UC_ERR_OK) { + // Non-fatal — just log it; the emulator will still function + std::cerr << "[WardenEmulator] Note: could not map null guard page: " << uc_strerror(err) << '\n'; + } + // Add hooks for debugging and invalid memory access uc_hook hh; uc_hook_add(uc_, &hh, UC_HOOK_MEM_INVALID, (void*)hookMemInvalid, this, 1, 0); diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 4ecc1555..d47c568d 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -228,10 +228,10 @@ std::vector AuthSessionPacket::computeAuthHash( } return s; }; - LOG_INFO("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed, - " serverSeed=0x", serverSeed, std::dec); - LOG_INFO("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size())); - LOG_INFO("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size())); + LOG_DEBUG("AUTH HASH: account='", accountName, "' clientSeed=0x", std::hex, clientSeed, + " serverSeed=0x", serverSeed, std::dec); + LOG_DEBUG("AUTH HASH: sessionKey=", toHex(sessionKey.data(), sessionKey.size())); + LOG_DEBUG("AUTH HASH: input(", hashInput.size(), ")=", toHex(hashInput.data(), hashInput.size())); } // Compute SHA1 hash @@ -245,7 +245,7 @@ std::vector AuthSessionPacket::computeAuthHash( } return s; }; - LOG_INFO("AUTH HASH: digest=", toHex(result.data(), result.size())); + LOG_DEBUG("AUTH HASH: digest=", toHex(result.data(), result.size())); } return result; @@ -265,22 +265,22 @@ bool AuthChallengeParser::parse(network::Packet& packet, AuthChallengeData& data // Original vanilla/TBC format: just the server seed (4 bytes) data.unknown1 = 0; data.serverSeed = packet.readUInt32(); - LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (TBC format, 4 bytes):"); + LOG_INFO("SMSG_AUTH_CHALLENGE: TBC format (", packet.getSize(), " bytes)"); } else if (packet.getSize() < 40) { // Vanilla with encryption seeds (36 bytes): serverSeed + 32 bytes seeds // No "unknown1" prefix — first uint32 IS the server seed data.unknown1 = 0; data.serverSeed = packet.readUInt32(); - LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (Classic+seeds format, ", packet.getSize(), " bytes):"); + LOG_INFO("SMSG_AUTH_CHALLENGE: Classic+seeds format (", packet.getSize(), " bytes)"); } else { // WotLK format (40+ bytes): unknown1 + serverSeed + 32 bytes encryption seeds data.unknown1 = packet.readUInt32(); data.serverSeed = packet.readUInt32(); - LOG_INFO("Parsed SMSG_AUTH_CHALLENGE (WotLK format, ", packet.getSize(), " bytes):"); - LOG_INFO(" Unknown1: 0x", std::hex, data.unknown1, std::dec); + LOG_INFO("SMSG_AUTH_CHALLENGE: WotLK format (", packet.getSize(), " bytes)"); + LOG_DEBUG(" Unknown1: 0x", std::hex, data.unknown1, std::dec); } - LOG_INFO(" Server seed: 0x", std::hex, data.serverSeed, std::dec); + LOG_DEBUG(" Server seed: 0x", std::hex, data.serverSeed, std::dec); return true; } @@ -480,21 +480,9 @@ bool CharEnumParser::parse(network::Packet& packet, CharEnumResponse& response) character.equipment.push_back(item); } - LOG_INFO(" Character ", (int)(i + 1), ": ", character.name); - LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec); - LOG_INFO(" ", getRaceName(character.race), " ", - getClassName(character.characterClass), " (", - getGenderName(character.gender), ")"); - LOG_INFO(" Level: ", (int)character.level); - LOG_INFO(" Location: Zone ", character.zoneId, ", Map ", character.mapId); - LOG_INFO(" Position: (", character.x, ", ", character.y, ", ", character.z, ")"); - if (character.hasGuild()) { - LOG_INFO(" Guild ID: ", character.guildId); - } - if (character.hasPet()) { - LOG_INFO(" Pet: Model ", character.pet.displayModel, - ", Level ", character.pet.level); - } + LOG_DEBUG(" Character ", (int)(i + 1), ": ", character.name, + " (", getRaceName(character.race), " ", getClassName(character.characterClass), + " level ", (int)character.level, " zone ", character.zoneId, ")"); response.characters.push_back(character); } @@ -598,8 +586,7 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) { uint32_t lineCount = packet.readUInt32(); - LOG_INFO("Parsed SMSG_MOTD:"); - LOG_INFO(" Line count: ", lineCount); + LOG_INFO("Parsed SMSG_MOTD: ", lineCount, " line(s)"); data.lines.clear(); data.lines.reserve(lineCount); @@ -607,7 +594,7 @@ bool MotdParser::parse(network::Packet& packet, MotdData& data) { for (uint32_t i = 0; i < lineCount; ++i) { std::string line = packet.readString(); data.lines.push_back(line); - LOG_INFO(" [", i + 1, "] ", line); + LOG_DEBUG(" MOTD[", i + 1, "]: ", line); } return true; @@ -878,7 +865,19 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock } // Swimming/flying pitch - if ((moveFlags & 0x02000000) || (moveFlags2 & 0x0010)) { // MOVEMENTFLAG_SWIMMING or MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING + // WotLK 3.3.5a movement flags relevant here: + // SWIMMING = 0x00200000 + // FLYING = 0x01000000 (player/creature actively flying) + // SPLINE_ELEVATION = 0x02000000 (smooth vertical spline offset — no pitch field) + // MovementFlags2: + // MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING = 0x0010 + // + // Pitch is present when SWIMMING or FLYING are set, or the always-allow flag is set. + // The original code checked 0x02000000 (SPLINE_ELEVATION) which neither covers SWIMMING + // nor FLYING, causing misaligned reads for swimming/flying entities in SMSG_UPDATE_OBJECT. + if ((moveFlags & 0x00200000) /* SWIMMING */ || + (moveFlags & 0x01000000) /* FLYING */ || + (moveFlags2 & 0x0010) /* MOVEMENTFLAG2_ALWAYS_ALLOW_PITCHING */) { /*float pitch =*/ packet.readFloat(); } @@ -910,6 +909,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock /*float pitchRate =*/ packet.readFloat(); block.runSpeed = runSpeed; + block.moveFlags = moveFlags; // Spline data if (moveFlags & 0x08000000) { // MOVEMENTFLAG_SPLINE_ENABLED @@ -1033,9 +1033,9 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock block.hasMovement = true; if (block.onTransport) { - LOG_INFO(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec, - " pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation, - " offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")"); + LOG_DEBUG(" TRANSPORT POSITION UPDATE: guid=0x", std::hex, transportGuid, std::dec, + " pos=(", block.x, ", ", block.y, ", ", block.z, "), o=", block.orientation, + " offset=(", block.transportX, ", ", block.transportY, ", ", block.transportZ, ")"); } } else if (updateFlags & UPDATEFLAG_STATIONARY_POSITION) { @@ -1265,11 +1265,14 @@ bool UpdateObjectParser::parse(network::Packet& packet, UpdateObjectData& data) if (!parseUpdateBlock(packet, block)) { static int parseBlockErrors = 0; if (++parseBlockErrors <= 5) { - LOG_ERROR("Failed to parse update block ", i + 1); + LOG_ERROR("Failed to parse update block ", i + 1, " of ", data.blockCount, + " (", i, " blocks parsed successfully before failure)"); if (parseBlockErrors == 5) LOG_ERROR("(suppressing further update block parse errors)"); } - return false; + // Cannot reliably re-sync to the next block after a parse failure, + // but still return true so the blocks already parsed are processed. + break; } data.blocks.emplace_back(std::move(block)); @@ -2241,7 +2244,7 @@ bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseDa data.gender = packet.readUInt8(); data.classId = packet.readUInt8(); - LOG_INFO("Name query response: ", data.name, " (race=", (int)data.race, + LOG_DEBUG("Name query response: ", data.name, " (race=", (int)data.race, " class=", (int)data.classId, ")"); return true; } @@ -2731,7 +2734,7 @@ bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) { if (packet.getSize() < 16) return false; data.attackerGuid = packet.readUInt64(); data.victimGuid = packet.readUInt64(); - LOG_INFO("Attack started: 0x", std::hex, data.attackerGuid, + LOG_DEBUG("Attack started: 0x", std::hex, data.attackerGuid, " -> 0x", data.victimGuid, std::dec); return true; } @@ -2742,7 +2745,7 @@ bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) { if (packet.getReadPos() < packet.getSize()) { data.unknown = packet.readUInt32(); } - LOG_INFO("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec); + LOG_DEBUG("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec); return true; } @@ -2771,9 +2774,9 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda data.blocked = packet.readUInt32(); } - LOG_INFO("Melee hit: ", data.totalDamage, " damage", - data.isCrit() ? " (CRIT)" : "", - data.isMiss() ? " (MISS)" : ""); + LOG_DEBUG("Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); return true; } @@ -2797,8 +2800,8 @@ bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& da // Check crit flag data.isCrit = (flags & 0x02) != 0; - LOG_INFO("Spell damage: spellId=", data.spellId, " dmg=", data.damage, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); return true; } @@ -2812,8 +2815,8 @@ bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) uint8_t critFlag = packet.readUInt8(); data.isCrit = (critFlag != 0); - LOG_INFO("Spell heal: spellId=", data.spellId, " heal=", data.heal, - data.isCrit ? " CRIT" : ""); + LOG_DEBUG("Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); return true; } @@ -2834,7 +2837,7 @@ bool XpGainParser::parse(network::Packet& packet, XpGainData& data) { data.groupBonus = data.totalXp - static_cast(data.totalXp / groupRate); } } - LOG_INFO("XP gain: ", data.totalXp, " xp (type=", static_cast(data.type), ")"); + LOG_DEBUG("XP gain: ", data.totalXp, " xp (type=", static_cast(data.type), ")"); return data.totalXp > 0; } @@ -2852,8 +2855,8 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data size_t remainingAfterHeader = packetSize - 3; // subtract talentSpec(1) + spellCount(2) bool vanillaFormat = remainingAfterHeader < static_cast(spellCount) * 6 + 2; - LOG_INFO("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount, - vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)"); + LOG_DEBUG("SMSG_INITIAL_SPELLS: packetSize=", packetSize, " bytes, spellCount=", spellCount, + vanillaFormat ? " (vanilla uint16 format)" : " (WotLK uint32 format)"); data.spellIds.reserve(spellCount); for (uint16_t i = 0; i < spellCount; ++i) { @@ -2889,14 +2892,13 @@ bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data LOG_INFO("Initial spells parsed: ", data.spellIds.size(), " spells, ", data.cooldowns.size(), " cooldowns"); - // Log first 10 spell IDs for debugging if (!data.spellIds.empty()) { std::string first10; for (size_t i = 0; i < std::min(size_t(10), data.spellIds.size()); ++i) { if (!first10.empty()) first10 += ", "; first10 += std::to_string(data.spellIds[i]); } - LOG_INFO("First spells: ", first10); + LOG_DEBUG("Initial spell IDs (first 10): ", first10); } return true; @@ -3187,7 +3189,7 @@ bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResult data.command = static_cast(packet.readUInt32()); data.name = packet.readString(); data.result = static_cast(packet.readUInt32()); - LOG_INFO("Party command result: ", (int)data.result); + LOG_DEBUG("Party command result: ", (int)data.result); return true; } @@ -3334,7 +3336,7 @@ bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data) } } - LOG_INFO("Loot response: ", (int)itemCount, " regular + ", (int)questItemCount, + LOG_DEBUG("Loot response: ", (int)itemCount, " regular + ", (int)questItemCount, " quest items, ", data.gold, " copper"); return true; } @@ -3403,7 +3405,7 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) data.objectives = normalizeWowTextTokens(packet.readString()); if (packet.getReadPos() + 10 > packet.getSize()) { - LOG_INFO("Quest details (short): id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest details (short): id=", data.questId, " title='", data.title, "'"); return true; } @@ -3440,7 +3442,7 @@ bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) if (packet.getReadPos() + 4 <= packet.getSize()) data.rewardXp = packet.readUInt32(); - LOG_INFO("Quest details: id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest details: id=", data.questId, " title='", data.title, "'"); return true; } @@ -3477,7 +3479,7 @@ bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data data.quests.push_back(quest); } - LOG_INFO("Gossip: ", optionCount, " options, ", questCount, " quests"); + LOG_DEBUG("Gossip: ", optionCount, " options, ", questCount, " quests"); return true; } @@ -3509,7 +3511,7 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa data.completionText = normalizeWowTextTokens(packet.readString()); if (packet.getReadPos() + 9 > packet.getSize()) { - LOG_INFO("Quest request items (short): id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest request items (short): id=", data.questId, " title='", data.title, "'"); return true; } @@ -3585,7 +3587,7 @@ bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsDa data.completableFlags = chosen->completableFlags; data.requiredItems = chosen->requiredItems; - LOG_INFO("Quest request items: id=", data.questId, " title='", data.title, + LOG_DEBUG("Quest request items: id=", data.questId, " title='", data.title, "' items=", data.requiredItems.size(), " completable=", data.isCompletable()); return true; } @@ -3598,7 +3600,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData data.rewardText = normalizeWowTextTokens(packet.readString()); if (packet.getReadPos() + 10 > packet.getSize()) { - LOG_INFO("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); + LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); return true; } @@ -3712,7 +3714,7 @@ bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData data.rewardXp = best->rewardXp; } - LOG_INFO("Quest offer reward: id=", data.questId, " title='", data.title, + LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title, "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size()); return true; } @@ -3825,7 +3827,7 @@ bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data data.items.push_back(item); } - LOG_INFO("Vendor inventory: ", (int)itemCount, " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")"); + LOG_DEBUG("Vendor inventory: ", (int)itemCount, " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")"); return true; } diff --git a/src/pipeline/asset_manager.cpp b/src/pipeline/asset_manager.cpp index 19fa13f8..89b063c5 100644 --- a/src/pipeline/asset_manager.cpp +++ b/src/pipeline/asset_manager.cpp @@ -137,8 +137,31 @@ std::string AssetManager::resolveFile(const std::string& normalizedPath) const { } } } - // Fall back to base manifest - return manifest_.resolveFilesystemPath(normalizedPath); + // Primary manifest + std::string primaryPath = manifest_.resolveFilesystemPath(normalizedPath); + if (!primaryPath.empty()) return primaryPath; + + // If a base-path fallback is configured (expansion-specific primary that only + // holds DBC overrides), retry against the base extraction. + if (!baseFallbackDataPath_.empty()) { + return baseFallbackManifest_.resolveFilesystemPath(normalizedPath); + } + return {}; +} + +void AssetManager::setBaseFallbackPath(const std::string& basePath) { + if (basePath.empty() || basePath == dataPath) return; // nothing to do + std::string manifestPath = basePath + "/manifest.json"; + if (!std::filesystem::exists(manifestPath)) { + LOG_DEBUG("AssetManager: base fallback manifest not found at ", manifestPath, + " — fallback disabled"); + return; + } + if (baseFallbackManifest_.load(manifestPath)) { + baseFallbackDataPath_ = basePath; + LOG_INFO("AssetManager: base fallback path set to '", basePath, + "' (", baseFallbackManifest_.getEntryCount(), " files)"); + } } BLPImage AssetManager::loadTexture(const std::string& path) { @@ -296,6 +319,55 @@ std::shared_ptr AssetManager::loadDBC(const std::string& name) { return dbc; } +std::shared_ptr AssetManager::loadDBCOptional(const std::string& name) { + // Check cache first + auto it = dbcCache.find(name); + if (it != dbcCache.end()) return it->second; + + // Try binary DBC + std::vector dbcData; + { + std::string dbcPath = "DBFilesClient\\" + name; + dbcData = readFile(dbcPath); + } + + // Fall back to expansion-specific CSV + if (dbcData.empty() && !expansionDataPath_.empty()) { + std::string baseName = name; + auto dot = baseName.rfind('.'); + if (dot != std::string::npos) baseName = baseName.substr(0, dot); + std::string csvPath = expansionDataPath_ + "/db/" + baseName + ".csv"; + if (std::filesystem::exists(csvPath)) { + std::ifstream f(csvPath, std::ios::binary | std::ios::ate); + if (f) { + auto size = f.tellg(); + if (size > 0) { + f.seekg(0); + dbcData.resize(static_cast(size)); + f.read(reinterpret_cast(dbcData.data()), size); + LOG_INFO("Binary DBC not found, using CSV fallback: ", csvPath); + } + } + } + } + + if (dbcData.empty()) { + // Expected on some expansions — log at debug level only. + LOG_DEBUG("Optional DBC not found (expected on some expansions): ", name); + return nullptr; + } + + auto dbc = std::make_shared(); + if (!dbc->load(dbcData)) { + LOG_ERROR("Failed to load DBC: ", name); + return nullptr; + } + + dbcCache[name] = dbc; + LOG_INFO("Loaded optional DBC: ", name, " (", dbc->getRecordCount(), " records)"); + return dbc; +} + std::shared_ptr AssetManager::getDBC(const std::string& name) const { auto it = dbcCache.find(name); if (it != dbcCache.end()) { diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index 891d53ba..cd6f7c27 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1316,12 +1316,36 @@ void CameraController::update(float deltaTime) { } } - // ===== Camera collision (sphere sweep approximation) ===== - // Find max safe distance using raycast + sphere radius + // ===== Camera collision (WMO raycast) ===== + // Cast a ray from the pivot toward the camera direction to find the + // nearest WMO wall. Uses asymmetric smoothing: pull-in is fast (so + // the camera never visibly clips through a wall) but recovery is slow + // (so passing through a doorway doesn't cause a zoom-out snap). collisionDistance = currentDistance; - // WMO/M2 camera collision disabled — was pulling camera through - // geometry at doorway transitions and causing erratic zoom behaviour. + if (wmoRenderer && currentDistance > MIN_DISTANCE) { + float rawHitDist = wmoRenderer->raycastBoundingBoxes(pivot, camDir, currentDistance); + // rawHitDist == currentDistance means no hit (function returns maxDistance on miss) + float rawLimit = (rawHitDist < currentDistance) + ? std::max(MIN_DISTANCE, rawHitDist - CAM_SPHERE_RADIUS - CAM_EPSILON) + : currentDistance; + + // Initialise smoothed state on first use. + if (smoothedCollisionDist_ < 0.0f) { + smoothedCollisionDist_ = rawLimit; + } + + // Asymmetric smoothing: + // • Pull-in: τ ≈ 60 ms — react quickly to prevent clipping + // • Recover: τ ≈ 400 ms — zoom out slowly after leaving geometry + const float tau = (rawLimit < smoothedCollisionDist_) ? 0.06f : 0.40f; + float alpha = 1.0f - std::exp(-deltaTime / tau); + smoothedCollisionDist_ += (rawLimit - smoothedCollisionDist_) * alpha; + + collisionDistance = std::min(collisionDistance, smoothedCollisionDist_); + } else { + smoothedCollisionDist_ = -1.0f; // Reset when wmoRenderer unavailable + } // Camera collision: terrain-only floor clamping auto getTerrainFloorAt = [&](float x, float y) -> std::optional { @@ -1421,6 +1445,9 @@ void CameraController::update(float deltaTime) { // Honor first-person intent even if anti-clipping pushes camera back slightly. bool shouldHidePlayer = isFirstPersonView() || (actualDist < MIN_DISTANCE + 0.1f); characterRenderer->setInstanceVisible(playerInstanceId, !shouldHidePlayer); + + // Note: the Renderer's CharAnimState machine drives player character animations + // (Run, Walk, Jump, Swim, etc.) — no additional animation driving needed here. } } else { // Free-fly camera mode (original behavior) diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 9dc0efcf..2314e6e9 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -547,20 +547,6 @@ bool CharacterPreview::applyEquipment(const std::vector& eq return false; } - // Diagnostic: log equipment vector and DBC state - LOG_INFO("applyEquipment: ", equipment.size(), " items, ItemDisplayInfo.dbc records=", - displayInfoDbc->getRecordCount(), " fields=", displayInfoDbc->getFieldCount(), - " bodySkin=", bodySkinPath_.empty() ? "(empty)" : bodySkinPath_); - for (size_t ei = 0; ei < equipment.size(); ++ei) { - const auto& it = equipment[ei]; - if (it.displayModel == 0) continue; - int32_t dbcRec = displayInfoDbc->findRecordById(it.displayModel); - LOG_INFO(" slot[", ei, "]: displayModel=", it.displayModel, - " invType=", (int)it.inventoryType, - " dbcRec=", dbcRec, - (dbcRec >= 0 ? " (found)" : " (NOT FOUND in ItemDisplayInfo.dbc)")); - } - auto hasInvType = [&](std::initializer_list types) -> bool { for (const auto& it : equipment) { if (it.displayModel == 0) continue; @@ -586,10 +572,6 @@ bool CharacterPreview::applyEquipment(const std::vector& eq int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); if (recIdx < 0) return 0; uint32_t val = displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); - if (val > 0) { - LOG_INFO(" getGeosetGroup: displayInfoId=", displayInfoId, - " groupField=", groupField, " field=", (7 + groupField), " val=", val); - } return val; }; @@ -661,6 +643,20 @@ bool CharacterPreview::applyEquipment(const std::vector& eq "LegUpperTexture", "LegLowerTexture", "FootTexture", }; + // Texture component region fields — use DBC layout when available, fall back to binary offsets. + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + const uint32_t texRegionFields[8] = { + idiL ? (*idiL)["TextureArmUpper"] : 14u, + idiL ? (*idiL)["TextureArmLower"] : 15u, + idiL ? (*idiL)["TextureHand"] : 16u, + idiL ? (*idiL)["TextureTorsoUpper"] : 17u, + idiL ? (*idiL)["TextureTorsoLower"] : 18u, + idiL ? (*idiL)["TextureLegUpper"] : 19u, + idiL ? (*idiL)["TextureLegLower"] : 20u, + idiL ? (*idiL)["TextureFoot"] : 21u, + }; + std::vector> regionLayers; regionLayers.reserve(32); @@ -670,13 +666,9 @@ bool CharacterPreview::applyEquipment(const std::vector& eq if (recIdx < 0) continue; for (int region = 0; region < 8; region++) { - uint32_t fieldIdx = 14 + region; // texture_1..texture_8 - std::string texName = displayInfoDbc->getString(static_cast(recIdx), fieldIdx); + std::string texName = displayInfoDbc->getString(static_cast(recIdx), texRegionFields[region]); if (texName.empty()) continue; - LOG_INFO(" texture region ", region, " (field ", fieldIdx, "): texName=", texName, - " for displayModel=", it.displayModel); - std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; @@ -692,7 +684,6 @@ bool CharacterPreview::applyEquipment(const std::vector& eq } else if (assetManager_->fileExists(basePath)) { fullPath = basePath; } else { - LOG_INFO(" texture path not found: ", base, " (_M/_F/_U/.blp)"); continue; } regionLayers.emplace_back(region, fullPath); diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index f69ae75c..5683af91 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -38,7 +38,6 @@ #include #include #include -#include #include #include @@ -1061,19 +1060,6 @@ VkTexture* CharacterRenderer::compositeTextures(const std::vector& } } - // Debug: dump composite to temp dir for visual inspection - { - std::string dumpPath = (std::filesystem::temp_directory_path() / ("wowee_composite_debug_" + - std::to_string(width) + "x" + std::to_string(height) + ".raw")).string(); - std::ofstream dump(dumpPath, std::ios::binary); - if (dump) { - dump.write(reinterpret_cast(composite.data()), - static_cast(composite.size())); - core::Logger::getInstance().info("Composite debug dump: ", dumpPath, - " (", width, "x", height, ", ", composite.size(), " bytes)"); - } - } - // Upload composite to GPU via VkTexture auto tex = std::make_unique(); tex->upload(*vkCtx_, composite.data(), width, height, VK_FORMAT_R8G8B8A8_UNORM, true); @@ -1673,7 +1659,13 @@ void CharacterRenderer::update(float deltaTime, const glm::vec3& cameraPos) { if (inst.animationLoop) { inst.animationTime = std::fmod(inst.animationTime, static_cast(seq.duration)); } else { - inst.animationTime = static_cast(seq.duration); + // One-shot animation finished: return to Stand (0) unless dead + if (inst.currentAnimationId != 1 /*Death*/) { + playAnimation(pair.first, 0, true); + } else { + // Stay on last frame of death + inst.animationTime = static_cast(seq.duration); + } } } } @@ -2207,7 +2199,6 @@ void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, return whiteTexture_.get(); }; - // One-time debug dump of rendered batches per model // Draw batches (submeshes) with per-batch textures for (const auto& batch : gpuModel.data.batches) { if (applyGeosetFilter) { @@ -2885,6 +2876,15 @@ void CharacterRenderer::startFadeIn(uint32_t instanceId, float durationSeconds) it->second.fadeInDuration = durationSeconds; } +void CharacterRenderer::setInstanceOpacity(uint32_t instanceId, float opacity) { + auto it = instances.find(instanceId); + if (it != instances.end()) { + it->second.opacity = std::clamp(opacity, 0.0f, 1.0f); + // Cancel any fade-in in progress to avoid overwriting the new opacity + it->second.fadeInDuration = 0.0f; + } +} + void CharacterRenderer::setActiveGeosets(uint32_t instanceId, const std::unordered_set& geosets) { auto it = instances.find(instanceId); if (it != instances.end()) { @@ -3175,6 +3175,13 @@ bool CharacterRenderer::getInstanceFootZ(uint32_t instanceId, float& outFootZ) c return true; } +bool CharacterRenderer::getInstancePosition(uint32_t instanceId, glm::vec3& outPos) const { + auto it = instances.find(instanceId); + if (it == instances.end()) return false; + outPos = it->second.position; + return true; +} + void CharacterRenderer::detachWeapon(uint32_t charInstanceId, uint32_t attachmentId) { auto charIt = instances.find(charInstanceId); if (charIt == instances.end()) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 50c2a062..1a7ac0e1 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -400,7 +400,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderCastBar(gameHandler); renderMirrorTimers(gameHandler); renderQuestObjectiveTracker(gameHandler); - if (showNameplates_) renderNameplates(gameHandler); + renderNameplates(gameHandler); // player names always shown; NPC plates gated by showNameplates_ renderBattlegroundScore(gameHandler); renderCombatText(gameHandler); renderPartyFrames(gameHandler); @@ -522,11 +522,25 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (gameHandler.hasTarget()) { auto target = gameHandler.getTarget(); if (target) { - targetGLPos = core::coords::canonicalToRender( - glm::vec3(target->getX(), target->getY(), target->getZ())); - float footZ = 0.0f; - if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) { - targetGLPos.z = footZ; + // Prefer the renderer's actual instance position so the selection + // circle tracks the rendered model (not a parallel entity-space + // interpolator that can drift from the visual position). + glm::vec3 instPos; + if (core::Application::getInstance().getRenderPositionForGuid(target->getGuid(), instPos)) { + targetGLPos = instPos; + // Override Z with foot position to sit the circle on the ground. + float footZ = 0.0f; + if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) { + targetGLPos.z = footZ; + } + } else { + // Fallback: entity game-logic position (no CharacterRenderer instance yet) + targetGLPos = core::coords::canonicalToRender( + glm::vec3(target->getX(), target->getY(), target->getZ())); + float footZ = 0.0f; + if (core::Application::getInstance().getRenderFootZForGuid(target->getGuid(), footZ)) { + targetGLPos.z = footZ; + } } renderer->setTargetPosition(&targetGLPos); @@ -1417,14 +1431,16 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { SDL_SCANCODE_5, SDL_SCANCODE_6, SDL_SCANCODE_7, SDL_SCANCODE_8, SDL_SCANCODE_9, SDL_SCANCODE_0, SDL_SCANCODE_MINUS, SDL_SCANCODE_EQUALS }; - for (int i = 0; i < 12; ++i) { + const bool shiftDown = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const auto& bar = gameHandler.getActionBar(); + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { if (input.isKeyJustPressed(actionBarKeys[i])) { - const auto& bar = gameHandler.getActionBar(); - if (bar[i].type == game::ActionBarSlot::SPELL && bar[i].isReady()) { + int slotIdx = shiftDown ? (game::GameHandler::SLOTS_PER_BAR + i) : i; + if (bar[slotIdx].type == game::ActionBarSlot::SPELL && bar[slotIdx].isReady()) { uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(bar[i].id, target); - } else if (bar[i].type == game::ActionBarSlot::ITEM && bar[i].id != 0) { - gameHandler.useItemById(bar[i].id); + gameHandler.castSpell(bar[slotIdx].id, target); + } else if (bar[slotIdx].type == game::ActionBarSlot::ITEM && bar[slotIdx].id != 0) { + gameHandler.useItemById(bar[slotIdx].id); } } } @@ -2063,11 +2079,31 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { ImGui::PushStyleColor(ImGuiCol_Border, borderColor); if (ImGui::Begin("##TargetFrame", nullptr, flags)) { + // Raid mark icon (Star/Circle/Diamond/Triangle/Moon/Square/Cross/Skull) + static const struct { const char* sym; ImU32 col; } kRaidMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, // 0 Star (yellow) + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, // 1 Circle (orange) + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, // 2 Diamond (purple) + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, // 3 Triangle (green) + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, // 4 Moon (blue) + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, // 5 Square (teal) + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, // 6 Cross (red) + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, // 7 Skull (white) + }; + uint8_t mark = gameHandler.getEntityRaidMark(target->getGuid()); + if (mark < game::GameHandler::kRaidMarkCount) { + ImGui::GetWindowDrawList()->AddText( + ImGui::GetCursorScreenPos(), + kRaidMarks[mark].col, kRaidMarks[mark].sym); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); + } + // Entity name and type std::string name = getEntityName(target); ImVec4 nameColor = hostileColor; + ImGui::SameLine(0.0f, 0.0f); ImGui::TextColored(nameColor, "%s", name.c_str()); // Level (for units/players) — colored by difficulty @@ -3842,237 +3878,250 @@ void GameScreen::renderActionBar(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.9f)); - if (ImGui::Begin("##ActionBar", nullptr, flags)) { - const auto& bar = gameHandler.getActionBar(); - static const char* keyLabels[] = {"1","2","3","4","5","6","7","8","9","0","-","="}; + // Per-slot rendering lambda — shared by both action bars + const auto& bar = gameHandler.getActionBar(); + static const char* keyLabels1[] = {"1","2","3","4","5","6","7","8","9","0","-","="}; + // "⇧N" labels for bar 2 (UTF-8: E2 87 A7 = U+21E7 UPWARDS WHITE ARROW) + static const char* keyLabels2[] = { + "\xe2\x87\xa7" "1", "\xe2\x87\xa7" "2", "\xe2\x87\xa7" "3", + "\xe2\x87\xa7" "4", "\xe2\x87\xa7" "5", "\xe2\x87\xa7" "6", + "\xe2\x87\xa7" "7", "\xe2\x87\xa7" "8", "\xe2\x87\xa7" "9", + "\xe2\x87\xa7" "0", "\xe2\x87\xa7" "-", "\xe2\x87\xa7" "=" + }; - for (int i = 0; i < 12; ++i) { - if (i > 0) ImGui::SameLine(0, spacing); + auto renderBarSlot = [&](int absSlot, const char* keyLabel) { + ImGui::BeginGroup(); + ImGui::PushID(absSlot); - ImGui::BeginGroup(); - ImGui::PushID(i); + const auto& slot = bar[absSlot]; + bool onCooldown = !slot.isReady(); - const auto& slot = bar[i]; - bool onCooldown = !slot.isReady(); + auto getSpellName = [&](uint32_t spellId) -> std::string { + std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); + if (!name.empty()) return name; + return "Spell #" + std::to_string(spellId); + }; - auto getSpellName = [&](uint32_t spellId) -> std::string { - std::string name = spellbookScreen.lookupSpellName(spellId, assetMgr); - if (!name.empty()) return name; - return "Spell #" + std::to_string(spellId); - }; - - // Try to get icon texture for this slot - VkDescriptorSet iconTex = VK_NULL_HANDLE; - const game::ItemDef* barItemDef = nullptr; - uint32_t itemDisplayInfoId = 0; - std::string itemNameFromQuery; - if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) { - iconTex = getSpellIcon(slot.id, assetMgr); - } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { - // Search backpack - auto& inv = gameHandler.getInventory(); - for (int bi = 0; bi < inv.getBackpackSize(); bi++) { - const auto& bs = inv.getBackpackSlot(bi); - if (!bs.empty() && bs.item.itemId == slot.id) { - barItemDef = &bs.item; - break; - } - } - // Search equipped slots - if (!barItemDef) { - for (int ei = 0; ei < game::Inventory::NUM_EQUIP_SLOTS; ei++) { - const auto& es = inv.getEquipSlot(static_cast(ei)); - if (!es.empty() && es.item.itemId == slot.id) { - barItemDef = &es.item; - break; - } - } - } - // Search extra bags - if (!barItemDef) { - for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS && !barItemDef; bag++) { - for (int si = 0; si < inv.getBagSize(bag); si++) { - const auto& bs = inv.getBagSlot(bag, si); - if (!bs.empty() && bs.item.itemId == slot.id) { - barItemDef = &bs.item; - break; - } - } - } - } - if (barItemDef && barItemDef->displayInfoId != 0) { - itemDisplayInfoId = barItemDef->displayInfoId; - } - // Fallback: use item info cache (from server query responses) - if (itemDisplayInfoId == 0) { - if (auto* info = gameHandler.getItemInfo(slot.id)) { - itemDisplayInfoId = info->displayInfoId; - if (itemNameFromQuery.empty() && !info->name.empty()) - itemNameFromQuery = info->name; - } - } - if (itemDisplayInfoId != 0) { - iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); + // Try to get icon texture for this slot + VkDescriptorSet iconTex = VK_NULL_HANDLE; + const game::ItemDef* barItemDef = nullptr; + uint32_t itemDisplayInfoId = 0; + std::string itemNameFromQuery; + if (slot.type == game::ActionBarSlot::SPELL && slot.id != 0) { + iconTex = getSpellIcon(slot.id, assetMgr); + } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + auto& inv = gameHandler.getInventory(); + for (int bi = 0; bi < inv.getBackpackSize(); bi++) { + const auto& bs = inv.getBackpackSlot(bi); + if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; } + } + if (!barItemDef) { + for (int ei = 0; ei < game::Inventory::NUM_EQUIP_SLOTS; ei++) { + const auto& es = inv.getEquipSlot(static_cast(ei)); + if (!es.empty() && es.item.itemId == slot.id) { barItemDef = &es.item; break; } } } - - bool clicked = false; - if (iconTex) { - // Render icon-based button - ImVec4 tintColor(1, 1, 1, 1); - ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); - if (onCooldown) { - tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); - bgColor = ImVec4(0.1f, 0.1f, 0.1f, 0.8f); + if (!barItemDef) { + for (int bag = 0; bag < game::Inventory::NUM_BAG_SLOTS && !barItemDef; bag++) { + for (int si = 0; si < inv.getBagSize(bag); si++) { + const auto& bs = inv.getBagSlot(bag, si); + if (!bs.empty() && bs.item.itemId == slot.id) { barItemDef = &bs.item; break; } + } } - clicked = ImGui::ImageButton("##icon", - (ImTextureID)(uintptr_t)iconTex, - ImVec2(slotSize, slotSize), - ImVec2(0, 0), ImVec2(1, 1), - bgColor, tintColor); + } + if (barItemDef && barItemDef->displayInfoId != 0) + itemDisplayInfoId = barItemDef->displayInfoId; + if (itemDisplayInfoId == 0) { + if (auto* info = gameHandler.getItemInfo(slot.id)) { + itemDisplayInfoId = info->displayInfoId; + if (itemNameFromQuery.empty() && !info->name.empty()) + itemNameFromQuery = info->name; + } + } + if (itemDisplayInfoId != 0) + iconTex = inventoryScreen.getItemIcon(itemDisplayInfoId); + } + + bool clicked = false; + if (iconTex) { + ImVec4 tintColor(1, 1, 1, 1); + ImVec4 bgColor(0.1f, 0.1f, 0.1f, 0.9f); + if (onCooldown) { tintColor = ImVec4(0.4f, 0.4f, 0.4f, 0.8f); } + clicked = ImGui::ImageButton("##icon", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(slotSize, slotSize), + ImVec2(0, 0), ImVec2(1, 1), + bgColor, tintColor); + } else { + if (onCooldown) ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); + else if (slot.isEmpty())ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + else ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); + + char label[32]; + if (slot.type == game::ActionBarSlot::SPELL) { + std::string spellName = getSpellName(slot.id); + if (spellName.size() > 6) spellName = spellName.substr(0, 6); + snprintf(label, sizeof(label), "%s", spellName.c_str()); + } else if (slot.type == game::ActionBarSlot::ITEM && barItemDef) { + std::string itemName = barItemDef->name; + if (itemName.size() > 6) itemName = itemName.substr(0, 6); + snprintf(label, sizeof(label), "%s", itemName.c_str()); + } else if (slot.type == game::ActionBarSlot::ITEM) { + snprintf(label, sizeof(label), "Item"); + } else if (slot.type == game::ActionBarSlot::MACRO) { + snprintf(label, sizeof(label), "Macro"); } else { - // Fallback to text button - if (onCooldown) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.2f, 0.2f, 0.2f, 0.8f)); - } else if (slot.isEmpty()) { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - } else { - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.3f, 0.3f, 0.5f, 0.9f)); - } - - char label[32]; - if (slot.type == game::ActionBarSlot::SPELL) { - std::string spellName = getSpellName(slot.id); - if (spellName.size() > 6) spellName = spellName.substr(0, 6); - snprintf(label, sizeof(label), "%s", spellName.c_str()); - } else if (slot.type == game::ActionBarSlot::ITEM && barItemDef) { - std::string itemName = barItemDef->name; - if (itemName.size() > 6) itemName = itemName.substr(0, 6); - snprintf(label, sizeof(label), "%s", itemName.c_str()); - } else if (slot.type == game::ActionBarSlot::ITEM) { - snprintf(label, sizeof(label), "Item"); - } else if (slot.type == game::ActionBarSlot::MACRO) { - snprintf(label, sizeof(label), "Macro"); - } else { - snprintf(label, sizeof(label), "--"); - } - - clicked = ImGui::Button(label, ImVec2(slotSize, slotSize)); - ImGui::PopStyleColor(); + snprintf(label, sizeof(label), "--"); } + clicked = ImGui::Button(label, ImVec2(slotSize, slotSize)); + ImGui::PopStyleColor(); + } - bool rightClicked = ImGui::IsItemClicked(ImGuiMouseButton_Right); - bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && - ImGui::IsMouseReleased(ImGuiMouseButton_Left); + bool rightClicked = ImGui::IsItemClicked(ImGuiMouseButton_Right); + bool hoveredOnRelease = ImGui::IsItemHovered(ImGuiHoveredFlags_AllowWhenBlockedByActiveItem) && + ImGui::IsMouseReleased(ImGuiMouseButton_Left); - // Drop dragged spell from spellbook onto this slot - // (mouse release over slot — button click won't fire since press was in spellbook) - if (hoveredOnRelease && spellbookScreen.isDraggingSpell()) { - gameHandler.setActionBarSlot(i, game::ActionBarSlot::SPELL, - spellbookScreen.getDragSpellId()); - spellbookScreen.consumeDragSpell(); - } else if (hoveredOnRelease && inventoryScreen.isHoldingItem()) { - // Drop held item from inventory onto action bar - const auto& held = inventoryScreen.getHeldItem(); - gameHandler.setActionBarSlot(i, game::ActionBarSlot::ITEM, held.itemId); - inventoryScreen.returnHeldItem(gameHandler.getInventory()); - } else if (clicked && actionBarDragSlot_ >= 0) { - // Dropping a dragged action bar slot onto another slot - swap or place - if (i != actionBarDragSlot_) { - const auto& dragSrc = bar[actionBarDragSlot_]; - auto srcType = dragSrc.type; - auto srcId = dragSrc.id; - gameHandler.setActionBarSlot(actionBarDragSlot_, slot.type, slot.id); - gameHandler.setActionBarSlot(i, srcType, srcId); - } - actionBarDragSlot_ = -1; - actionBarDragIcon_ = 0; - } else if (clicked && !slot.isEmpty()) { - // Left-click on non-empty slot: cast spell or use item - if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.castSpell(slot.id, target); - } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { - gameHandler.useItemById(slot.id); - } - } else if (rightClicked && !slot.isEmpty()) { - // Right-click on non-empty slot: pick up for dragging - actionBarDragSlot_ = i; - actionBarDragIcon_ = iconTex; + if (hoveredOnRelease && spellbookScreen.isDraggingSpell()) { + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::SPELL, + spellbookScreen.getDragSpellId()); + spellbookScreen.consumeDragSpell(); + } else if (hoveredOnRelease && inventoryScreen.isHoldingItem()) { + const auto& held = inventoryScreen.getHeldItem(); + gameHandler.setActionBarSlot(absSlot, game::ActionBarSlot::ITEM, held.itemId); + inventoryScreen.returnHeldItem(gameHandler.getInventory()); + } else if (clicked && actionBarDragSlot_ >= 0) { + if (absSlot != actionBarDragSlot_) { + const auto& dragSrc = bar[actionBarDragSlot_]; + gameHandler.setActionBarSlot(actionBarDragSlot_, slot.type, slot.id); + gameHandler.setActionBarSlot(absSlot, dragSrc.type, dragSrc.id); } + actionBarDragSlot_ = -1; + actionBarDragIcon_ = 0; + } else if (clicked && !slot.isEmpty()) { + if (slot.type == game::ActionBarSlot::SPELL && slot.isReady()) { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.castSpell(slot.id, target); + } else if (slot.type == game::ActionBarSlot::ITEM && slot.id != 0) { + gameHandler.useItemById(slot.id); + } + } else if (rightClicked && !slot.isEmpty()) { + actionBarDragSlot_ = absSlot; + actionBarDragIcon_ = iconTex; + } - // Tooltip - if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { - ImGui::BeginTooltip(); - if (slot.type == game::ActionBarSlot::SPELL) { - std::string fullName = getSpellName(slot.id); - ImGui::Text("%s", fullName.c_str()); - // Hearthstone: show bind point info - if (slot.id == 8690) { - uint32_t mapId = 0; - glm::vec3 pos; - if (gameHandler.getHomeBind(mapId, pos)) { - const char* mapName = "Unknown"; - switch (mapId) { - case 0: mapName = "Eastern Kingdoms"; break; - case 1: mapName = "Kalimdor"; break; - case 530: mapName = "Outland"; break; - case 571: mapName = "Northrend"; break; - } - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), - "Home: %s", mapName); + // Tooltip + if (ImGui::IsItemHovered() && !slot.isEmpty() && slot.id != 0) { + ImGui::BeginTooltip(); + if (slot.type == game::ActionBarSlot::SPELL) { + ImGui::Text("%s", getSpellName(slot.id).c_str()); + if (slot.id == 8690) { + uint32_t mapId = 0; glm::vec3 pos; + if (gameHandler.getHomeBind(mapId, pos)) { + const char* mapName = "Unknown"; + switch (mapId) { + case 0: mapName = "Eastern Kingdoms"; break; + case 1: mapName = "Kalimdor"; break; + case 530: mapName = "Outland"; break; + case 571: mapName = "Northrend"; break; } - ImGui::TextDisabled("Use: Teleport home"); - } - } else if (slot.type == game::ActionBarSlot::ITEM) { - if (barItemDef && !barItemDef->name.empty()) { - ImGui::Text("%s", barItemDef->name.c_str()); - } else if (!itemNameFromQuery.empty()) { - ImGui::Text("%s", itemNameFromQuery.c_str()); - } else { - ImGui::Text("Item #%u", slot.id); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "Home: %s", mapName); } + ImGui::TextDisabled("Use: Teleport home"); } - // Show cooldown time remaining - if (onCooldown) { - float cd = slot.cooldownRemaining; - if (cd >= 60.0f) { - int mins = static_cast(cd) / 60; - int secs = static_cast(cd) % 60; - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), - "Cooldown: %d min %d sec", mins, secs); - } else { - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), - "Cooldown: %.1f sec", cd); - } + } else if (slot.type == game::ActionBarSlot::ITEM) { + if (barItemDef && !barItemDef->name.empty()) + ImGui::Text("%s", barItemDef->name.c_str()); + else if (!itemNameFromQuery.empty()) + ImGui::Text("%s", itemNameFromQuery.c_str()); + else + ImGui::Text("Item #%u", slot.id); + } + if (onCooldown) { + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), + "Cooldown: %d min %d sec", (int)cd/60, (int)cd%60); + else + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "Cooldown: %.1f sec", cd); + } + ImGui::EndTooltip(); + } + + // Cooldown overlay: WoW-style clock-sweep + time text + if (onCooldown) { + ImVec2 btnMin = ImGui::GetItemRectMin(); + ImVec2 btnMax = ImGui::GetItemRectMax(); + float cx = (btnMin.x + btnMax.x) * 0.5f; + float cy = (btnMin.y + btnMax.y) * 0.5f; + float r = (btnMax.x - btnMin.x) * 0.5f; + auto* dl = ImGui::GetWindowDrawList(); + + float total = (slot.cooldownTotal > 0.0f) ? slot.cooldownTotal : 1.0f; + float elapsed = total - slot.cooldownRemaining; + float elapsedFrac = std::min(1.0f, std::max(0.0f, elapsed / total)); + if (elapsedFrac > 0.005f) { + constexpr int N_SEGS = 32; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + elapsedFrac * 2.0f * IM_PI; + float fanR = r * 1.5f; + ImVec2 pts[N_SEGS + 2]; + pts[0] = ImVec2(cx, cy); + for (int s = 0; s <= N_SEGS; ++s) { + float a = startAngle + (endAngle - startAngle) * s / static_cast(N_SEGS); + pts[s + 1] = ImVec2(cx + std::cos(a) * fanR, cy + std::sin(a) * fanR); } - ImGui::EndTooltip(); + dl->AddConvexPolyFilled(pts, N_SEGS + 2, IM_COL32(0, 0, 0, 170)); } - // Cooldown overlay - if (onCooldown && iconTex) { - // Draw cooldown text centered over the icon - ImVec2 btnMin = ImGui::GetItemRectMin(); - ImVec2 btnMax = ImGui::GetItemRectMax(); - char cdText[16]; - snprintf(cdText, sizeof(cdText), "%.0f", slot.cooldownRemaining); - ImVec2 textSize = ImGui::CalcTextSize(cdText); - float cx = btnMin.x + (btnMax.x - btnMin.x - textSize.x) * 0.5f; - float cy = btnMin.y + (btnMax.y - btnMin.y - textSize.y) * 0.5f; - ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - IM_COL32(255, 255, 0, 255), cdText); - } else if (onCooldown) { - char cdText[16]; - snprintf(cdText, sizeof(cdText), "%.0f", slot.cooldownRemaining); - ImGui::SetCursorPosY(ImGui::GetCursorPosY() - slotSize / 2 - 8); - ImGui::TextColored(ImVec4(1, 1, 0, 1), "%s", cdText); + char cdText[16]; + float cd = slot.cooldownRemaining; + if (cd >= 60.0f) snprintf(cdText, sizeof(cdText), "%dm", (int)cd / 60); + else snprintf(cdText, sizeof(cdText), "%.0f", cd); + ImVec2 textSize = ImGui::CalcTextSize(cdText); + float tx = cx - textSize.x * 0.5f; + float ty = cy - textSize.y * 0.5f; + dl->AddText(ImVec2(tx + 1.0f, ty + 1.0f), IM_COL32(0, 0, 0, 220), cdText); + dl->AddText(ImVec2(tx, ty), IM_COL32(255, 255, 255, 255), cdText); + } + + // Key label below + ImGui::TextDisabled("%s", keyLabel); + + ImGui::PopID(); + ImGui::EndGroup(); + }; + + // Bar 2 (slots 12-23) — only show if at least one slot is populated + { + bool bar2HasContent = false; + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) + if (!bar[game::GameHandler::SLOTS_PER_BAR + i].isEmpty()) { bar2HasContent = true; break; } + + float bar2Y = barY - barH - 2.0f; + ImGui::SetNextWindowPos(ImVec2(barX, bar2Y), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(padding, padding)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0.0f, 0.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowBorderSize, 0.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, + bar2HasContent ? ImVec4(0.05f, 0.05f, 0.05f, 0.85f) : ImVec4(0.05f, 0.05f, 0.05f, 0.4f)); + if (ImGui::Begin("##ActionBar2", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + renderBarSlot(game::GameHandler::SLOTS_PER_BAR + i, keyLabels2[i]); } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(4); + } - // Key label below - ImGui::TextDisabled("%s", keyLabels[i]); - - ImGui::PopID(); - ImGui::EndGroup(); + // Bar 1 (slots 0-11) + if (ImGui::Begin("##ActionBar", nullptr, flags)) { + for (int i = 0; i < game::GameHandler::SLOTS_PER_BAR; ++i) { + if (i > 0) ImGui::SameLine(0, spacing); + renderBarSlot(i, keyLabels1[i]); } } ImGui::End(); @@ -4360,23 +4409,25 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { uint32_t nextLevelXp = gameHandler.getPlayerNextLevelXp(); if (nextLevelXp == 0) return; // No XP data yet (level 80 or not initialized) - uint32_t currentXp = gameHandler.getPlayerXp(); + uint32_t currentXp = gameHandler.getPlayerXp(); + uint32_t restedXp = gameHandler.getPlayerRestedXp(); + bool isResting = gameHandler.isPlayerResting(); auto* window = core::Application::getInstance().getWindow(); float screenW = window ? static_cast(window->getWidth()) : 1280.0f; float screenH = window ? static_cast(window->getHeight()) : 720.0f; - // Position just above the action bar + // Position just above both action bars (bar1 at screenH-barH, bar2 above that) float slotSize = 48.0f; float spacing = 4.0f; float padding = 8.0f; float barW = 12 * slotSize + 11 * spacing + padding * 2; float barH = slotSize + 24.0f; - float actionBarY = screenH - barH; float xpBarH = 20.0f; float xpBarW = barW; float xpBarX = (screenW - xpBarW) / 2.0f; - float xpBarY = actionBarY - xpBarH - 2.0f; + // bar1 is at screenH-barH, bar2 is at screenH-2*barH-2; XP bar sits above bar2 + float xpBarY = screenH - 2.0f * barH - 2.0f - xpBarH - 2.0f; ImGui::SetNextWindowPos(ImVec2(xpBarX, xpBarY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(xpBarW, xpBarH + 4.0f), ImGuiCond_Always); @@ -4400,9 +4451,10 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { ImVec2 barMax = ImVec2(barMin.x + barSize.x, barMin.y + barSize.y); auto* drawList = ImGui::GetWindowDrawList(); - ImU32 bg = IM_COL32(15, 15, 20, 220); - ImU32 fg = IM_COL32(148, 51, 238, 255); - ImU32 seg = IM_COL32(35, 35, 45, 255); + ImU32 bg = IM_COL32(15, 15, 20, 220); + ImU32 fg = IM_COL32(148, 51, 238, 255); + ImU32 fgRest = IM_COL32(200, 170, 255, 220); // lighter purple for rested portion + ImU32 seg = IM_COL32(35, 35, 45, 255); drawList->AddRectFilled(barMin, barMax, bg, 2.0f); drawList->AddRect(barMin, barMax, IM_COL32(80, 80, 90, 220), 2.0f); @@ -4411,6 +4463,19 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { drawList->AddRectFilled(barMin, ImVec2(barMin.x + fillW, barMax.y), fg, 2.0f); } + // Rested XP overlay: draw from current XP fill to (currentXp + restedXp) fill + if (restedXp > 0) { + float restedEndPct = std::min(1.0f, static_cast(currentXp + restedXp) + / static_cast(nextLevelXp)); + float restedStartX = barMin.x + fillW; + float restedEndX = barMin.x + barSize.x * restedEndPct; + if (restedEndX > restedStartX) { + drawList->AddRectFilled(ImVec2(restedStartX, barMin.y), + ImVec2(restedEndX, barMax.y), + fgRest, 2.0f); + } + } + const int segments = 20; float segW = barSize.x / static_cast(segments); for (int i = 1; i < segments; ++i) { @@ -4418,8 +4483,21 @@ void GameScreen::renderXpBar(game::GameHandler& gameHandler) { drawList->AddLine(ImVec2(x, barMin.y + 1.0f), ImVec2(x, barMax.y - 1.0f), seg, 1.0f); } + // Rest indicator "zzz" to the right of the bar when resting + if (isResting) { + const char* zzz = "zzz"; + ImVec2 zSize = ImGui::CalcTextSize(zzz); + float zx = barMax.x - zSize.x - 4.0f; + float zy = barMin.y + (barSize.y - zSize.y) * 0.5f; + drawList->AddText(ImVec2(zx, zy), IM_COL32(180, 150, 255, 220), zzz); + } + char overlay[96]; - snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); + if (restedXp > 0) { + snprintf(overlay, sizeof(overlay), "%u / %u XP (+%u rested)", currentXp, nextLevelXp, restedXp); + } else { + snprintf(overlay, sizeof(overlay), "%u / %u XP", currentXp, nextLevelXp); + } ImVec2 textSize = ImGui::CalcTextSize(overlay); float tx = barMin.x + (barSize.x - textSize.x) * 0.5f; float ty = barMin.y + (barSize.y - textSize.y) * 0.5f; @@ -4549,13 +4627,34 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { constexpr float RIGHT_MARGIN = 10.0f; constexpr int MAX_QUESTS = 5; + // Build display list: tracked quests only, or all quests if none tracked + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + std::vector toShow; + toShow.reserve(MAX_QUESTS); + if (!trackedIds.empty()) { + for (const auto& q : questLog) { + if (q.questId == 0) continue; + if (trackedIds.count(q.questId)) toShow.push_back(&q); + if (static_cast(toShow.size()) >= MAX_QUESTS) break; + } + } + // Fallback: show all quests if nothing is tracked + if (toShow.empty()) { + for (const auto& q : questLog) { + if (q.questId == 0) continue; + toShow.push_back(&q); + if (static_cast(toShow.size()) >= MAX_QUESTS) break; + } + } + if (toShow.empty()) return; + float x = screenW - TRACKER_W - RIGHT_MARGIN; float y = 200.0f; // below minimap area ImGui::SetNextWindowPos(ImVec2(x, y), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus; @@ -4564,15 +4663,23 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); if (ImGui::Begin("##QuestTracker", nullptr, flags)) { - int shown = 0; - for (const auto& q : questLog) { - if (q.questId == 0) continue; - if (shown >= MAX_QUESTS) break; + for (int i = 0; i < static_cast(toShow.size()); ++i) { + const auto& q = *toShow[i]; - // Quest title in yellow (gold) if complete, white if in progress + // Clickable quest title — opens quest log + ImGui::PushID(q.questId); ImVec4 titleCol = q.complete ? ImVec4(1.0f, 0.84f, 0.0f, 1.0f) : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); - ImGui::TextColored(titleCol, "%s", q.title.c_str()); + ImGui::PushStyleColor(ImGuiCol_Text, titleCol); + if (ImGui::Selectable(q.title.c_str(), false, + ImGuiSelectableFlags_None, ImVec2(TRACKER_W - 12.0f, 0))) { + questLogScreen.openAndSelectQuest(q.questId); + } + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Click to open Quest Log"); + } + ImGui::PopStyleColor(); + ImGui::PopID(); // Objectives line (condensed) if (q.complete) { @@ -4580,8 +4687,15 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } else { // Kill counts for (const auto& [entry, progress] : q.killCounts) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), - " %u/%u", progress.first, progress.second); + std::string creatureName = gameHandler.getCachedCreatureName(entry); + if (!creatureName.empty()) { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %s: %u/%u", creatureName.c_str(), + progress.first, progress.second); + } else { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %u/%u", progress.first, progress.second); + } } // Item counts for (const auto& [itemId, count] : q.itemCounts) { @@ -4599,7 +4713,6 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } } if (q.killCounts.empty() && q.itemCounts.empty() && !q.objectives.empty()) { - // Show the raw objectives text, truncated if needed const std::string& obj = q.objectives; if (obj.size() > 40) { ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), @@ -4611,10 +4724,9 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } } - if (shown < MAX_QUESTS - 1 && shown < static_cast(questLog.size()) - 1) { + if (i < static_cast(toShow.size()) - 1) { ImGui::Spacing(); } - ++shown; } } ImGui::End(); @@ -4772,17 +4884,21 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { auto* unit = dynamic_cast(entityPtr.get()); if (!unit || unit->getMaxHealth() == 0) continue; - // Only show nameplate for the currently targeted unit - if (guid != targetGuid) continue; + bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER); + bool isTarget = (guid == targetGuid); + + // 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())); renderPos.z += 2.3f; - // Cull if too far (render units ≈ WoW yards) + // Cull distance: target or other players up to 40 units; NPC others up to 20 units float dist = glm::length(renderPos - camPos); - if (dist > 40.0f) continue; + float cullDist = (isTarget || isPlayer) ? 40.0f : 20.0f; + if (dist > cullDist) continue; // Project to clip space glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); @@ -4798,8 +4914,8 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { float sx = (ndc.x * 0.5f + 0.5f) * screenW; float sy = (ndc.y * 0.5f + 0.5f) * screenH; - // Fade out in the last 5 units of range - float alpha = dist < 35.0f ? 1.0f : 1.0f - (dist - 35.0f) / 5.0f; + // Fade out in the last 5 units of cull range + float alpha = dist < (cullDist - 5.0f) ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f; auto A = [&](int v) { return static_cast(v * alpha); }; // Bar colour by hostility @@ -4811,7 +4927,7 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { barColor = IM_COL32(60, 200, 80, A(200)); bgColor = IM_COL32(25, 100, 35, A(160)); } - ImU32 borderColor = (guid == targetGuid) + ImU32 borderColor = isTarget ? IM_COL32(255, 215, 0, A(255)) : IM_COL32(20, 20, 20, A(180)); @@ -4844,12 +4960,46 @@ void GameScreen::renderNameplates(game::GameHandler& gameHandler) { ImVec2 textSize = ImGui::CalcTextSize(labelBuf); float nameX = sx - textSize.x * 0.5f; float nameY = sy - barH - 12.0f; - // Name color: hostile=red, non-hostile=yellow (WoW convention) - ImU32 nameColor = unit->isHostile() - ? IM_COL32(220, 80, 80, A(230)) - : IM_COL32(240, 200, 100, A(230)); + // Name color: other player=cyan, hostile=red, non-hostile=yellow (WoW convention) + ImU32 nameColor = isPlayer + ? IM_COL32( 80, 200, 255, A(230)) // cyan — other players + : unit->isHostile() + ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC + : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + + // Raid mark (if any) to the left of the name + { + static const struct { const char* sym; ImU32 col; } kNPMarks[] = { + { "\xe2\x98\x85", IM_COL32(255,220, 50,230) }, // Star + { "\xe2\x97\x8f", IM_COL32(255,140, 0,230) }, // Circle + { "\xe2\x97\x86", IM_COL32(160, 32,240,230) }, // Diamond + { "\xe2\x96\xb2", IM_COL32( 50,200, 50,230) }, // Triangle + { "\xe2\x97\x8c", IM_COL32( 80,160,255,230) }, // Moon + { "\xe2\x96\xa0", IM_COL32( 50,200,220,230) }, // Square + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80,230) }, // Cross + { "\xe2\x98\xa0", IM_COL32(255,255,255,230) }, // Skull + }; + uint8_t raidMark = gameHandler.getEntityRaidMark(guid); + if (raidMark < game::GameHandler::kRaidMarkCount) { + float markX = nameX - 14.0f; + 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); + } + } + + // Click to target: detect left-click inside the combined nameplate region + if (!ImGui::GetIO().WantCaptureMouse && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + ImVec2 mouse = ImGui::GetIO().MousePos; + float nx0 = nameX - 2.0f; + float ny0 = nameY - 1.0f; + float nx1 = nameX + textSize.x + 2.0f; + float ny1 = sy + barH + 2.0f; + if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { + gameHandler.setTarget(guid); + } + } } } @@ -4861,8 +5011,153 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { if (!gameHandler.isInGroup()) return; const auto& partyData = gameHandler.getPartyData(); + const bool isRaid = (partyData.groupType == 1); float frameY = 120.0f; + // ---- Raid frame layout ---- + if (isRaid) { + // Organize members by subgroup (0-7, up to 5 members each) + constexpr int MAX_SUBGROUPS = 8; + constexpr int MAX_PER_GROUP = 5; + std::vector subgroups[MAX_SUBGROUPS]; + for (const auto& m : partyData.members) { + int sg = m.subGroup < MAX_SUBGROUPS ? m.subGroup : 0; + if (static_cast(subgroups[sg].size()) < MAX_PER_GROUP) + subgroups[sg].push_back(&m); + } + + // Count non-empty subgroups to determine layout + int activeSgs = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) + if (!subgroups[sg].empty()) activeSgs++; + + // Compact raid cell: name + 2 narrow bars + constexpr float CELL_W = 90.0f; + constexpr float CELL_H = 42.0f; + constexpr float BAR_H = 7.0f; + constexpr float CELL_PAD = 3.0f; + + float winW = activeSgs * (CELL_W + CELL_PAD) + CELL_PAD + 8.0f; + float winH = MAX_PER_GROUP * (CELL_H + CELL_PAD) + CELL_PAD + 20.0f; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + float raidX = (screenW - winW) / 2.0f; + float raidY = screenH - winH - 120.0f; // above action bar area + + ImGui::SetNextWindowPos(ImVec2(raidX, raidY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(winW, winH), ImGuiCond_Always); + + ImGuiWindowFlags raidFlags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar; + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(CELL_PAD, CELL_PAD)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.07f, 0.07f, 0.1f, 0.85f)); + + if (ImGui::Begin("##RaidFrames", nullptr, raidFlags)) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImVec2 winPos = ImGui::GetWindowPos(); + + int colIdx = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) { + if (subgroups[sg].empty()) continue; + + float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD); + + for (int row = 0; row < static_cast(subgroups[sg].size()); row++) { + const auto& m = *subgroups[sg][row]; + float cellY = winPos.y + CELL_PAD + 14.0f + row * (CELL_H + CELL_PAD); + + ImVec2 cellMin(colX, cellY); + ImVec2 cellMax(colX + CELL_W, cellY + CELL_H); + + // Cell background + bool isTarget = (gameHandler.getTargetGuid() == m.guid); + ImU32 bg = isTarget ? IM_COL32(60, 80, 120, 200) : IM_COL32(30, 30, 40, 180); + draw->AddRectFilled(cellMin, cellMax, bg, 3.0f); + if (isTarget) + draw->AddRect(cellMin, cellMax, IM_COL32(100, 150, 255, 200), 3.0f); + + // Dead/ghost overlay + bool isOnline = (m.onlineStatus & 0x0001) != 0; + bool isDead = (m.onlineStatus & 0x0020) != 0; + bool isGhost = (m.onlineStatus & 0x0010) != 0; + + // Name text (truncated) + 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); + draw->AddText(ImVec2(cellMin.x + 4.0f, cellMin.y + 3.0f), nameCol, truncName); + + // Health bar + uint32_t hp = m.hasPartyStats ? m.curHealth : 0; + uint32_t maxHp = m.hasPartyStats ? m.maxHealth : 0; + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + float barY = cellMin.y + 16.0f; + ImVec2 barBg(cellMin.x + 3.0f, barY); + ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H); + draw->AddRectFilled(barBg, barBgEnd, IM_COL32(40, 40, 40, 200), 2.0f); + ImVec2 barFill(barBg.x, barBg.y); + ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); + ImU32 hpCol = pct > 0.5f ? IM_COL32(60, 180, 60, 255) : + pct > 0.2f ? IM_COL32(200, 180, 50, 255) : + IM_COL32(200, 60, 60, 255); + draw->AddRectFilled(barFill, barFillEnd, hpCol, 2.0f); + } + + // Power bar + if (m.hasPartyStats && m.maxPower > 0) { + float pct = static_cast(m.curPower) / static_cast(m.maxPower); + float barY = cellMin.y + 16.0f + BAR_H + 2.0f; + ImVec2 barBg(cellMin.x + 3.0f, barY); + ImVec2 barBgEnd(cellMax.x - 3.0f, barY + BAR_H - 2.0f); + draw->AddRectFilled(barBg, barBgEnd, IM_COL32(30, 30, 40, 200), 2.0f); + ImVec2 barFill(barBg.x, barBg.y); + ImVec2 barFillEnd(barBg.x + (barBgEnd.x - barBg.x) * pct, barBgEnd.y); + ImU32 pwrCol; + switch (m.powerType) { + case 0: pwrCol = IM_COL32(50, 80, 220, 255); break; // Mana + case 1: pwrCol = IM_COL32(200, 50, 50, 255); break; // Rage + case 3: pwrCol = IM_COL32(220, 210, 50, 255); break; // Energy + case 6: pwrCol = IM_COL32(180, 30, 50, 255); break; // Runic Power + default: pwrCol = IM_COL32(80, 120, 80, 255); break; + } + draw->AddRectFilled(barFill, barFillEnd, pwrCol, 2.0f); + } + + // Clickable invisible region over the whole cell + ImGui::SetCursorScreenPos(cellMin); + ImGui::PushID(static_cast(m.guid)); + if (ImGui::InvisibleButton("raidCell", ImVec2(CELL_W, CELL_H))) { + gameHandler.setTarget(m.guid); + } + ImGui::PopID(); + } + colIdx++; + } + + // Subgroup header row + colIdx = 0; + for (int sg = 0; sg < MAX_SUBGROUPS; sg++) { + if (subgroups[sg].empty()) continue; + float colX = winPos.x + CELL_PAD + colIdx * (CELL_W + CELL_PAD); + char sgLabel[8]; + snprintf(sgLabel, sizeof(sgLabel), "G%d", sg + 1); + draw->AddText(ImVec2(colX + CELL_W / 2 - 8.0f, winPos.y + CELL_PAD), IM_COL32(160, 160, 180, 200), sgLabel); + colIdx++; + } + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); + return; + } + + // ---- Party frame layout (5-man) ---- ImGui::SetNextWindowPos(ImVec2(10.0f, frameY), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always); @@ -5333,21 +5628,20 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { if (!ImGui::GetIO().WantCaptureKeyboard && ImGui::IsKeyPressed(ImGuiKey_O)) { showGuildRoster_ = !showGuildRoster_; if (showGuildRoster_) { + // Open friends tab directly if not in guild if (!gameHandler.isInGuild()) { - gameHandler.addLocalChatMessage(game::MessageChatData{ - game::ChatType::SYSTEM, game::ChatLanguage::UNIVERSAL, 0, "", 0, "", "You are not in a guild.", "", 0}); - showGuildRoster_ = false; - return; - } - // Re-query guild name if we have guildId but no name yet - if (gameHandler.getGuildName().empty()) { - const auto* ch = gameHandler.getActiveCharacter(); - if (ch && ch->hasGuild()) { - gameHandler.queryGuildInfo(ch->guildId); + guildRosterTab_ = 2; // Friends tab + } else { + // Re-query guild name if we have guildId but no name yet + if (gameHandler.getGuildName().empty()) { + const auto* ch = gameHandler.getActiveCharacter(); + if (ch && ch->hasGuild()) { + gameHandler.queryGuildInfo(ch->guildId); + } } + gameHandler.requestGuildRoster(); + gameHandler.requestGuildInfo(); } - gameHandler.requestGuildRoster(); - gameHandler.requestGuildInfo(); } } @@ -5398,7 +5692,7 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::SetNextWindowPos(ImVec2(screenW / 2 - 375, screenH / 2 - 250), ImGuiCond_Once); ImGui::SetNextWindowSize(ImVec2(750, 500), ImGuiCond_Once); - std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Guild") : "Guild"; + std::string title = gameHandler.isInGuild() ? (gameHandler.getGuildName() + " - Social") : "Social"; bool open = showGuildRoster_; if (ImGui::Begin(title.c_str(), &open, ImGuiWindowFlags_NoCollapse)) { // Tab bar: Roster | Guild Info @@ -5702,6 +5996,57 @@ void GameScreen::renderGuildRoster(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } + // ---- Friends tab ---- + if (ImGui::BeginTabItem("Friends")) { + guildRosterTab_ = 2; + const auto& contacts = gameHandler.getContacts(); + + // Filter to friends only + int friendCount = 0; + for (const auto& c : contacts) { + if (!c.isFriend()) continue; + ++friendCount; + + // Status dot + ImU32 dotColor = c.isOnline() + ? IM_COL32(80, 200, 80, 255) + : IM_COL32(120, 120, 120, 255); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(cursor.x + 6.0f, cursor.y + 8.0f), 5.0f, dotColor); + ImGui::Dummy(ImVec2(14.0f, 0.0f)); + ImGui::SameLine(); + + // Name + const char* displayName = c.name.empty() ? "(unknown)" : c.name.c_str(); + ImVec4 nameCol = c.isOnline() + ? ImVec4(1.0f, 1.0f, 1.0f, 1.0f) + : ImVec4(0.55f, 0.55f, 0.55f, 1.0f); + ImGui::TextColored(nameCol, "%s", displayName); + + // Level and status on same line (right-aligned) + if (c.isOnline()) { + ImGui::SameLine(); + const char* statusLabel = + (c.status == 2) ? "(AFK)" : + (c.status == 3) ? "(DND)" : ""; + if (c.level > 0) { + ImGui::TextDisabled("Lv %u %s", c.level, statusLabel); + } else if (*statusLabel) { + ImGui::TextDisabled("%s", statusLabel); + } + } + } + + if (friendCount == 0) { + ImGui::TextDisabled("No friends online."); + } + + ImGui::Separator(); + ImGui::TextDisabled("Right-click a player's name in chat to add friends."); + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); } } @@ -8182,6 +8527,38 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { drawList->AddCircleFilled(ImVec2(sx, sy), 2.5f, col); } + // Party member dots on minimap + { + const auto& partyData = gameHandler.getPartyData(); + const uint64_t leaderGuid = partyData.leaderGuid; + for (const auto& member : partyData.members) { + if (!member.isOnline || !member.hasPartyStats) continue; + if (member.posX == 0 && member.posY == 0) continue; + + // posX/posY follow same server axis convention as minimap pings: + // server posX = east/west axis → canonical Y (west) + // server posY = north/south axis → canonical X (north) + float wowX = static_cast(member.posY); + float wowY = static_cast(member.posX); + glm::vec3 memberRender = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f)); + + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(memberRender, sx, sy)) continue; + + ImU32 dotColor = (member.guid == leaderGuid) + ? IM_COL32(255, 210, 0, 235) + : IM_COL32(100, 180, 255, 235); + drawList->AddCircleFilled(ImVec2(sx, sy), 4.0f, dotColor); + drawList->AddCircle(ImVec2(sx, sy), 4.0f, IM_COL32(255, 255, 255, 160), 12, 1.0f); + + ImVec2 cursorPos = ImGui::GetMousePos(); + float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy; + if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) { + ImGui::SetTooltip("%s", member.name.c_str()); + } + } + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; @@ -8219,6 +8596,22 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } }; + // Zone name label above the minimap (centered, WoW-style) + { + const std::string& zoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + if (!zoneName.empty()) { + auto* fgDl = ImGui::GetForegroundDrawList(); + float zoneTextY = centerY - mapRadius - 16.0f; + ImFont* font = ImGui::GetFont(); + ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + float tzx = centerX - tsz.x * 0.5f; + fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f), + IM_COL32(0, 0, 0, 180), zoneName.c_str()); + fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY), + IM_COL32(255, 220, 120, 230), zoneName.c_str()); + } + } + // Speaker mute button at the minimap top-right corner ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - 26.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always); ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always); @@ -9891,6 +10284,7 @@ void GameScreen::renderDingEffect() { if (dingTimer_ < 0.0f) dingTimer_ = 0.0f; float alpha = dingTimer_ < 0.8f ? (dingTimer_ / 0.8f) : 1.0f; // fade out last 0.8s + float elapsed = DING_DURATION - dingTimer_; // 0 → DING_DURATION ImGuiIO& io = ImGui::GetIO(); float cx = io.DisplaySize.x * 0.5f; @@ -9898,6 +10292,37 @@ void GameScreen::renderDingEffect() { ImDrawList* draw = ImGui::GetForegroundDrawList(); + // ---- Golden radial ring burst (3 waves staggered by 0.45s) ---- + { + constexpr float kMaxRadius = 420.0f; + constexpr float kRingWidth = 18.0f; + constexpr float kWaveLen = 1.4f; // each wave lasts 1.4s + constexpr int kNumWaves = 3; + constexpr float kStagger = 0.45f; // seconds between waves + + for (int w = 0; w < kNumWaves; ++w) { + float waveElapsed = elapsed - w * kStagger; + if (waveElapsed <= 0.0f || waveElapsed >= kWaveLen) continue; + + float t = waveElapsed / kWaveLen; // 0 → 1 + float radius = t * kMaxRadius; + float ringAlpha = (1.0f - t) * alpha; // fades as it expands + + ImU32 outerCol = IM_COL32(255, 215, 60, (int)(ringAlpha * 200)); + ImU32 innerCol = IM_COL32(255, 255, 150, (int)(ringAlpha * 120)); + + draw->AddCircle(ImVec2(cx, cy), radius, outerCol, 64, kRingWidth); + draw->AddCircle(ImVec2(cx, cy), radius * 0.92f, innerCol, 64, kRingWidth * 0.5f); + } + } + + // ---- Full-screen golden flash on first frame ---- + if (elapsed < 0.15f) { + float flashA = (1.0f - elapsed / 0.15f) * 0.45f; + draw->AddRectFilled(ImVec2(0, 0), io.DisplaySize, + IM_COL32(255, 200, 50, (int)(flashA * 255))); + } + // "LEVEL X!" text — visible for first 2.2s if (dingTimer_ > 0.8f) { ImFont* font = ImGui::GetFont(); diff --git a/src/ui/quest_log_screen.cpp b/src/ui/quest_log_screen.cpp index ad7df081..00fbd173 100644 --- a/src/ui/quest_log_screen.cpp +++ b/src/ui/quest_log_screen.cpp @@ -261,6 +261,18 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { ImGui::BeginChild("QuestListPane", ImVec2(paneW, 0), true); ImGui::TextColored(ImVec4(0.85f, 0.82f, 0.74f, 1.0f), "Quest List"); ImGui::Separator(); + + // Resolve pending select from tracker click + if (pendingSelectQuestId_ != 0) { + for (size_t i = 0; i < quests.size(); i++) { + if (quests[i].questId == pendingSelectQuestId_) { + selectedIndex = static_cast(i); + break; + } + } + pendingSelectQuestId_ = 0; + } + for (size_t i = 0; i < quests.size(); i++) { const auto& q = quests[i]; ImGui::PushID(static_cast(i)); @@ -274,6 +286,11 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { if (rowW < 1.0f) rowW = 1.0f; bool clicked = ImGui::InvisibleButton("questRowBtn", ImVec2(rowW, rowH)); bool hovered = ImGui::IsItemHovered(); + // Scroll to selected quest on the first frame after openAndSelectQuest() + if (selected && scrollToSelected_) { + ImGui::SetScrollHereY(0.5f); + scrollToSelected_ = false; + } ImVec2 rowMin = ImGui::GetItemRectMin(); ImVec2 rowMax = ImGui::GetItemRectMax(); @@ -373,11 +390,17 @@ void QuestLogScreen::render(game::GameHandler& gameHandler) { } } - // Abandon button + // Track / Abandon buttons + ImGui::Separator(); + bool isTracked = gameHandler.isQuestTracked(sel.questId); + if (ImGui::Button(isTracked ? "Untrack" : "Track", ImVec2(100.0f, 0.0f))) { + gameHandler.setQuestTracked(sel.questId, !isTracked); + } if (!sel.complete) { - ImGui::Separator(); + ImGui::SameLine(); if (ImGui::Button("Abandon Quest", ImVec2(150.0f, 0.0f))) { gameHandler.abandonQuest(sel.questId); + gameHandler.setQuestTracked(sel.questId, false); selectedIndex = -1; } } diff --git a/tools/asset_extract/extractor.cpp b/tools/asset_extract/extractor.cpp index 615b43b9..1df2d510 100644 --- a/tools/asset_extract/extractor.cpp +++ b/tools/asset_extract/extractor.cpp @@ -76,8 +76,29 @@ static std::vector readFileBytes(const std::string& path) { return buf; } -static bool isValidStringOffset(const std::vector& stringBlock, uint32_t offset) { +// Precompute the set of valid string-boundary offsets in the string block. +// An offset is a valid boundary if it is 0 or immediately follows a null byte. +// This prevents small integer values (e.g. RaceID=1, 2, 3) from being falsely +// detected as string offsets just because they land in the middle of a longer +// string that starts at a lower offset. +static std::set computeStringBoundaries(const std::vector& stringBlock) { + std::set boundaries; + if (stringBlock.empty()) return boundaries; + boundaries.insert(0); + for (size_t i = 0; i + 1 < stringBlock.size(); ++i) { + if (stringBlock[i] == 0) { + boundaries.insert(static_cast(i + 1)); + } + } + return boundaries; +} + +static bool isValidStringOffset(const std::vector& stringBlock, + const std::set& boundaries, + uint32_t offset) { if (offset >= stringBlock.size()) return false; + // Must start at a string boundary (offset 0 or right after a null byte). + if (!boundaries.count(offset)) return false; for (size_t i = offset; i < stringBlock.size(); ++i) { uint8_t c = stringBlock[i]; if (c == 0) return true; @@ -105,21 +126,33 @@ static std::set detectStringColumns(const DBCFile& dbc, std::set cols; if (stringBlock.size() <= 1) return cols; + auto boundaries = computeStringBoundaries(stringBlock); + for (uint32_t col = 0; col < fieldCount; ++col) { bool allZeroOrValid = true; bool hasNonZero = false; + std::set distinctStrings; for (uint32_t row = 0; row < recordCount; ++row) { uint32_t val = dbc.getUInt32(row, col); if (val == 0) continue; hasNonZero = true; - if (!isValidStringOffset(stringBlock, val)) { + if (!isValidStringOffset(stringBlock, boundaries, val)) { allZeroOrValid = false; break; } + // Collect distinct non-empty strings for diversity check. + const char* s = reinterpret_cast(stringBlock.data() + val); + if (*s != '\0') { + distinctStrings.insert(std::string(s, strnlen(s, 256))); + } } - if (allZeroOrValid && hasNonZero) { + // Require at least 2 distinct non-empty string values. Columns that + // only ever point to a single string (e.g. SexID=1 always resolves to + // the same path fragment at offset 1 in the block) are almost certainly + // integer fields whose small values accidentally land at a string boundary. + if (allZeroOrValid && hasNonZero && distinctStrings.size() >= 2) { cols.insert(col); } } diff --git a/tools/dbc_to_csv/main.cpp b/tools/dbc_to_csv/main.cpp index 514d5a40..53e28bf8 100644 --- a/tools/dbc_to_csv/main.cpp +++ b/tools/dbc_to_csv/main.cpp @@ -41,9 +41,31 @@ std::vector readFileBytes(const std::string& path) { return buf; } -// Check whether offset points to a plausible string in the string block. -bool isValidStringOffset(const std::vector& stringBlock, uint32_t offset) { +// Precompute the set of valid string-boundary offsets in the string block. +// An offset is a valid boundary if it is 0 or immediately follows a null byte. +// This prevents small integer values (e.g. RaceID=1, 2, 3) from being falsely +// detected as string offsets just because they land in the middle of a longer +// string that starts at a lower offset. +std::set computeStringBoundaries(const std::vector& stringBlock) { + std::set boundaries; + if (stringBlock.empty()) return boundaries; + boundaries.insert(0); // offset 0 is always a valid start + for (size_t i = 0; i + 1 < stringBlock.size(); ++i) { + if (stringBlock[i] == 0) { + boundaries.insert(static_cast(i + 1)); + } + } + return boundaries; +} + +// Check whether offset points to a valid string-boundary position in the block +// and that the string there is printable and null-terminated. +bool isValidStringOffset(const std::vector& stringBlock, + const std::set& boundaries, + uint32_t offset) { if (offset >= stringBlock.size()) return false; + // Must start at a string boundary (offset 0 or right after a null byte). + if (!boundaries.count(offset)) return false; // Must be null-terminated within the block and contain only printable/whitespace bytes. for (size_t i = offset; i < stringBlock.size(); ++i) { uint8_t c = stringBlock[i]; @@ -75,21 +97,35 @@ std::set detectStringColumns(const DBCFile& dbc, // If no string block (or trivial size), no string columns. if (stringBlock.size() <= 1) return stringCols; + // Precompute valid string-start boundaries to avoid false positives from + // integer fields whose small values accidentally land inside longer strings. + auto boundaries = computeStringBoundaries(stringBlock); + for (uint32_t col = 0; col < fieldCount; ++col) { bool allZeroOrValid = true; bool hasNonZero = false; + std::set distinctStrings; for (uint32_t row = 0; row < recordCount; ++row) { uint32_t val = dbc.getUInt32(row, col); if (val == 0) continue; hasNonZero = true; - if (!isValidStringOffset(stringBlock, val)) { + if (!isValidStringOffset(stringBlock, boundaries, val)) { allZeroOrValid = false; break; } + // Collect distinct non-empty strings for diversity check. + const char* s = reinterpret_cast(stringBlock.data() + val); + if (*s != '\0') { + distinctStrings.insert(std::string(s, strnlen(s, 256))); + } } - if (allZeroOrValid && hasNonZero) { + // Require at least 2 distinct non-empty string values. Columns that + // only ever point to a single string (e.g. SexID=1 always resolves to + // the same path fragment at offset 1 in the block) are almost certainly + // integer fields whose small values accidentally land at a string boundary. + if (allZeroOrValid && hasNonZero && distinctStrings.size() >= 2) { stringCols.insert(col); } }