diff --git a/CMakeLists.txt b/CMakeLists.txt index b9cacced..f752df5a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -488,6 +488,8 @@ set(WOWEE_SOURCES # Core src/core/application.cpp src/core/entity_spawner.cpp + src/core/entity_spawner_player.cpp + src/core/entity_spawner_processing.cpp src/core/appearance_composer.cpp src/core/world_loader.cpp src/core/npc_interaction_callback_handler.cpp @@ -525,6 +527,8 @@ set(WOWEE_SOURCES src/game/opcode_table.cpp src/game/update_field_table.cpp src/game/game_handler.cpp + src/game/game_handler_packets.cpp + src/game/game_handler_callbacks.cpp src/game/chat_handler.cpp src/game/movement_handler.cpp src/game/combat_handler.cpp @@ -544,6 +548,10 @@ set(WOWEE_SOURCES src/game/entity.cpp src/game/opcodes.cpp src/game/world_packets.cpp + src/game/world_packets_social.cpp + src/game/world_packets_entity.cpp + src/game/world_packets_world.cpp + src/game/world_packets_economy.cpp src/game/packet_parsers_tbc.cpp src/game/packet_parsers_classic.cpp src/game/character.cpp @@ -611,6 +619,9 @@ set(WOWEE_SOURCES src/rendering/character_preview.cpp src/rendering/wmo_renderer.cpp src/rendering/m2_renderer.cpp + src/rendering/m2_renderer_render.cpp + src/rendering/m2_renderer_particles.cpp + src/rendering/m2_renderer_instance.cpp src/rendering/m2_model_classifier.cpp src/rendering/render_graph.cpp src/rendering/quest_marker_renderer.cpp @@ -622,6 +633,7 @@ set(WOWEE_SOURCES src/rendering/charge_effect.cpp src/rendering/spell_visual_system.cpp src/rendering/post_process_pipeline.cpp + src/rendering/overlay_system.cpp src/rendering/animation_controller.cpp src/rendering/animation/animation_ids.cpp src/rendering/animation/emote_registry.cpp @@ -643,7 +655,12 @@ set(WOWEE_SOURCES src/ui/character_create_screen.cpp src/ui/character_screen.cpp src/ui/game_screen.cpp + src/ui/game_screen_frames.cpp + src/ui/game_screen_hud.cpp + src/ui/game_screen_minimap.cpp src/ui/chat_panel.cpp + src/ui/chat_panel_commands.cpp + src/ui/chat_panel_utils.cpp src/ui/toast_manager.cpp src/ui/dialog_manager.cpp src/ui/settings_panel.cpp diff --git a/include/audio/audio_coordinator.hpp b/include/audio/audio_coordinator.hpp index f181164f..9a8305b9 100644 --- a/include/audio/audio_coordinator.hpp +++ b/include/audio/audio_coordinator.hpp @@ -1,9 +1,13 @@ #pragma once +#include #include +#include +#include namespace wowee { namespace pipeline { class AssetManager; } +namespace game { class ZoneManager; } namespace audio { class MusicManager; @@ -17,6 +21,26 @@ class CombatSoundManager; class SpellSoundManager; class MovementSoundManager; +/// Flat context passed from Renderer into updateZoneAudio() each frame. +/// All values are pre-queried so AudioCoordinator needs no rendering pointers. +struct ZoneAudioContext { + float deltaTime = 0.0f; + glm::vec3 cameraPosition{0.0f}; + bool isSwimming = false; + bool insideWmo = false; + uint32_t insideWmoId = 0; + // Visual weather state for ambient audio sync + int weatherType = 0; // 0=none, 1=rain, 2=snow, 3=storm + float weatherIntensity = 0.0f; + // Terrain tile for offline zone lookup + int tileX = 0, tileY = 0; + bool hasTile = false; + // Server-authoritative zone (from SMSG_INIT_WORLD_STATES); 0 = offline + uint32_t serverZoneId = 0; + // Zone manager pointer (for zone info and music queries) + game::ZoneManager* zoneManager = nullptr; +}; + /// Coordinates all audio subsystems. /// Extracted from Renderer to separate audio lifecycle from rendering. /// Owned by Application; Renderer and UI components access through Application. @@ -35,6 +59,13 @@ public: /// Shutdown all audio managers and engine. void shutdown(); + /// Per-frame zone detection, music transitions, and ambient weather sync. + /// Called from Renderer::update() with a pre-filled context. + void updateZoneAudio(const ZoneAudioContext& ctx); + + const std::string& getCurrentZoneName() const { return currentZoneName_; } + uint32_t getCurrentZoneId() const { return currentZoneId_; } + // Accessors for all audio managers (same interface as Renderer had) MusicManager* getMusicManager() { return musicManager_.get(); } FootstepManager* getFootstepManager() { return footstepManager_.get(); } @@ -48,6 +79,8 @@ public: MovementSoundManager* getMovementSoundManager() { return movementSoundManager_.get(); } private: + void playZoneMusic(const std::string& music); + std::unique_ptr musicManager_; std::unique_ptr footstepManager_; std::unique_ptr activitySoundManager_; @@ -60,6 +93,13 @@ private: std::unique_ptr movementSoundManager_; bool audioAvailable_ = false; + + // Zone/music state — moved from Renderer + uint32_t currentZoneId_ = 0; + std::string currentZoneName_; + bool inTavern_ = false; + bool inBlacksmith_ = false; + float musicSwitchCooldown_ = 0.0f; }; } // namespace audio diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 0d2883f6..044c12c9 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -1825,7 +1825,11 @@ public: } // Convenience: invoke a callback with a sound manager obtained from the renderer. template - void withSoundManager(ManagerGetter getter, Callback cb); + void withSoundManager(ManagerGetter getter, Callback cb) { + if (auto* ac = services_.audioCoordinator) { + if (auto* mgr = (ac->*getter)()) cb(mgr); + } + } // Reputation change toast: factionName, delta, new standing using RepChangeCallback = std::function; @@ -2138,17 +2142,370 @@ public: */ void resetDbcCaches(); -private: - friend class ChatHandler; - friend class MovementHandler; - friend class CombatHandler; - friend class SpellHandler; - friend class InventoryHandler; - friend class SocialHandler; - friend class QuestHandler; - friend class WardenHandler; - friend class EntityController; + // ═══════════════════════════════════════════════════════════════════ + // Domain handler access — public accessors for friend-class elimination + // ═══════════════════════════════════════════════════════════════════ + // ── Handler & Subsystem Accessors (unique_ptr → raw pointer) ───── + network::WorldSocket* getSocket() { return socket.get(); } + const network::WorldSocket* getSocket() const { return socket.get(); } + ChatHandler* getChatHandler() { return chatHandler_.get(); } + CombatHandler* getCombatHandler() { return combatHandler_.get(); } + MovementHandler* getMovementHandler() { return movementHandler_.get(); } + SpellHandler* getSpellHandler() { return spellHandler_.get(); } + + // ── Mutable Accessors for Members with Existing Const Getters ──── + void setTargetGuidRaw(uint64_t g) { targetGuid = g; } + uint64_t& lastTargetGuidRef() { return lastTargetGuid; } + uint64_t& focusGuidRef() { return focusGuid; } + uint64_t& mouseoverGuidRef() { return mouseoverGuid_; } + MovementInfo& movementInfoRef() { return movementInfo; } + Inventory& inventoryRef() { return inventory; } + + // ── Core / Session ─────────────────────────────────────────────── + uint32_t getBuild() const { return build; } + const std::vector& getSessionKey() const { return sessionKey; } + auto& charactersRef() { return characters; } + auto& updateFieldTableRef() { return updateFieldTable_; } + auto& lastPlayerFieldsRef() { return lastPlayerFields_; } + auto& timeSinceLastPingRef() { return timeSinceLastPing; } + auto& activeCharacterGuidRef() { return activeCharacterGuid_; } + + // ── Character & Appearance ─────────────────────────────────────── + auto& chosenTitleBitRef() { return chosenTitleBit_; } + auto& cloakVisibleRef() { return cloakVisible_; } + auto& helmVisibleRef() { return helmVisible_; } + auto& currentMountDisplayIdRef() { return currentMountDisplayId_; } + auto& mountAuraSpellIdRef() { return mountAuraSpellId_; } + auto& shapeshiftFormIdRef() { return shapeshiftFormId_; } + auto& playerRaceRef() { return playerRace_; } + auto& serverPlayerLevelRef() { return serverPlayerLevel_; } + + // ── AFK / DND ──────────────────────────────────────────────────── + auto& afkMessageRef() { return afkMessage_; } + auto& afkStatusRef() { return afkStatus_; } + auto& dndMessageRef() { return dndMessage_; } + auto& dndStatusRef() { return dndStatus_; } + + // ── Movement & Transport ───────────────────────────────────────── + auto& followRenderPosRef() { return followRenderPos_; } + auto& followTargetGuidRef() { return followTargetGuid_; } + auto& serverRunSpeedRef() { return serverRunSpeed_; } + auto& onTaxiFlightRef() { return onTaxiFlight_; } + auto& taxiLandingCooldownRef() { return taxiLandingCooldown_; } + auto& taxiMountActiveRef() { return taxiMountActive_; } + auto& taxiStartGraceRef() { return taxiStartGrace_; } + auto& vehicleIdRef() { return vehicleId_; } + auto& playerTransportGuidRef() { return playerTransportGuid_; } + auto& playerTransportOffsetRef() { return playerTransportOffset_; } + auto& playerTransportStickyGuidRef() { return playerTransportStickyGuid_; } + auto& playerTransportStickyTimerRef() { return playerTransportStickyTimer_; } + auto& transportAttachmentsRef() { return transportAttachments_; } + + // ── Inventory & Equipment ──────────────────────────────────────── + auto& actionBarRef() { return actionBar; } + auto& backpackSlotGuidsRef() { return backpackSlotGuids_; } + auto& equipSlotGuidsRef() { return equipSlotGuids_; } + auto& keyringSlotGuidsRef() { return keyringSlotGuids_; } + auto& containerContentsRef() { return containerContents_; } + auto& invSlotBaseRef() { return invSlotBase_; } + auto& packSlotBaseRef() { return packSlotBase_; } + auto& visibleItemEntryBaseRef() { return visibleItemEntryBase_; } + auto& visibleItemLayoutVerifiedRef() { return visibleItemLayoutVerified_; } + auto& visibleItemStrideRef() { return visibleItemStride_; } + auto& itemInfoCacheRef() { return itemInfoCache_; } + auto& lastEquipDisplayIdsRef() { return lastEquipDisplayIds_; } + auto& onlineEquipDirtyRef() { return onlineEquipDirty_; } + auto& onlineItemsRef() { return onlineItems_; } + auto& inspectedPlayerItemEntriesRef() { return inspectedPlayerItemEntries_; } + auto& otherPlayerVisibleDirtyRef() { return otherPlayerVisibleDirty_; } + auto& otherPlayerVisibleItemEntriesRef() { return otherPlayerVisibleItemEntries_; } + auto& otherPlayerMoveTimeMsRef() { return otherPlayerMoveTimeMs_; } + auto& pendingItemPushNotifsRef() { return pendingItemPushNotifs_; } + auto& pendingItemQueriesRef() { return pendingItemQueries_; } + auto& pendingMoneyDeltaRef() { return pendingMoneyDelta_; } + auto& pendingMoneyDeltaTimerRef() { return pendingMoneyDeltaTimer_; } + auto& pendingAutoInspectRef() { return pendingAutoInspect_; } + auto& pendingGameObjectLootRetriesRef() { return pendingGameObjectLootRetries_; } + auto& tempEnchantTimersRef() { return tempEnchantTimers_; } + auto& localLootStateRef() { return localLootState_; } + static const auto& getTempEnchantSlotNames() { return kTempEnchantSlotNames; } + + // ── Combat & Player Stats ──────────────────────────────────────── + auto& comboPointsRef() { return comboPoints_; } + auto& comboTargetRef() { return comboTarget_; } + auto& isRestingRef() { return isResting_; } + auto& playerArenaPointsRef() { return playerArenaPoints_; } + auto& playerArmorRatingRef() { return playerArmorRating_; } + auto& playerBlockPctRef() { return playerBlockPct_; } + auto& playerCombatRatingsRef() { return playerCombatRatings_; } + auto& playerCritPctRef() { return playerCritPct_; } + auto& playerDodgePctRef() { return playerDodgePct_; } + auto& playerHealBonusRef() { return playerHealBonus_; } + auto& playerHonorPointsRef() { return playerHonorPoints_; } + auto& playerMeleeAPRef() { return playerMeleeAP_; } + auto& playerMoneyCopperRef() { return playerMoneyCopper_; } + auto& playerNextLevelXpRef() { return playerNextLevelXp_; } + auto& playerParryPctRef() { return playerParryPct_; } + auto& playerRangedAPRef() { return playerRangedAP_; } + auto& playerRangedCritPctRef() { return playerRangedCritPct_; } + auto* playerResistancesArr() { return playerResistances_; } + auto& playerRestedXpRef() { return playerRestedXp_; } + auto* playerSpellCritPctArr() { return playerSpellCritPct_; } + auto* playerSpellDmgBonusArr() { return playerSpellDmgBonus_; } + auto& playerStatsArr() { return playerStats_; } + auto& playerXpRef() { return playerXp_; } + + // ── Skills ─────────────────────────────────────────────────────── + auto& playerSkillsRef() { return playerSkills_; } + auto& skillLineAbilityLoadedRef() { return skillLineAbilityLoaded_; } + auto& skillLineCategoriesRef() { return skillLineCategories_; } + auto& skillLineDbcLoadedRef() { return skillLineDbcLoaded_; } + auto& skillLineNamesRef() { return skillLineNames_; } + auto& spellToSkillLineRef() { return spellToSkillLine_; } + + // ── Spells & Talents ───────────────────────────────────────────── + auto& activeTalentSpecRef() { return activeTalentSpec_; } + auto* unspentTalentPointsArr() { return unspentTalentPoints_; } + auto* learnedTalentsArr() { return learnedTalents_; } + auto& learnedGlyphsRef() { return learnedGlyphs_; } + auto& talentsInitializedRef() { return talentsInitialized_; } + auto& spellFlatModsRef() { return spellFlatMods_; } + auto& spellPctModsRef() { return spellPctMods_; } + auto& spellNameCacheRef() { return spellNameCache_; } + auto& spellNameCacheLoadedRef() { return spellNameCacheLoaded_; } + + // ── Quests & Achievements ──────────────────────────────────────── + auto& completedQuestsRef() { return completedQuests_; } + auto& npcQuestStatusRef() { return npcQuestStatus_; } + auto& achievementDatesRef() { return achievementDates_; } + auto& achievementNameCacheRef() { return achievementNameCache_; } + auto& earnedAchievementsRef() { return earnedAchievements_; } + + // ── Social, Chat & Contacts ────────────────────────────────────── + auto& contactsRef() { return contacts_; } + auto& friendGuidsRef() { return friendGuids_; } + auto& friendsCacheRef() { return friendsCache; } + auto& ignoreCacheRef() { return ignoreCache; } + auto& ignoreListGuidsRef() { return ignoreListGuids_; } + auto& lastContactListCountRef() { return lastContactListCount_; } + auto& lastContactListMaskRef() { return lastContactListMask_; } + auto& lastWhisperSenderRef() { return lastWhisperSender_; } + auto& lastWhisperSenderGuidRef() { return lastWhisperSenderGuid_; } + auto& mailInboxRef() { return mailInbox_; } + + // ── World, Map & Zones ─────────────────────────────────────────── + auto& currentMapIdRef() { return currentMapId_; } + auto& inInstanceRef() { return inInstance_; } + auto& worldStateMapIdRef() { return worldStateMapId_; } + auto& worldStatesRef() { return worldStates_; } + auto& worldStateZoneIdRef() { return worldStateZoneId_; } + auto& minimapPingsRef() { return minimapPings_; } + auto& gossipPoisRef() { return gossipPois_; } + auto& playerExploredZonesRef() { return playerExploredZones_; } + auto& hasPlayerExploredZonesRef() { return hasPlayerExploredZones_; } + auto& factionStandingsRef() { return factionStandings_; } + auto& initialFactionsRef() { return initialFactions_; } + auto& watchedFactionIdRef() { return watchedFactionId_; } + + // ── Corpse & Home Bind ─────────────────────────────────────────── + auto& corpseGuidRef() { return corpseGuid_; } + auto& corpseMapIdRef() { return corpseMapId_; } + auto& corpseReclaimAvailableMsRef() { return corpseReclaimAvailableMs_; } + auto& corpseXRef() { return corpseX_; } + auto& corpseYRef() { return corpseY_; } + auto& corpseZRef() { return corpseZ_; } + auto& hasHomeBindRef() { return hasHomeBind_; } + auto& homeBindMapIdRef() { return homeBindMapId_; } + auto& homeBindPosRef() { return homeBindPos_; } + + // ── Area Triggers ──────────────────────────────────────────────── + auto& activeAreaTriggersRef() { return activeAreaTriggers_; } + auto& areaTriggerCheckTimerRef() { return areaTriggerCheckTimer_; } + auto& areaTriggerDbcLoadedRef() { return areaTriggerDbcLoaded_; } + auto& areaTriggerMsgsRef() { return areaTriggerMsgs_; } + auto& areaTriggersRef() { return areaTriggers_; } + auto& areaTriggerSuppressFirstRef() { return areaTriggerSuppressFirst_; } + + // ── Death & Resurrection ───────────────────────────────────────── + auto& playerDeadRef() { return playerDead_; } + auto& releasedSpiritRef() { return releasedSpirit_; } + auto& repopPendingRef() { return repopPending_; } + auto& lastRepopRequestMsRef() { return lastRepopRequestMs_; } + auto& pendingSpiritHealerGuidRef() { return pendingSpiritHealerGuid_; } + auto& resurrectCasterGuidRef() { return resurrectCasterGuid_; } + auto& resurrectIsSpiritHealerRef() { return resurrectIsSpiritHealer_; } + auto& resurrectPendingRef() { return resurrectPending_; } + auto& resurrectRequestPendingRef() { return resurrectRequestPending_; } + auto& selfResAvailableRef() { return selfResAvailable_; } + + // ── Summon & Battlefield ───────────────────────────────────────── + auto& pendingSummonRequestRef() { return pendingSummonRequest_; } + auto& summonerGuidRef() { return summonerGuid_; } + auto& summonerNameRef() { return summonerName_; } + auto& summonTimeoutSecRef() { return summonTimeoutSec_; } + auto& bfMgrInvitePendingRef() { return bfMgrInvitePending_; } + + // ── Pet & Stable ───────────────────────────────────────────────── + auto& petActionSlotsRef() { return petActionSlots_; } + auto& petAutocastSpellsRef() { return petAutocastSpells_; } + auto& petCommandRef() { return petCommand_; } + auto& petGuidRef() { return petGuid_; } + auto& petReactRef() { return petReact_; } + auto& petSpellListRef() { return petSpellList_; } + auto& stabledPetsRef() { return stabledPets_; } + auto& stableMasterGuidRef() { return stableMasterGuid_; } + auto& stableNumSlotsRef() { return stableNumSlots_; } + auto& stableWindowOpenRef() { return stableWindowOpen_; } + + // ── Trainer, GM & Misc ─────────────────────────────────────────── + auto& currentTrainerListRef() { return currentTrainerList_; } + auto& trainerTabsRef() { return trainerTabs_; } + auto& gmTicketActiveRef() { return gmTicketActive_; } + auto& gmTicketTextRef() { return gmTicketText_; } + auto& bookPagesRef() { return bookPages_; } + auto& activeTotemSlotsRef() { return activeTotemSlots_; } + auto& unitAurasCacheRef() { return unitAurasCache_; } + auto& lastInteractedGoGuidRef() { return lastInteractedGoGuid_; } + auto& pendingGameObjectInteractGuidRef() { return pendingGameObjectInteractGuid_; } + + // ── Tab Cycling ────────────────────────────────────────────────── + auto& tabCycleIndexRef() { return tabCycleIndex; } + auto& tabCycleListRef() { return tabCycleList; } + auto& tabCycleStaleRef() { return tabCycleStale; } + + // ── UI & Event Callbacks ───────────────────────────────────────── + auto& achievementEarnedCallbackRef() { return achievementEarnedCallback_; } + auto& addonChatCallbackRef() { return addonChatCallback_; } + auto& addonEventCallbackRef() { return addonEventCallback_; } + auto& appearanceChangedCallbackRef() { return appearanceChangedCallback_; } + auto& autoFollowCallbackRef() { return autoFollowCallback_; } + auto& chargeCallbackRef() { return chargeCallback_; } + auto& chatBubbleCallbackRef() { return chatBubbleCallback_; } + auto& creatureDespawnCallbackRef() { return creatureDespawnCallback_; } + auto& creatureMoveCallbackRef() { return creatureMoveCallback_; } + auto& creatureSpawnCallbackRef() { return creatureSpawnCallback_; } + auto& emoteAnimCallbackRef() { return emoteAnimCallback_; } + auto& gameObjectDespawnCallbackRef() { return gameObjectDespawnCallback_; } + auto& gameObjectMoveCallbackRef() { return gameObjectMoveCallback_; } + auto& gameObjectSpawnCallbackRef() { return gameObjectSpawnCallback_; } + auto& gameObjectStateCallbackRef() { return gameObjectStateCallback_; } + auto& ghostStateCallbackRef() { return ghostStateCallback_; } + auto& hearthstonePreloadCallbackRef() { return hearthstonePreloadCallback_; } + auto& hitReactionCallbackRef() { return hitReactionCallback_; } + auto& itemLootCallbackRef() { return itemLootCallback_; } + auto& knockBackCallbackRef() { return knockBackCallback_; } + auto& lootWindowCallbackRef() { return lootWindowCallback_; } + auto& meleeSwingCallbackRef() { return meleeSwingCallback_; } + auto& mountCallbackRef() { return mountCallback_; } + auto& npcAggroCallbackRef() { return npcAggroCallback_; } + auto& npcDeathCallbackRef() { return npcDeathCallback_; } + auto& npcFarewellCallbackRef() { return npcFarewellCallback_; } + auto& npcGreetingCallbackRef() { return npcGreetingCallback_; } + auto& npcRespawnCallbackRef() { return npcRespawnCallback_; } + auto& npcSwingCallbackRef() { return npcSwingCallback_; } + auto& npcVendorCallbackRef() { return npcVendorCallback_; } + auto& openLfgCallbackRef() { return openLfgCallback_; } + auto& otherPlayerLevelUpCallbackRef() { return otherPlayerLevelUpCallback_; } + auto& playerDespawnCallbackRef() { return playerDespawnCallback_; } + auto& playerEquipmentCallbackRef() { return playerEquipmentCallback_; } + auto& playerHealthCallbackRef() { return playerHealthCallback_; } + auto& playerSpawnCallbackRef() { return playerSpawnCallback_; } + auto& pvpHonorCallbackRef() { return pvpHonorCallback_; } + auto& questCompleteCallbackRef() { return questCompleteCallback_; } + auto& questProgressCallbackRef() { return questProgressCallback_; } + auto& repChangeCallbackRef() { return repChangeCallback_; } + auto& spellCastAnimCallbackRef() { return spellCastAnimCallback_; } + auto& spellCastFailedCallbackRef() { return spellCastFailedCallback_; } + auto& sprintAuraCallbackRef() { return sprintAuraCallback_; } + auto& stealthStateCallbackRef() { return stealthStateCallback_; } + auto& stunStateCallbackRef() { return stunStateCallback_; } + auto& taxiFlightStartCallbackRef() { return taxiFlightStartCallback_; } + auto& taxiOrientationCallbackRef() { return taxiOrientationCallback_; } + auto& taxiPrecacheCallbackRef() { return taxiPrecacheCallback_; } + auto& transportMoveCallbackRef() { return transportMoveCallback_; } + auto& unitAnimHintCallbackRef() { return unitAnimHintCallback_; } + auto& unitMoveFlagsCallbackRef() { return unitMoveFlagsCallback_; } + auto& worldEntryCallbackRef() { return worldEntryCallback_; } + + // ── Methods moved from private (domain handler use) ────────────── + void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, + bool isPlayerSource, uint8_t powerType = 0, + uint64_t srcGuid = 0, uint64_t dstGuid = 0); + bool shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId); + void addSystemChatMessage(const std::string& message); + void sendPing(); + void setTransportAttachment(uint64_t childGuid, ObjectType type, + uint64_t transportGuid, const glm::vec3& localOffset, + bool hasLocalOrientation, float localOrientation); + void clearTransportAttachment(uint64_t childGuid); + std::string guidToUnitId(uint64_t guid) const; + Unit* getUnitByGuid(uint64_t guid); + uint64_t resolveOnlineItemGuid(uint32_t itemId) const; + void rebuildOnlineInventory(); + void maybeDetectVisibleItemLayout(); + void updateOtherPlayerVisibleItems(uint64_t guid, const std::map& fields); + void detectInventorySlotBases(const std::map& fields); + bool applyInventoryFields(const std::map& fields); + void extractContainerFields(uint64_t containerGuid, const std::map& fields); + void extractSkillFields(const std::map& fields); + void extractExploredZoneFields(const std::map& fields); + void applyQuestStateFromFields(const std::map& fields); + void sanitizeMovementForTaxi(); + void loadSpellNameCache() const; + void loadFactionNameCache() const; + void loadAchievementNameCache(); + void loadSkillLineDbc(); + void loadSkillLineAbilityDbc(); + std::string getFactionName(uint32_t factionId) const; + std::string getLfgDungeonName(uint32_t dungeonId) const; + void queryItemInfo(uint32_t entry, uint64_t guid); + + // --- Inner types exposed for former friend classes --- + struct TransportAttachment { + ObjectType type = ObjectType::OBJECT; + uint64_t transportGuid = 0; + glm::vec3 localOffset{0.0f}; + float localOrientation = 0.0f; + bool hasLocalOrientation = false; + }; + struct AreaTriggerEntry { + uint32_t id = 0; + uint32_t mapId = 0; + float x = 0, y = 0, z = 0; + float radius = 0; + float boxLength = 0, boxWidth = 0, boxHeight = 0; + float boxYaw = 0; + }; + struct PendingLootRetry { + uint64_t guid = 0; + float timer = 0.0f; + uint8_t remainingRetries = 0; + bool sendLoot = false; + }; + struct SpellNameEntry { + std::string name; std::string rank; std::string description; + uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0; + int32_t effectBasePoints[3] = {0, 0, 0}; + float durationSec = 0.0f; + }; + static constexpr size_t PLAYER_EXPLORED_ZONES_COUNT = 128; + std::string getAreaName(uint32_t areaId) const; + struct OnlineItemInfo { + uint32_t entry = 0; + uint32_t stackCount = 1; + uint32_t curDurability = 0; + uint32_t maxDurability = 0; + uint32_t permanentEnchantId = 0; + uint32_t temporaryEnchantId = 0; + std::array socketEnchantIds{}; + }; + bool isHostileFaction(uint32_t factionTemplateId) const { + auto it = factionHostileMap_.find(factionTemplateId); + return it != factionHostileMap_.end() ? it->second : true; + } + +private: // Dead: autoTargetAttacker moved to CombatHandler /** @@ -2223,16 +2580,8 @@ private: void handlePong(network::Packet& packet); void handleItemQueryResponse(network::Packet& packet); - void queryItemInfo(uint32_t entry, uint64_t guid); - void rebuildOnlineInventory(); - void maybeDetectVisibleItemLayout(); - void updateOtherPlayerVisibleItems(uint64_t guid, const std::map& fields); void emitOtherPlayerEquipment(uint64_t guid); void emitAllOtherPlayerEquipment(); - void detectInventorySlotBases(const std::map& fields); - bool applyInventoryFields(const std::map& fields); - void extractContainerFields(uint64_t containerGuid, const std::map& fields); - uint64_t resolveOnlineItemGuid(uint32_t itemId) const; // handleAttackStart, handleAttackStop, handleAttackerStateUpdate, // handleSpellDamageLog, handleSpellHealLog removed @@ -2257,8 +2606,6 @@ private: void clearPendingQuestAccept(uint32_t questId); void triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason); bool hasQuestInLog(uint32_t questId) const; - std::string guidToUnitId(uint64_t guid) const; - Unit* getUnitByGuid(uint64_t guid); std::string getQuestTitle(uint32_t questId) const; const QuestLogEntry* findQuestLogEntry(uint32_t questId) const; int findQuestLogSlotIndexFromServer(uint32_t questId) const; @@ -2302,15 +2649,7 @@ private: // ---- Logout handlers ---- - void addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType = 0, - uint64_t srcGuid = 0, uint64_t dstGuid = 0); - bool shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId); - void addSystemChatMessage(const std::string& message); - /** - * Send CMSG_PING to server (heartbeat) - */ - void sendPing(); /** * Send CMSG_AUTH_SESSION to server @@ -2332,10 +2671,6 @@ private: */ void fail(const std::string& reason); void updateAttachedTransportChildren(float deltaTime); - void setTransportAttachment(uint64_t childGuid, ObjectType type, uint64_t transportGuid, - const glm::vec3& localOffset, bool hasLocalOrientation, - float localOrientation); - void clearTransportAttachment(uint64_t childGuid); // Explicit service dependencies (owned by Application) GameServices& services_; @@ -2487,15 +2822,6 @@ private: uint64_t lastWhisperSenderGuid_ = 0; // ---- Online item tracking ---- - struct OnlineItemInfo { - uint32_t entry = 0; - uint32_t stackCount = 1; - uint32_t curDurability = 0; - uint32_t maxDurability = 0; - uint32_t permanentEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 0 (enchanting) - uint32_t temporaryEnchantId = 0; // ITEM_ENCHANTMENT_SLOT 1 (sharpening stones, poisons) - std::array socketEnchantIds{}; // ITEM_ENCHANTMENT_SLOT 2-4 (gems) - }; std::unordered_map onlineItems_; std::unordered_map itemInfoCache_; std::unordered_set pendingItemQueries_; @@ -2576,13 +2902,6 @@ private: VehicleStateCallback vehicleStateCallback_; // Transport tracking - struct TransportAttachment { - ObjectType type = ObjectType::OBJECT; - uint64_t transportGuid = 0; - glm::vec3 localOffset{0.0f}; - float localOrientation = 0.0f; - bool hasLocalOrientation = false; - }; std::unordered_map transportAttachments_; // Transport GUID tracking moved to EntityController uint64_t playerTransportGuid_ = 0; // Transport the player is riding (0 = none) @@ -2606,14 +2925,6 @@ private: bool talentsInitialized_ = false; // Reset on world entry; guards first-spec selection // ---- Area trigger detection ---- - struct AreaTriggerEntry { - uint32_t id = 0; - uint32_t mapId = 0; - float x = 0, y = 0, z = 0; // canonical WoW coords (converted from DBC) - float radius = 0; - float boxLength = 0, boxWidth = 0, boxHeight = 0; - float boxYaw = 0; - }; bool areaTriggerDbcLoaded_ = false; std::vector areaTriggers_; std::unordered_set activeAreaTriggers_; // triggers player is currently inside @@ -2709,8 +3020,6 @@ private: // factionId → repListId reverse mapping mutable std::unordered_map factionIdToRepList_; mutable bool factionNameCacheLoaded_ = false; - void loadFactionNameCache() const; - std::string getFactionName(uint32_t factionId) const; // ---- Group ---- GroupListData partyData; @@ -2800,12 +3109,6 @@ private: bool itemAutoLootSent = false; }; std::unordered_map localLootState_; - struct PendingLootRetry { - uint64_t guid = 0; - float timer = 0.0f; - uint8_t remainingRetries = 0; - bool sendLoot = false; - }; std::vector pendingGameObjectLootRetries_; struct PendingLootOpen { uint64_t guid = 0; @@ -2879,10 +3182,6 @@ private: // Faction hostility lookup (populated from FactionTemplate.dbc) std::unordered_map factionHostileMap_; - bool isHostileFaction(uint32_t factionTemplateId) const { - auto it = factionHostileMap_.find(factionTemplateId); - return it != factionHostileMap_.end() ? it->second : true; // default hostile if unknown - } // Vehicle (WotLK): non-zero when player is seated in a vehicle uint32_t vehicleId_ = 0; @@ -2916,7 +3215,6 @@ private: bool taxiMaskInitialized_ = false; // First SMSG_SHOWTAXINODES seeds mask without alerts std::unordered_map taxiCostMap_; // destNodeId -> total cost in copper uint32_t nextMovementTimestampMs(); - void sanitizeMovementForTaxi(); void updateClientTaxi(float deltaTime); // Mail @@ -2980,12 +3278,6 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { - std::string name; std::string rank; std::string description; - uint32_t schoolMask = 0; uint8_t dispelType = 0; uint32_t attrEx = 0; - int32_t effectBasePoints[3] = {0, 0, 0}; - float durationSec = 0.0f; // resolved from DurationIndex → SpellDuration.dbc - }; mutable std::unordered_map spellNameCache_; mutable bool spellNameCacheLoaded_ = false; @@ -3004,7 +3296,6 @@ private: std::unordered_map achievementDescCache_; std::unordered_map achievementPointsCache_; bool achievementNameCacheLoaded_ = false; - void loadAchievementNameCache(); // Set of achievement IDs earned by the player (populated from SMSG_ALL_ACHIEVEMENT_DATA) std::unordered_set earnedAchievements_; // Earn dates: achievementId → WoW PackedTime (from SMSG_ACHIEVEMENT_EARNED / SMSG_ALL_ACHIEVEMENT_DATA) @@ -3021,7 +3312,6 @@ private: mutable std::unordered_map areaNameCache_; mutable bool areaNameCacheLoaded_ = false; void loadAreaNameCache() const; - std::string getAreaName(uint32_t areaId) const; // Map name cache (lazy-loaded from Map.dbc; maps mapId → localized display name) mutable std::unordered_map mapNameCache_; @@ -3032,9 +3322,7 @@ private: mutable std::unordered_map lfgDungeonNameCache_; mutable bool lfgDungeonNameCacheLoaded_ = false; void loadLfgDungeonDbc() const; - std::string getLfgDungeonName(uint32_t dungeonId) const; std::vector trainerTabs_; - void loadSpellNameCache() const; void preloadDBCCaches() const; void categorizeTrainerSpells(); @@ -3127,15 +3415,9 @@ private: bool spellBookTabsDirty_ = true; bool skillLineDbcLoaded_ = false; bool skillLineAbilityLoaded_ = false; - static constexpr size_t PLAYER_EXPLORED_ZONES_COUNT = 128; std::vector playerExploredZones_ = std::vector(PLAYER_EXPLORED_ZONES_COUNT, 0u); bool hasPlayerExploredZones_ = false; - void loadSkillLineDbc(); - void loadSkillLineAbilityDbc(); - void extractSkillFields(const std::map& fields); - void extractExploredZoneFields(const std::map& fields); - void applyQuestStateFromFields(const std::map& fields); // Apply packed kill counts from player update fields to a quest entry that has // already had its killObjectives populated from SMSG_QUEST_QUERY_RESPONSE. void applyPackedKillCountsFromFields(QuestLogEntry& quest); diff --git a/include/rendering/overlay_system.hpp b/include/rendering/overlay_system.hpp new file mode 100644 index 00000000..78bd55c4 --- /dev/null +++ b/include/rendering/overlay_system.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +class VkContext; + +/// Manages selection circle and fullscreen overlay Vulkan pipelines. +/// Extracted from Renderer to isolate overlay rendering resources. +class OverlaySystem { +public: + /// Height query callable: returns floor height at (x, y) or (x, y, probeZ). + using HeightQuery2D = std::function(float x, float y)>; + using HeightQuery3D = std::function(float x, float y, float probeZ)>; + + explicit OverlaySystem(VkContext* ctx); + ~OverlaySystem(); + + OverlaySystem(const OverlaySystem&) = delete; + OverlaySystem& operator=(const OverlaySystem&) = delete; + + // Selection circle + void setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color); + void clearSelectionCircle(); + void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection, + VkCommandBuffer cmd, + HeightQuery2D terrainHeight, + HeightQuery3D wmoHeight, + HeightQuery3D m2Height); + + // Fullscreen color overlay (underwater tint, etc.) + void renderOverlay(const glm::vec4& color, VkCommandBuffer cmd); + + /// Destroy all Vulkan resources (called before VkContext teardown). + void cleanup(); + + /// Recreate pipelines after swapchain resize / MSAA change. + void recreatePipelines(); + +private: + void initSelectionCircle(); + void initOverlayPipeline(); + + VkContext* vkCtx_ = nullptr; + + // Selection circle resources + VkPipeline selCirclePipeline_ = VK_NULL_HANDLE; + VkPipelineLayout selCirclePipelineLayout_ = VK_NULL_HANDLE; + ::VkBuffer selCircleVertBuf_ = VK_NULL_HANDLE; + VmaAllocation selCircleVertAlloc_ = VK_NULL_HANDLE; + ::VkBuffer selCircleIdxBuf_ = VK_NULL_HANDLE; + VmaAllocation selCircleIdxAlloc_ = VK_NULL_HANDLE; + int selCircleVertCount_ = 0; + glm::vec3 selCirclePos_{0.0f}; + glm::vec3 selCircleColor_{1.0f, 0.0f, 0.0f}; + float selCircleRadius_ = 1.5f; + bool selCircleVisible_ = false; + + // Fullscreen overlay resources + VkPipeline overlayPipeline_ = VK_NULL_HANDLE; + VkPipelineLayout overlayPipelineLayout_ = VK_NULL_HANDLE; +}; + +} // namespace rendering +} // namespace wowee diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index a8c3520d..6e3f12d7 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -53,11 +53,11 @@ class AmdFsr3Runtime; class SpellVisualSystem; class PostProcessPipeline; class AnimationController; -enum class RangedWeaponType : uint8_t; class LevelUpEffect; class ChargeEffect; class SwimEffects; class RenderGraph; +class OverlaySystem; class Renderer { public: @@ -139,7 +139,8 @@ public: WorldMap* getWorldMap() const { return worldMap.get(); } QuestMarkerRenderer* getQuestMarkerRenderer() const { return questMarkerRenderer.get(); } SkySystem* getSkySystem() const { return skySystem.get(); } - const std::string& getCurrentZoneName() const { return currentZoneName; } + const std::string& getCurrentZoneName() const; + uint32_t getCurrentZoneId() const; bool isPlayerIndoors() const { return playerIndoors_; } VkContext* getVkContext() const { return vkCtx; } VkDescriptorSetLayout getPerFrameSetLayout() const { return perFrameSetLayout; } @@ -152,52 +153,17 @@ public: float getCharacterYaw() const { return characterYaw; } void setCharacterYaw(float yawDeg) { characterYaw = yawDeg; } - // Emote support — delegates to AnimationController (§4.2) - void playEmote(const std::string& emoteName); - void triggerLevelUpEffect(const glm::vec3& position); - void cancelEmote(); - // Screenshot capture — copies swapchain image to PNG file bool captureScreenshot(const std::string& outputPath); // Spell visual effects (SMSG_PLAY_SPELL_VISUAL / SMSG_PLAY_SPELL_IMPACT) // Delegates to SpellVisualSystem (owned by Renderer) - void playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, - bool useImpactKit = false); SpellVisualSystem* getSpellVisualSystem() const { return spellVisualSystem_.get(); } - bool isEmoteActive() const; - static std::string getEmoteText(const std::string& emoteName, const std::string* targetName = nullptr); - static uint32_t getEmoteDbcId(const std::string& emoteName); - static std::string getEmoteTextByDbcId(uint32_t dbcId, const std::string& senderName, const std::string* targetName = nullptr); - static uint32_t getEmoteAnimByDbcId(uint32_t dbcId); - // Targeting support — delegates to AnimationController (§4.2) - void setTargetPosition(const glm::vec3* pos); - void setInCombat(bool combat); + // Combat visual state (compound: resets AnimationController + SpellVisualSystem) void resetCombatVisualState(); - bool isMoving() const; - void triggerMeleeSwing(); - void setEquippedWeaponType(uint32_t inventoryType, bool is2HLoose = false, - bool isFist = false, bool isDagger = false, - bool hasOffHand = false, bool hasShield = false); - void triggerSpecialAttack(uint32_t spellId); - void setEquippedRangedType(RangedWeaponType type); - void triggerRangedShot(); - RangedWeaponType getEquippedRangedType() const; - void setCharging(bool charging); - bool isCharging() const; - void startChargeEffect(const glm::vec3& position, const glm::vec3& direction); - void emitChargeEffect(const glm::vec3& position, const glm::vec3& direction); - void stopChargeEffect(); - // Mount rendering — delegates to AnimationController (§4.2) - void setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float heightOffset, const std::string& modelPath = ""); - void setTaxiFlight(bool onTaxi); - void setMountPitchRoll(float pitch, float roll); - void clearMount(); - bool isMounted() const; - - // AnimationController access (§4.2) + // Sub-system accessors (§4.2) AnimationController* getAnimationController() const { return animationController_.get(); } LevelUpEffect* getLevelUpEffect() const { return levelUpEffect.get(); } ChargeEffect* getChargeEffect() const { return chargeEffect.get(); } @@ -286,33 +252,8 @@ public: // Post-process pipeline API — delegates to PostProcessPipeline (§4.3) PostProcessPipeline* getPostProcessPipeline() const; - void setFXAAEnabled(bool enabled); - bool isFXAAEnabled() const; void setFSREnabled(bool enabled); - bool isFSREnabled() const; - void setFSRQuality(float scaleFactor); - void setFSRSharpness(float sharpness); - float getFSRScaleFactor() const; - float getFSRSharpness() const; void setFSR2Enabled(bool enabled); - bool isFSR2Enabled() const; - void setFSR2DebugTuning(float jitterSign, float motionVecScaleX, float motionVecScaleY); - void setAmdFsr3FramegenEnabled(bool enabled); - bool isAmdFsr3FramegenEnabled() const; - float getFSR2JitterSign() const; - float getFSR2MotionVecScaleX() const; - float getFSR2MotionVecScaleY() const; - bool isAmdFsr2SdkAvailable() const; - bool isAmdFsr3FramegenSdkAvailable() const; - bool isAmdFsr3FramegenRuntimeActive() const; - bool isAmdFsr3FramegenRuntimeReady() const; - const char* getAmdFsr3FramegenRuntimePath() const; - const std::string& getAmdFsr3FramegenRuntimeError() const; - size_t getAmdFsr3UpscaleDispatchCount() const; - size_t getAmdFsr3FramegenDispatchCount() const; - size_t getAmdFsr3FallbackCount() const; - void setBrightness(float b); - float getBrightness() const; void setWaterRefractionEnabled(bool enabled); bool isWaterRefractionEnabled() const; @@ -332,12 +273,7 @@ private: // Post-process pipeline — owns all FSR/FXAA/FSR2 state (extracted §4.3) std::unique_ptr postProcessPipeline_; - uint32_t currentZoneId = 0; - std::string currentZoneName; - bool inTavern_ = false; - bool inBlacksmith_ = false; bool playerIndoors_ = false; // Cached WMO inside state for macro conditionals - float musicSwitchCooldown_ = 0.0f; bool deferredWorldInitEnabled_ = true; bool deferredWorldInitPending_ = false; uint8_t deferredWorldInitStage_ = 0; @@ -350,26 +286,8 @@ private: - // Selection circle rendering (Vulkan) - VkPipeline selCirclePipeline = VK_NULL_HANDLE; - VkPipelineLayout selCirclePipelineLayout = VK_NULL_HANDLE; - ::VkBuffer selCircleVertBuf = VK_NULL_HANDLE; - VmaAllocation selCircleVertAlloc = VK_NULL_HANDLE; - ::VkBuffer selCircleIdxBuf = VK_NULL_HANDLE; - VmaAllocation selCircleIdxAlloc = VK_NULL_HANDLE; - int selCircleVertCount = 0; - void initSelectionCircle(); - void renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection, VkCommandBuffer overrideCmd = VK_NULL_HANDLE); - glm::vec3 selCirclePos{0.0f}; - glm::vec3 selCircleColor{1.0f, 0.0f, 0.0f}; - float selCircleRadius = 1.5f; - bool selCircleVisible = false; - - // Fullscreen color overlay (underwater tint) - VkPipeline overlayPipeline = VK_NULL_HANDLE; - VkPipelineLayout overlayPipelineLayout = VK_NULL_HANDLE; - void initOverlayPipeline(); - void renderOverlay(const glm::vec4& color, VkCommandBuffer overrideCmd = VK_NULL_HANDLE); + // Selection circle + overlay rendering (owned by OverlaySystem) + std::unique_ptr overlaySystem_; diff --git a/src/audio/audio_coordinator.cpp b/src/audio/audio_coordinator.cpp index deeb0861..323053f5 100644 --- a/src/audio/audio_coordinator.cpp +++ b/src/audio/audio_coordinator.cpp @@ -11,6 +11,7 @@ #include "audio/spell_sound_manager.hpp" #include "audio/movement_sound_manager.hpp" #include "pipeline/asset_manager.hpp" +#include "game/zone_manager.hpp" #include "core/logger.hpp" namespace wowee { @@ -86,5 +87,162 @@ void AudioCoordinator::shutdown() { LOG_INFO("AudioCoordinator shutdown complete"); } +void AudioCoordinator::playZoneMusic(const std::string& music) { + if (music.empty() || !musicManager_) return; + if (music.rfind("file:", 0) == 0) { + musicManager_->crossfadeToFile(music.substr(5)); + } else { + musicManager_->crossfadeTo(music); + } +} + +void AudioCoordinator::updateZoneAudio(const ZoneAudioContext& ctx) { + float deltaTime = ctx.deltaTime; + if (musicSwitchCooldown_ > 0.0f) { + musicSwitchCooldown_ = std::max(0.0f, musicSwitchCooldown_ - deltaTime); + } + + // ── Ambient weather audio sync ── + if (ambientSoundManager_) { + bool isBlacksmith = (ctx.insideWmoId == 96048); + + // Map visual weather type to ambient sound weather type + AmbientSoundManager::WeatherType audioWeatherType = AmbientSoundManager::WeatherType::NONE; + if (ctx.weatherType == 1) { // RAIN + if (ctx.weatherIntensity < 0.33f) audioWeatherType = AmbientSoundManager::WeatherType::RAIN_LIGHT; + else if (ctx.weatherIntensity < 0.66f) audioWeatherType = AmbientSoundManager::WeatherType::RAIN_MEDIUM; + else audioWeatherType = AmbientSoundManager::WeatherType::RAIN_HEAVY; + } else if (ctx.weatherType == 2) { // SNOW + if (ctx.weatherIntensity < 0.33f) audioWeatherType = AmbientSoundManager::WeatherType::SNOW_LIGHT; + else if (ctx.weatherIntensity < 0.66f) audioWeatherType = AmbientSoundManager::WeatherType::SNOW_MEDIUM; + else audioWeatherType = AmbientSoundManager::WeatherType::SNOW_HEAVY; + } + ambientSoundManager_->setWeather(audioWeatherType); + ambientSoundManager_->update(deltaTime, ctx.cameraPosition, ctx.insideWmo, ctx.isSwimming, isBlacksmith); + } + + // ── Zone detection and music transitions ── + auto* zm = ctx.zoneManager; + if (!zm || !musicManager_ || !ctx.hasTile) return; + + uint32_t zoneId = (ctx.serverZoneId != 0) + ? ctx.serverZoneId + : zm->getZoneId(ctx.tileX, ctx.tileY); + + bool insideTavern = false; + bool insideBlacksmith = false; + std::string tavernMusic; + + // WMO-based location overrides (taverns, blacksmiths, city zones) + if (ctx.insideWmo) { + uint32_t wmoModelId = ctx.insideWmoId; + + // Stormwind WMO → force Stormwind City zone + if (wmoModelId == 10047) zoneId = 1519; + + // Log WMO transitions + static uint32_t lastLoggedWmoId = 0; + if (wmoModelId != lastLoggedWmoId) { + LOG_INFO("Inside WMO model ID: ", wmoModelId); + lastLoggedWmoId = wmoModelId; + } + + // Blacksmith detection (ambient forge sounds) + if (wmoModelId == 96048) { + insideBlacksmith = true; + LOG_INFO("Detected blacksmith WMO ", wmoModelId); + } + + // Tavern / inn detection + if (wmoModelId == 191 || wmoModelId == 71414 || wmoModelId == 190 || + wmoModelId == 220 || wmoModelId == 221 || + wmoModelId == 5392 || wmoModelId == 5393) { + insideTavern = true; + static const std::vector tavernTracks = { + "Sound\\Music\\ZoneMusic\\TavernAlliance\\TavernAlliance01.mp3", + "Sound\\Music\\ZoneMusic\\TavernAlliance\\TavernAlliance02.mp3", + "Sound\\Music\\ZoneMusic\\TavernHuman\\RA_HumanTavern1A.mp3", + "Sound\\Music\\ZoneMusic\\TavernHuman\\RA_HumanTavern2A.mp3", + }; + static int tavernTrackIndex = 0; + tavernMusic = tavernTracks[tavernTrackIndex++ % tavernTracks.size()]; + LOG_INFO("Detected tavern WMO ", wmoModelId, ", playing: ", tavernMusic); + } + } + + // Tavern music transitions + if (insideTavern) { + if (!inTavern_ && !tavernMusic.empty()) { + inTavern_ = true; + LOG_INFO("Entered tavern"); + musicManager_->playMusic(tavernMusic, true); + musicSwitchCooldown_ = 6.0f; + } + } else if (inTavern_) { + inTavern_ = false; + LOG_INFO("Exited tavern"); + auto* info = zm->getZoneInfo(currentZoneId_); + if (info) { + std::string music = zm->getRandomMusic(currentZoneId_); + if (!music.empty()) { + playZoneMusic(music); + musicSwitchCooldown_ = 6.0f; + } + } + } + + // Blacksmith transitions (stop music, let ambience play) + if (insideBlacksmith) { + if (!inBlacksmith_) { + inBlacksmith_ = true; + LOG_INFO("Entered blacksmith - stopping music"); + musicManager_->stopMusic(); + } + } else if (inBlacksmith_) { + inBlacksmith_ = false; + LOG_INFO("Exited blacksmith - restoring music"); + auto* info = zm->getZoneInfo(currentZoneId_); + if (info) { + std::string music = zm->getRandomMusic(currentZoneId_); + if (!music.empty()) { + playZoneMusic(music); + musicSwitchCooldown_ = 6.0f; + } + } + } + + // Normal zone transitions + if (!insideTavern && !insideBlacksmith && zoneId != currentZoneId_ && zoneId != 0) { + currentZoneId_ = zoneId; + auto* info = zm->getZoneInfo(zoneId); + if (info) { + currentZoneName_ = info->name; + LOG_INFO("Entered zone: ", info->name); + if (musicSwitchCooldown_ <= 0.0f) { + std::string music = zm->getRandomMusic(zoneId); + if (!music.empty()) { + playZoneMusic(music); + musicSwitchCooldown_ = 6.0f; + } + } + } + if (ambientSoundManager_) { + ambientSoundManager_->setZoneId(zoneId); + } + } + + musicManager_->update(deltaTime); + + // When a track finishes, pick a new random track from the current zone + if (!musicManager_->isPlaying() && !inTavern_ && !inBlacksmith_ && + currentZoneId_ != 0 && musicSwitchCooldown_ <= 0.0f) { + std::string music = zm->getRandomMusic(currentZoneId_); + if (!music.empty()) { + playZoneMusic(music); + musicSwitchCooldown_ = 2.0f; + } + } +} + } // namespace audio } // namespace wowee diff --git a/src/core/animation_callback_handler.cpp b/src/core/animation_callback_handler.cpp index b5967819..9f891c34 100644 --- a/src/core/animation_callback_handler.cpp +++ b/src/core/animation_callback_handler.cpp @@ -51,7 +51,7 @@ bool AnimationCallbackHandler::updateCharge(float deltaTime) { dir *= glm::inversesqrt(dirLenSq); float yawDeg = glm::degrees(std::atan2(dir.x, dir.y)); renderer_.setCharacterYaw(yawDeg); - renderer_.emitChargeEffect(renderPos, dir); + if (auto* ac = renderer_.getAnimationController()) ac->emitChargeEffect(renderPos, dir); } // Sync to game handler @@ -69,8 +69,8 @@ bool AnimationCallbackHandler::updateCharge(float deltaTime) { // Charge complete if (t >= 1.0f) { chargeActive_ = false; - renderer_.setCharging(false); - renderer_.stopChargeEffect(); + if (auto* ac = renderer_.getAnimationController()) ac->setCharging(false); + if (auto* ac = renderer_.getAnimationController()) ac->stopChargeEffect(); renderer_.getCameraController()->setExternalFollow(false); renderer_.getCameraController()->setExternalMoving(false); @@ -95,7 +95,7 @@ bool AnimationCallbackHandler::updateCharge(float deltaTime) { } } gameHandler_.startAutoAttack(chargeTargetGuid_); - renderer_.triggerMeleeSwing(); + if (auto* ac = renderer_.getAnimationController()) ac->triggerMeleeSwing(); } // Send movement heartbeat so server knows our new position @@ -157,11 +157,11 @@ void AnimationCallbackHandler::setupCallbacks() { // Disable player input, play charge animation renderer_.getCameraController()->setExternalFollow(true); renderer_.getCameraController()->clearMovementInputs(); - renderer_.setCharging(true); + if (auto* ac = renderer_.getAnimationController()) ac->setCharging(true); // Start charge visual effect (red haze + dust) glm::vec3 chargeDir = glm::normalize(endRender - startRender); - renderer_.startChargeEffect(startRender, chargeDir); + if (auto* ac = renderer_.getAnimationController()) ac->startChargeEffect(startRender, chargeDir); // Play charge whoosh sound (try multiple paths) auto& audio = audio::AudioEngine::instance(); diff --git a/src/core/application.cpp b/src/core/application.cpp index ee8b0e64..e8ca5c93 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -934,7 +934,7 @@ void Application::setState(AppState newState) { uint32_t oldInst = renderer->getCharacterInstanceId(); if (oldInst > 0) { renderer->setCharacterFollow(0); - renderer->clearMount(); + if (auto* ac = renderer->getAnimationController()) ac->clearMount(); renderer->getCharacterRenderer()->removeInstance(oldInst); } } @@ -975,11 +975,11 @@ void Application::setState(AppState newState) { if (renderer) { // Ranged auto-attack spells: Auto Shot (75), Shoot (5019), Throw (2764) if (spellId == 75 || spellId == 5019 || spellId == 2764) { - renderer->triggerRangedShot(); + if (auto* ac = renderer->getAnimationController()) ac->triggerRangedShot(); } else if (spellId != 0) { - renderer->triggerSpecialAttack(spellId); + if (auto* ac = renderer->getAnimationController()) ac->triggerSpecialAttack(spellId); } else { - renderer->triggerMeleeSwing(); + if (auto* ac = renderer->getAnimationController()) ac->triggerMeleeSwing(); } } }); @@ -1118,7 +1118,7 @@ void Application::logoutToLogin() { if (auto* questMarkers = renderer->getQuestMarkerRenderer()) { questMarkers->clear(); } - renderer->clearMount(); + if (auto* ac = renderer->getAnimationController()) ac->clearMount(); renderer->setCharacterFollow(0); if (auto* music = audioCoordinator_ ? audioCoordinator_->getMusicManager() : nullptr) { music->stopMusic(0.0f); @@ -1346,13 +1346,13 @@ void Application::update(float deltaTime) { // Tilt the mount/character model to match flight direction // (taxi flight uses setTaxiOrientationCallback for this instead) if (gameHandler->isPlayerFlying() && gameHandler->isMounted()) { - renderer->setMountPitchRoll(pitchRad, 0.0f); + if (auto* ac = renderer->getAnimationController()) ac->setMountPitchRoll(pitchRad, 0.0f); } } } } else if (gameHandler->isMounted()) { // Reset mount pitch when not flying - renderer->setMountPitchRoll(0.0f, 0.0f); + if (auto* ac = renderer->getAnimationController()) ac->setMountPitchRoll(0.0f, 0.0f); } } @@ -1454,14 +1454,14 @@ void Application::update(float deltaTime) { } bool idleOrbit = renderer->getCameraController()->isIdleOrbit(); if (idleOrbit && !idleYawned_ && renderer) { - renderer->playEmote("yawn"); + if (auto* ac = renderer->getAnimationController()) ac->playEmote("yawn"); idleYawned_ = true; } else if (!idleOrbit) { idleYawned_ = false; } } if (renderer) { - renderer->setTaxiFlight(onTaxi); + if (auto* ac = renderer->getAnimationController()) ac->setTaxiFlight(onTaxi); } if (renderer && renderer->getTerrainManager()) { renderer->getTerrainManager()->setStreamingEnabled(true); diff --git a/src/core/audio_callback_handler.cpp b/src/core/audio_callback_handler.cpp index 088fc270..66c40159 100644 --- a/src/core/audio_callback_handler.cpp +++ b/src/core/audio_callback_handler.cpp @@ -2,6 +2,8 @@ #include "core/coordinates.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" +#include "pipeline/asset_manager.hpp" #include "pipeline/dbc_loader.hpp" #include "game/game_handler.hpp" #include "audio/audio_coordinator.hpp" @@ -50,7 +52,7 @@ void AudioCallbackHandler::setupCallbacks() { uiManager_->getGameScreen().toastManager().triggerDing(newLevel); } if (renderer_) { - renderer_->triggerLevelUpEffect(renderer_->getCharacterPosition()); + if (auto* ac = renderer_->getAnimationController()) ac->triggerLevelUpEffect(renderer_->getCharacterPosition()); } }); @@ -108,7 +110,7 @@ void AudioCallbackHandler::setupCallbacks() { if (entity) { glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - renderer_->triggerLevelUpEffect(renderPos); + if (auto* ac = renderer_->getAnimationController()) ac->triggerLevelUpEffect(renderPos); } // Show chat message if in group diff --git a/src/core/entity_spawner.cpp b/src/core/entity_spawner.cpp index b745bb85..8dd5f3bd 100644 --- a/src/core/entity_spawner.cpp +++ b/src/core/entity_spawner.cpp @@ -2,6 +2,7 @@ #include "core/coordinates.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" #include "rendering/vk_context.hpp" #include "rendering/character_renderer.hpp" #include "rendering/wmo_renderer.hpp" @@ -2259,2754 +2260,5 @@ void EntitySpawner::spawnOnlineCreature(uint64_t guid, uint32_t displayId, float " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); } -void EntitySpawner::spawnOnlinePlayer(uint64_t guid, - uint8_t raceId, - uint8_t genderId, - uint32_t appearanceBytes, - uint8_t facialFeatures, - float x, float y, float z, float orientation) { - if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; - if (playerInstances_.count(guid)) return; - - // Skip local player — already spawned as the main character - if (gameHandler_) { - uint64_t localGuid = gameHandler_->getPlayerGuid(); - uint64_t activeGuid = gameHandler_->getActiveCharacterGuid(); - if ((localGuid != 0 && guid == localGuid) || - (activeGuid != 0 && guid == activeGuid) || - (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { - return; - } - } - auto* charRenderer = renderer_->getCharacterRenderer(); - - // Base geometry model: cache by (race, gender) - uint32_t cacheKey = (static_cast(raceId) << 8) | static_cast(genderId & 0xFF); - uint32_t modelId = 0; - auto itCache = playerModelCache_.find(cacheKey); - if (itCache != playerModelCache_.end()) { - modelId = itCache->second; - } else { - game::Race race = static_cast(raceId); - game::Gender gender = (genderId == 1) ? game::Gender::FEMALE : game::Gender::MALE; - std::string m2Path = game::getPlayerModelPath(race, gender); - if (m2Path.empty()) { - LOG_WARNING("spawnOnlinePlayer: unknown race/gender for guid 0x", std::hex, guid, std::dec, - " race=", static_cast(raceId), " gender=", static_cast(genderId)); - return; - } - - // Parse modelDir/baseName for skin/anim loading - std::string modelDir; - std::string baseName; - { - size_t slash = m2Path.rfind('\\'); - if (slash != std::string::npos) { - modelDir = m2Path.substr(0, slash + 1); - baseName = m2Path.substr(slash + 1); - } else { - baseName = m2Path; - } - size_t dot = baseName.rfind('.'); - if (dot != std::string::npos) baseName = baseName.substr(0, dot); - } - - auto m2Data = assetManager_->readFile(m2Path); - if (m2Data.empty()) { - LOG_WARNING("spawnOnlinePlayer: failed to read M2: ", m2Path); - return; - } - - pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (model.vertices.empty()) { - LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); - return; - } - - // Skin file (only for WotLK M2s - vanilla has embedded skin) - std::string skinPath = modelDir + baseName + "00.skin"; - auto skinData = assetManager_->readFile(skinPath); - if (!skinData.empty() && model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, model); - } - - // After skin loading, full model must be valid (vertices + indices) - if (!model.isValid()) { - LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path); - return; - } - - // Load only core external animations (stand/walk/run) to avoid stalls - for (uint32_t si = 0; si < model.sequences.size(); si++) { - if (!(model.sequences[si].flags & 0x20)) { - uint32_t animId = model.sequences[si].id; - if (animId != rendering::anim::STAND && animId != rendering::anim::WALK && animId != rendering::anim::RUN) continue; - char animFileName[256]; - snprintf(animFileName, sizeof(animFileName), - "%s%s%04u-%02u.anim", - modelDir.c_str(), - baseName.c_str(), - animId, - model.sequences[si].variationIndex); - auto animData = assetManager_->readFileOptional(animFileName); - if (!animData.empty()) { - pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); - } - } - } - - modelId = nextPlayerModelId_++; - if (!charRenderer->loadModel(model, modelId)) { - LOG_WARNING("spawnOnlinePlayer: failed to load model to GPU: ", m2Path); - return; - } - - playerModelCache_[cacheKey] = modelId; - } - - // Determine texture slots once per model - { - auto [slotIt, inserted] = playerTextureSlotsByModelId_.try_emplace(modelId); - if (inserted) { - PlayerTextureSlots slots; - if (const auto* md = charRenderer->getModelData(modelId)) { - for (size_t ti = 0; ti < md->textures.size(); ti++) { - uint32_t t = md->textures[ti].type; - if (t == 1 && slots.skin < 0) slots.skin = static_cast(ti); - else if (t == 6 && slots.hair < 0) slots.hair = static_cast(ti); - else if (t == 8 && slots.underwear < 0) slots.underwear = static_cast(ti); - } - } - slotIt->second = slots; - } - } - - // Create instance at server position - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - float renderYaw = orientation + glm::radians(90.0f); - uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); - if (instanceId == 0) return; - - // Resolve skin/hair texture paths via CharSections, then apply as per-instance overrides - const char* raceFolderName = "Human"; - switch (static_cast(raceId)) { - case game::Race::HUMAN: raceFolderName = "Human"; break; - case game::Race::ORC: raceFolderName = "Orc"; break; - case game::Race::DWARF: raceFolderName = "Dwarf"; break; - case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break; - case game::Race::UNDEAD: raceFolderName = "Scourge"; break; - case game::Race::TAUREN: raceFolderName = "Tauren"; break; - case game::Race::GNOME: raceFolderName = "Gnome"; break; - case game::Race::TROLL: raceFolderName = "Troll"; break; - case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break; - case game::Race::DRAENEI: raceFolderName = "Draenei"; break; - default: break; - } - const char* genderFolder = (genderId == 1) ? "Female" : "Male"; - std::string raceGender = std::string(raceFolderName) + genderFolder; - std::string bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp"; - std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp"; - std::vector underwearPaths; - std::string hairTexturePath; - std::string faceLowerPath; - std::string faceUpperPath; - - uint8_t skinId = appearanceBytes & 0xFF; - uint8_t faceId = (appearanceBytes >> 8) & 0xFF; - uint8_t hairStyleId = (appearanceBytes >> 16) & 0xFF; - uint8_t hairColorId = (appearanceBytes >> 24) & 0xFF; - - if (auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc"); charSectionsDbc && charSectionsDbc->isLoaded()) { - const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; - auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL); - uint32_t targetRaceId = raceId; - uint32_t targetSexId = genderId; - - bool foundSkin = false; - bool foundUnderwear = false; - bool foundHair = false; - bool foundFaceLower = false; - - for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { - uint32_t rRace = charSectionsDbc->getUInt32(r, csF.raceId); - uint32_t rSex = charSectionsDbc->getUInt32(r, csF.sexId); - uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection); - uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex); - uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex); - - if (rRace != targetRaceId || rSex != targetSexId) continue; - - if (baseSection == 0 && !foundSkin && colorIndex == skinId) { - std::string tex1 = charSectionsDbc->getString(r, csF.texture1); - if (!tex1.empty()) { bodySkinPath = tex1; foundSkin = true; } - } else if (baseSection == 3 && !foundHair && - variationIndex == hairStyleId && colorIndex == hairColorId) { - hairTexturePath = charSectionsDbc->getString(r, csF.texture1); - if (!hairTexturePath.empty()) foundHair = true; - } else if (baseSection == 4 && !foundUnderwear && colorIndex == skinId) { - for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { - std::string tex = charSectionsDbc->getString(r, f); - if (!tex.empty()) underwearPaths.push_back(tex); - } - foundUnderwear = true; - } else if (baseSection == 1 && !foundFaceLower && - variationIndex == faceId && colorIndex == skinId) { - std::string tex1 = charSectionsDbc->getString(r, csF.texture1); - std::string tex2 = charSectionsDbc->getString(r, csF.texture2); - if (!tex1.empty()) faceLowerPath = tex1; - if (!tex2.empty()) faceUpperPath = tex2; - foundFaceLower = true; - } - - if (foundSkin && foundUnderwear && foundHair && foundFaceLower) break; - } - } - - // Composite base skin + face + underwear overlays - rendering::VkTexture* compositeTex = nullptr; - { - std::vector layers; - layers.push_back(bodySkinPath); - if (!faceLowerPath.empty()) layers.push_back(faceLowerPath); - if (!faceUpperPath.empty()) layers.push_back(faceUpperPath); - for (const auto& up : underwearPaths) layers.push_back(up); - if (layers.size() > 1) { - compositeTex = charRenderer->compositeTextures(layers); - } else { - compositeTex = charRenderer->loadTexture(bodySkinPath); - } - } - - rendering::VkTexture* hairTex = nullptr; - if (!hairTexturePath.empty()) { - hairTex = charRenderer->loadTexture(hairTexturePath); - } - rendering::VkTexture* underwearTex = nullptr; - if (!underwearPaths.empty()) underwearTex = charRenderer->loadTexture(underwearPaths[0]); - else underwearTex = charRenderer->loadTexture(pelvisPath); - - const PlayerTextureSlots& slots = playerTextureSlotsByModelId_[modelId]; - if (slots.skin >= 0 && compositeTex) { - charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.skin), compositeTex); - } - if (slots.hair >= 0 && hairTex) { - charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.hair), hairTex); - } - if (slots.underwear >= 0 && underwearTex) { - charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.underwear), underwearTex); - } - - // Geosets: body + hair/facial hair selections - std::unordered_set activeGeosets; - // Body parts (group 0: IDs 0-99, some models use up to 27) - for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i); - activeGeosets.insert(static_cast(100 + hairStyleId + 1)); - activeGeosets.insert(static_cast(200 + facialFeatures + 1)); - activeGeosets.insert(kGeosetBareForearms); - activeGeosets.insert(kGeosetBareShins); - activeGeosets.insert(kGeosetDefaultEars); - activeGeosets.insert(kGeosetBareSleeves); - activeGeosets.insert(kGeosetDefaultKneepads); - activeGeosets.insert(kGeosetBarePants); - activeGeosets.insert(kGeosetWithCape); - activeGeosets.insert(kGeosetBareFeet); - charRenderer->setActiveGeosets(instanceId, activeGeosets); - - charRenderer->playAnimation(instanceId, rendering::anim::STAND, true); - playerInstances_[guid] = instanceId; - - OnlinePlayerAppearanceState st; - st.instanceId = instanceId; - st.modelId = modelId; - st.raceId = raceId; - st.genderId = genderId; - st.appearanceBytes = appearanceBytes; - st.facialFeatures = facialFeatures; - st.bodySkinPath = bodySkinPath; - // Include face textures so compositeWithRegions can rebuild the full base - if (!faceLowerPath.empty()) st.underwearPaths.push_back(faceLowerPath); - if (!faceUpperPath.empty()) st.underwearPaths.push_back(faceUpperPath); - for (const auto& up : underwearPaths) st.underwearPaths.push_back(up); - onlinePlayerAppearance_[guid] = std::move(st); -} - -void EntitySpawner::setOnlinePlayerEquipment(uint64_t guid, - const std::array& displayInfoIds, - const std::array& inventoryTypes) { - if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; - - // Skip local player — equipment handled by GameScreen::updateCharacterGeosets/Textures - // via consumeOnlineEquipmentDirty(), which fires on the same server update. - if (gameHandler_) { - uint64_t localGuid = gameHandler_->getPlayerGuid(); - if (localGuid != 0 && guid == localGuid) return; - } - - // If the player isn't spawned yet, store equipment until spawn. - auto appIt = onlinePlayerAppearance_.find(guid); - if (!playerInstances_.count(guid) || appIt == onlinePlayerAppearance_.end()) { - pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes}; - return; - } - - const OnlinePlayerAppearanceState& st = appIt->second; - - auto* charRenderer = renderer_->getCharacterRenderer(); - if (!charRenderer) return; - if (st.instanceId == 0 || st.modelId == 0) return; - - if (st.bodySkinPath.empty()) { - LOG_WARNING("setOnlinePlayerEquipment: bodySkinPath empty for guid=0x", std::hex, guid, std::dec, - " instanceId=", st.instanceId, " — skipping equipment"); - return; - } - - int nonZeroDisplay = 0; - for (uint32_t d : displayInfoIds) if (d != 0) nonZeroDisplay++; - LOG_WARNING("setOnlinePlayerEquipment: guid=0x", std::hex, guid, std::dec, - " instanceId=", st.instanceId, " nonZeroDisplayIds=", nonZeroDisplay, - " head=", displayInfoIds[0], " chest=", displayInfoIds[4], - " legs=", displayInfoIds[6], " mainhand=", displayInfoIds[15]); - - auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); - if (!displayInfoDbc) return; - const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - - auto getGeosetGroup = [&](uint32_t displayInfoId, uint32_t fieldIdx) -> uint32_t { - if (displayInfoId == 0) return 0; - int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); - if (recIdx < 0) return 0; - return displayInfoDbc->getUInt32(static_cast(recIdx), fieldIdx); - }; - - auto findDisplayIdByInvType = [&](std::initializer_list types) -> uint32_t { - for (int s = 0; s < 19; s++) { - uint8_t inv = inventoryTypes[s]; - if (inv == 0 || displayInfoIds[s] == 0) continue; - for (uint8_t t : types) { - if (inv == t) return displayInfoIds[s]; - } - } - return 0; - }; - - auto hasInvType = [&](std::initializer_list types) -> bool { - for (int s = 0; s < 19; s++) { - uint8_t inv = inventoryTypes[s]; - if (inv == 0) continue; - for (uint8_t t : types) { - if (inv == t) return true; - } - } - return false; - }; - - // --- Geosets --- - // Mirror the same group-range logic as CharacterPreview::applyEquipment to - // keep other-player rendering consistent with the local character preview. - // Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves, - // 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md). - std::unordered_set geosets; - // Body parts (group 0: IDs 0-99, some models use up to 27) - for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); - - uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); - geosets.insert(static_cast(100 + hairStyleId + 1)); - geosets.insert(static_cast(200 + st.facialFeatures + 1)); - geosets.insert(701); // Ears - geosets.insert(kGeosetDefaultKneepads); // Kneepads - geosets.insert(kGeosetBareFeet); // Bare feet mesh - - const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; - const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; - - // Per-group defaults — overridden below when equipment provides a geoset value. - uint16_t geosetGloves = kGeosetBareForearms; - uint16_t geosetBoots = kGeosetBareShins; - uint16_t geosetSleeves = kGeosetBareSleeves; - uint16_t geosetPants = kGeosetBarePants; - - // Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8 - { - uint32_t did = findDisplayIdByInvType({4, 5, 20}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); - // Robe kilt → leg group 13 - uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); - if (gg3 > 0) geosetPants = static_cast(kGeosetBarePants + gg3); - } - - // Legs (invType 7) → leg group 13 - { - uint32_t did = findDisplayIdByInvType({7}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetPants = static_cast(kGeosetBarePants + gg1); - } - - // Feet/Boots (invType 8) → shin group 5 - { - uint32_t did = findDisplayIdByInvType({8}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetBoots = static_cast(501 + gg1); - } - - // Hands/Gloves (invType 10) → forearm group 4 - { - uint32_t did = findDisplayIdByInvType({10}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetGloves = static_cast(kGeosetBareForearms + gg1); - } - - // Wrists/Bracers (invType 9) → sleeve group 8 (only if chest/shirt didn't set it) - { - uint32_t did = findDisplayIdByInvType({9}); - if (did != 0 && geosetSleeves == kGeosetBareSleeves) { - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); - } - } - - // Waist/Belt (invType 6) → buckle group 18 - uint16_t geosetBelt = 0; - { - uint32_t did = findDisplayIdByInvType({6}); - uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); - if (gg1 > 0) geosetBelt = static_cast(1801 + gg1); - } - - geosets.insert(geosetGloves); - geosets.insert(geosetBoots); - geosets.insert(geosetSleeves); - geosets.insert(geosetPants); - if (geosetBelt != 0) geosets.insert(geosetBelt); - // Back/Cloak (invType 16) - geosets.insert(hasInvType({16}) ? kGeosetWithCape : kGeosetNoCape); - // Tabard (invType 19) - if (hasInvType({19})) geosets.insert(kGeosetDefaultTabard); - - // Hide hair under helmets: replace style-specific scalp with bald scalp - // HEAD slot is index 0 in the 19-element equipment array - if (displayInfoIds[0] != 0 && hairStyleId > 0) { - uint16_t hairGeoset = static_cast(hairStyleId + 1); - geosets.erase(static_cast(100 + hairGeoset)); // Remove style group 1 - geosets.insert(kGeosetDefaultConnector); // Default group 1 connector - } - - charRenderer->setActiveGeosets(st.instanceId, geosets); - - // --- Helmet model attachment --- - // HEAD slot is index 0 in the 19-element equipment array. - // Helmet M2s are race/gender-specific (e.g. Helm_Plate_B_01_HuM.m2 for Human Male). - if (displayInfoIds[0] != 0) { - // Detach any previously attached helmet before attaching a new one - charRenderer->detachWeapon(st.instanceId, 0); - charRenderer->detachWeapon(st.instanceId, 11); - - int32_t helmIdx = displayInfoDbc->findRecordById(displayInfoIds[0]); - if (helmIdx >= 0) { - const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; - std::string helmModelName = displayInfoDbc->getString(static_cast(helmIdx), leftModelField); - if (!helmModelName.empty()) { - // Strip .mdx/.m2 extension - size_t dotPos = helmModelName.rfind('.'); - if (dotPos != std::string::npos) helmModelName = helmModelName.substr(0, dotPos); - - // Race/gender suffix for helmet variants - static const std::unordered_map racePrefix = { - {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, - {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} - }; - std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; - std::string raceSuffix; - auto itRace = racePrefix.find(st.raceId); - if (itRace != racePrefix.end()) { - raceSuffix = "_" + itRace->second + genderSuffix; - } - - // Try race/gender-specific variant first, then base name - std::string helmPath; - pipeline::M2Model helmModel; - if (!raceSuffix.empty()) { - helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2"; - if (!loadWeaponM2(helmPath, helmModel)) helmModel = {}; - } - if (!helmModel.isValid()) { - helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2"; - loadWeaponM2(helmPath, helmModel); - } - - if (helmModel.isValid()) { - uint32_t helmModelId = nextWeaponModelId_++; - // Get texture from ItemDisplayInfo (LeftModelTexture) - const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; - std::string helmTexName = displayInfoDbc->getString(static_cast(helmIdx), leftTexField); - std::string helmTexPath; - if (!helmTexName.empty()) { - if (!raceSuffix.empty()) { - std::string suffixedTex = "Item\\ObjectComponents\\Head\\" + helmTexName + raceSuffix + ".blp"; - if (assetManager_->fileExists(suffixedTex)) helmTexPath = suffixedTex; - } - if (helmTexPath.empty()) { - helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp"; - } - } - // Attachment point 0 (head bone), fallback to 11 (explicit head attachment) - bool attached = charRenderer->attachWeapon(st.instanceId, 0, helmModel, helmModelId, helmTexPath); - if (!attached) { - attached = charRenderer->attachWeapon(st.instanceId, 11, helmModel, helmModelId, helmTexPath); - } - if (attached) { - LOG_DEBUG("Attached player helmet: ", helmPath, " tex: ", helmTexPath); - } - } - } - } - } else { - // No helmet equipped — detach any existing helmet model - charRenderer->detachWeapon(st.instanceId, 0); - charRenderer->detachWeapon(st.instanceId, 11); - } - - // --- Shoulder model attachment --- - // SHOULDERS slot is index 2 in the 19-element equipment array. - // Shoulders have TWO M2 models (left + right) attached at points 5 and 6. - // ItemDisplayInfo.dbc: LeftModel → left shoulder, RightModel → right shoulder. - if (displayInfoIds[2] != 0) { - // Detach any previously attached shoulder models - charRenderer->detachWeapon(st.instanceId, 5); - charRenderer->detachWeapon(st.instanceId, 6); - - int32_t shoulderIdx = displayInfoDbc->findRecordById(displayInfoIds[2]); - if (shoulderIdx >= 0) { - const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; - const uint32_t rightModelField = idiL ? (*idiL)["RightModel"] : 2u; - const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; - const uint32_t rightTexField = idiL ? (*idiL)["RightModelTexture"] : 4u; - - // Race/gender suffix for shoulder variants (same as helmets) - static const std::unordered_map shoulderRacePrefix = { - {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, - {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} - }; - std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; - std::string raceSuffix; - auto itRace = shoulderRacePrefix.find(st.raceId); - if (itRace != shoulderRacePrefix.end()) { - raceSuffix = "_" + itRace->second + genderSuffix; - } - - // Attach left shoulder (attachment point 5) using LeftModel - std::string leftModelName = displayInfoDbc->getString(static_cast(shoulderIdx), leftModelField); - if (!leftModelName.empty()) { - size_t dotPos = leftModelName.rfind('.'); - if (dotPos != std::string::npos) leftModelName = leftModelName.substr(0, dotPos); - - std::string leftPath; - pipeline::M2Model leftModel; - if (!raceSuffix.empty()) { - leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + raceSuffix + ".m2"; - if (!loadWeaponM2(leftPath, leftModel)) leftModel = {}; - } - if (!leftModel.isValid()) { - leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + ".m2"; - loadWeaponM2(leftPath, leftModel); - } - - if (leftModel.isValid()) { - uint32_t leftModelId = nextWeaponModelId_++; - std::string leftTexName = displayInfoDbc->getString(static_cast(shoulderIdx), leftTexField); - std::string leftTexPath; - if (!leftTexName.empty()) { - if (!raceSuffix.empty()) { - std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + raceSuffix + ".blp"; - if (assetManager_->fileExists(suffixedTex)) leftTexPath = suffixedTex; - } - if (leftTexPath.empty()) { - leftTexPath = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + ".blp"; - } - } - bool attached = charRenderer->attachWeapon(st.instanceId, 5, leftModel, leftModelId, leftTexPath); - if (attached) { - LOG_DEBUG("Attached left shoulder: ", leftPath, " tex: ", leftTexPath); - } - } - } - - // Attach right shoulder (attachment point 6) using RightModel - std::string rightModelName = displayInfoDbc->getString(static_cast(shoulderIdx), rightModelField); - if (!rightModelName.empty()) { - size_t dotPos = rightModelName.rfind('.'); - if (dotPos != std::string::npos) rightModelName = rightModelName.substr(0, dotPos); - - std::string rightPath; - pipeline::M2Model rightModel; - if (!raceSuffix.empty()) { - rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + raceSuffix + ".m2"; - if (!loadWeaponM2(rightPath, rightModel)) rightModel = {}; - } - if (!rightModel.isValid()) { - rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + ".m2"; - loadWeaponM2(rightPath, rightModel); - } - - if (rightModel.isValid()) { - uint32_t rightModelId = nextWeaponModelId_++; - std::string rightTexName = displayInfoDbc->getString(static_cast(shoulderIdx), rightTexField); - std::string rightTexPath; - if (!rightTexName.empty()) { - if (!raceSuffix.empty()) { - std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + raceSuffix + ".blp"; - if (assetManager_->fileExists(suffixedTex)) rightTexPath = suffixedTex; - } - if (rightTexPath.empty()) { - rightTexPath = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + ".blp"; - } - } - bool attached = charRenderer->attachWeapon(st.instanceId, 6, rightModel, rightModelId, rightTexPath); - if (attached) { - LOG_DEBUG("Attached right shoulder: ", rightPath, " tex: ", rightTexPath); - } - } - } - } - } else { - // No shoulders equipped — detach any existing shoulder models - charRenderer->detachWeapon(st.instanceId, 5); - charRenderer->detachWeapon(st.instanceId, 6); - } - - // --- Cape texture (group 15 / texture type 2) --- - // The geoset above enables the cape mesh, but without a texture it renders blank. - if (hasInvType({16})) { - // Back/cloak is WoW equipment slot 14 (BACK) in the 19-element array. - uint32_t capeDid = displayInfoIds[14]; - if (capeDid != 0) { - int32_t capeRecIdx = displayInfoDbc->findRecordById(capeDid); - if (capeRecIdx >= 0) { - const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; - std::string capeName = displayInfoDbc->getString( - static_cast(capeRecIdx), leftTexField); - - if (!capeName.empty()) { - std::replace(capeName.begin(), capeName.end(), '/', '\\'); - - auto hasBlpExt = [](const std::string& p) { - if (p.size() < 4) return false; - std::string ext = p.substr(p.size() - 4); - std::transform(ext.begin(), ext.end(), ext.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - return ext == ".blp"; - }; - - const bool hasDir = (capeName.find('\\') != std::string::npos); - const bool hasExt = hasBlpExt(capeName); - - std::vector capeCandidates; - auto addCapeCandidate = [&](const std::string& p) { - if (p.empty()) return; - if (std::find(capeCandidates.begin(), capeCandidates.end(), p) == capeCandidates.end()) { - capeCandidates.push_back(p); - } - }; - - if (hasDir) { - addCapeCandidate(capeName); - if (!hasExt) addCapeCandidate(capeName + ".blp"); - } else { - std::string baseObj = "Item\\ObjectComponents\\Cape\\" + capeName; - std::string baseTex = "Item\\TextureComponents\\Cape\\" + capeName; - addCapeCandidate(baseObj); - addCapeCandidate(baseTex); - if (!hasExt) { - addCapeCandidate(baseObj + ".blp"); - addCapeCandidate(baseTex + ".blp"); - } - addCapeCandidate(baseObj + (st.genderId == 1 ? "_F.blp" : "_M.blp")); - addCapeCandidate(baseObj + "_U.blp"); - addCapeCandidate(baseTex + (st.genderId == 1 ? "_F.blp" : "_M.blp")); - addCapeCandidate(baseTex + "_U.blp"); - } - - const rendering::VkTexture* whiteTex = charRenderer->loadTexture(""); - rendering::VkTexture* capeTexture = nullptr; - for (const auto& candidate : capeCandidates) { - rendering::VkTexture* tex = charRenderer->loadTexture(candidate); - if (tex && tex != whiteTex) { - capeTexture = tex; - break; - } - } - - if (capeTexture) { - charRenderer->setGroupTextureOverride(st.instanceId, 15, capeTexture); - if (const auto* md = charRenderer->getModelData(st.modelId)) { - for (size_t ti = 0; ti < md->textures.size(); ti++) { - if (md->textures[ti].type == 2) { - charRenderer->setTextureSlotOverride( - st.instanceId, static_cast(ti), capeTexture); - } - } - } - } - } - } - } - } - - // --- Textures (skin atlas compositing) --- - static constexpr const char* componentDirs[] = { - "ArmUpperTexture", - "ArmLowerTexture", - "HandTexture", - "TorsoUpperTexture", - "TorsoLowerTexture", - "LegUpperTexture", - "LegLowerTexture", - "FootTexture", - }; - - uint32_t texRegionFields[8]; - pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields); - - std::vector> regionLayers; - const bool isFemale = (st.genderId == 1); - - for (int s = 0; s < 19; s++) { - uint32_t did = displayInfoIds[s]; - if (did == 0) continue; - int32_t recIdx = displayInfoDbc->findRecordById(did); - if (recIdx < 0) continue; - - for (int region = 0; region < 8; region++) { - std::string texName = displayInfoDbc->getString( - static_cast(recIdx), texRegionFields[region]); - if (texName.empty()) continue; - - std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; - std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); - std::string unisexPath = base + "_U.blp"; - std::string fullPath; - if (assetManager_->fileExists(genderPath)) fullPath = genderPath; - else if (assetManager_->fileExists(unisexPath)) fullPath = unisexPath; - else fullPath = base + ".blp"; - - regionLayers.emplace_back(region, fullPath); - } - } - - const auto slotsIt = playerTextureSlotsByModelId_.find(st.modelId); - if (slotsIt == playerTextureSlotsByModelId_.end()) return; - const PlayerTextureSlots& slots = slotsIt->second; - if (slots.skin < 0) return; - - rendering::VkTexture* newTex = charRenderer->compositeWithRegions(st.bodySkinPath, st.underwearPaths, regionLayers); - if (newTex) { - charRenderer->setTextureSlotOverride(st.instanceId, static_cast(slots.skin), newTex); - } - - // --- Weapon model attachment --- - // Slot indices in the 19-element EquipSlot array: - // 15 = MAIN_HAND → attachment 1 (right hand) - // 16 = OFF_HAND → attachment 2 (left hand) - struct OnlineWeaponSlot { - int slotIndex; - uint32_t attachmentId; - }; - static constexpr OnlineWeaponSlot weaponSlots[] = { - { 15, 1 }, // MAIN_HAND → right hand - { 16, 2 }, // OFF_HAND → left hand - }; - - const uint32_t modelFieldL = idiL ? (*idiL)["LeftModel"] : 1u; - const uint32_t modelFieldR = idiL ? (*idiL)["RightModel"] : 2u; - const uint32_t texFieldL = idiL ? (*idiL)["LeftModelTexture"] : 3u; - const uint32_t texFieldR = idiL ? (*idiL)["RightModelTexture"] : 4u; - - for (const auto& ws : weaponSlots) { - uint32_t weapDisplayId = displayInfoIds[ws.slotIndex]; - if (weapDisplayId == 0) { - charRenderer->detachWeapon(st.instanceId, ws.attachmentId); - continue; - } - - int32_t recIdx = displayInfoDbc->findRecordById(weapDisplayId); - if (recIdx < 0) { - charRenderer->detachWeapon(st.instanceId, ws.attachmentId); - continue; - } - - // Prefer LeftModel (full weapon), fall back to RightModel (hilt variants) - std::string modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldL); - std::string textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldL); - if (modelName.empty()) { - modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldR); - textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldR); - } - if (modelName.empty()) { - charRenderer->detachWeapon(st.instanceId, ws.attachmentId); - continue; - } - - // Convert .mdx → .m2 - std::string modelFile = modelName; - { - size_t dotPos = modelFile.rfind('.'); - if (dotPos != std::string::npos) modelFile = modelFile.substr(0, dotPos); - modelFile += ".m2"; - } - - // Try Weapon directory first, then Shield - std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile; - pipeline::M2Model weaponModel; - if (!loadWeaponM2(m2Path, weaponModel)) { - m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile; - if (!loadWeaponM2(m2Path, weaponModel)) { - charRenderer->detachWeapon(st.instanceId, ws.attachmentId); - continue; - } - } - - // Build texture path - std::string texturePath; - if (!textureName.empty()) { - texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp"; - if (!assetManager_->fileExists(texturePath)) { - texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp"; - if (!assetManager_->fileExists(texturePath)) texturePath.clear(); - } - } - - uint32_t weaponModelId = nextWeaponModelId_++; - charRenderer->attachWeapon(st.instanceId, ws.attachmentId, - weaponModel, weaponModelId, texturePath); - } -} - -void EntitySpawner::despawnPlayer(uint64_t guid) { - if (!renderer_ || !renderer_->getCharacterRenderer()) return; - auto it = playerInstances_.find(guid); - if (it == playerInstances_.end()) return; - renderer_->getCharacterRenderer()->removeInstance(it->second); - playerInstances_.erase(it); - onlinePlayerAppearance_.erase(guid); - pendingOnlinePlayerEquipment_.erase(guid); - creatureRenderPosCache_.erase(guid); - creatureSwimmingState_.erase(guid); - creatureWalkingState_.erase(guid); - creatureFlyingState_.erase(guid); - creatureWasMoving_.erase(guid); - creatureWasSwimming_.erase(guid); - creatureWasFlying_.erase(guid); - creatureWasWalking_.erase(guid); -} - -void EntitySpawner::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { - if (!renderer_ || !assetManager_) return; - - if (!gameObjectLookupsBuilt_) { - buildGameObjectDisplayLookups(); - } - if (!gameObjectLookupsBuilt_) return; - - LOG_DEBUG("GO spawn attempt: guid=0x", std::hex, guid, std::dec, - " displayId=", displayId, " entry=", entry, - " pos=(", x, ", ", y, ", ", z, ")"); - - auto goIt = gameObjectInstances_.find(guid); - if (goIt != gameObjectInstances_.end()) { - // Already have a render instance — update its position (e.g. transport re-creation) - auto& info = goIt->second; - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - LOG_DEBUG("GameObject position update: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, - " pos=(", x, ", ", y, ", ", z, ")"); - if (renderer_) { - if (info.isWmo) { - if (auto* wr = renderer_->getWMORenderer()) { - glm::mat4 transform(1.0f); - transform = glm::translate(transform, renderPos); - transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); - wr->setInstanceTransform(info.instanceId, transform); - } - } else { - if (auto* mr = renderer_->getM2Renderer()) { - glm::mat4 transform(1.0f); - transform = glm::translate(transform, renderPos); - mr->setInstanceTransform(info.instanceId, transform); - } - } - } - return; - } - - std::string modelPath; - - // Override model path for transports with wrong displayIds (preloaded transports) - // Check if this GUID is a known transport - bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(guid); - if (isTransport) { - // Map common transport displayIds to correct WMO paths - // NOTE: displayIds 455/462 are elevators in Thunder Bluff and should NOT be forced to ships. - // Keep ship/zeppelin overrides entry-driven where possible. - // DisplayIds 807, 808 = Zeppelins - // DisplayIds 2454, 1587 = Special ships/icebreakers - if (entry == 20808 || entry == 176231 || entry == 176310) { - modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; - LOG_INFO("Overriding transport entry/display ", entry, "/", displayId, " → transportship.wmo"); - } else if (displayId == 807 || displayId == 808 || displayId == 175080 || displayId == 176495 || displayId == 164871) { - modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; - LOG_INFO("Overriding transport displayId ", displayId, " → transport_zeppelin.wmo"); - } else if (displayId == 1587) { - modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; - LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Horde_Zeppelin.wmo"); - } else if (displayId == 2454 || displayId == 181688 || displayId == 190536) { - modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; - LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Icebreaker_ship.wmo"); - } else if (displayId == 3831) { - // Deeprun Tram car - modelPath = "World\\Generic\\Gnome\\Passive Doodads\\Subway\\SubwayCar.m2"; - LOG_WARNING("Overriding transport displayId ", displayId, " → SubwayCar.m2"); - } - } - - // Fallback to normal displayId lookup if not a transport or no override matched - if (modelPath.empty()) { - modelPath = getGameObjectModelPathForDisplayId(displayId); - } - - if (modelPath.empty()) { - LOG_WARNING("No model path for gameobject displayId ", displayId, " (guid 0x", std::hex, guid, std::dec, ")"); - return; - } - - // Log spawns to help debug duplicate objects (e.g., cathedral issue) - LOG_DEBUG("GameObject spawn: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, - " model=", modelPath, " pos=(", x, ", ", y, ", ", z, ")"); - - std::string lowerPath = modelPath; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; - - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); - const float renderYawWmo = orientation; - // M2 game objects: model default faces +renderX. renderYaw = canonical + 90° = server_yaw - // (same offset as creature/character renderer_ so all M2 models face consistently) - const float renderYawM2go = orientation + glm::radians(90.0f); - - bool loadedAsWmo = false; - if (isWmo) { - auto* wmoRenderer = renderer_->getWMORenderer(); - if (!wmoRenderer) return; - - uint32_t modelId = 0; - auto itCache = gameObjectDisplayIdWmoCache_.find(displayId); - if (itCache != gameObjectDisplayIdWmoCache_.end()) { - modelId = itCache->second; - // Only use cached entry if the model is still resident in the renderer_ - if (wmoRenderer->isModelLoaded(modelId)) { - loadedAsWmo = true; - } else { - gameObjectDisplayIdWmoCache_.erase(itCache); - modelId = 0; - } - } - if (!loadedAsWmo && modelId == 0) { - auto wmoData = assetManager_->readFile(modelPath); - if (!wmoData.empty()) { - pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); - LOG_DEBUG("Gameobject WMO root loaded: ", modelPath, " nGroups=", wmoModel.nGroups); - int loadedGroups = 0; - if (wmoModel.nGroups > 0) { - std::string basePath = modelPath; - std::string extension; - if (basePath.size() > 4) { - extension = basePath.substr(basePath.size() - 4); - std::string extLower = extension; - for (char& c : extLower) c = static_cast(std::tolower(static_cast(c))); - if (extLower == ".wmo") { - basePath = basePath.substr(0, basePath.size() - 4); - } - } - - for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { - char groupSuffix[16]; - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str()); - std::string groupPath = basePath + groupSuffix; - std::vector groupData = assetManager_->readFile(groupPath); - if (groupData.empty()) { - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); - groupData = assetManager_->readFile(basePath + groupSuffix); - } - if (groupData.empty()) { - snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi); - groupData = assetManager_->readFile(basePath + groupSuffix); - } - if (!groupData.empty()) { - pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); - loadedGroups++; - } else { - LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath); - } - } - } - - if (loadedGroups > 0 || wmoModel.nGroups == 0) { - modelId = nextGameObjectWmoModelId_++; - if (wmoRenderer->loadModel(wmoModel, modelId)) { - gameObjectDisplayIdWmoCache_[displayId] = modelId; - loadedAsWmo = true; - } else { - LOG_WARNING("Failed to load gameobject WMO model: ", modelPath); - } - } else { - LOG_WARNING("No WMO groups loaded for gameobject: ", modelPath, - " — falling back to M2"); - } - } else { - LOG_WARNING("Failed to read gameobject WMO: ", modelPath, " — falling back to M2"); - } - } - - if (loadedAsWmo) { - uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawWmo), scale); - if (instanceId == 0) { - LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec); - return; - } - - gameObjectInstances_[guid] = {modelId, instanceId, true}; - LOG_DEBUG("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, - " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); - - // Spawn transport WMO doodads (chairs, furniture, etc.) as child M2 instances - bool isTransport = false; - if (gameHandler_) { - std::string lowerModelPath = modelPath; - std::transform(lowerModelPath.begin(), lowerModelPath.end(), lowerModelPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - isTransport = (lowerModelPath.find("transport") != std::string::npos); - } - - auto* m2Renderer = renderer_->getM2Renderer(); - if (m2Renderer && isTransport) { - const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); - if (doodadTemplates && !doodadTemplates->empty()) { - constexpr size_t kMaxTransportDoodads = 192; - const size_t doodadBudget = std::min(doodadTemplates->size(), kMaxTransportDoodads); - LOG_DEBUG("Queueing ", doodadBudget, "/", doodadTemplates->size(), - " transport doodads for WMO instance ", instanceId); - pendingTransportDoodadBatches_.push_back(PendingTransportDoodadBatch{ - guid, - modelId, - instanceId, - 0, - doodadBudget, - 0, - x, y, z, - orientation - }); - } else { - LOG_DEBUG("Transport WMO has no doodads or templates not available"); - } - } - - // Transport GameObjects are not always named "transport" in their WMO path - // (e.g. elevators/lifts). If the server marks it as a transport, always - // notify so TransportManager can animate/carry passengers. - bool isTG = gameHandler_ && gameHandler_->isTransportGuid(guid); - LOG_WARNING("WMO GO spawned: guid=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, - " isTransport=", isTG, - " pos=(", x, ", ", y, ", ", z, ")"); - if (isTG) { - gameHandler_->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); - } - - return; - } - - // WMO failed — fall through to try as M2 - // Convert .wmo path to .m2 for fallback - modelPath = modelPath.substr(0, modelPath.size() - 4) + ".m2"; - } - - { - auto* m2Renderer = renderer_->getM2Renderer(); - if (!m2Renderer) return; - - // Skip displayIds that permanently failed to load (e.g. empty/unsupported M2s). - // Without this guard the same empty model is re-parsed every frame, causing - // sustained log spam and wasted CPU. - if (gameObjectDisplayIdFailedCache_.count(displayId)) return; - - uint32_t modelId = 0; - auto itCache = gameObjectDisplayIdModelCache_.find(displayId); - if (itCache != gameObjectDisplayIdModelCache_.end()) { - modelId = itCache->second; - if (!m2Renderer->hasModel(modelId)) { - LOG_WARNING("GO M2 cache hit but model gone: displayId=", displayId, - " modelId=", modelId, " path=", modelPath, - " — reloading"); - gameObjectDisplayIdModelCache_.erase(itCache); - itCache = gameObjectDisplayIdModelCache_.end(); - } - } - if (itCache == gameObjectDisplayIdModelCache_.end()) { - modelId = nextGameObjectModelId_++; - - auto m2Data = assetManager_->readFile(modelPath); - if (m2Data.empty()) { - LOG_WARNING("Failed to read gameobject M2: ", modelPath); - gameObjectDisplayIdFailedCache_.insert(displayId); - return; - } - - pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (model.vertices.empty()) { - LOG_WARNING("Failed to parse gameobject M2: ", modelPath); - gameObjectDisplayIdFailedCache_.insert(displayId); - return; - } - - std::string skinPath = modelPath.substr(0, modelPath.size() - 3) + "00.skin"; - auto skinData = assetManager_->readFile(skinPath); - if (!skinData.empty() && model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, model); - } else if (skinData.empty() && model.version >= 264) { - LOG_WARNING("GO skin file MISSING for WotLK M2 (no indices/batches): ", skinPath); - } - - LOG_DEBUG("GO model: ", modelPath, " v=", model.version, - " verts=", model.vertices.size(), - " idx=", model.indices.size(), - " batches=", model.batches.size(), - " bones=", model.bones.size(), - " skin=", (skinData.empty() ? "MISSING" : "ok")); - - if (!m2Renderer->loadModel(model, modelId)) { - LOG_WARNING("Failed to load gameobject model: ", modelPath); - gameObjectDisplayIdFailedCache_.insert(displayId); - return; - } - - gameObjectDisplayIdModelCache_[displayId] = modelId; - } - - uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, - glm::vec3(0.0f, 0.0f, renderYawM2go), scale); - if (instanceId == 0) { - LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); - return; - } - - // Freeze animation for static gameobjects, but let portals/effects/transports animate - bool isTransportGO = gameHandler_ && gameHandler_->isTransportGuid(guid); - std::string lowerPath = modelPath; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || - lowerPath.find("instancenewportal") != std::string::npos || - lowerPath.find("portalfx") != std::string::npos || - lowerPath.find("spellportal") != std::string::npos); - if (!isAnimatedEffect && !isTransportGO) { - // Check for totem idle animations — totems should animate, not freeze - bool isTotem = false; - if (m2Renderer->hasAnimation(instanceId, 245)) { // TOTEM_SMALL - m2Renderer->setInstanceAnimation(instanceId, 245, true); - isTotem = true; - } else if (m2Renderer->hasAnimation(instanceId, 246)) { // TOTEM_MEDIUM - m2Renderer->setInstanceAnimation(instanceId, 246, true); - isTotem = true; - } else if (m2Renderer->hasAnimation(instanceId, 247)) { // TOTEM_LARGE - m2Renderer->setInstanceAnimation(instanceId, 247, true); - isTotem = true; - } - if (!isTotem) { - m2Renderer->setInstanceAnimationFrozen(instanceId, true); - } - } - - gameObjectInstances_[guid] = {modelId, instanceId, false}; - - // Notify transport system for M2 transports (e.g. Deeprun Tram cars) - if (gameHandler_ && gameHandler_->isTransportGuid(guid)) { - LOG_WARNING("M2 transport spawned: guid=0x", std::hex, guid, std::dec, - " entry=", entry, " displayId=", displayId, - " instanceId=", instanceId); - gameHandler_->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); - } - } - - LOG_DEBUG("Spawned gameobject: guid=0x", std::hex, guid, std::dec, - " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); -} - -void EntitySpawner::processAsyncCreatureResults(bool unlimited) { - // Check completed async model loads and finalize on main thread (GPU upload + instance creation). - // Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates. - // Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls. - static constexpr int kMaxModelUploadsPerTick = 1; - static constexpr int kMaxModelUploadsPerTickWarmup = 1; - static constexpr float kFinalizeBudgetMs = 2.0f; - static constexpr float kFinalizeBudgetWarmupMs = 2.0f; - const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick; - const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs; - const auto tickStart = std::chrono::steady_clock::now(); - int modelUploads = 0; - - for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) { - if (std::chrono::duration( - std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) { - break; - } - - if (!it->future.valid() || - it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { - ++it; - continue; - } - - auto result = it->future.get(); - it = asyncCreatureLoads_.erase(it); - asyncCreatureDisplayLoads_.erase(result.displayId); - - // Failures and cache hits need no GPU work — process them even when the - // upload budget is exhausted. Previously the budget check was above this - // point, blocking ALL ready futures (including zero-cost ones) after a - // single upload, which throttled creature spawn throughput during world load. - if (result.permanent_failure) { - nonRenderableCreatureDisplayIds_.insert(result.displayId); - creaturePermanentFailureGuids_.insert(result.guid); - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - continue; - } - if (!result.valid || !result.model) { - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - continue; - } - - // Another async result may have already uploaded this displayId while this - // task was still running; in that case, skip duplicate GPU upload. - if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) { - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - if (!creatureInstances_.count(result.guid) && - !creaturePermanentFailureGuids_.count(result.guid)) { - PendingCreatureSpawn s{}; - s.guid = result.guid; - s.displayId = result.displayId; - s.x = result.x; - s.y = result.y; - s.z = result.z; - s.orientation = result.orientation; - s.scale = result.scale; - pendingCreatureSpawns_.push_back(s); - pendingCreatureSpawnGuids_.insert(result.guid); - } - continue; - } - - // Only actual GPU uploads count toward the per-tick budget. - if (modelUploads >= maxUploadsThisTick) { - // Re-queue this result — it needs a GPU upload but we're at budget. - // Push a new pending spawn so it's retried next frame. - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - PendingCreatureSpawn s{}; - s.guid = result.guid; - s.displayId = result.displayId; - s.x = result.x; s.y = result.y; s.z = result.z; - s.orientation = result.orientation; - s.scale = result.scale; - pendingCreatureSpawns_.push_back(s); - pendingCreatureSpawnGuids_.insert(result.guid); - continue; - } - - // Model parsed on background thread — upload to GPU on main thread. - auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; - if (!charRenderer) { - pendingCreatureSpawnGuids_.erase(result.guid); - continue; - } - - // Count upload attempts toward the frame budget even if upload fails. - // Otherwise repeated failures can consume an unbounded amount of frame time. - modelUploads++; - - // Upload model to GPU (must happen on main thread) - // Use pre-decoded BLP cache to skip main-thread texture decode - auto uploadStart = std::chrono::steady_clock::now(); - charRenderer->setPredecodedBLPCache(&result.predecodedTextures); - if (!charRenderer->loadModel(*result.model, result.modelId)) { - charRenderer->setPredecodedBLPCache(nullptr); - nonRenderableCreatureDisplayIds_.insert(result.displayId); - creaturePermanentFailureGuids_.insert(result.guid); - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - continue; - } - charRenderer->setPredecodedBLPCache(nullptr); - { - auto uploadEnd = std::chrono::steady_clock::now(); - float uploadMs = std::chrono::duration(uploadEnd - uploadStart).count(); - if (uploadMs > 100.0f) { - LOG_WARNING("charRenderer->loadModel took ", uploadMs, "ms displayId=", result.displayId, - " preDecoded=", result.predecodedTextures.size()); - } - } - // Save remaining pre-decoded textures (display skins) for spawnOnlineCreature - if (!result.predecodedTextures.empty()) { - displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures); - } - displayIdModelCache_[result.displayId] = result.modelId; - pendingCreatureSpawnGuids_.erase(result.guid); - creatureSpawnRetryCounts_.erase(result.guid); - - // Re-queue as a normal pending spawn — model is now cached, so sync spawn is fast - // (only creates instance + applies textures, no file I/O). - if (!creatureInstances_.count(result.guid) && - !creaturePermanentFailureGuids_.count(result.guid)) { - PendingCreatureSpawn s{}; - s.guid = result.guid; - s.displayId = result.displayId; - s.x = result.x; - s.y = result.y; - s.z = result.z; - s.orientation = result.orientation; - s.scale = result.scale; - pendingCreatureSpawns_.push_back(s); - pendingCreatureSpawnGuids_.insert(result.guid); - } - } -} - -void EntitySpawner::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; - continue; - } - auto result = it->future.get(); - it = asyncNpcCompositeLoads_.erase(it); - - const auto& info = result.info; - - // Set pre-decoded cache so texture loads skip synchronous BLP decode - charRenderer->setPredecodedBLPCache(&result.predecodedTextures); - - // --- Apply skin to type-1 slots --- - rendering::VkTexture* skinTex = nullptr; - - if (info.hasBakedSkin) { - // Baked skin: load from pre-decoded cache - skinTex = charRenderer->loadTexture(info.bakedSkinPath); - } - - if (info.hasComposite) { - // Composite with face/underwear/equipment regions on top of base skin - rendering::VkTexture* compositeTex = nullptr; - if (!info.regionLayers.empty()) { - compositeTex = charRenderer->compositeWithRegions(info.basePath, - info.overlayPaths, info.regionLayers); - } else if (!info.overlayPaths.empty()) { - std::vector skinLayers; - skinLayers.push_back(info.basePath); - for (const auto& op : info.overlayPaths) skinLayers.push_back(op); - compositeTex = charRenderer->compositeTextures(skinLayers); - } - if (compositeTex) skinTex = compositeTex; - } else if (info.hasSimpleSkin) { - // Simple skin: just base texture, no compositing - auto* baseTex = charRenderer->loadTexture(info.basePath); - if (baseTex) skinTex = baseTex; - } - - if (skinTex) { - for (uint32_t slot : info.skinTextureSlots) { - charRenderer->setModelTexture(info.modelId, slot, skinTex); - } - } - - // --- Apply hair texture to type-6 slots --- - if (!info.hairTexturePath.empty()) { - rendering::VkTexture* hairTex = charRenderer->loadTexture(info.hairTexturePath); - rendering::VkTexture* whTex = charRenderer->loadTexture(""); - if (hairTex && hairTex != whTex) { - for (uint32_t slot : info.hairTextureSlots) { - charRenderer->setModelTexture(info.modelId, slot, hairTex); - } - } - } else if (info.useBakedForHair && skinTex) { - // Bald NPC: use skin/baked texture for scalp cap - for (uint32_t slot : info.hairTextureSlots) { - charRenderer->setModelTexture(info.modelId, slot, skinTex); - } - } - - charRenderer->setPredecodedBLPCache(nullptr); - } -} - -void EntitySpawner::processCreatureSpawnQueue(bool unlimited) { - auto startTime = std::chrono::steady_clock::now(); - // Budget: max 2ms per frame for creature spawning to prevent stutter. - // In unlimited mode (load screen), process everything without budget cap. - static constexpr float kSpawnBudgetMs = 2.0f; - - // First, finalize any async model loads that completed on background threads. - processAsyncCreatureResults(unlimited); - { - auto now = std::chrono::steady_clock::now(); - float asyncMs = std::chrono::duration(now - startTime).count(); - if (asyncMs > 100.0f) { - LOG_WARNING("processAsyncCreatureResults took ", asyncMs, "ms"); - } - } - - if (pendingCreatureSpawns_.empty()) return; - if (!creatureLookupsBuilt_) { - buildCreatureDisplayLookups(); - if (!creatureLookupsBuilt_) return; - } - - int processed = 0; - int asyncLaunched = 0; - size_t rotationsLeft = pendingCreatureSpawns_.size(); - while (!pendingCreatureSpawns_.empty() && - (unlimited || processed < MAX_SPAWNS_PER_FRAME) && - rotationsLeft > 0) { - // Check time budget every iteration (including first — async results may - // have already consumed the budget via GPU model uploads). - if (!unlimited) { - auto now = std::chrono::steady_clock::now(); - float elapsedMs = std::chrono::duration(now - startTime).count(); - if (elapsedMs >= kSpawnBudgetMs) break; - } - - PendingCreatureSpawn s = pendingCreatureSpawns_.front(); - pendingCreatureSpawns_.pop_front(); - - if (nonRenderableCreatureDisplayIds_.count(s.displayId)) { - pendingCreatureSpawnGuids_.erase(s.guid); - creatureSpawnRetryCounts_.erase(s.guid); - processed++; - rotationsLeft = pendingCreatureSpawns_.size(); - continue; - } - - const bool needsNewModel = (displayIdModelCache_.find(s.displayId) == displayIdModelCache_.end()); - - // For new models: launch async load on background thread instead of blocking. - if (needsNewModel) { - // Keep exactly one background load per displayId. Additional spawns for - // the same displayId stay queued and will spawn once cache is populated. - if (asyncCreatureDisplayLoads_.count(s.displayId)) { - pendingCreatureSpawns_.push_back(s); - rotationsLeft--; - continue; - } - - const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS; - if (static_cast(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) { - // Too many in-flight — defer to next frame - pendingCreatureSpawns_.push_back(s); - rotationsLeft--; - continue; - } - - std::string m2Path = getModelPathForDisplayId(s.displayId); - if (m2Path.empty()) { - nonRenderableCreatureDisplayIds_.insert(s.displayId); - creaturePermanentFailureGuids_.insert(s.guid); - pendingCreatureSpawnGuids_.erase(s.guid); - creatureSpawnRetryCounts_.erase(s.guid); - processed++; - rotationsLeft = pendingCreatureSpawns_.size(); - continue; - } - - // Check for invisible stalkers - { - std::string lowerPath = m2Path; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (lowerPath.find("invisiblestalker") != std::string::npos || - lowerPath.find("invisible_stalker") != std::string::npos) { - nonRenderableCreatureDisplayIds_.insert(s.displayId); - creaturePermanentFailureGuids_.insert(s.guid); - pendingCreatureSpawnGuids_.erase(s.guid); - processed++; - rotationsLeft = pendingCreatureSpawns_.size(); - continue; - } - } - - // Launch async M2 load — file I/O and parsing happen off the main thread. - uint32_t modelId = nextCreatureModelId_++; - auto* am = assetManager_; - - // Collect display skin texture paths for background pre-decode - std::vector displaySkinPaths; - { - auto itDD = displayDataMap_.find(s.displayId); - if (itDD != displayDataMap_.end()) { - std::string modelDir; - size_t lastSlash = m2Path.find_last_of("\\/"); - if (lastSlash != std::string::npos) modelDir = m2Path.substr(0, lastSlash + 1); - - auto resolveForAsync = [&](const std::string& skinField) { - if (skinField.empty()) return; - std::string raw = skinField; - std::replace(raw.begin(), raw.end(), '/', '\\'); - while (!raw.empty() && std::isspace(static_cast(raw.front()))) raw.erase(raw.begin()); - while (!raw.empty() && std::isspace(static_cast(raw.back()))) raw.pop_back(); - if (raw.empty()) return; - bool hasExt = raw.size() >= 4 && raw.substr(raw.size()-4) == ".blp"; - bool hasDir = raw.find('\\') != std::string::npos; - std::vector candidates; - if (hasDir) { - candidates.push_back(raw); - if (!hasExt) candidates.push_back(raw + ".blp"); - } else { - candidates.push_back(modelDir + raw); - if (!hasExt) candidates.push_back(modelDir + raw + ".blp"); - candidates.push_back(raw); - if (!hasExt) candidates.push_back(raw + ".blp"); - } - for (const auto& c : candidates) { - if (am->fileExists(c)) { displaySkinPaths.push_back(c); return; } - } - }; - resolveForAsync(itDD->second.skin1); - resolveForAsync(itDD->second.skin2); - resolveForAsync(itDD->second.skin3); - - // Pre-decode humanoid NPC textures (bake, skin, face, underwear, hair, equipment) - if (itDD->second.extraDisplayId != 0) { - auto itHE = humanoidExtraMap_.find(itDD->second.extraDisplayId); - if (itHE != humanoidExtraMap_.end()) { - const auto& he = itHE->second; - // Baked texture - if (!he.bakeName.empty()) { - displaySkinPaths.push_back("Textures\\BakedNpcTextures\\" + he.bakeName); - } - // CharSections: skin, face, underwear - auto csDbc = am->loadDBC("CharSections.dbc"); - if (csDbc) { - const auto* csL = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; - auto csF = pipeline::detectCharSectionsFields(csDbc.get(), csL); - uint32_t nRace = static_cast(he.raceId); - uint32_t nSex = static_cast(he.sexId); - uint32_t nSkin = static_cast(he.skinId); - uint32_t nFace = static_cast(he.faceId); - for (uint32_t r = 0; r < csDbc->getRecordCount(); r++) { - uint32_t rId = csDbc->getUInt32(r, csF.raceId); - uint32_t sId = csDbc->getUInt32(r, csF.sexId); - if (rId != nRace || sId != nSex) continue; - uint32_t section = csDbc->getUInt32(r, csF.baseSection); - uint32_t variation = csDbc->getUInt32(r, csF.variationIndex); - uint32_t color = csDbc->getUInt32(r, csF.colorIndex); - if (section == 0 && color == nSkin) { - std::string t = csDbc->getString(r, csF.texture1); - if (!t.empty()) displaySkinPaths.push_back(t); - } else if (section == 1 && variation == nFace && color == nSkin) { - std::string t1 = csDbc->getString(r, csF.texture1); - std::string t2 = csDbc->getString(r, csF.texture2); - if (!t1.empty()) displaySkinPaths.push_back(t1); - if (!t2.empty()) displaySkinPaths.push_back(t2); - } else if (section == 3 && variation == static_cast(he.hairStyleId) - && color == static_cast(he.hairColorId)) { - std::string t = csDbc->getString(r, csF.texture1); - if (!t.empty()) displaySkinPaths.push_back(t); - } else if (section == 4 && color == nSkin) { - for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { - std::string t = csDbc->getString(r, f); - if (!t.empty()) displaySkinPaths.push_back(t); - } - } - } - } - // Equipment region textures - auto idiDbc = am->loadDBC("ItemDisplayInfo.dbc"); - if (idiDbc) { - static constexpr const char* compDirs[] = { - "ArmUpperTexture", "ArmLowerTexture", "HandTexture", - "TorsoUpperTexture", "TorsoLowerTexture", - "LegUpperTexture", "LegLowerTexture", "FootTexture", - }; - const auto* idiL = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - const uint32_t trf[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, - }; - const bool isFem = (he.sexId == 1); - for (int eq = 0; eq < 11; eq++) { - uint32_t did = he.equipDisplayId[eq]; - if (did == 0) continue; - int32_t recIdx = idiDbc->findRecordById(did); - if (recIdx < 0) continue; - for (int region = 0; region < 8; region++) { - std::string texName = idiDbc->getString(static_cast(recIdx), trf[region]); - if (texName.empty()) continue; - std::string base = "Item\\TextureComponents\\" + - std::string(compDirs[region]) + "\\" + texName; - std::string gp = base + (isFem ? "_F.blp" : "_M.blp"); - std::string up = base + "_U.blp"; - if (am->fileExists(gp)) displaySkinPaths.push_back(gp); - else if (am->fileExists(up)) displaySkinPaths.push_back(up); - else displaySkinPaths.push_back(base + ".blp"); - } - } - } - } - } - } - } - - AsyncCreatureLoad load; - load.future = std::async(std::launch::async, - [am, m2Path, modelId, s, skinPaths = std::move(displaySkinPaths)]() -> PreparedCreatureModel { - PreparedCreatureModel result; - result.guid = s.guid; - result.displayId = s.displayId; - result.modelId = modelId; - result.x = s.x; - result.y = s.y; - result.z = s.z; - result.orientation = s.orientation; - result.scale = s.scale; - - auto m2Data = am->readFile(m2Path); - if (m2Data.empty()) { - result.permanent_failure = true; - return result; - } - - auto model = std::make_shared(pipeline::M2Loader::load(m2Data)); - if (model->vertices.empty()) { - result.permanent_failure = true; - return result; - } - - // Load skin file - if (model->version >= 264) { - std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; - auto skinData = am->readFile(skinPath); - if (!skinData.empty()) { - pipeline::M2Loader::loadSkin(skinData, *model); - } - } - - // Load external .anim files - std::string basePath = m2Path.substr(0, m2Path.size() - 3); - for (uint32_t si = 0; si < model->sequences.size(); si++) { - if (!(model->sequences[si].flags & 0x20)) { - char animFileName[256]; - snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", - basePath.c_str(), model->sequences[si].id, model->sequences[si].variationIndex); - auto animData = am->readFileOptional(animFileName); - if (!animData.empty()) { - pipeline::M2Loader::loadAnimFile(m2Data, animData, si, *model); - } - } - } - - // Pre-decode model textures on background thread - for (const auto& tex : model->textures) { - if (tex.filename.empty()) continue; - std::string texKey = tex.filename; - std::replace(texKey.begin(), texKey.end(), '/', '\\'); - std::transform(texKey.begin(), texKey.end(), texKey.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; - auto blp = am->loadTexture(texKey); - if (blp.isValid()) { - result.predecodedTextures[texKey] = std::move(blp); - } - } - - // Pre-decode display skin textures (skin1/skin2/skin3 from CreatureDisplayInfo) - for (const auto& sp : skinPaths) { - std::string key = sp; - std::replace(key.begin(), key.end(), '/', '\\'); - std::transform(key.begin(), key.end(), key.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (result.predecodedTextures.count(key)) continue; - auto blp = am->loadTexture(key); - if (blp.isValid()) { - result.predecodedTextures[key] = std::move(blp); - } - } - - result.model = std::move(model); - result.valid = true; - return result; - }); - asyncCreatureLoads_.push_back(std::move(load)); - asyncCreatureDisplayLoads_.insert(s.displayId); - asyncLaunched++; - // Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it - rotationsLeft = pendingCreatureSpawns_.size(); - processed++; - continue; - } - - // Cached model — spawn is fast (no file I/O, just instance creation + texture setup) - { - auto spawnStart = std::chrono::steady_clock::now(); - spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); - auto spawnEnd = std::chrono::steady_clock::now(); - float spawnMs = std::chrono::duration(spawnEnd - spawnStart).count(); - if (spawnMs > 100.0f) { - LOG_WARNING("spawnOnlineCreature took ", spawnMs, "ms displayId=", s.displayId); - } - } - pendingCreatureSpawnGuids_.erase(s.guid); - - // If spawn still failed, retry for a limited number of frames. - if (!creatureInstances_.count(s.guid)) { - if (creaturePermanentFailureGuids_.erase(s.guid) > 0) { - creatureSpawnRetryCounts_.erase(s.guid); - processed++; - continue; - } - uint16_t retries = 0; - auto it = creatureSpawnRetryCounts_.find(s.guid); - if (it != creatureSpawnRetryCounts_.end()) { - retries = it->second; - } - if (retries < MAX_CREATURE_SPAWN_RETRIES) { - creatureSpawnRetryCounts_[s.guid] = static_cast(retries + 1); - pendingCreatureSpawns_.push_back(s); - pendingCreatureSpawnGuids_.insert(s.guid); - } else { - creatureSpawnRetryCounts_.erase(s.guid); - LOG_WARNING("Dropping creature spawn after retries: guid=0x", std::hex, s.guid, std::dec, - " displayId=", s.displayId); - } - } else { - creatureSpawnRetryCounts_.erase(s.guid); - } - rotationsLeft = pendingCreatureSpawns_.size(); - processed++; - } -} - -void EntitySpawner::processPlayerSpawnQueue() { - if (pendingPlayerSpawns_.empty()) return; - if (!assetManager_ || !assetManager_->isInitialized()) return; - - int processed = 0; - while (!pendingPlayerSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) { - PendingPlayerSpawn s = pendingPlayerSpawns_.front(); - pendingPlayerSpawns_.erase(pendingPlayerSpawns_.begin()); - pendingPlayerSpawnGuids_.erase(s.guid); - - // Skip if already spawned (could have been spawned by a previous update this frame) - if (playerInstances_.count(s.guid)) { - processed++; - continue; - } - - spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation); - // Apply any equipment updates that arrived before the player was spawned. - auto pit = pendingOnlinePlayerEquipment_.find(s.guid); - if (pit != pendingOnlinePlayerEquipment_.end()) { - deferredEquipmentQueue_.push_back({s.guid, pit->second}); - pendingOnlinePlayerEquipment_.erase(pit); - } - processed++; - } -} - -std::vector EntitySpawner::resolveEquipmentTexturePaths(uint64_t guid, - const std::array& displayInfoIds, - const std::array& /*inventoryTypes*/) const { - std::vector paths; - - auto it = onlinePlayerAppearance_.find(guid); - if (it == onlinePlayerAppearance_.end()) return paths; - const OnlinePlayerAppearanceState& st = it->second; - - // Add base skin + underwear paths - if (!st.bodySkinPath.empty()) paths.push_back(st.bodySkinPath); - for (const auto& up : st.underwearPaths) { - if (!up.empty()) paths.push_back(up); - } - - // Resolve equipment region texture paths (same logic as setOnlinePlayerEquipment) - auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); - if (!displayInfoDbc) return paths; - const auto* idiL = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - - static constexpr const char* componentDirs[] = { - "ArmUpperTexture", "ArmLowerTexture", "HandTexture", - "TorsoUpperTexture", "TorsoLowerTexture", - "LegUpperTexture", "LegLowerTexture", "FootTexture", - }; - uint32_t texRegionFields[8]; - pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields); - const bool isFemale = (st.genderId == 1); - - for (int s = 0; s < 19; s++) { - uint32_t did = displayInfoIds[s]; - if (did == 0) continue; - int32_t recIdx = displayInfoDbc->findRecordById(did); - if (recIdx < 0) continue; - for (int region = 0; region < 8; region++) { - std::string texName = displayInfoDbc->getString( - static_cast(recIdx), texRegionFields[region]); - if (texName.empty()) continue; - std::string base = "Item\\TextureComponents\\" + - std::string(componentDirs[region]) + "\\" + texName; - std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); - std::string unisexPath = base + "_U.blp"; - if (assetManager_->fileExists(genderPath)) paths.push_back(genderPath); - else if (assetManager_->fileExists(unisexPath)) paths.push_back(unisexPath); - else paths.push_back(base + ".blp"); - } - } - return paths; -} - -void EntitySpawner::processAsyncEquipmentResults() { - for (auto it = asyncEquipmentLoads_.begin(); it != asyncEquipmentLoads_.end(); ) { - if (!it->future.valid() || - it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { - ++it; - continue; - } - auto result = it->future.get(); - it = asyncEquipmentLoads_.erase(it); - - auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; - if (!charRenderer) continue; - - // Set pre-decoded cache so compositeWithRegions skips synchronous BLP decode - charRenderer->setPredecodedBLPCache(&result.predecodedTextures); - setOnlinePlayerEquipment(result.guid, result.displayInfoIds, result.inventoryTypes); - charRenderer->setPredecodedBLPCache(nullptr); - } -} - -void EntitySpawner::processDeferredEquipmentQueue() { - // First, finalize any completed async pre-decodes - processAsyncEquipmentResults(); - - if (deferredEquipmentQueue_.empty()) return; - // Limit in-flight async equipment loads - if (asyncEquipmentLoads_.size() >= 2) return; - - auto [guid, equipData] = deferredEquipmentQueue_.front(); - deferredEquipmentQueue_.erase(deferredEquipmentQueue_.begin()); - - // Resolve all texture paths that compositeWithRegions will need - auto texturePaths = resolveEquipmentTexturePaths(guid, equipData.first, equipData.second); - - if (texturePaths.empty()) { - // No textures to pre-decode — just apply directly (fast path) - LOG_WARNING("Equipment fast path for guid=0x", std::hex, guid, std::dec, - " (no textures to pre-decode)"); - setOnlinePlayerEquipment(guid, equipData.first, equipData.second); - return; - } - LOG_WARNING("Equipment async pre-decode for guid=0x", std::hex, guid, std::dec, - " textures=", texturePaths.size()); - - // Launch background BLP pre-decode - auto* am = assetManager_; - auto displayInfoIds = equipData.first; - auto inventoryTypes = equipData.second; - AsyncEquipmentLoad load; - load.future = std::async(std::launch::async, - [am, guid, displayInfoIds, inventoryTypes, paths = std::move(texturePaths)]() -> PreparedEquipmentUpdate { - PreparedEquipmentUpdate result; - result.guid = guid; - result.displayInfoIds = displayInfoIds; - result.inventoryTypes = inventoryTypes; - for (const auto& path : paths) { - std::string key = path; - std::replace(key.begin(), key.end(), '/', '\\'); - std::transform(key.begin(), key.end(), key.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (result.predecodedTextures.count(key)) continue; - auto blp = am->loadTexture(key); - if (blp.isValid()) { - result.predecodedTextures[key] = std::move(blp); - } - } - return result; - }); - asyncEquipmentLoads_.push_back(std::move(load)); -} - -void EntitySpawner::processAsyncGameObjectResults() { - for (auto it = asyncGameObjectLoads_.begin(); it != asyncGameObjectLoads_.end(); ) { - if (!it->future.valid() || - it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { - ++it; - continue; - } - - auto result = it->future.get(); - it = asyncGameObjectLoads_.erase(it); - - if (!result.valid || !result.isWmo || !result.wmoModel) { - // Fallback: spawn via sync path (likely an M2 or failed WMO) - spawnOnlineGameObject(result.guid, result.entry, result.displayId, - result.x, result.y, result.z, result.orientation, result.scale); - continue; - } - - // WMO parsed on background thread — do GPU upload + instance creation on main thread - auto* wmoRenderer = renderer_ ? renderer_->getWMORenderer() : nullptr; - if (!wmoRenderer) continue; - - uint32_t modelId = 0; - auto itCache = gameObjectDisplayIdWmoCache_.find(result.displayId); - if (itCache != gameObjectDisplayIdWmoCache_.end()) { - modelId = itCache->second; - } else { - modelId = nextGameObjectWmoModelId_++; - wmoRenderer->setPredecodedBLPCache(&result.predecodedTextures); - if (!wmoRenderer->loadModel(*result.wmoModel, modelId)) { - wmoRenderer->setPredecodedBLPCache(nullptr); - LOG_WARNING("Failed to load async gameobject WMO: ", result.modelPath); - continue; - } - wmoRenderer->setPredecodedBLPCache(nullptr); - gameObjectDisplayIdWmoCache_[result.displayId] = modelId; - } - - glm::vec3 renderPos = core::coords::canonicalToRender( - glm::vec3(result.x, result.y, result.z)); - uint32_t instanceId = wmoRenderer->createInstance( - modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale); - if (instanceId == 0) continue; - - gameObjectInstances_[result.guid] = {modelId, instanceId, true}; - - // Queue transport doodad loading if applicable - std::string lowerPath = result.modelPath; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (lowerPath.find("transport") != std::string::npos) { - const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); - if (doodadTemplates && !doodadTemplates->empty()) { - PendingTransportDoodadBatch batch; - batch.guid = result.guid; - batch.modelId = modelId; - batch.instanceId = instanceId; - batch.x = result.x; - batch.y = result.y; - batch.z = result.z; - batch.orientation = result.orientation; - batch.doodadBudget = doodadTemplates->size(); - pendingTransportDoodadBatches_.push_back(batch); - } - } - } -} - -void EntitySpawner::processGameObjectSpawnQueue() { - // Finalize any completed async WMO loads first - processAsyncGameObjectResults(); - - if (pendingGameObjectSpawns_.empty()) return; - - static int goQueueLogCounter = 0; - if (++goQueueLogCounter % 60 == 1) { - LOG_DEBUG("GO queue: ", pendingGameObjectSpawns_.size(), " pending, ", - gameObjectInstances_.size(), " spawned, ", - gameObjectDisplayIdFailedCache_.size(), " failed"); - } - - // Process spawns: cached WMOs and M2s go sync (cheap), uncached WMOs go async - auto startTime = std::chrono::steady_clock::now(); - static constexpr float kBudgetMs = 2.0f; - static constexpr int kMaxAsyncLoads = 2; - - while (!pendingGameObjectSpawns_.empty()) { - float elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (elapsedMs >= kBudgetMs) break; - - auto& s = pendingGameObjectSpawns_.front(); - - // Check if this is an uncached WMO that needs async loading - std::string modelPath; - if (gameObjectLookupsBuilt_) { - // Check transport overrides first - bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(s.guid); - if (isTransport) { - if (s.entry == 20808 || s.entry == 176231 || s.entry == 176310) - modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; - else if (s.displayId == 807 || s.displayId == 808 || s.displayId == 175080 || s.displayId == 176495 || s.displayId == 164871) - modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; - else if (s.displayId == 1587) - modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; - else if (s.displayId == 2454 || s.displayId == 181688 || s.displayId == 190536) - modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; - } - if (modelPath.empty()) - modelPath = getGameObjectModelPathForDisplayId(s.displayId); - } - - std::string lowerPath = modelPath; - std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; - bool isCached = isWmo && gameObjectDisplayIdWmoCache_.count(s.displayId); - - if (isWmo && !isCached && !modelPath.empty() && - static_cast(asyncGameObjectLoads_.size()) < kMaxAsyncLoads) { - // Launch async WMO load — file I/O + parse on background thread - auto* am = assetManager_; - PendingGameObjectSpawn capture = s; - std::string capturePath = modelPath; - AsyncGameObjectLoad load; - load.future = std::async(std::launch::async, - [am, capture, capturePath]() -> PreparedGameObjectWMO { - PreparedGameObjectWMO result; - result.guid = capture.guid; - result.entry = capture.entry; - result.displayId = capture.displayId; - result.x = capture.x; - result.y = capture.y; - result.z = capture.z; - result.orientation = capture.orientation; - result.scale = capture.scale; - result.modelPath = capturePath; - result.isWmo = true; - - auto wmoData = am->readFile(capturePath); - if (wmoData.empty()) return result; - - auto wmo = std::make_shared( - pipeline::WMOLoader::load(wmoData)); - - // Load groups - if (wmo->nGroups > 0) { - std::string basePath = capturePath; - std::string ext; - if (basePath.size() > 4) { - ext = basePath.substr(basePath.size() - 4); - basePath = basePath.substr(0, basePath.size() - 4); - } - for (uint32_t gi = 0; gi < wmo->nGroups; gi++) { - char suffix[16]; - snprintf(suffix, sizeof(suffix), "_%03u%s", gi, ext.c_str()); - auto groupData = am->readFile(basePath + suffix); - if (groupData.empty()) { - snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi); - groupData = am->readFile(basePath + suffix); - } - if (!groupData.empty()) { - pipeline::WMOLoader::loadGroup(groupData, *wmo, gi); - } - } - } - - // Pre-decode WMO textures on background thread - for (const auto& texPath : wmo->textures) { - if (texPath.empty()) continue; - std::string texKey = texPath; - size_t nul = texKey.find('\0'); - if (nul != std::string::npos) texKey.resize(nul); - std::replace(texKey.begin(), texKey.end(), '/', '\\'); - std::transform(texKey.begin(), texKey.end(), texKey.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (texKey.empty()) continue; - // Convert to .blp extension - if (texKey.size() >= 4) { - std::string ext = texKey.substr(texKey.size() - 4); - if (ext == ".tga" || ext == ".dds") { - texKey = texKey.substr(0, texKey.size() - 4) + ".blp"; - } - } - if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; - auto blp = am->loadTexture(texKey); - if (blp.isValid()) { - result.predecodedTextures[texKey] = std::move(blp); - } - } - - result.wmoModel = wmo; - result.valid = true; - return result; - }); - asyncGameObjectLoads_.push_back(std::move(load)); - pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); - continue; - } - - // Cached WMO or M2 — spawn synchronously (cheap) - spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); - pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); - } -} - -void EntitySpawner::processPendingTransportRegistrations() { - if (pendingTransportRegistrations_.empty()) return; - if (!gameHandler_ || !renderer_) return; - - auto* transportManager = gameHandler_->getTransportManager(); - if (!transportManager) return; - - auto startTime = std::chrono::steady_clock::now(); - static constexpr int kMaxRegistrationsPerFrame = 2; - static constexpr float kRegistrationBudgetMs = 2.0f; - int processed = 0; - - for (auto it = pendingTransportRegistrations_.begin(); - it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) { - float elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (elapsedMs >= kRegistrationBudgetMs) break; - - const PendingTransportRegistration pending = *it; - auto goIt = gameObjectInstances_.find(pending.guid); - if (goIt == gameObjectInstances_.end()) { - it = pendingTransportRegistrations_.erase(it); - continue; - } - - if (transportManager->getTransport(pending.guid)) { - transportManager->updateServerTransport( - pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); - it = pendingTransportRegistrations_.erase(it); - continue; - } - - const uint32_t wmoInstanceId = goIt->second.instanceId; - LOG_WARNING("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec, - " entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId, - " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")"); - - // TransportAnimation.dbc is indexed by GameObject entry. - uint32_t pathId = pending.entry; - const bool preferServerData = gameHandler_->hasServerTransportUpdate(pending.guid); - - bool clientAnim = transportManager->isClientSideAnimation(); - LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, - " guid=0x", std::hex, pending.guid, std::dec, - " entry=", pending.entry, " pathId=", pathId, - " preferServer=", preferServerData); - - glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z); - const bool shipOrZeppelinDisplay = - (pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 || - pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 || - pending.displayId == 807 || pending.displayId == 808); - bool hasUsablePath = transportManager->hasPathForEntry(pending.entry); - if (shipOrZeppelinDisplay) { - hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f); - } - - LOG_WARNING("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath, - " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); - - if (preferServerData) { - if (!hasUsablePath) { - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", - std::hex, pending.guid, std::dec, " entry=", pending.entry); - } else { - LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", pending.entry); - } - } else if (!hasUsablePath) { - bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462); - uint32_t inferredPath = transportManager->inferDbcPathForSpawn( - canonicalSpawnPos, 1200.0f, allowZOnly); - if (inferredPath != 0) { - pathId = inferredPath; - LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry); - } else { - uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId); - if (remappedPath != 0) { - pathId = remappedPath; - LOG_WARNING("Using remapped fallback transport path ", pathId, - " for entry ", pending.entry, " displayId=", pending.displayId, - " (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")"); - } else { - LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry, - " - transport will be stationary"); - std::vector path = { canonicalSpawnPos }; - transportManager->loadPathFromNodes(pathId, path, false, 0.0f); - } - } - } else { - LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry); - } - - transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry); - - if (!goIt->second.isWmo) { - if (auto* tr = transportManager->getTransport(pending.guid)) { - tr->isM2 = true; - } - } - - transportManager->updateServerTransport( - pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); - - auto moveIt = pendingTransportMoves_.find(pending.guid); - if (moveIt != pendingTransportMoves_.end()) { - const PendingTransportMove latestMove = moveIt->second; - transportManager->updateServerTransport( - pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation); - LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec, - " pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z, - ") orientation=", latestMove.orientation); - pendingTransportMoves_.erase(moveIt); - } - - if (glm::dot(canonicalSpawnPos, canonicalSpawnPos) < 1.0f) { - auto goData = gameHandler_->getCachedGameObjectInfo(pending.entry); - if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { - uint32_t taxiPathId = goData->data[0]; - if (transportManager->hasTaxiPath(taxiPathId)) { - transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId); - LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry, - " taxiPathId=", taxiPathId); - } - } - } - - if (auto* tr = transportManager->getTransport(pending.guid); tr) { - LOG_WARNING("Transport registered: guid=0x", std::hex, pending.guid, std::dec, - " entry=", pending.entry, " displayId=", pending.displayId, - " pathId=", tr->pathId, - " mode=", (tr->useClientAnimation ? "client" : "server"), - " serverUpdates=", tr->serverUpdateCount); - } else { - LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, - " entry=", pending.entry, " displayId=", pending.displayId, - " (TransportManager instance missing)"); - } - - ++processed; - it = pendingTransportRegistrations_.erase(it); - } -} - -void EntitySpawner::processPendingTransportDoodads() { - if (pendingTransportDoodadBatches_.empty()) return; - if (!renderer_ || !assetManager_) return; - - auto* wmoRenderer = renderer_->getWMORenderer(); - auto* m2Renderer = renderer_->getM2Renderer(); - if (!wmoRenderer || !m2Renderer) return; - - auto startTime = std::chrono::steady_clock::now(); - static constexpr float kDoodadBudgetMs = 4.0f; - - // Batch all GPU uploads into a single async command buffer submission so that - // N doodads with multiple textures each don't each block on vkQueueSubmit + - // vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds - // of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST. - auto* vkCtx = renderer_->getVkContext(); - if (vkCtx) vkCtx->beginUploadBatch(); - - size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME; - for (auto it = pendingTransportDoodadBatches_.begin(); - it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) { - // Time budget check - float elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (elapsedMs >= kDoodadBudgetMs) break; - auto goIt = gameObjectInstances_.find(it->guid); - if (goIt == gameObjectInstances_.end() || !goIt->second.isWmo || - goIt->second.instanceId != it->instanceId || goIt->second.modelId != it->modelId) { - it = pendingTransportDoodadBatches_.erase(it); - continue; - } - - const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(it->modelId); - if (!doodadTemplates || doodadTemplates->empty()) { - it = pendingTransportDoodadBatches_.erase(it); - continue; - } - - const size_t maxIndex = std::min(it->doodadBudget, doodadTemplates->size()); - while (it->nextIndex < maxIndex && budgetLeft > 0) { - // Per-doodad time budget (each does synchronous file I/O + parse + GPU upload) - float innerMs = std::chrono::duration( - std::chrono::steady_clock::now() - startTime).count(); - if (innerMs >= kDoodadBudgetMs) { budgetLeft = 0; break; } - - const auto& doodadTemplate = (*doodadTemplates)[it->nextIndex]; - it->nextIndex++; - budgetLeft--; - - uint32_t doodadModelId = static_cast(std::hash{}(doodadTemplate.m2Path)); - auto m2Data = assetManager_->readFile(doodadTemplate.m2Path); - if (m2Data.empty()) continue; - - pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); - std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin"; - std::vector skinData = assetManager_->readFile(skinPath); - if (!skinData.empty() && m2Model.version >= 264) { - pipeline::M2Loader::loadSkin(skinData, m2Model); - } - if (!m2Model.isValid()) continue; - - if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; - uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); - if (m2InstanceId == 0) continue; - m2Renderer->setSkipCollision(m2InstanceId, true); - - wmoRenderer->addDoodadToInstance(it->instanceId, m2InstanceId, doodadTemplate.localTransform); - it->spawnedDoodads++; - } - - if (it->nextIndex >= maxIndex) { - if (it->spawnedDoodads > 0) { - LOG_DEBUG("Spawned ", it->spawnedDoodads, - " transport doodads for WMO instance ", it->instanceId); - glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(it->x, it->y, it->z)); - glm::mat4 wmoTransform(1.0f); - wmoTransform = glm::translate(wmoTransform, renderPos); - wmoTransform = glm::rotate(wmoTransform, it->orientation, glm::vec3(0, 0, 1)); - wmoRenderer->setInstanceTransform(it->instanceId, wmoTransform); - } - it = pendingTransportDoodadBatches_.erase(it); - } else { - ++it; - } - } - - // Finalize the upload batch — submit all GPU copies in one shot (async, no wait). - if (vkCtx) vkCtx->endUploadBatch(); -} - -void EntitySpawner::processPendingMount() { - if (pendingMountDisplayId_ == 0) return; - uint32_t mountDisplayId = pendingMountDisplayId_; - pendingMountDisplayId_ = 0; - LOG_INFO("processPendingMount: loading displayId ", mountDisplayId); - - if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_) return; - auto* charRenderer = renderer_->getCharacterRenderer(); - - std::string m2Path = getModelPathForDisplayId(mountDisplayId); - if (m2Path.empty()) { - LOG_WARNING("No model path for mount displayId ", mountDisplayId); - return; - } - - // Check model cache - uint32_t modelId = 0; - auto cacheIt = displayIdModelCache_.find(mountDisplayId); - if (cacheIt != displayIdModelCache_.end()) { - modelId = cacheIt->second; - } else { - modelId = nextCreatureModelId_++; - - auto m2Data = assetManager_->readFile(m2Path); - if (m2Data.empty()) { - LOG_WARNING("Failed to read mount M2: ", m2Path); - return; - } - - pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (model.vertices.empty()) { - LOG_WARNING("Failed to parse mount M2: ", m2Path); - return; - } - - // Load skin file (only for WotLK M2s - vanilla has embedded skin) - if (model.version >= 264) { - std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; - auto skinData = assetManager_->readFile(skinPath); - if (!skinData.empty()) { - pipeline::M2Loader::loadSkin(skinData, model); - } else { - LOG_WARNING("Missing skin file for WotLK mount M2: ", skinPath); - } - } - - // Load external .anim files (only idle + run needed for mounts) - std::string basePath = m2Path.substr(0, m2Path.size() - 3); - for (uint32_t si = 0; si < model.sequences.size(); si++) { - if (!(model.sequences[si].flags & 0x20)) { - uint32_t animId = model.sequences[si].id; - // Only load stand, walk, run anims to avoid hang - if (animId != rendering::anim::STAND && animId != rendering::anim::WALK && animId != rendering::anim::RUN) continue; - char animFileName[256]; - snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", - basePath.c_str(), animId, model.sequences[si].variationIndex); - auto animData = assetManager_->readFileOptional(animFileName); - if (!animData.empty()) { - pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); - } - } - } - - if (!charRenderer->loadModel(model, modelId)) { - LOG_WARNING("Failed to load mount model: ", m2Path); - return; - } - - displayIdModelCache_[mountDisplayId] = modelId; - } - - // Apply creature skin textures from CreatureDisplayInfo.dbc. - // Re-apply even for cached models so transient failures can self-heal. - std::string modelDir; - size_t lastSlash = m2Path.find_last_of("\\/"); - if (lastSlash != std::string::npos) { - modelDir = m2Path.substr(0, lastSlash + 1); - } - - auto itDisplayData = displayDataMap_.find(mountDisplayId); - bool haveDisplayData = false; - CreatureDisplayData dispData{}; - if (itDisplayData != displayDataMap_.end()) { - dispData = itDisplayData->second; - haveDisplayData = true; - } else { - // Some taxi mount display IDs are sparse; recover skins by matching model path. - std::string lowerMountPath = m2Path; - std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - int bestScore = -1; - for (const auto& [dispId, data] : displayDataMap_) { - auto pit = modelIdToPath_.find(data.modelId); - if (pit == modelIdToPath_.end()) continue; - std::string p = pit->second; - std::transform(p.begin(), p.end(), p.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (p != lowerMountPath) continue; - int score = 0; - if (!data.skin1.empty()) { - std::string p1 = modelDir + data.skin1 + ".blp"; - score += assetManager_->fileExists(p1) ? 30 : 3; - } - if (!data.skin2.empty()) { - std::string p2 = modelDir + data.skin2 + ".blp"; - score += assetManager_->fileExists(p2) ? 20 : 2; - } - if (!data.skin3.empty()) { - std::string p3 = modelDir + data.skin3 + ".blp"; - score += assetManager_->fileExists(p3) ? 10 : 1; - } - if (score > bestScore) { - bestScore = score; - dispData = data; - haveDisplayData = true; - } - } - if (haveDisplayData) { - LOG_INFO("Recovered mount display data by model path for displayId=", mountDisplayId, - " skin1='", dispData.skin1, "' skin2='", dispData.skin2, - "' skin3='", dispData.skin3, "'"); - } - } - if (haveDisplayData) { - // If this displayId has no skins, try to find another displayId for the same model with skins. - if (dispData.skin1.empty() && dispData.skin2.empty() && dispData.skin3.empty()) { - uint32_t sourceModelId = dispData.modelId; - int bestScore = -1; - for (const auto& [dispId, data] : displayDataMap_) { - if (data.modelId != sourceModelId) continue; - int score = 0; - if (!data.skin1.empty()) { - std::string p = modelDir + data.skin1 + ".blp"; - score += assetManager_->fileExists(p) ? 30 : 3; - } - if (!data.skin2.empty()) { - std::string p = modelDir + data.skin2 + ".blp"; - score += assetManager_->fileExists(p) ? 20 : 2; - } - if (!data.skin3.empty()) { - std::string p = modelDir + data.skin3 + ".blp"; - score += assetManager_->fileExists(p) ? 10 : 1; - } - if (score > bestScore) { - bestScore = score; - dispData = data; - } - } - LOG_INFO("Mount skin fallback for displayId=", mountDisplayId, - " modelId=", sourceModelId, " skin1='", dispData.skin1, - "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); - } - const auto* md = charRenderer->getModelData(modelId); - if (md) { - LOG_INFO("Mount model textures: ", md->textures.size(), " slots, skin1='", dispData.skin1, - "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); - for (size_t ti = 0; ti < md->textures.size(); ti++) { - LOG_INFO(" tex[", ti, "] type=", md->textures[ti].type, - " filename='", md->textures[ti].filename, "'"); - } - - int replaced = 0; - for (size_t ti = 0; ti < md->textures.size(); ti++) { - const auto& tex = md->textures[ti]; - std::string texPath; - if (tex.type == 11 && !dispData.skin1.empty()) { - texPath = modelDir + dispData.skin1 + ".blp"; - } else if (tex.type == 12 && !dispData.skin2.empty()) { - texPath = modelDir + dispData.skin2 + ".blp"; - } else if (tex.type == 13 && !dispData.skin3.empty()) { - texPath = modelDir + dispData.skin3 + ".blp"; - } - if (!texPath.empty()) { - rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); - if (skinTex) { - charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); - LOG_INFO(" Applied skin texture slot ", ti, ": ", texPath); - replaced++; - } else { - LOG_WARNING(" Failed to load skin texture slot ", ti, ": ", texPath); - } - } - } - - // Force skin textures onto type-0 (hardcoded) slots that have no filename - if (replaced == 0) { - for (size_t ti = 0; ti < md->textures.size(); ti++) { - const auto& tex = md->textures[ti]; - if (tex.type == 0 && tex.filename.empty()) { - // Empty hardcoded slot — try skin1 then skin2 - std::string texPath; - if (!dispData.skin1.empty() && replaced == 0) { - texPath = modelDir + dispData.skin1 + ".blp"; - } else if (!dispData.skin2.empty()) { - texPath = modelDir + dispData.skin2 + ".blp"; - } - if (!texPath.empty()) { - rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); - if (skinTex) { - charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); - LOG_INFO(" Forced skin on empty hardcoded slot ", ti, ": ", texPath); - replaced++; - } - } - } - } - } - - // If still no textures, try hardcoded model texture filenames - if (replaced == 0) { - for (size_t ti = 0; ti < md->textures.size(); ti++) { - if (!md->textures[ti].filename.empty()) { - rendering::VkTexture* texId = charRenderer->loadTexture(md->textures[ti].filename); - if (texId) { - charRenderer->setModelTexture(modelId, static_cast(ti), texId); - LOG_INFO(" Used model embedded texture slot ", ti, ": ", md->textures[ti].filename); - replaced++; - } - } - } - } - - // Final fallback for gryphon/wyvern: try well-known skin texture names - if (replaced == 0 && !md->textures.empty()) { - std::string lowerMountPath = m2Path; - std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (lowerMountPath.find("gryphon") != std::string::npos) { - const char* gryphonSkins[] = { - "Creature\\Gryphon\\Gryphon_Skin.blp", - "Creature\\Gryphon\\Gryphon_Skin01.blp", - "Creature\\Gryphon\\GRYPHON_SKIN01.BLP", - nullptr - }; - for (const char** p = gryphonSkins; *p; ++p) { - rendering::VkTexture* texId = charRenderer->loadTexture(*p); - if (texId) { - charRenderer->setModelTexture(modelId, 0, texId); - LOG_INFO(" Forced gryphon skin fallback: ", *p); - replaced++; - break; - } - } - } else if (lowerMountPath.find("wyvern") != std::string::npos) { - const char* wyvernSkins[] = { - "Creature\\Wyvern\\Wyvern_Skin.blp", - "Creature\\Wyvern\\Wyvern_Skin01.blp", - nullptr - }; - for (const char** p = wyvernSkins; *p; ++p) { - rendering::VkTexture* texId = charRenderer->loadTexture(*p); - if (texId) { - charRenderer->setModelTexture(modelId, 0, texId); - LOG_INFO(" Forced wyvern skin fallback: ", *p); - replaced++; - break; - } - } - } - } - LOG_INFO("Mount texture setup: ", replaced, " textures applied"); - } - } - - mountModelId_ = modelId; - - // Create mount instance at player position - glm::vec3 mountPos = renderer_->getCharacterPosition(); - float yawRad = glm::radians(renderer_->getCharacterYaw()); - uint32_t instanceId = charRenderer->createInstance(modelId, mountPos, - glm::vec3(0.0f, 0.0f, yawRad), 1.0f); - - if (instanceId == 0) { - LOG_WARNING("Failed to create mount instance"); - return; - } - - mountInstanceId_ = instanceId; - - // Compute height offset — place player above mount's back - // Use tight bounds from actual vertices (M2 header bounds can be inaccurate) - const auto* modelData = charRenderer->getModelData(modelId); - float heightOffset = 1.8f; - if (modelData && !modelData->vertices.empty()) { - float minZ = std::numeric_limits::max(); - float maxZ = -std::numeric_limits::max(); - for (const auto& v : modelData->vertices) { - if (v.position.z < minZ) minZ = v.position.z; - if (v.position.z > maxZ) maxZ = v.position.z; - } - float extentZ = maxZ - minZ; - LOG_INFO("Mount tight bounds: minZ=", minZ, " maxZ=", maxZ, " extentZ=", extentZ); - if (extentZ > 0.5f) { - // Saddle point is roughly 75% up the model, measured from model origin - heightOffset = maxZ * 0.8f; - if (heightOffset < 1.0f) heightOffset = extentZ * 0.75f; - if (heightOffset < 1.0f) heightOffset = 1.8f; - } - } - - renderer_->setMounted(instanceId, mountDisplayId, heightOffset, m2Path); - - // For taxi mounts, start with flying animation; for ground mounts, start with stand - bool isTaxi = gameHandler_ && gameHandler_->isOnTaxiFlight(); - uint32_t startAnim = rendering::anim::STAND; - if (isTaxi) { - // Try WotLK fly anims first, then Vanilla-friendly fallbacks - using namespace rendering::anim; - uint32_t taxiCandidates[] = {FLY_FORWARD, FLY_IDLE, FLY_RUN_2, FLY_SPELL, FLY_RISE, SPELL_KNEEL_LOOP, FLY_CUSTOM_SPELL_10, DEAD, RUN}; - for (uint32_t anim : taxiCandidates) { - if (charRenderer->hasAnimation(instanceId, anim)) { - startAnim = anim; - break; - } - } - // If none found, startAnim stays 0 (Stand/hover) which is fine for flying creatures - } - charRenderer->playAnimation(instanceId, startAnim, true); - - LOG_INFO("processPendingMount: DONE displayId=", mountDisplayId, " model=", m2Path, " heightOffset=", heightOffset); -} - -void EntitySpawner::despawnCreature(uint64_t guid) { - // If this guid is a PLAYER, it will be tracked in playerInstances_. - // Route to the correct despawn path so we don't leak instances. - if (playerInstances_.count(guid)) { - despawnPlayer(guid); - return; - } - - pendingCreatureSpawnGuids_.erase(guid); - creatureSpawnRetryCounts_.erase(guid); - creaturePermanentFailureGuids_.erase(guid); - deadCreatureGuids_.erase(guid); - - auto it = creatureInstances_.find(guid); - if (it == creatureInstances_.end()) return; - - if (renderer_ && renderer_->getCharacterRenderer()) { - renderer_->getCharacterRenderer()->removeInstance(it->second); - } - - creatureInstances_.erase(it); - creatureModelIds_.erase(guid); - creatureRenderPosCache_.erase(guid); - creatureWeaponsAttached_.erase(guid); - creatureWeaponAttachAttempts_.erase(guid); - creatureWasMoving_.erase(guid); - creatureWasSwimming_.erase(guid); - creatureWasFlying_.erase(guid); - creatureWasWalking_.erase(guid); - creatureSwimmingState_.erase(guid); - creatureWalkingState_.erase(guid); - creatureFlyingState_.erase(guid); - - LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); -} - -void EntitySpawner::despawnGameObject(uint64_t guid) { - pendingTransportDoodadBatches_.erase( - std::remove_if(pendingTransportDoodadBatches_.begin(), pendingTransportDoodadBatches_.end(), - [guid](const PendingTransportDoodadBatch& b) { return b.guid == guid; }), - pendingTransportDoodadBatches_.end()); - - auto it = gameObjectInstances_.find(guid); - if (it == gameObjectInstances_.end()) return; - - if (renderer_) { - if (it->second.isWmo) { - if (auto* wmoRenderer = renderer_->getWMORenderer()) { - wmoRenderer->removeInstance(it->second.instanceId); - } - } else { - if (auto* m2Renderer = renderer_->getM2Renderer()) { - m2Renderer->removeInstance(it->second.instanceId); - } - } - } - - gameObjectInstances_.erase(it); - - LOG_DEBUG("Despawned gameobject: guid=0x", std::hex, guid, std::dec); -} - -bool EntitySpawner::loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel) { - auto m2Data = assetManager_->readFile(m2Path); - if (m2Data.empty()) return false; - outModel = pipeline::M2Loader::load(m2Data); - // Load skin (WotLK+ M2 format): strip .m2, append 00.skin - std::string skinPath = m2Path; - size_t dotPos = skinPath.rfind('.'); - if (dotPos != std::string::npos) skinPath = skinPath.substr(0, dotPos); - skinPath += "00.skin"; - auto skinData = assetManager_->readFile(skinPath); - if (!skinData.empty() && outModel.version >= 264) - pipeline::M2Loader::loadSkin(skinData, outModel); - return outModel.isValid(); -} - - } // namespace core } // namespace wowee diff --git a/src/core/entity_spawner_player.cpp b/src/core/entity_spawner_player.cpp new file mode 100644 index 00000000..85fa6820 --- /dev/null +++ b/src/core/entity_spawner_player.cpp @@ -0,0 +1,1230 @@ +#include "core/entity_spawner.hpp" +#include "core/coordinates.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/m2_renderer.hpp" +#include "audio/npc_voice_manager.hpp" +#include "pipeline/m2_loader.hpp" +#include "pipeline/wmo_loader.hpp" +#include "rendering/animation/animation_ids.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "game/game_handler.hpp" +#include "game/game_services.hpp" +#include "game/transport_manager.hpp" + +#include +#include +#include +#include +#include + +namespace wowee { +namespace core { + +namespace { +// Default (bare) geoset IDs per equipment group. +// Each group's base is groupNumber * 100; variant 01 is typically bare/default. +constexpr uint16_t kGeosetDefaultConnector = 101; // Group 1: default hair connector +constexpr uint16_t kGeosetBareForearms = 401; // Group 4: no gloves +constexpr uint16_t kGeosetBareShins = 503; // Group 5: no boots +constexpr uint16_t kGeosetDefaultEars = 702; // Group 7: ears +constexpr uint16_t kGeosetBareSleeves = 801; // Group 8: no chest armor sleeves +constexpr uint16_t kGeosetDefaultKneepads = 902; // Group 9: kneepads +constexpr uint16_t kGeosetDefaultTabard = 1201; // Group 12: tabard base +constexpr uint16_t kGeosetBarePants = 1301; // Group 13: no leggings +constexpr uint16_t kGeosetNoCape = 1501; // Group 15: no cape +constexpr uint16_t kGeosetWithCape = 1502; // Group 15: with cape +constexpr uint16_t kGeosetBareFeet = 2002; // Group 20: bare feet +} // namespace + +void EntitySpawner::spawnOnlinePlayer(uint64_t guid, + uint8_t raceId, + uint8_t genderId, + uint32_t appearanceBytes, + uint8_t facialFeatures, + float x, float y, float z, float orientation) { + if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; + if (playerInstances_.count(guid)) return; + + // Skip local player — already spawned as the main character + if (gameHandler_) { + uint64_t localGuid = gameHandler_->getPlayerGuid(); + uint64_t activeGuid = gameHandler_->getActiveCharacterGuid(); + if ((localGuid != 0 && guid == localGuid) || + (activeGuid != 0 && guid == activeGuid) || + (spawnedPlayerGuid_ != 0 && guid == spawnedPlayerGuid_)) { + return; + } + } + auto* charRenderer = renderer_->getCharacterRenderer(); + + // Base geometry model: cache by (race, gender) + uint32_t cacheKey = (static_cast(raceId) << 8) | static_cast(genderId & 0xFF); + uint32_t modelId = 0; + auto itCache = playerModelCache_.find(cacheKey); + if (itCache != playerModelCache_.end()) { + modelId = itCache->second; + } else { + game::Race race = static_cast(raceId); + game::Gender gender = (genderId == 1) ? game::Gender::FEMALE : game::Gender::MALE; + std::string m2Path = game::getPlayerModelPath(race, gender); + if (m2Path.empty()) { + LOG_WARNING("spawnOnlinePlayer: unknown race/gender for guid 0x", std::hex, guid, std::dec, + " race=", static_cast(raceId), " gender=", static_cast(genderId)); + return; + } + + // Parse modelDir/baseName for skin/anim loading + std::string modelDir; + std::string baseName; + { + size_t slash = m2Path.rfind('\\'); + if (slash != std::string::npos) { + modelDir = m2Path.substr(0, slash + 1); + baseName = m2Path.substr(slash + 1); + } else { + baseName = m2Path; + } + size_t dot = baseName.rfind('.'); + if (dot != std::string::npos) baseName = baseName.substr(0, dot); + } + + auto m2Data = assetManager_->readFile(m2Path); + if (m2Data.empty()) { + LOG_WARNING("spawnOnlinePlayer: failed to read M2: ", m2Path); + return; + } + + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty()) { + LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); + return; + } + + // Skin file (only for WotLK M2s - vanilla has embedded skin) + std::string skinPath = modelDir + baseName + "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty() && model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, model); + } + + // After skin loading, full model must be valid (vertices + indices) + if (!model.isValid()) { + LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path); + return; + } + + // Load only core external animations (stand/walk/run) to avoid stalls + for (uint32_t si = 0; si < model.sequences.size(); si++) { + if (!(model.sequences[si].flags & 0x20)) { + uint32_t animId = model.sequences[si].id; + if (animId != rendering::anim::STAND && animId != rendering::anim::WALK && animId != rendering::anim::RUN) continue; + char animFileName[256]; + snprintf(animFileName, sizeof(animFileName), + "%s%s%04u-%02u.anim", + modelDir.c_str(), + baseName.c_str(), + animId, + model.sequences[si].variationIndex); + auto animData = assetManager_->readFileOptional(animFileName); + if (!animData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); + } + } + } + + modelId = nextPlayerModelId_++; + if (!charRenderer->loadModel(model, modelId)) { + LOG_WARNING("spawnOnlinePlayer: failed to load model to GPU: ", m2Path); + return; + } + + playerModelCache_[cacheKey] = modelId; + } + + // Determine texture slots once per model + { + auto [slotIt, inserted] = playerTextureSlotsByModelId_.try_emplace(modelId); + if (inserted) { + PlayerTextureSlots slots; + if (const auto* md = charRenderer->getModelData(modelId)) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + uint32_t t = md->textures[ti].type; + if (t == 1 && slots.skin < 0) slots.skin = static_cast(ti); + else if (t == 6 && slots.hair < 0) slots.hair = static_cast(ti); + else if (t == 8 && slots.underwear < 0) slots.underwear = static_cast(ti); + } + } + slotIt->second = slots; + } + } + + // Create instance at server position + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + float renderYaw = orientation + glm::radians(90.0f); + uint32_t instanceId = charRenderer->createInstance(modelId, renderPos, glm::vec3(0.0f, 0.0f, renderYaw), 1.0f); + if (instanceId == 0) return; + + // Resolve skin/hair texture paths via CharSections, then apply as per-instance overrides + const char* raceFolderName = "Human"; + switch (static_cast(raceId)) { + case game::Race::HUMAN: raceFolderName = "Human"; break; + case game::Race::ORC: raceFolderName = "Orc"; break; + case game::Race::DWARF: raceFolderName = "Dwarf"; break; + case game::Race::NIGHT_ELF: raceFolderName = "NightElf"; break; + case game::Race::UNDEAD: raceFolderName = "Scourge"; break; + case game::Race::TAUREN: raceFolderName = "Tauren"; break; + case game::Race::GNOME: raceFolderName = "Gnome"; break; + case game::Race::TROLL: raceFolderName = "Troll"; break; + case game::Race::BLOOD_ELF: raceFolderName = "BloodElf"; break; + case game::Race::DRAENEI: raceFolderName = "Draenei"; break; + default: break; + } + const char* genderFolder = (genderId == 1) ? "Female" : "Male"; + std::string raceGender = std::string(raceFolderName) + genderFolder; + std::string bodySkinPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "Skin00_00.blp"; + std::string pelvisPath = std::string("Character\\") + raceFolderName + "\\" + genderFolder + "\\" + raceGender + "NakedPelvisSkin00_00.blp"; + std::vector underwearPaths; + std::string hairTexturePath; + std::string faceLowerPath; + std::string faceUpperPath; + + uint8_t skinId = appearanceBytes & 0xFF; + uint8_t faceId = (appearanceBytes >> 8) & 0xFF; + uint8_t hairStyleId = (appearanceBytes >> 16) & 0xFF; + uint8_t hairColorId = (appearanceBytes >> 24) & 0xFF; + + if (auto charSectionsDbc = assetManager_->loadDBC("CharSections.dbc"); charSectionsDbc && charSectionsDbc->isLoaded()) { + const auto* csL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; + auto csF = pipeline::detectCharSectionsFields(charSectionsDbc.get(), csL); + uint32_t targetRaceId = raceId; + uint32_t targetSexId = genderId; + + bool foundSkin = false; + bool foundUnderwear = false; + bool foundHair = false; + bool foundFaceLower = false; + + for (uint32_t r = 0; r < charSectionsDbc->getRecordCount(); r++) { + uint32_t rRace = charSectionsDbc->getUInt32(r, csF.raceId); + uint32_t rSex = charSectionsDbc->getUInt32(r, csF.sexId); + uint32_t baseSection = charSectionsDbc->getUInt32(r, csF.baseSection); + uint32_t variationIndex = charSectionsDbc->getUInt32(r, csF.variationIndex); + uint32_t colorIndex = charSectionsDbc->getUInt32(r, csF.colorIndex); + + if (rRace != targetRaceId || rSex != targetSexId) continue; + + if (baseSection == 0 && !foundSkin && colorIndex == skinId) { + std::string tex1 = charSectionsDbc->getString(r, csF.texture1); + if (!tex1.empty()) { bodySkinPath = tex1; foundSkin = true; } + } else if (baseSection == 3 && !foundHair && + variationIndex == hairStyleId && colorIndex == hairColorId) { + hairTexturePath = charSectionsDbc->getString(r, csF.texture1); + if (!hairTexturePath.empty()) foundHair = true; + } else if (baseSection == 4 && !foundUnderwear && colorIndex == skinId) { + for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { + std::string tex = charSectionsDbc->getString(r, f); + if (!tex.empty()) underwearPaths.push_back(tex); + } + foundUnderwear = true; + } else if (baseSection == 1 && !foundFaceLower && + variationIndex == faceId && colorIndex == skinId) { + std::string tex1 = charSectionsDbc->getString(r, csF.texture1); + std::string tex2 = charSectionsDbc->getString(r, csF.texture2); + if (!tex1.empty()) faceLowerPath = tex1; + if (!tex2.empty()) faceUpperPath = tex2; + foundFaceLower = true; + } + + if (foundSkin && foundUnderwear && foundHair && foundFaceLower) break; + } + } + + // Composite base skin + face + underwear overlays + rendering::VkTexture* compositeTex = nullptr; + { + std::vector layers; + layers.push_back(bodySkinPath); + if (!faceLowerPath.empty()) layers.push_back(faceLowerPath); + if (!faceUpperPath.empty()) layers.push_back(faceUpperPath); + for (const auto& up : underwearPaths) layers.push_back(up); + if (layers.size() > 1) { + compositeTex = charRenderer->compositeTextures(layers); + } else { + compositeTex = charRenderer->loadTexture(bodySkinPath); + } + } + + rendering::VkTexture* hairTex = nullptr; + if (!hairTexturePath.empty()) { + hairTex = charRenderer->loadTexture(hairTexturePath); + } + rendering::VkTexture* underwearTex = nullptr; + if (!underwearPaths.empty()) underwearTex = charRenderer->loadTexture(underwearPaths[0]); + else underwearTex = charRenderer->loadTexture(pelvisPath); + + const PlayerTextureSlots& slots = playerTextureSlotsByModelId_[modelId]; + if (slots.skin >= 0 && compositeTex) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.skin), compositeTex); + } + if (slots.hair >= 0 && hairTex) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.hair), hairTex); + } + if (slots.underwear >= 0 && underwearTex) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(slots.underwear), underwearTex); + } + + // Geosets: body + hair/facial hair selections + std::unordered_set activeGeosets; + // Body parts (group 0: IDs 0-99, some models use up to 27) + for (uint16_t i = 0; i <= 99; i++) activeGeosets.insert(i); + activeGeosets.insert(static_cast(100 + hairStyleId + 1)); + activeGeosets.insert(static_cast(200 + facialFeatures + 1)); + activeGeosets.insert(kGeosetBareForearms); + activeGeosets.insert(kGeosetBareShins); + activeGeosets.insert(kGeosetDefaultEars); + activeGeosets.insert(kGeosetBareSleeves); + activeGeosets.insert(kGeosetDefaultKneepads); + activeGeosets.insert(kGeosetBarePants); + activeGeosets.insert(kGeosetWithCape); + activeGeosets.insert(kGeosetBareFeet); + charRenderer->setActiveGeosets(instanceId, activeGeosets); + + charRenderer->playAnimation(instanceId, rendering::anim::STAND, true); + playerInstances_[guid] = instanceId; + + OnlinePlayerAppearanceState st; + st.instanceId = instanceId; + st.modelId = modelId; + st.raceId = raceId; + st.genderId = genderId; + st.appearanceBytes = appearanceBytes; + st.facialFeatures = facialFeatures; + st.bodySkinPath = bodySkinPath; + // Include face textures so compositeWithRegions can rebuild the full base + if (!faceLowerPath.empty()) st.underwearPaths.push_back(faceLowerPath); + if (!faceUpperPath.empty()) st.underwearPaths.push_back(faceUpperPath); + for (const auto& up : underwearPaths) st.underwearPaths.push_back(up); + onlinePlayerAppearance_[guid] = std::move(st); +} + +void EntitySpawner::setOnlinePlayerEquipment(uint64_t guid, + const std::array& displayInfoIds, + const std::array& inventoryTypes) { + if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_ || !assetManager_->isInitialized()) return; + + // Skip local player — equipment handled by GameScreen::updateCharacterGeosets/Textures + // via consumeOnlineEquipmentDirty(), which fires on the same server update. + if (gameHandler_) { + uint64_t localGuid = gameHandler_->getPlayerGuid(); + if (localGuid != 0 && guid == localGuid) return; + } + + // If the player isn't spawned yet, store equipment until spawn. + auto appIt = onlinePlayerAppearance_.find(guid); + if (!playerInstances_.count(guid) || appIt == onlinePlayerAppearance_.end()) { + pendingOnlinePlayerEquipment_[guid] = {displayInfoIds, inventoryTypes}; + return; + } + + const OnlinePlayerAppearanceState& st = appIt->second; + + auto* charRenderer = renderer_->getCharacterRenderer(); + if (!charRenderer) return; + if (st.instanceId == 0 || st.modelId == 0) return; + + if (st.bodySkinPath.empty()) { + LOG_WARNING("setOnlinePlayerEquipment: bodySkinPath empty for guid=0x", std::hex, guid, std::dec, + " instanceId=", st.instanceId, " — skipping equipment"); + return; + } + + int nonZeroDisplay = 0; + for (uint32_t d : displayInfoIds) if (d != 0) nonZeroDisplay++; + LOG_WARNING("setOnlinePlayerEquipment: guid=0x", std::hex, guid, std::dec, + " instanceId=", st.instanceId, " nonZeroDisplayIds=", nonZeroDisplay, + " head=", displayInfoIds[0], " chest=", displayInfoIds[4], + " legs=", displayInfoIds[6], " mainhand=", displayInfoIds[15]); + + auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc) return; + const auto* idiL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + + auto getGeosetGroup = [&](uint32_t displayInfoId, uint32_t fieldIdx) -> uint32_t { + if (displayInfoId == 0) return 0; + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx < 0) return 0; + return displayInfoDbc->getUInt32(static_cast(recIdx), fieldIdx); + }; + + auto findDisplayIdByInvType = [&](std::initializer_list types) -> uint32_t { + for (int s = 0; s < 19; s++) { + uint8_t inv = inventoryTypes[s]; + if (inv == 0 || displayInfoIds[s] == 0) continue; + for (uint8_t t : types) { + if (inv == t) return displayInfoIds[s]; + } + } + return 0; + }; + + auto hasInvType = [&](std::initializer_list types) -> bool { + for (int s = 0; s < 19; s++) { + uint8_t inv = inventoryTypes[s]; + if (inv == 0) continue; + for (uint8_t t : types) { + if (inv == t) return true; + } + } + return false; + }; + + // --- Geosets --- + // Mirror the same group-range logic as CharacterPreview::applyEquipment to + // keep other-player rendering consistent with the local character preview. + // Group 4 (4xx) = forearms/gloves, 5 (5xx) = shins/boots, 8 (8xx) = wrists/sleeves, + // 13 (13xx) = legs/trousers. Missing defaults caused the shin-mesh gap (status.md). + std::unordered_set geosets; + // Body parts (group 0: IDs 0-99, some models use up to 27) + for (uint16_t i = 0; i <= 99; i++) geosets.insert(i); + + uint8_t hairStyleId = static_cast((st.appearanceBytes >> 16) & 0xFF); + geosets.insert(static_cast(100 + hairStyleId + 1)); + geosets.insert(static_cast(200 + st.facialFeatures + 1)); + geosets.insert(701); // Ears + geosets.insert(kGeosetDefaultKneepads); // Kneepads + geosets.insert(kGeosetBareFeet); // Bare feet mesh + + const uint32_t geosetGroup1Field = idiL ? (*idiL)["GeosetGroup1"] : 7; + const uint32_t geosetGroup3Field = idiL ? (*idiL)["GeosetGroup3"] : 9; + + // Per-group defaults — overridden below when equipment provides a geoset value. + uint16_t geosetGloves = kGeosetBareForearms; + uint16_t geosetBoots = kGeosetBareShins; + uint16_t geosetSleeves = kGeosetBareSleeves; + uint16_t geosetPants = kGeosetBarePants; + + // Chest/Shirt/Robe (invType 4,5,20) → wrist/sleeve group 8 + { + uint32_t did = findDisplayIdByInvType({4, 5, 20}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); + // Robe kilt → leg group 13 + uint32_t gg3 = getGeosetGroup(did, geosetGroup3Field); + if (gg3 > 0) geosetPants = static_cast(kGeosetBarePants + gg3); + } + + // Legs (invType 7) → leg group 13 + { + uint32_t did = findDisplayIdByInvType({7}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetPants = static_cast(kGeosetBarePants + gg1); + } + + // Feet/Boots (invType 8) → shin group 5 + { + uint32_t did = findDisplayIdByInvType({8}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetBoots = static_cast(501 + gg1); + } + + // Hands/Gloves (invType 10) → forearm group 4 + { + uint32_t did = findDisplayIdByInvType({10}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetGloves = static_cast(kGeosetBareForearms + gg1); + } + + // Wrists/Bracers (invType 9) → sleeve group 8 (only if chest/shirt didn't set it) + { + uint32_t did = findDisplayIdByInvType({9}); + if (did != 0 && geosetSleeves == kGeosetBareSleeves) { + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetSleeves = static_cast(kGeosetBareSleeves + gg1); + } + } + + // Waist/Belt (invType 6) → buckle group 18 + uint16_t geosetBelt = 0; + { + uint32_t did = findDisplayIdByInvType({6}); + uint32_t gg1 = getGeosetGroup(did, geosetGroup1Field); + if (gg1 > 0) geosetBelt = static_cast(1801 + gg1); + } + + geosets.insert(geosetGloves); + geosets.insert(geosetBoots); + geosets.insert(geosetSleeves); + geosets.insert(geosetPants); + if (geosetBelt != 0) geosets.insert(geosetBelt); + // Back/Cloak (invType 16) + geosets.insert(hasInvType({16}) ? kGeosetWithCape : kGeosetNoCape); + // Tabard (invType 19) + if (hasInvType({19})) geosets.insert(kGeosetDefaultTabard); + + // Hide hair under helmets: replace style-specific scalp with bald scalp + // HEAD slot is index 0 in the 19-element equipment array + if (displayInfoIds[0] != 0 && hairStyleId > 0) { + uint16_t hairGeoset = static_cast(hairStyleId + 1); + geosets.erase(static_cast(100 + hairGeoset)); // Remove style group 1 + geosets.insert(kGeosetDefaultConnector); // Default group 1 connector + } + + charRenderer->setActiveGeosets(st.instanceId, geosets); + + // --- Helmet model attachment --- + // HEAD slot is index 0 in the 19-element equipment array. + // Helmet M2s are race/gender-specific (e.g. Helm_Plate_B_01_HuM.m2 for Human Male). + if (displayInfoIds[0] != 0) { + // Detach any previously attached helmet before attaching a new one + charRenderer->detachWeapon(st.instanceId, 0); + charRenderer->detachWeapon(st.instanceId, 11); + + int32_t helmIdx = displayInfoDbc->findRecordById(displayInfoIds[0]); + if (helmIdx >= 0) { + const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; + std::string helmModelName = displayInfoDbc->getString(static_cast(helmIdx), leftModelField); + if (!helmModelName.empty()) { + // Strip .mdx/.m2 extension + size_t dotPos = helmModelName.rfind('.'); + if (dotPos != std::string::npos) helmModelName = helmModelName.substr(0, dotPos); + + // Race/gender suffix for helmet variants + static const std::unordered_map racePrefix = { + {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, + {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} + }; + std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; + std::string raceSuffix; + auto itRace = racePrefix.find(st.raceId); + if (itRace != racePrefix.end()) { + raceSuffix = "_" + itRace->second + genderSuffix; + } + + // Try race/gender-specific variant first, then base name + std::string helmPath; + pipeline::M2Model helmModel; + if (!raceSuffix.empty()) { + helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + raceSuffix + ".m2"; + if (!loadWeaponM2(helmPath, helmModel)) helmModel = {}; + } + if (!helmModel.isValid()) { + helmPath = "Item\\ObjectComponents\\Head\\" + helmModelName + ".m2"; + loadWeaponM2(helmPath, helmModel); + } + + if (helmModel.isValid()) { + uint32_t helmModelId = nextWeaponModelId_++; + // Get texture from ItemDisplayInfo (LeftModelTexture) + const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; + std::string helmTexName = displayInfoDbc->getString(static_cast(helmIdx), leftTexField); + std::string helmTexPath; + if (!helmTexName.empty()) { + if (!raceSuffix.empty()) { + std::string suffixedTex = "Item\\ObjectComponents\\Head\\" + helmTexName + raceSuffix + ".blp"; + if (assetManager_->fileExists(suffixedTex)) helmTexPath = suffixedTex; + } + if (helmTexPath.empty()) { + helmTexPath = "Item\\ObjectComponents\\Head\\" + helmTexName + ".blp"; + } + } + // Attachment point 0 (head bone), fallback to 11 (explicit head attachment) + bool attached = charRenderer->attachWeapon(st.instanceId, 0, helmModel, helmModelId, helmTexPath); + if (!attached) { + attached = charRenderer->attachWeapon(st.instanceId, 11, helmModel, helmModelId, helmTexPath); + } + if (attached) { + LOG_DEBUG("Attached player helmet: ", helmPath, " tex: ", helmTexPath); + } + } + } + } + } else { + // No helmet equipped — detach any existing helmet model + charRenderer->detachWeapon(st.instanceId, 0); + charRenderer->detachWeapon(st.instanceId, 11); + } + + // --- Shoulder model attachment --- + // SHOULDERS slot is index 2 in the 19-element equipment array. + // Shoulders have TWO M2 models (left + right) attached at points 5 and 6. + // ItemDisplayInfo.dbc: LeftModel → left shoulder, RightModel → right shoulder. + if (displayInfoIds[2] != 0) { + // Detach any previously attached shoulder models + charRenderer->detachWeapon(st.instanceId, 5); + charRenderer->detachWeapon(st.instanceId, 6); + + int32_t shoulderIdx = displayInfoDbc->findRecordById(displayInfoIds[2]); + if (shoulderIdx >= 0) { + const uint32_t leftModelField = idiL ? (*idiL)["LeftModel"] : 1u; + const uint32_t rightModelField = idiL ? (*idiL)["RightModel"] : 2u; + const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; + const uint32_t rightTexField = idiL ? (*idiL)["RightModelTexture"] : 4u; + + // Race/gender suffix for shoulder variants (same as helmets) + static const std::unordered_map shoulderRacePrefix = { + {1, "Hu"}, {2, "Or"}, {3, "Dw"}, {4, "Ni"}, {5, "Sc"}, + {6, "Ta"}, {7, "Gn"}, {8, "Tr"}, {10, "Be"}, {11, "Dr"} + }; + std::string genderSuffix = (st.genderId == 0) ? "M" : "F"; + std::string raceSuffix; + auto itRace = shoulderRacePrefix.find(st.raceId); + if (itRace != shoulderRacePrefix.end()) { + raceSuffix = "_" + itRace->second + genderSuffix; + } + + // Attach left shoulder (attachment point 5) using LeftModel + std::string leftModelName = displayInfoDbc->getString(static_cast(shoulderIdx), leftModelField); + if (!leftModelName.empty()) { + size_t dotPos = leftModelName.rfind('.'); + if (dotPos != std::string::npos) leftModelName = leftModelName.substr(0, dotPos); + + std::string leftPath; + pipeline::M2Model leftModel; + if (!raceSuffix.empty()) { + leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + raceSuffix + ".m2"; + if (!loadWeaponM2(leftPath, leftModel)) leftModel = {}; + } + if (!leftModel.isValid()) { + leftPath = "Item\\ObjectComponents\\Shoulder\\" + leftModelName + ".m2"; + loadWeaponM2(leftPath, leftModel); + } + + if (leftModel.isValid()) { + uint32_t leftModelId = nextWeaponModelId_++; + std::string leftTexName = displayInfoDbc->getString(static_cast(shoulderIdx), leftTexField); + std::string leftTexPath; + if (!leftTexName.empty()) { + if (!raceSuffix.empty()) { + std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + raceSuffix + ".blp"; + if (assetManager_->fileExists(suffixedTex)) leftTexPath = suffixedTex; + } + if (leftTexPath.empty()) { + leftTexPath = "Item\\ObjectComponents\\Shoulder\\" + leftTexName + ".blp"; + } + } + bool attached = charRenderer->attachWeapon(st.instanceId, 5, leftModel, leftModelId, leftTexPath); + if (attached) { + LOG_DEBUG("Attached left shoulder: ", leftPath, " tex: ", leftTexPath); + } + } + } + + // Attach right shoulder (attachment point 6) using RightModel + std::string rightModelName = displayInfoDbc->getString(static_cast(shoulderIdx), rightModelField); + if (!rightModelName.empty()) { + size_t dotPos = rightModelName.rfind('.'); + if (dotPos != std::string::npos) rightModelName = rightModelName.substr(0, dotPos); + + std::string rightPath; + pipeline::M2Model rightModel; + if (!raceSuffix.empty()) { + rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + raceSuffix + ".m2"; + if (!loadWeaponM2(rightPath, rightModel)) rightModel = {}; + } + if (!rightModel.isValid()) { + rightPath = "Item\\ObjectComponents\\Shoulder\\" + rightModelName + ".m2"; + loadWeaponM2(rightPath, rightModel); + } + + if (rightModel.isValid()) { + uint32_t rightModelId = nextWeaponModelId_++; + std::string rightTexName = displayInfoDbc->getString(static_cast(shoulderIdx), rightTexField); + std::string rightTexPath; + if (!rightTexName.empty()) { + if (!raceSuffix.empty()) { + std::string suffixedTex = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + raceSuffix + ".blp"; + if (assetManager_->fileExists(suffixedTex)) rightTexPath = suffixedTex; + } + if (rightTexPath.empty()) { + rightTexPath = "Item\\ObjectComponents\\Shoulder\\" + rightTexName + ".blp"; + } + } + bool attached = charRenderer->attachWeapon(st.instanceId, 6, rightModel, rightModelId, rightTexPath); + if (attached) { + LOG_DEBUG("Attached right shoulder: ", rightPath, " tex: ", rightTexPath); + } + } + } + } + } else { + // No shoulders equipped — detach any existing shoulder models + charRenderer->detachWeapon(st.instanceId, 5); + charRenderer->detachWeapon(st.instanceId, 6); + } + + // --- Cape texture (group 15 / texture type 2) --- + // The geoset above enables the cape mesh, but without a texture it renders blank. + if (hasInvType({16})) { + // Back/cloak is WoW equipment slot 14 (BACK) in the 19-element array. + uint32_t capeDid = displayInfoIds[14]; + if (capeDid != 0) { + int32_t capeRecIdx = displayInfoDbc->findRecordById(capeDid); + if (capeRecIdx >= 0) { + const uint32_t leftTexField = idiL ? (*idiL)["LeftModelTexture"] : 3u; + std::string capeName = displayInfoDbc->getString( + static_cast(capeRecIdx), leftTexField); + + if (!capeName.empty()) { + std::replace(capeName.begin(), capeName.end(), '/', '\\'); + + auto hasBlpExt = [](const std::string& p) { + if (p.size() < 4) return false; + std::string ext = p.substr(p.size() - 4); + std::transform(ext.begin(), ext.end(), ext.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return ext == ".blp"; + }; + + const bool hasDir = (capeName.find('\\') != std::string::npos); + const bool hasExt = hasBlpExt(capeName); + + std::vector capeCandidates; + auto addCapeCandidate = [&](const std::string& p) { + if (p.empty()) return; + if (std::find(capeCandidates.begin(), capeCandidates.end(), p) == capeCandidates.end()) { + capeCandidates.push_back(p); + } + }; + + if (hasDir) { + addCapeCandidate(capeName); + if (!hasExt) addCapeCandidate(capeName + ".blp"); + } else { + std::string baseObj = "Item\\ObjectComponents\\Cape\\" + capeName; + std::string baseTex = "Item\\TextureComponents\\Cape\\" + capeName; + addCapeCandidate(baseObj); + addCapeCandidate(baseTex); + if (!hasExt) { + addCapeCandidate(baseObj + ".blp"); + addCapeCandidate(baseTex + ".blp"); + } + addCapeCandidate(baseObj + (st.genderId == 1 ? "_F.blp" : "_M.blp")); + addCapeCandidate(baseObj + "_U.blp"); + addCapeCandidate(baseTex + (st.genderId == 1 ? "_F.blp" : "_M.blp")); + addCapeCandidate(baseTex + "_U.blp"); + } + + const rendering::VkTexture* whiteTex = charRenderer->loadTexture(""); + rendering::VkTexture* capeTexture = nullptr; + for (const auto& candidate : capeCandidates) { + rendering::VkTexture* tex = charRenderer->loadTexture(candidate); + if (tex && tex != whiteTex) { + capeTexture = tex; + break; + } + } + + if (capeTexture) { + charRenderer->setGroupTextureOverride(st.instanceId, 15, capeTexture); + if (const auto* md = charRenderer->getModelData(st.modelId)) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + if (md->textures[ti].type == 2) { + charRenderer->setTextureSlotOverride( + st.instanceId, static_cast(ti), capeTexture); + } + } + } + } + } + } + } + } + + // --- Textures (skin atlas compositing) --- + static constexpr const char* componentDirs[] = { + "ArmUpperTexture", + "ArmLowerTexture", + "HandTexture", + "TorsoUpperTexture", + "TorsoLowerTexture", + "LegUpperTexture", + "LegLowerTexture", + "FootTexture", + }; + + uint32_t texRegionFields[8]; + pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields); + + std::vector> regionLayers; + const bool isFemale = (st.genderId == 1); + + for (int s = 0; s < 19; s++) { + uint32_t did = displayInfoIds[s]; + if (did == 0) continue; + int32_t recIdx = displayInfoDbc->findRecordById(did); + if (recIdx < 0) continue; + + for (int region = 0; region < 8; region++) { + std::string texName = displayInfoDbc->getString( + static_cast(recIdx), texRegionFields[region]); + if (texName.empty()) continue; + + std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; + std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); + std::string unisexPath = base + "_U.blp"; + std::string fullPath; + if (assetManager_->fileExists(genderPath)) fullPath = genderPath; + else if (assetManager_->fileExists(unisexPath)) fullPath = unisexPath; + else fullPath = base + ".blp"; + + regionLayers.emplace_back(region, fullPath); + } + } + + const auto slotsIt = playerTextureSlotsByModelId_.find(st.modelId); + if (slotsIt == playerTextureSlotsByModelId_.end()) return; + const PlayerTextureSlots& slots = slotsIt->second; + if (slots.skin < 0) return; + + rendering::VkTexture* newTex = charRenderer->compositeWithRegions(st.bodySkinPath, st.underwearPaths, regionLayers); + if (newTex) { + charRenderer->setTextureSlotOverride(st.instanceId, static_cast(slots.skin), newTex); + } + + // --- Weapon model attachment --- + // Slot indices in the 19-element EquipSlot array: + // 15 = MAIN_HAND → attachment 1 (right hand) + // 16 = OFF_HAND → attachment 2 (left hand) + struct OnlineWeaponSlot { + int slotIndex; + uint32_t attachmentId; + }; + static constexpr OnlineWeaponSlot weaponSlots[] = { + { 15, 1 }, // MAIN_HAND → right hand + { 16, 2 }, // OFF_HAND → left hand + }; + + const uint32_t modelFieldL = idiL ? (*idiL)["LeftModel"] : 1u; + const uint32_t modelFieldR = idiL ? (*idiL)["RightModel"] : 2u; + const uint32_t texFieldL = idiL ? (*idiL)["LeftModelTexture"] : 3u; + const uint32_t texFieldR = idiL ? (*idiL)["RightModelTexture"] : 4u; + + for (const auto& ws : weaponSlots) { + uint32_t weapDisplayId = displayInfoIds[ws.slotIndex]; + if (weapDisplayId == 0) { + charRenderer->detachWeapon(st.instanceId, ws.attachmentId); + continue; + } + + int32_t recIdx = displayInfoDbc->findRecordById(weapDisplayId); + if (recIdx < 0) { + charRenderer->detachWeapon(st.instanceId, ws.attachmentId); + continue; + } + + // Prefer LeftModel (full weapon), fall back to RightModel (hilt variants) + std::string modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldL); + std::string textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldL); + if (modelName.empty()) { + modelName = displayInfoDbc->getString(static_cast(recIdx), modelFieldR); + textureName = displayInfoDbc->getString(static_cast(recIdx), texFieldR); + } + if (modelName.empty()) { + charRenderer->detachWeapon(st.instanceId, ws.attachmentId); + continue; + } + + // Convert .mdx → .m2 + std::string modelFile = modelName; + { + size_t dotPos = modelFile.rfind('.'); + if (dotPos != std::string::npos) modelFile = modelFile.substr(0, dotPos); + modelFile += ".m2"; + } + + // Try Weapon directory first, then Shield + std::string m2Path = "Item\\ObjectComponents\\Weapon\\" + modelFile; + pipeline::M2Model weaponModel; + if (!loadWeaponM2(m2Path, weaponModel)) { + m2Path = "Item\\ObjectComponents\\Shield\\" + modelFile; + if (!loadWeaponM2(m2Path, weaponModel)) { + charRenderer->detachWeapon(st.instanceId, ws.attachmentId); + continue; + } + } + + // Build texture path + std::string texturePath; + if (!textureName.empty()) { + texturePath = "Item\\ObjectComponents\\Weapon\\" + textureName + ".blp"; + if (!assetManager_->fileExists(texturePath)) { + texturePath = "Item\\ObjectComponents\\Shield\\" + textureName + ".blp"; + if (!assetManager_->fileExists(texturePath)) texturePath.clear(); + } + } + + uint32_t weaponModelId = nextWeaponModelId_++; + charRenderer->attachWeapon(st.instanceId, ws.attachmentId, + weaponModel, weaponModelId, texturePath); + } +} + +void EntitySpawner::despawnPlayer(uint64_t guid) { + if (!renderer_ || !renderer_->getCharacterRenderer()) return; + auto it = playerInstances_.find(guid); + if (it == playerInstances_.end()) return; + renderer_->getCharacterRenderer()->removeInstance(it->second); + playerInstances_.erase(it); + onlinePlayerAppearance_.erase(guid); + pendingOnlinePlayerEquipment_.erase(guid); + creatureRenderPosCache_.erase(guid); + creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); + creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); +} + +void EntitySpawner::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t displayId, float x, float y, float z, float orientation, float scale) { + if (!renderer_ || !assetManager_) return; + + if (!gameObjectLookupsBuilt_) { + buildGameObjectDisplayLookups(); + } + if (!gameObjectLookupsBuilt_) return; + + LOG_DEBUG("GO spawn attempt: guid=0x", std::hex, guid, std::dec, + " displayId=", displayId, " entry=", entry, + " pos=(", x, ", ", y, ", ", z, ")"); + + auto goIt = gameObjectInstances_.find(guid); + if (goIt != gameObjectInstances_.end()) { + // Already have a render instance — update its position (e.g. transport re-creation) + auto& info = goIt->second; + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + LOG_DEBUG("GameObject position update: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, + " pos=(", x, ", ", y, ", ", z, ")"); + if (renderer_) { + if (info.isWmo) { + if (auto* wr = renderer_->getWMORenderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + transform = glm::rotate(transform, orientation, glm::vec3(0, 0, 1)); + wr->setInstanceTransform(info.instanceId, transform); + } + } else { + if (auto* mr = renderer_->getM2Renderer()) { + glm::mat4 transform(1.0f); + transform = glm::translate(transform, renderPos); + mr->setInstanceTransform(info.instanceId, transform); + } + } + } + return; + } + + std::string modelPath; + + // Override model path for transports with wrong displayIds (preloaded transports) + // Check if this GUID is a known transport + bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(guid); + if (isTransport) { + // Map common transport displayIds to correct WMO paths + // NOTE: displayIds 455/462 are elevators in Thunder Bluff and should NOT be forced to ships. + // Keep ship/zeppelin overrides entry-driven where possible. + // DisplayIds 807, 808 = Zeppelins + // DisplayIds 2454, 1587 = Special ships/icebreakers + if (entry == 20808 || entry == 176231 || entry == 176310) { + modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; + LOG_INFO("Overriding transport entry/display ", entry, "/", displayId, " → transportship.wmo"); + } else if (displayId == 807 || displayId == 808 || displayId == 175080 || displayId == 176495 || displayId == 164871) { + modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; + LOG_INFO("Overriding transport displayId ", displayId, " → transport_zeppelin.wmo"); + } else if (displayId == 1587) { + modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; + LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Horde_Zeppelin.wmo"); + } else if (displayId == 2454 || displayId == 181688 || displayId == 190536) { + modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; + LOG_INFO("Overriding transport displayId ", displayId, " → Transport_Icebreaker_ship.wmo"); + } else if (displayId == 3831) { + // Deeprun Tram car + modelPath = "World\\Generic\\Gnome\\Passive Doodads\\Subway\\SubwayCar.m2"; + LOG_WARNING("Overriding transport displayId ", displayId, " → SubwayCar.m2"); + } + } + + // Fallback to normal displayId lookup if not a transport or no override matched + if (modelPath.empty()) { + modelPath = getGameObjectModelPathForDisplayId(displayId); + } + + if (modelPath.empty()) { + LOG_WARNING("No model path for gameobject displayId ", displayId, " (guid 0x", std::hex, guid, std::dec, ")"); + return; + } + + // Log spawns to help debug duplicate objects (e.g., cathedral issue) + LOG_DEBUG("GameObject spawn: displayId=", displayId, " guid=0x", std::hex, guid, std::dec, + " model=", modelPath, " pos=(", x, ", ", y, ", ", z, ")"); + + std::string lowerPath = modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; + + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + const float renderYawWmo = orientation; + // M2 game objects: model default faces +renderX. renderYaw = canonical + 90° = server_yaw + // (same offset as creature/character renderer_ so all M2 models face consistently) + const float renderYawM2go = orientation + glm::radians(90.0f); + + bool loadedAsWmo = false; + if (isWmo) { + auto* wmoRenderer = renderer_->getWMORenderer(); + if (!wmoRenderer) return; + + uint32_t modelId = 0; + auto itCache = gameObjectDisplayIdWmoCache_.find(displayId); + if (itCache != gameObjectDisplayIdWmoCache_.end()) { + modelId = itCache->second; + // Only use cached entry if the model is still resident in the renderer_ + if (wmoRenderer->isModelLoaded(modelId)) { + loadedAsWmo = true; + } else { + gameObjectDisplayIdWmoCache_.erase(itCache); + modelId = 0; + } + } + if (!loadedAsWmo && modelId == 0) { + auto wmoData = assetManager_->readFile(modelPath); + if (!wmoData.empty()) { + pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); + LOG_DEBUG("Gameobject WMO root loaded: ", modelPath, " nGroups=", wmoModel.nGroups); + int loadedGroups = 0; + if (wmoModel.nGroups > 0) { + std::string basePath = modelPath; + std::string extension; + if (basePath.size() > 4) { + extension = basePath.substr(basePath.size() - 4); + std::string extLower = extension; + for (char& c : extLower) c = static_cast(std::tolower(static_cast(c))); + if (extLower == ".wmo") { + basePath = basePath.substr(0, basePath.size() - 4); + } + } + + for (uint32_t gi = 0; gi < wmoModel.nGroups; gi++) { + char groupSuffix[16]; + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u%s", gi, extension.c_str()); + std::string groupPath = basePath + groupSuffix; + std::vector groupData = assetManager_->readFile(groupPath); + if (groupData.empty()) { + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.wmo", gi); + groupData = assetManager_->readFile(basePath + groupSuffix); + } + if (groupData.empty()) { + snprintf(groupSuffix, sizeof(groupSuffix), "_%03u.WMO", gi); + groupData = assetManager_->readFile(basePath + groupSuffix); + } + if (!groupData.empty()) { + pipeline::WMOLoader::loadGroup(groupData, wmoModel, gi); + loadedGroups++; + } else { + LOG_WARNING(" Failed to load WMO group ", gi, " for: ", basePath); + } + } + } + + if (loadedGroups > 0 || wmoModel.nGroups == 0) { + modelId = nextGameObjectWmoModelId_++; + if (wmoRenderer->loadModel(wmoModel, modelId)) { + gameObjectDisplayIdWmoCache_[displayId] = modelId; + loadedAsWmo = true; + } else { + LOG_WARNING("Failed to load gameobject WMO model: ", modelPath); + } + } else { + LOG_WARNING("No WMO groups loaded for gameobject: ", modelPath, + " — falling back to M2"); + } + } else { + LOG_WARNING("Failed to read gameobject WMO: ", modelPath, " — falling back to M2"); + } + } + + if (loadedAsWmo) { + uint32_t instanceId = wmoRenderer->createInstance(modelId, renderPos, + glm::vec3(0.0f, 0.0f, renderYawWmo), scale); + if (instanceId == 0) { + LOG_WARNING("Failed to create gameobject WMO instance for guid 0x", std::hex, guid, std::dec); + return; + } + + gameObjectInstances_[guid] = {modelId, instanceId, true}; + LOG_DEBUG("Spawned gameobject WMO: guid=0x", std::hex, guid, std::dec, + " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); + + // Spawn transport WMO doodads (chairs, furniture, etc.) as child M2 instances + bool isTransport = false; + if (gameHandler_) { + std::string lowerModelPath = modelPath; + std::transform(lowerModelPath.begin(), lowerModelPath.end(), lowerModelPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + isTransport = (lowerModelPath.find("transport") != std::string::npos); + } + + auto* m2Renderer = renderer_->getM2Renderer(); + if (m2Renderer && isTransport) { + const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); + if (doodadTemplates && !doodadTemplates->empty()) { + constexpr size_t kMaxTransportDoodads = 192; + const size_t doodadBudget = std::min(doodadTemplates->size(), kMaxTransportDoodads); + LOG_DEBUG("Queueing ", doodadBudget, "/", doodadTemplates->size(), + " transport doodads for WMO instance ", instanceId); + pendingTransportDoodadBatches_.push_back(PendingTransportDoodadBatch{ + guid, + modelId, + instanceId, + 0, + doodadBudget, + 0, + x, y, z, + orientation + }); + } else { + LOG_DEBUG("Transport WMO has no doodads or templates not available"); + } + } + + // Transport GameObjects are not always named "transport" in their WMO path + // (e.g. elevators/lifts). If the server marks it as a transport, always + // notify so TransportManager can animate/carry passengers. + bool isTG = gameHandler_ && gameHandler_->isTransportGuid(guid); + LOG_WARNING("WMO GO spawned: guid=0x", std::hex, guid, std::dec, + " entry=", entry, " displayId=", displayId, + " isTransport=", isTG, + " pos=(", x, ", ", y, ", ", z, ")"); + if (isTG) { + gameHandler_->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); + } + + return; + } + + // WMO failed — fall through to try as M2 + // Convert .wmo path to .m2 for fallback + modelPath = modelPath.substr(0, modelPath.size() - 4) + ".m2"; + } + + { + auto* m2Renderer = renderer_->getM2Renderer(); + if (!m2Renderer) return; + + // Skip displayIds that permanently failed to load (e.g. empty/unsupported M2s). + // Without this guard the same empty model is re-parsed every frame, causing + // sustained log spam and wasted CPU. + if (gameObjectDisplayIdFailedCache_.count(displayId)) return; + + uint32_t modelId = 0; + auto itCache = gameObjectDisplayIdModelCache_.find(displayId); + if (itCache != gameObjectDisplayIdModelCache_.end()) { + modelId = itCache->second; + if (!m2Renderer->hasModel(modelId)) { + LOG_WARNING("GO M2 cache hit but model gone: displayId=", displayId, + " modelId=", modelId, " path=", modelPath, + " — reloading"); + gameObjectDisplayIdModelCache_.erase(itCache); + itCache = gameObjectDisplayIdModelCache_.end(); + } + } + if (itCache == gameObjectDisplayIdModelCache_.end()) { + modelId = nextGameObjectModelId_++; + + auto m2Data = assetManager_->readFile(modelPath); + if (m2Data.empty()) { + LOG_WARNING("Failed to read gameobject M2: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); + return; + } + + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty()) { + LOG_WARNING("Failed to parse gameobject M2: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); + return; + } + + std::string skinPath = modelPath.substr(0, modelPath.size() - 3) + "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty() && model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, model); + } else if (skinData.empty() && model.version >= 264) { + LOG_WARNING("GO skin file MISSING for WotLK M2 (no indices/batches): ", skinPath); + } + + LOG_DEBUG("GO model: ", modelPath, " v=", model.version, + " verts=", model.vertices.size(), + " idx=", model.indices.size(), + " batches=", model.batches.size(), + " bones=", model.bones.size(), + " skin=", (skinData.empty() ? "MISSING" : "ok")); + + if (!m2Renderer->loadModel(model, modelId)) { + LOG_WARNING("Failed to load gameobject model: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); + return; + } + + gameObjectDisplayIdModelCache_[displayId] = modelId; + } + + uint32_t instanceId = m2Renderer->createInstance(modelId, renderPos, + glm::vec3(0.0f, 0.0f, renderYawM2go), scale); + if (instanceId == 0) { + LOG_WARNING("Failed to create gameobject instance for guid 0x", std::hex, guid, std::dec); + return; + } + + // Freeze animation for static gameobjects, but let portals/effects/transports animate + bool isTransportGO = gameHandler_ && gameHandler_->isTransportGuid(guid); + std::string lowerPath = modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + bool isAnimatedEffect = (lowerPath.find("instanceportal") != std::string::npos || + lowerPath.find("instancenewportal") != std::string::npos || + lowerPath.find("portalfx") != std::string::npos || + lowerPath.find("spellportal") != std::string::npos); + if (!isAnimatedEffect && !isTransportGO) { + // Check for totem idle animations — totems should animate, not freeze + bool isTotem = false; + if (m2Renderer->hasAnimation(instanceId, 245)) { // TOTEM_SMALL + m2Renderer->setInstanceAnimation(instanceId, 245, true); + isTotem = true; + } else if (m2Renderer->hasAnimation(instanceId, 246)) { // TOTEM_MEDIUM + m2Renderer->setInstanceAnimation(instanceId, 246, true); + isTotem = true; + } else if (m2Renderer->hasAnimation(instanceId, 247)) { // TOTEM_LARGE + m2Renderer->setInstanceAnimation(instanceId, 247, true); + isTotem = true; + } + if (!isTotem) { + m2Renderer->setInstanceAnimationFrozen(instanceId, true); + } + } + + gameObjectInstances_[guid] = {modelId, instanceId, false}; + + // Notify transport system for M2 transports (e.g. Deeprun Tram cars) + if (gameHandler_ && gameHandler_->isTransportGuid(guid)) { + LOG_WARNING("M2 transport spawned: guid=0x", std::hex, guid, std::dec, + " entry=", entry, " displayId=", displayId, + " instanceId=", instanceId); + gameHandler_->notifyTransportSpawned(guid, entry, displayId, x, y, z, orientation); + } + } + + LOG_DEBUG("Spawned gameobject: guid=0x", std::hex, guid, std::dec, + " displayId=", displayId, " at (", x, ", ", y, ", ", z, ")"); +} + +} // namespace core +} // namespace wowee diff --git a/src/core/entity_spawner_processing.cpp b/src/core/entity_spawner_processing.cpp new file mode 100644 index 00000000..fc8f4394 --- /dev/null +++ b/src/core/entity_spawner_processing.cpp @@ -0,0 +1,1597 @@ +#include "core/entity_spawner.hpp" +#include "core/coordinates.hpp" +#include "core/logger.hpp" +#include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/m2_renderer.hpp" +#include "audio/npc_voice_manager.hpp" +#include "pipeline/m2_loader.hpp" +#include "pipeline/wmo_loader.hpp" +#include "rendering/animation/animation_ids.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "game/game_handler.hpp" +#include "game/game_services.hpp" +#include "game/transport_manager.hpp" + +#include +#include +#include +#include +#include + +namespace wowee { +namespace core { + +void EntitySpawner::processAsyncCreatureResults(bool unlimited) { + // Check completed async model loads and finalize on main thread (GPU upload + instance creation). + // Limit GPU model uploads per tick to avoid long main-thread stalls that can starve socket updates. + // Even in unlimited mode (load screen), keep a small cap and budget to prevent multi-second stalls. + static constexpr int kMaxModelUploadsPerTick = 1; + static constexpr int kMaxModelUploadsPerTickWarmup = 1; + static constexpr float kFinalizeBudgetMs = 2.0f; + static constexpr float kFinalizeBudgetWarmupMs = 2.0f; + const int maxUploadsThisTick = unlimited ? kMaxModelUploadsPerTickWarmup : kMaxModelUploadsPerTick; + const float budgetMs = unlimited ? kFinalizeBudgetWarmupMs : kFinalizeBudgetMs; + const auto tickStart = std::chrono::steady_clock::now(); + int modelUploads = 0; + + for (auto it = asyncCreatureLoads_.begin(); it != asyncCreatureLoads_.end(); ) { + if (std::chrono::duration( + std::chrono::steady_clock::now() - tickStart).count() >= budgetMs) { + break; + } + + if (!it->future.valid() || + it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { + ++it; + continue; + } + + auto result = it->future.get(); + it = asyncCreatureLoads_.erase(it); + asyncCreatureDisplayLoads_.erase(result.displayId); + + // Failures and cache hits need no GPU work — process them even when the + // upload budget is exhausted. Previously the budget check was above this + // point, blocking ALL ready futures (including zero-cost ones) after a + // single upload, which throttled creature spawn throughput during world load. + if (result.permanent_failure) { + nonRenderableCreatureDisplayIds_.insert(result.displayId); + creaturePermanentFailureGuids_.insert(result.guid); + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + continue; + } + if (!result.valid || !result.model) { + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + continue; + } + + // Another async result may have already uploaded this displayId while this + // task was still running; in that case, skip duplicate GPU upload. + if (displayIdModelCache_.find(result.displayId) != displayIdModelCache_.end()) { + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + if (!creatureInstances_.count(result.guid) && + !creaturePermanentFailureGuids_.count(result.guid)) { + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; + s.y = result.y; + s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + } + continue; + } + + // Only actual GPU uploads count toward the per-tick budget. + if (modelUploads >= maxUploadsThisTick) { + // Re-queue this result — it needs a GPU upload but we're at budget. + // Push a new pending spawn so it's retried next frame. + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; s.y = result.y; s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + continue; + } + + // Model parsed on background thread — upload to GPU on main thread. + auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; + if (!charRenderer) { + pendingCreatureSpawnGuids_.erase(result.guid); + continue; + } + + // Count upload attempts toward the frame budget even if upload fails. + // Otherwise repeated failures can consume an unbounded amount of frame time. + modelUploads++; + + // Upload model to GPU (must happen on main thread) + // Use pre-decoded BLP cache to skip main-thread texture decode + auto uploadStart = std::chrono::steady_clock::now(); + charRenderer->setPredecodedBLPCache(&result.predecodedTextures); + if (!charRenderer->loadModel(*result.model, result.modelId)) { + charRenderer->setPredecodedBLPCache(nullptr); + nonRenderableCreatureDisplayIds_.insert(result.displayId); + creaturePermanentFailureGuids_.insert(result.guid); + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + continue; + } + charRenderer->setPredecodedBLPCache(nullptr); + { + auto uploadEnd = std::chrono::steady_clock::now(); + float uploadMs = std::chrono::duration(uploadEnd - uploadStart).count(); + if (uploadMs > 100.0f) { + LOG_WARNING("charRenderer->loadModel took ", uploadMs, "ms displayId=", result.displayId, + " preDecoded=", result.predecodedTextures.size()); + } + } + // Save remaining pre-decoded textures (display skins) for spawnOnlineCreature + if (!result.predecodedTextures.empty()) { + displayIdPredecodedTextures_[result.displayId] = std::move(result.predecodedTextures); + } + displayIdModelCache_[result.displayId] = result.modelId; + pendingCreatureSpawnGuids_.erase(result.guid); + creatureSpawnRetryCounts_.erase(result.guid); + + // Re-queue as a normal pending spawn — model is now cached, so sync spawn is fast + // (only creates instance + applies textures, no file I/O). + if (!creatureInstances_.count(result.guid) && + !creaturePermanentFailureGuids_.count(result.guid)) { + PendingCreatureSpawn s{}; + s.guid = result.guid; + s.displayId = result.displayId; + s.x = result.x; + s.y = result.y; + s.z = result.z; + s.orientation = result.orientation; + s.scale = result.scale; + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(result.guid); + } + } +} + +void EntitySpawner::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; + continue; + } + auto result = it->future.get(); + it = asyncNpcCompositeLoads_.erase(it); + + const auto& info = result.info; + + // Set pre-decoded cache so texture loads skip synchronous BLP decode + charRenderer->setPredecodedBLPCache(&result.predecodedTextures); + + // --- Apply skin to type-1 slots --- + rendering::VkTexture* skinTex = nullptr; + + if (info.hasBakedSkin) { + // Baked skin: load from pre-decoded cache + skinTex = charRenderer->loadTexture(info.bakedSkinPath); + } + + if (info.hasComposite) { + // Composite with face/underwear/equipment regions on top of base skin + rendering::VkTexture* compositeTex = nullptr; + if (!info.regionLayers.empty()) { + compositeTex = charRenderer->compositeWithRegions(info.basePath, + info.overlayPaths, info.regionLayers); + } else if (!info.overlayPaths.empty()) { + std::vector skinLayers; + skinLayers.push_back(info.basePath); + for (const auto& op : info.overlayPaths) skinLayers.push_back(op); + compositeTex = charRenderer->compositeTextures(skinLayers); + } + if (compositeTex) skinTex = compositeTex; + } else if (info.hasSimpleSkin) { + // Simple skin: just base texture, no compositing + auto* baseTex = charRenderer->loadTexture(info.basePath); + if (baseTex) skinTex = baseTex; + } + + if (skinTex) { + for (uint32_t slot : info.skinTextureSlots) { + charRenderer->setModelTexture(info.modelId, slot, skinTex); + } + } + + // --- Apply hair texture to type-6 slots --- + if (!info.hairTexturePath.empty()) { + rendering::VkTexture* hairTex = charRenderer->loadTexture(info.hairTexturePath); + rendering::VkTexture* whTex = charRenderer->loadTexture(""); + if (hairTex && hairTex != whTex) { + for (uint32_t slot : info.hairTextureSlots) { + charRenderer->setModelTexture(info.modelId, slot, hairTex); + } + } + } else if (info.useBakedForHair && skinTex) { + // Bald NPC: use skin/baked texture for scalp cap + for (uint32_t slot : info.hairTextureSlots) { + charRenderer->setModelTexture(info.modelId, slot, skinTex); + } + } + + charRenderer->setPredecodedBLPCache(nullptr); + } +} + +void EntitySpawner::processCreatureSpawnQueue(bool unlimited) { + auto startTime = std::chrono::steady_clock::now(); + // Budget: max 2ms per frame for creature spawning to prevent stutter. + // In unlimited mode (load screen), process everything without budget cap. + static constexpr float kSpawnBudgetMs = 2.0f; + + // First, finalize any async model loads that completed on background threads. + processAsyncCreatureResults(unlimited); + { + auto now = std::chrono::steady_clock::now(); + float asyncMs = std::chrono::duration(now - startTime).count(); + if (asyncMs > 100.0f) { + LOG_WARNING("processAsyncCreatureResults took ", asyncMs, "ms"); + } + } + + if (pendingCreatureSpawns_.empty()) return; + if (!creatureLookupsBuilt_) { + buildCreatureDisplayLookups(); + if (!creatureLookupsBuilt_) return; + } + + int processed = 0; + int asyncLaunched = 0; + size_t rotationsLeft = pendingCreatureSpawns_.size(); + while (!pendingCreatureSpawns_.empty() && + (unlimited || processed < MAX_SPAWNS_PER_FRAME) && + rotationsLeft > 0) { + // Check time budget every iteration (including first — async results may + // have already consumed the budget via GPU model uploads). + if (!unlimited) { + auto now = std::chrono::steady_clock::now(); + float elapsedMs = std::chrono::duration(now - startTime).count(); + if (elapsedMs >= kSpawnBudgetMs) break; + } + + PendingCreatureSpawn s = pendingCreatureSpawns_.front(); + pendingCreatureSpawns_.pop_front(); + + if (nonRenderableCreatureDisplayIds_.count(s.displayId)) { + pendingCreatureSpawnGuids_.erase(s.guid); + creatureSpawnRetryCounts_.erase(s.guid); + processed++; + rotationsLeft = pendingCreatureSpawns_.size(); + continue; + } + + const bool needsNewModel = (displayIdModelCache_.find(s.displayId) == displayIdModelCache_.end()); + + // For new models: launch async load on background thread instead of blocking. + if (needsNewModel) { + // Keep exactly one background load per displayId. Additional spawns for + // the same displayId stay queued and will spawn once cache is populated. + if (asyncCreatureDisplayLoads_.count(s.displayId)) { + pendingCreatureSpawns_.push_back(s); + rotationsLeft--; + continue; + } + + const int maxAsync = unlimited ? (MAX_ASYNC_CREATURE_LOADS * 4) : MAX_ASYNC_CREATURE_LOADS; + if (static_cast(asyncCreatureLoads_.size()) + asyncLaunched >= maxAsync) { + // Too many in-flight — defer to next frame + pendingCreatureSpawns_.push_back(s); + rotationsLeft--; + continue; + } + + std::string m2Path = getModelPathForDisplayId(s.displayId); + if (m2Path.empty()) { + nonRenderableCreatureDisplayIds_.insert(s.displayId); + creaturePermanentFailureGuids_.insert(s.guid); + pendingCreatureSpawnGuids_.erase(s.guid); + creatureSpawnRetryCounts_.erase(s.guid); + processed++; + rotationsLeft = pendingCreatureSpawns_.size(); + continue; + } + + // Check for invisible stalkers + { + std::string lowerPath = m2Path; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerPath.find("invisiblestalker") != std::string::npos || + lowerPath.find("invisible_stalker") != std::string::npos) { + nonRenderableCreatureDisplayIds_.insert(s.displayId); + creaturePermanentFailureGuids_.insert(s.guid); + pendingCreatureSpawnGuids_.erase(s.guid); + processed++; + rotationsLeft = pendingCreatureSpawns_.size(); + continue; + } + } + + // Launch async M2 load — file I/O and parsing happen off the main thread. + uint32_t modelId = nextCreatureModelId_++; + auto* am = assetManager_; + + // Collect display skin texture paths for background pre-decode + std::vector displaySkinPaths; + { + auto itDD = displayDataMap_.find(s.displayId); + if (itDD != displayDataMap_.end()) { + std::string modelDir; + size_t lastSlash = m2Path.find_last_of("\\/"); + if (lastSlash != std::string::npos) modelDir = m2Path.substr(0, lastSlash + 1); + + auto resolveForAsync = [&](const std::string& skinField) { + if (skinField.empty()) return; + std::string raw = skinField; + std::replace(raw.begin(), raw.end(), '/', '\\'); + while (!raw.empty() && std::isspace(static_cast(raw.front()))) raw.erase(raw.begin()); + while (!raw.empty() && std::isspace(static_cast(raw.back()))) raw.pop_back(); + if (raw.empty()) return; + bool hasExt = raw.size() >= 4 && raw.substr(raw.size()-4) == ".blp"; + bool hasDir = raw.find('\\') != std::string::npos; + std::vector candidates; + if (hasDir) { + candidates.push_back(raw); + if (!hasExt) candidates.push_back(raw + ".blp"); + } else { + candidates.push_back(modelDir + raw); + if (!hasExt) candidates.push_back(modelDir + raw + ".blp"); + candidates.push_back(raw); + if (!hasExt) candidates.push_back(raw + ".blp"); + } + for (const auto& c : candidates) { + if (am->fileExists(c)) { displaySkinPaths.push_back(c); return; } + } + }; + resolveForAsync(itDD->second.skin1); + resolveForAsync(itDD->second.skin2); + resolveForAsync(itDD->second.skin3); + + // Pre-decode humanoid NPC textures (bake, skin, face, underwear, hair, equipment) + if (itDD->second.extraDisplayId != 0) { + auto itHE = humanoidExtraMap_.find(itDD->second.extraDisplayId); + if (itHE != humanoidExtraMap_.end()) { + const auto& he = itHE->second; + // Baked texture + if (!he.bakeName.empty()) { + displaySkinPaths.push_back("Textures\\BakedNpcTextures\\" + he.bakeName); + } + // CharSections: skin, face, underwear + auto csDbc = am->loadDBC("CharSections.dbc"); + if (csDbc) { + const auto* csL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("CharSections") : nullptr; + auto csF = pipeline::detectCharSectionsFields(csDbc.get(), csL); + uint32_t nRace = static_cast(he.raceId); + uint32_t nSex = static_cast(he.sexId); + uint32_t nSkin = static_cast(he.skinId); + uint32_t nFace = static_cast(he.faceId); + for (uint32_t r = 0; r < csDbc->getRecordCount(); r++) { + uint32_t rId = csDbc->getUInt32(r, csF.raceId); + uint32_t sId = csDbc->getUInt32(r, csF.sexId); + if (rId != nRace || sId != nSex) continue; + uint32_t section = csDbc->getUInt32(r, csF.baseSection); + uint32_t variation = csDbc->getUInt32(r, csF.variationIndex); + uint32_t color = csDbc->getUInt32(r, csF.colorIndex); + if (section == 0 && color == nSkin) { + std::string t = csDbc->getString(r, csF.texture1); + if (!t.empty()) displaySkinPaths.push_back(t); + } else if (section == 1 && variation == nFace && color == nSkin) { + std::string t1 = csDbc->getString(r, csF.texture1); + std::string t2 = csDbc->getString(r, csF.texture2); + if (!t1.empty()) displaySkinPaths.push_back(t1); + if (!t2.empty()) displaySkinPaths.push_back(t2); + } else if (section == 3 && variation == static_cast(he.hairStyleId) + && color == static_cast(he.hairColorId)) { + std::string t = csDbc->getString(r, csF.texture1); + if (!t.empty()) displaySkinPaths.push_back(t); + } else if (section == 4 && color == nSkin) { + for (uint32_t f = csF.texture1; f <= csF.texture1 + 2; f++) { + std::string t = csDbc->getString(r, f); + if (!t.empty()) displaySkinPaths.push_back(t); + } + } + } + } + // Equipment region textures + auto idiDbc = am->loadDBC("ItemDisplayInfo.dbc"); + if (idiDbc) { + static constexpr const char* compDirs[] = { + "ArmUpperTexture", "ArmLowerTexture", "HandTexture", + "TorsoUpperTexture", "TorsoLowerTexture", + "LegUpperTexture", "LegLowerTexture", "FootTexture", + }; + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + const uint32_t trf[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, + }; + const bool isFem = (he.sexId == 1); + for (int eq = 0; eq < 11; eq++) { + uint32_t did = he.equipDisplayId[eq]; + if (did == 0) continue; + int32_t recIdx = idiDbc->findRecordById(did); + if (recIdx < 0) continue; + for (int region = 0; region < 8; region++) { + std::string texName = idiDbc->getString(static_cast(recIdx), trf[region]); + if (texName.empty()) continue; + std::string base = "Item\\TextureComponents\\" + + std::string(compDirs[region]) + "\\" + texName; + std::string gp = base + (isFem ? "_F.blp" : "_M.blp"); + std::string up = base + "_U.blp"; + if (am->fileExists(gp)) displaySkinPaths.push_back(gp); + else if (am->fileExists(up)) displaySkinPaths.push_back(up); + else displaySkinPaths.push_back(base + ".blp"); + } + } + } + } + } + } + } + + AsyncCreatureLoad load; + load.future = std::async(std::launch::async, + [am, m2Path, modelId, s, skinPaths = std::move(displaySkinPaths)]() -> PreparedCreatureModel { + PreparedCreatureModel result; + result.guid = s.guid; + result.displayId = s.displayId; + result.modelId = modelId; + result.x = s.x; + result.y = s.y; + result.z = s.z; + result.orientation = s.orientation; + result.scale = s.scale; + + auto m2Data = am->readFile(m2Path); + if (m2Data.empty()) { + result.permanent_failure = true; + return result; + } + + auto model = std::make_shared(pipeline::M2Loader::load(m2Data)); + if (model->vertices.empty()) { + result.permanent_failure = true; + return result; + } + + // Load skin file + if (model->version >= 264) { + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + auto skinData = am->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, *model); + } + } + + // Load external .anim files + std::string basePath = m2Path.substr(0, m2Path.size() - 3); + for (uint32_t si = 0; si < model->sequences.size(); si++) { + if (!(model->sequences[si].flags & 0x20)) { + char animFileName[256]; + snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", + basePath.c_str(), model->sequences[si].id, model->sequences[si].variationIndex); + auto animData = am->readFileOptional(animFileName); + if (!animData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animData, si, *model); + } + } + } + + // Pre-decode model textures on background thread + for (const auto& tex : model->textures) { + if (tex.filename.empty()) continue; + std::string texKey = tex.filename; + std::replace(texKey.begin(), texKey.end(), '/', '\\'); + std::transform(texKey.begin(), texKey.end(), texKey.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; + auto blp = am->loadTexture(texKey); + if (blp.isValid()) { + result.predecodedTextures[texKey] = std::move(blp); + } + } + + // Pre-decode display skin textures (skin1/skin2/skin3 from CreatureDisplayInfo) + for (const auto& sp : skinPaths) { + std::string key = sp; + std::replace(key.begin(), key.end(), '/', '\\'); + std::transform(key.begin(), key.end(), key.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (result.predecodedTextures.count(key)) continue; + auto blp = am->loadTexture(key); + if (blp.isValid()) { + result.predecodedTextures[key] = std::move(blp); + } + } + + result.model = std::move(model); + result.valid = true; + return result; + }); + asyncCreatureLoads_.push_back(std::move(load)); + asyncCreatureDisplayLoads_.insert(s.displayId); + asyncLaunched++; + // Don't erase from pendingCreatureSpawnGuids_ — the async result handler will do it + rotationsLeft = pendingCreatureSpawns_.size(); + processed++; + continue; + } + + // Cached model — spawn is fast (no file I/O, just instance creation + texture setup) + { + auto spawnStart = std::chrono::steady_clock::now(); + spawnOnlineCreature(s.guid, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); + auto spawnEnd = std::chrono::steady_clock::now(); + float spawnMs = std::chrono::duration(spawnEnd - spawnStart).count(); + if (spawnMs > 100.0f) { + LOG_WARNING("spawnOnlineCreature took ", spawnMs, "ms displayId=", s.displayId); + } + } + pendingCreatureSpawnGuids_.erase(s.guid); + + // If spawn still failed, retry for a limited number of frames. + if (!creatureInstances_.count(s.guid)) { + if (creaturePermanentFailureGuids_.erase(s.guid) > 0) { + creatureSpawnRetryCounts_.erase(s.guid); + processed++; + continue; + } + uint16_t retries = 0; + auto it = creatureSpawnRetryCounts_.find(s.guid); + if (it != creatureSpawnRetryCounts_.end()) { + retries = it->second; + } + if (retries < MAX_CREATURE_SPAWN_RETRIES) { + creatureSpawnRetryCounts_[s.guid] = static_cast(retries + 1); + pendingCreatureSpawns_.push_back(s); + pendingCreatureSpawnGuids_.insert(s.guid); + } else { + creatureSpawnRetryCounts_.erase(s.guid); + LOG_WARNING("Dropping creature spawn after retries: guid=0x", std::hex, s.guid, std::dec, + " displayId=", s.displayId); + } + } else { + creatureSpawnRetryCounts_.erase(s.guid); + } + rotationsLeft = pendingCreatureSpawns_.size(); + processed++; + } +} + +void EntitySpawner::processPlayerSpawnQueue() { + if (pendingPlayerSpawns_.empty()) return; + if (!assetManager_ || !assetManager_->isInitialized()) return; + + int processed = 0; + while (!pendingPlayerSpawns_.empty() && processed < MAX_SPAWNS_PER_FRAME) { + PendingPlayerSpawn s = pendingPlayerSpawns_.front(); + pendingPlayerSpawns_.erase(pendingPlayerSpawns_.begin()); + pendingPlayerSpawnGuids_.erase(s.guid); + + // Skip if already spawned (could have been spawned by a previous update this frame) + if (playerInstances_.count(s.guid)) { + processed++; + continue; + } + + spawnOnlinePlayer(s.guid, s.raceId, s.genderId, s.appearanceBytes, s.facialFeatures, s.x, s.y, s.z, s.orientation); + // Apply any equipment updates that arrived before the player was spawned. + auto pit = pendingOnlinePlayerEquipment_.find(s.guid); + if (pit != pendingOnlinePlayerEquipment_.end()) { + deferredEquipmentQueue_.push_back({s.guid, pit->second}); + pendingOnlinePlayerEquipment_.erase(pit); + } + processed++; + } +} + +std::vector EntitySpawner::resolveEquipmentTexturePaths(uint64_t guid, + const std::array& displayInfoIds, + const std::array& /*inventoryTypes*/) const { + std::vector paths; + + auto it = onlinePlayerAppearance_.find(guid); + if (it == onlinePlayerAppearance_.end()) return paths; + const OnlinePlayerAppearanceState& st = it->second; + + // Add base skin + underwear paths + if (!st.bodySkinPath.empty()) paths.push_back(st.bodySkinPath); + for (const auto& up : st.underwearPaths) { + if (!up.empty()) paths.push_back(up); + } + + // Resolve equipment region texture paths (same logic as setOnlinePlayerEquipment) + auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc) return paths; + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + + static constexpr const char* componentDirs[] = { + "ArmUpperTexture", "ArmLowerTexture", "HandTexture", + "TorsoUpperTexture", "TorsoLowerTexture", + "LegUpperTexture", "LegLowerTexture", "FootTexture", + }; + uint32_t texRegionFields[8]; + pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields); + const bool isFemale = (st.genderId == 1); + + for (int s = 0; s < 19; s++) { + uint32_t did = displayInfoIds[s]; + if (did == 0) continue; + int32_t recIdx = displayInfoDbc->findRecordById(did); + if (recIdx < 0) continue; + for (int region = 0; region < 8; region++) { + std::string texName = displayInfoDbc->getString( + static_cast(recIdx), texRegionFields[region]); + if (texName.empty()) continue; + std::string base = "Item\\TextureComponents\\" + + std::string(componentDirs[region]) + "\\" + texName; + std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); + std::string unisexPath = base + "_U.blp"; + if (assetManager_->fileExists(genderPath)) paths.push_back(genderPath); + else if (assetManager_->fileExists(unisexPath)) paths.push_back(unisexPath); + else paths.push_back(base + ".blp"); + } + } + return paths; +} + +void EntitySpawner::processAsyncEquipmentResults() { + for (auto it = asyncEquipmentLoads_.begin(); it != asyncEquipmentLoads_.end(); ) { + if (!it->future.valid() || + it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { + ++it; + continue; + } + auto result = it->future.get(); + it = asyncEquipmentLoads_.erase(it); + + auto* charRenderer = renderer_ ? renderer_->getCharacterRenderer() : nullptr; + if (!charRenderer) continue; + + // Set pre-decoded cache so compositeWithRegions skips synchronous BLP decode + charRenderer->setPredecodedBLPCache(&result.predecodedTextures); + setOnlinePlayerEquipment(result.guid, result.displayInfoIds, result.inventoryTypes); + charRenderer->setPredecodedBLPCache(nullptr); + } +} + +void EntitySpawner::processDeferredEquipmentQueue() { + // First, finalize any completed async pre-decodes + processAsyncEquipmentResults(); + + if (deferredEquipmentQueue_.empty()) return; + // Limit in-flight async equipment loads + if (asyncEquipmentLoads_.size() >= 2) return; + + auto [guid, equipData] = deferredEquipmentQueue_.front(); + deferredEquipmentQueue_.erase(deferredEquipmentQueue_.begin()); + + // Resolve all texture paths that compositeWithRegions will need + auto texturePaths = resolveEquipmentTexturePaths(guid, equipData.first, equipData.second); + + if (texturePaths.empty()) { + // No textures to pre-decode — just apply directly (fast path) + LOG_WARNING("Equipment fast path for guid=0x", std::hex, guid, std::dec, + " (no textures to pre-decode)"); + setOnlinePlayerEquipment(guid, equipData.first, equipData.second); + return; + } + LOG_WARNING("Equipment async pre-decode for guid=0x", std::hex, guid, std::dec, + " textures=", texturePaths.size()); + + // Launch background BLP pre-decode + auto* am = assetManager_; + auto displayInfoIds = equipData.first; + auto inventoryTypes = equipData.second; + AsyncEquipmentLoad load; + load.future = std::async(std::launch::async, + [am, guid, displayInfoIds, inventoryTypes, paths = std::move(texturePaths)]() -> PreparedEquipmentUpdate { + PreparedEquipmentUpdate result; + result.guid = guid; + result.displayInfoIds = displayInfoIds; + result.inventoryTypes = inventoryTypes; + for (const auto& path : paths) { + std::string key = path; + std::replace(key.begin(), key.end(), '/', '\\'); + std::transform(key.begin(), key.end(), key.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (result.predecodedTextures.count(key)) continue; + auto blp = am->loadTexture(key); + if (blp.isValid()) { + result.predecodedTextures[key] = std::move(blp); + } + } + return result; + }); + asyncEquipmentLoads_.push_back(std::move(load)); +} + +void EntitySpawner::processAsyncGameObjectResults() { + for (auto it = asyncGameObjectLoads_.begin(); it != asyncGameObjectLoads_.end(); ) { + if (!it->future.valid() || + it->future.wait_for(std::chrono::milliseconds(0)) != std::future_status::ready) { + ++it; + continue; + } + + auto result = it->future.get(); + it = asyncGameObjectLoads_.erase(it); + + if (!result.valid || !result.isWmo || !result.wmoModel) { + // Fallback: spawn via sync path (likely an M2 or failed WMO) + spawnOnlineGameObject(result.guid, result.entry, result.displayId, + result.x, result.y, result.z, result.orientation, result.scale); + continue; + } + + // WMO parsed on background thread — do GPU upload + instance creation on main thread + auto* wmoRenderer = renderer_ ? renderer_->getWMORenderer() : nullptr; + if (!wmoRenderer) continue; + + uint32_t modelId = 0; + auto itCache = gameObjectDisplayIdWmoCache_.find(result.displayId); + if (itCache != gameObjectDisplayIdWmoCache_.end()) { + modelId = itCache->second; + } else { + modelId = nextGameObjectWmoModelId_++; + wmoRenderer->setPredecodedBLPCache(&result.predecodedTextures); + if (!wmoRenderer->loadModel(*result.wmoModel, modelId)) { + wmoRenderer->setPredecodedBLPCache(nullptr); + LOG_WARNING("Failed to load async gameobject WMO: ", result.modelPath); + continue; + } + wmoRenderer->setPredecodedBLPCache(nullptr); + gameObjectDisplayIdWmoCache_[result.displayId] = modelId; + } + + glm::vec3 renderPos = core::coords::canonicalToRender( + glm::vec3(result.x, result.y, result.z)); + uint32_t instanceId = wmoRenderer->createInstance( + modelId, renderPos, glm::vec3(0.0f, 0.0f, result.orientation), result.scale); + if (instanceId == 0) continue; + + gameObjectInstances_[result.guid] = {modelId, instanceId, true}; + + // Queue transport doodad loading if applicable + std::string lowerPath = result.modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerPath.find("transport") != std::string::npos) { + const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(modelId); + if (doodadTemplates && !doodadTemplates->empty()) { + PendingTransportDoodadBatch batch; + batch.guid = result.guid; + batch.modelId = modelId; + batch.instanceId = instanceId; + batch.x = result.x; + batch.y = result.y; + batch.z = result.z; + batch.orientation = result.orientation; + batch.doodadBudget = doodadTemplates->size(); + pendingTransportDoodadBatches_.push_back(batch); + } + } + } +} + +void EntitySpawner::processGameObjectSpawnQueue() { + // Finalize any completed async WMO loads first + processAsyncGameObjectResults(); + + if (pendingGameObjectSpawns_.empty()) return; + + static int goQueueLogCounter = 0; + if (++goQueueLogCounter % 60 == 1) { + LOG_DEBUG("GO queue: ", pendingGameObjectSpawns_.size(), " pending, ", + gameObjectInstances_.size(), " spawned, ", + gameObjectDisplayIdFailedCache_.size(), " failed"); + } + + // Process spawns: cached WMOs and M2s go sync (cheap), uncached WMOs go async + auto startTime = std::chrono::steady_clock::now(); + static constexpr float kBudgetMs = 2.0f; + static constexpr int kMaxAsyncLoads = 2; + + while (!pendingGameObjectSpawns_.empty()) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kBudgetMs) break; + + auto& s = pendingGameObjectSpawns_.front(); + + // Check if this is an uncached WMO that needs async loading + std::string modelPath; + if (gameObjectLookupsBuilt_) { + // Check transport overrides first + bool isTransport = gameHandler_ && gameHandler_->isTransportGuid(s.guid); + if (isTransport) { + if (s.entry == 20808 || s.entry == 176231 || s.entry == 176310) + modelPath = "World\\wmo\\transports\\transport_ship\\transportship.wmo"; + else if (s.displayId == 807 || s.displayId == 808 || s.displayId == 175080 || s.displayId == 176495 || s.displayId == 164871) + modelPath = "World\\wmo\\transports\\transport_zeppelin\\transport_zeppelin.wmo"; + else if (s.displayId == 1587) + modelPath = "World\\wmo\\transports\\transport_horde_zeppelin\\Transport_Horde_Zeppelin.wmo"; + else if (s.displayId == 2454 || s.displayId == 181688 || s.displayId == 190536) + modelPath = "World\\wmo\\transports\\icebreaker\\Transport_Icebreaker_ship.wmo"; + } + if (modelPath.empty()) + modelPath = getGameObjectModelPathForDisplayId(s.displayId); + } + + std::string lowerPath = modelPath; + std::transform(lowerPath.begin(), lowerPath.end(), lowerPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + bool isWmo = lowerPath.size() >= 4 && lowerPath.substr(lowerPath.size() - 4) == ".wmo"; + bool isCached = isWmo && gameObjectDisplayIdWmoCache_.count(s.displayId); + + if (isWmo && !isCached && !modelPath.empty() && + static_cast(asyncGameObjectLoads_.size()) < kMaxAsyncLoads) { + // Launch async WMO load — file I/O + parse on background thread + auto* am = assetManager_; + PendingGameObjectSpawn capture = s; + std::string capturePath = modelPath; + AsyncGameObjectLoad load; + load.future = std::async(std::launch::async, + [am, capture, capturePath]() -> PreparedGameObjectWMO { + PreparedGameObjectWMO result; + result.guid = capture.guid; + result.entry = capture.entry; + result.displayId = capture.displayId; + result.x = capture.x; + result.y = capture.y; + result.z = capture.z; + result.orientation = capture.orientation; + result.scale = capture.scale; + result.modelPath = capturePath; + result.isWmo = true; + + auto wmoData = am->readFile(capturePath); + if (wmoData.empty()) return result; + + auto wmo = std::make_shared( + pipeline::WMOLoader::load(wmoData)); + + // Load groups + if (wmo->nGroups > 0) { + std::string basePath = capturePath; + std::string ext; + if (basePath.size() > 4) { + ext = basePath.substr(basePath.size() - 4); + basePath = basePath.substr(0, basePath.size() - 4); + } + for (uint32_t gi = 0; gi < wmo->nGroups; gi++) { + char suffix[16]; + snprintf(suffix, sizeof(suffix), "_%03u%s", gi, ext.c_str()); + auto groupData = am->readFile(basePath + suffix); + if (groupData.empty()) { + snprintf(suffix, sizeof(suffix), "_%03u.wmo", gi); + groupData = am->readFile(basePath + suffix); + } + if (!groupData.empty()) { + pipeline::WMOLoader::loadGroup(groupData, *wmo, gi); + } + } + } + + // Pre-decode WMO textures on background thread + for (const auto& texPath : wmo->textures) { + if (texPath.empty()) continue; + std::string texKey = texPath; + size_t nul = texKey.find('\0'); + if (nul != std::string::npos) texKey.resize(nul); + std::replace(texKey.begin(), texKey.end(), '/', '\\'); + std::transform(texKey.begin(), texKey.end(), texKey.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (texKey.empty()) continue; + // Convert to .blp extension + if (texKey.size() >= 4) { + std::string ext = texKey.substr(texKey.size() - 4); + if (ext == ".tga" || ext == ".dds") { + texKey = texKey.substr(0, texKey.size() - 4) + ".blp"; + } + } + if (result.predecodedTextures.find(texKey) != result.predecodedTextures.end()) continue; + auto blp = am->loadTexture(texKey); + if (blp.isValid()) { + result.predecodedTextures[texKey] = std::move(blp); + } + } + + result.wmoModel = wmo; + result.valid = true; + return result; + }); + asyncGameObjectLoads_.push_back(std::move(load)); + pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); + continue; + } + + // Cached WMO or M2 — spawn synchronously (cheap) + spawnOnlineGameObject(s.guid, s.entry, s.displayId, s.x, s.y, s.z, s.orientation, s.scale); + pendingGameObjectSpawns_.erase(pendingGameObjectSpawns_.begin()); + } +} + +void EntitySpawner::processPendingTransportRegistrations() { + if (pendingTransportRegistrations_.empty()) return; + if (!gameHandler_ || !renderer_) return; + + auto* transportManager = gameHandler_->getTransportManager(); + if (!transportManager) return; + + auto startTime = std::chrono::steady_clock::now(); + static constexpr int kMaxRegistrationsPerFrame = 2; + static constexpr float kRegistrationBudgetMs = 2.0f; + int processed = 0; + + for (auto it = pendingTransportRegistrations_.begin(); + it != pendingTransportRegistrations_.end() && processed < kMaxRegistrationsPerFrame;) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kRegistrationBudgetMs) break; + + const PendingTransportRegistration pending = *it; + auto goIt = gameObjectInstances_.find(pending.guid); + if (goIt == gameObjectInstances_.end()) { + it = pendingTransportRegistrations_.erase(it); + continue; + } + + if (transportManager->getTransport(pending.guid)) { + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + it = pendingTransportRegistrations_.erase(it); + continue; + } + + const uint32_t wmoInstanceId = goIt->second.instanceId; + LOG_WARNING("Registering server transport: GUID=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, " wmoInstance=", wmoInstanceId, + " pos=(", pending.x, ", ", pending.y, ", ", pending.z, ")"); + + // TransportAnimation.dbc is indexed by GameObject entry. + uint32_t pathId = pending.entry; + const bool preferServerData = gameHandler_->hasServerTransportUpdate(pending.guid); + + bool clientAnim = transportManager->isClientSideAnimation(); + LOG_DEBUG("Transport spawn callback: clientAnimation=", clientAnim, + " guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " pathId=", pathId, + " preferServer=", preferServerData); + + glm::vec3 canonicalSpawnPos(pending.x, pending.y, pending.z); + const bool shipOrZeppelinDisplay = + (pending.displayId == 3015 || pending.displayId == 3031 || pending.displayId == 7546 || + pending.displayId == 7446 || pending.displayId == 1587 || pending.displayId == 2454 || + pending.displayId == 807 || pending.displayId == 808); + bool hasUsablePath = transportManager->hasPathForEntry(pending.entry); + if (shipOrZeppelinDisplay) { + hasUsablePath = transportManager->hasUsableMovingPathForEntry(pending.entry, 25.0f); + } + + LOG_WARNING("Transport path check: entry=", pending.entry, " hasUsablePath=", hasUsablePath, + " preferServerData=", preferServerData, " shipOrZepDisplay=", shipOrZeppelinDisplay); + + if (preferServerData) { + if (!hasUsablePath) { + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + LOG_WARNING("Server-first strict registration: stationary fallback for GUID 0x", + std::hex, pending.guid, std::dec, " entry=", pending.entry); + } else { + LOG_WARNING("Server-first transport registration: using entry DBC path for entry ", pending.entry); + } + } else if (!hasUsablePath) { + bool allowZOnly = (pending.displayId == 455 || pending.displayId == 462); + uint32_t inferredPath = transportManager->inferDbcPathForSpawn( + canonicalSpawnPos, 1200.0f, allowZOnly); + if (inferredPath != 0) { + pathId = inferredPath; + LOG_WARNING("Using inferred transport path ", pathId, " for entry ", pending.entry); + } else { + uint32_t remappedPath = transportManager->pickFallbackMovingPath(pending.entry, pending.displayId); + if (remappedPath != 0) { + pathId = remappedPath; + LOG_WARNING("Using remapped fallback transport path ", pathId, + " for entry ", pending.entry, " displayId=", pending.displayId, + " (usableEntryPath=", transportManager->hasPathForEntry(pending.entry), ")"); + } else { + LOG_WARNING("No TransportAnimation.dbc path for entry ", pending.entry, + " - transport will be stationary"); + std::vector path = { canonicalSpawnPos }; + transportManager->loadPathFromNodes(pathId, path, false, 0.0f); + } + } + } else { + LOG_WARNING("Using real transport path from TransportAnimation.dbc for entry ", pending.entry); + } + + transportManager->registerTransport(pending.guid, wmoInstanceId, pathId, canonicalSpawnPos, pending.entry); + + if (!goIt->second.isWmo) { + if (auto* tr = transportManager->getTransport(pending.guid)) { + tr->isM2 = true; + } + } + + transportManager->updateServerTransport( + pending.guid, glm::vec3(pending.x, pending.y, pending.z), pending.orientation); + + auto moveIt = pendingTransportMoves_.find(pending.guid); + if (moveIt != pendingTransportMoves_.end()) { + const PendingTransportMove latestMove = moveIt->second; + transportManager->updateServerTransport( + pending.guid, glm::vec3(latestMove.x, latestMove.y, latestMove.z), latestMove.orientation); + LOG_DEBUG("Replayed queued transport move for GUID=0x", std::hex, pending.guid, std::dec, + " pos=(", latestMove.x, ", ", latestMove.y, ", ", latestMove.z, + ") orientation=", latestMove.orientation); + pendingTransportMoves_.erase(moveIt); + } + + if (glm::dot(canonicalSpawnPos, canonicalSpawnPos) < 1.0f) { + auto goData = gameHandler_->getCachedGameObjectInfo(pending.entry); + if (goData && goData->type == 15 && goData->hasData && goData->data[0] != 0) { + uint32_t taxiPathId = goData->data[0]; + if (transportManager->hasTaxiPath(taxiPathId)) { + transportManager->assignTaxiPathToTransport(pending.entry, taxiPathId); + LOG_DEBUG("Assigned cached TaxiPathNode path for MO_TRANSPORT entry=", pending.entry, + " taxiPathId=", taxiPathId); + } + } + } + + if (auto* tr = transportManager->getTransport(pending.guid); tr) { + LOG_WARNING("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " pathId=", tr->pathId, + " mode=", (tr->useClientAnimation ? "client" : "server"), + " serverUpdates=", tr->serverUpdateCount); + } else { + LOG_DEBUG("Transport registered: guid=0x", std::hex, pending.guid, std::dec, + " entry=", pending.entry, " displayId=", pending.displayId, + " (TransportManager instance missing)"); + } + + ++processed; + it = pendingTransportRegistrations_.erase(it); + } +} + +void EntitySpawner::processPendingTransportDoodads() { + if (pendingTransportDoodadBatches_.empty()) return; + if (!renderer_ || !assetManager_) return; + + auto* wmoRenderer = renderer_->getWMORenderer(); + auto* m2Renderer = renderer_->getM2Renderer(); + if (!wmoRenderer || !m2Renderer) return; + + auto startTime = std::chrono::steady_clock::now(); + static constexpr float kDoodadBudgetMs = 4.0f; + + // Batch all GPU uploads into a single async command buffer submission so that + // N doodads with multiple textures each don't each block on vkQueueSubmit + + // vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds + // of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST. + auto* vkCtx = renderer_->getVkContext(); + if (vkCtx) vkCtx->beginUploadBatch(); + + size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME; + for (auto it = pendingTransportDoodadBatches_.begin(); + it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) { + // Time budget check + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (elapsedMs >= kDoodadBudgetMs) break; + auto goIt = gameObjectInstances_.find(it->guid); + if (goIt == gameObjectInstances_.end() || !goIt->second.isWmo || + goIt->second.instanceId != it->instanceId || goIt->second.modelId != it->modelId) { + it = pendingTransportDoodadBatches_.erase(it); + continue; + } + + const auto* doodadTemplates = wmoRenderer->getDoodadTemplates(it->modelId); + if (!doodadTemplates || doodadTemplates->empty()) { + it = pendingTransportDoodadBatches_.erase(it); + continue; + } + + const size_t maxIndex = std::min(it->doodadBudget, doodadTemplates->size()); + while (it->nextIndex < maxIndex && budgetLeft > 0) { + // Per-doodad time budget (each does synchronous file I/O + parse + GPU upload) + float innerMs = std::chrono::duration( + std::chrono::steady_clock::now() - startTime).count(); + if (innerMs >= kDoodadBudgetMs) { budgetLeft = 0; break; } + + const auto& doodadTemplate = (*doodadTemplates)[it->nextIndex]; + it->nextIndex++; + budgetLeft--; + + uint32_t doodadModelId = static_cast(std::hash{}(doodadTemplate.m2Path)); + auto m2Data = assetManager_->readFile(doodadTemplate.m2Path); + if (m2Data.empty()) continue; + + pipeline::M2Model m2Model = pipeline::M2Loader::load(m2Data); + std::string skinPath = doodadTemplate.m2Path.substr(0, doodadTemplate.m2Path.size() - 3) + "00.skin"; + std::vector skinData = assetManager_->readFile(skinPath); + if (!skinData.empty() && m2Model.version >= 264) { + pipeline::M2Loader::loadSkin(skinData, m2Model); + } + if (!m2Model.isValid()) continue; + + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; + uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); + if (m2InstanceId == 0) continue; + m2Renderer->setSkipCollision(m2InstanceId, true); + + wmoRenderer->addDoodadToInstance(it->instanceId, m2InstanceId, doodadTemplate.localTransform); + it->spawnedDoodads++; + } + + if (it->nextIndex >= maxIndex) { + if (it->spawnedDoodads > 0) { + LOG_DEBUG("Spawned ", it->spawnedDoodads, + " transport doodads for WMO instance ", it->instanceId); + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(it->x, it->y, it->z)); + glm::mat4 wmoTransform(1.0f); + wmoTransform = glm::translate(wmoTransform, renderPos); + wmoTransform = glm::rotate(wmoTransform, it->orientation, glm::vec3(0, 0, 1)); + wmoRenderer->setInstanceTransform(it->instanceId, wmoTransform); + } + it = pendingTransportDoodadBatches_.erase(it); + } else { + ++it; + } + } + + // Finalize the upload batch — submit all GPU copies in one shot (async, no wait). + if (vkCtx) vkCtx->endUploadBatch(); +} + +void EntitySpawner::processPendingMount() { + if (pendingMountDisplayId_ == 0) return; + uint32_t mountDisplayId = pendingMountDisplayId_; + pendingMountDisplayId_ = 0; + LOG_INFO("processPendingMount: loading displayId ", mountDisplayId); + + if (!renderer_ || !renderer_->getCharacterRenderer() || !assetManager_) return; + auto* charRenderer = renderer_->getCharacterRenderer(); + + std::string m2Path = getModelPathForDisplayId(mountDisplayId); + if (m2Path.empty()) { + LOG_WARNING("No model path for mount displayId ", mountDisplayId); + return; + } + + // Check model cache + uint32_t modelId = 0; + auto cacheIt = displayIdModelCache_.find(mountDisplayId); + if (cacheIt != displayIdModelCache_.end()) { + modelId = cacheIt->second; + } else { + modelId = nextCreatureModelId_++; + + auto m2Data = assetManager_->readFile(m2Path); + if (m2Data.empty()) { + LOG_WARNING("Failed to read mount M2: ", m2Path); + return; + } + + pipeline::M2Model model = pipeline::M2Loader::load(m2Data); + if (model.vertices.empty()) { + LOG_WARNING("Failed to parse mount M2: ", m2Path); + return; + } + + // Load skin file (only for WotLK M2s - vanilla has embedded skin) + if (model.version >= 264) { + std::string skinPath = m2Path.substr(0, m2Path.size() - 3) + "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty()) { + pipeline::M2Loader::loadSkin(skinData, model); + } else { + LOG_WARNING("Missing skin file for WotLK mount M2: ", skinPath); + } + } + + // Load external .anim files (only idle + run needed for mounts) + std::string basePath = m2Path.substr(0, m2Path.size() - 3); + for (uint32_t si = 0; si < model.sequences.size(); si++) { + if (!(model.sequences[si].flags & 0x20)) { + uint32_t animId = model.sequences[si].id; + // Only load stand, walk, run anims to avoid hang + if (animId != rendering::anim::STAND && animId != rendering::anim::WALK && animId != rendering::anim::RUN) continue; + char animFileName[256]; + snprintf(animFileName, sizeof(animFileName), "%s%04u-%02u.anim", + basePath.c_str(), animId, model.sequences[si].variationIndex); + auto animData = assetManager_->readFileOptional(animFileName); + if (!animData.empty()) { + pipeline::M2Loader::loadAnimFile(m2Data, animData, si, model); + } + } + } + + if (!charRenderer->loadModel(model, modelId)) { + LOG_WARNING("Failed to load mount model: ", m2Path); + return; + } + + displayIdModelCache_[mountDisplayId] = modelId; + } + + // Apply creature skin textures from CreatureDisplayInfo.dbc. + // Re-apply even for cached models so transient failures can self-heal. + std::string modelDir; + size_t lastSlash = m2Path.find_last_of("\\/"); + if (lastSlash != std::string::npos) { + modelDir = m2Path.substr(0, lastSlash + 1); + } + + auto itDisplayData = displayDataMap_.find(mountDisplayId); + bool haveDisplayData = false; + CreatureDisplayData dispData{}; + if (itDisplayData != displayDataMap_.end()) { + dispData = itDisplayData->second; + haveDisplayData = true; + } else { + // Some taxi mount display IDs are sparse; recover skins by matching model path. + std::string lowerMountPath = m2Path; + std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + int bestScore = -1; + for (const auto& [dispId, data] : displayDataMap_) { + auto pit = modelIdToPath_.find(data.modelId); + if (pit == modelIdToPath_.end()) continue; + std::string p = pit->second; + std::transform(p.begin(), p.end(), p.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (p != lowerMountPath) continue; + int score = 0; + if (!data.skin1.empty()) { + std::string p1 = modelDir + data.skin1 + ".blp"; + score += assetManager_->fileExists(p1) ? 30 : 3; + } + if (!data.skin2.empty()) { + std::string p2 = modelDir + data.skin2 + ".blp"; + score += assetManager_->fileExists(p2) ? 20 : 2; + } + if (!data.skin3.empty()) { + std::string p3 = modelDir + data.skin3 + ".blp"; + score += assetManager_->fileExists(p3) ? 10 : 1; + } + if (score > bestScore) { + bestScore = score; + dispData = data; + haveDisplayData = true; + } + } + if (haveDisplayData) { + LOG_INFO("Recovered mount display data by model path for displayId=", mountDisplayId, + " skin1='", dispData.skin1, "' skin2='", dispData.skin2, + "' skin3='", dispData.skin3, "'"); + } + } + if (haveDisplayData) { + // If this displayId has no skins, try to find another displayId for the same model with skins. + if (dispData.skin1.empty() && dispData.skin2.empty() && dispData.skin3.empty()) { + uint32_t sourceModelId = dispData.modelId; + int bestScore = -1; + for (const auto& [dispId, data] : displayDataMap_) { + if (data.modelId != sourceModelId) continue; + int score = 0; + if (!data.skin1.empty()) { + std::string p = modelDir + data.skin1 + ".blp"; + score += assetManager_->fileExists(p) ? 30 : 3; + } + if (!data.skin2.empty()) { + std::string p = modelDir + data.skin2 + ".blp"; + score += assetManager_->fileExists(p) ? 20 : 2; + } + if (!data.skin3.empty()) { + std::string p = modelDir + data.skin3 + ".blp"; + score += assetManager_->fileExists(p) ? 10 : 1; + } + if (score > bestScore) { + bestScore = score; + dispData = data; + } + } + LOG_INFO("Mount skin fallback for displayId=", mountDisplayId, + " modelId=", sourceModelId, " skin1='", dispData.skin1, + "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); + } + const auto* md = charRenderer->getModelData(modelId); + if (md) { + LOG_INFO("Mount model textures: ", md->textures.size(), " slots, skin1='", dispData.skin1, + "' skin2='", dispData.skin2, "' skin3='", dispData.skin3, "'"); + for (size_t ti = 0; ti < md->textures.size(); ti++) { + LOG_INFO(" tex[", ti, "] type=", md->textures[ti].type, + " filename='", md->textures[ti].filename, "'"); + } + + int replaced = 0; + for (size_t ti = 0; ti < md->textures.size(); ti++) { + const auto& tex = md->textures[ti]; + std::string texPath; + if (tex.type == 11 && !dispData.skin1.empty()) { + texPath = modelDir + dispData.skin1 + ".blp"; + } else if (tex.type == 12 && !dispData.skin2.empty()) { + texPath = modelDir + dispData.skin2 + ".blp"; + } else if (tex.type == 13 && !dispData.skin3.empty()) { + texPath = modelDir + dispData.skin3 + ".blp"; + } + if (!texPath.empty()) { + rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); + if (skinTex) { + charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); + LOG_INFO(" Applied skin texture slot ", ti, ": ", texPath); + replaced++; + } else { + LOG_WARNING(" Failed to load skin texture slot ", ti, ": ", texPath); + } + } + } + + // Force skin textures onto type-0 (hardcoded) slots that have no filename + if (replaced == 0) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + const auto& tex = md->textures[ti]; + if (tex.type == 0 && tex.filename.empty()) { + // Empty hardcoded slot — try skin1 then skin2 + std::string texPath; + if (!dispData.skin1.empty() && replaced == 0) { + texPath = modelDir + dispData.skin1 + ".blp"; + } else if (!dispData.skin2.empty()) { + texPath = modelDir + dispData.skin2 + ".blp"; + } + if (!texPath.empty()) { + rendering::VkTexture* skinTex = charRenderer->loadTexture(texPath); + if (skinTex) { + charRenderer->setModelTexture(modelId, static_cast(ti), skinTex); + LOG_INFO(" Forced skin on empty hardcoded slot ", ti, ": ", texPath); + replaced++; + } + } + } + } + } + + // If still no textures, try hardcoded model texture filenames + if (replaced == 0) { + for (size_t ti = 0; ti < md->textures.size(); ti++) { + if (!md->textures[ti].filename.empty()) { + rendering::VkTexture* texId = charRenderer->loadTexture(md->textures[ti].filename); + if (texId) { + charRenderer->setModelTexture(modelId, static_cast(ti), texId); + LOG_INFO(" Used model embedded texture slot ", ti, ": ", md->textures[ti].filename); + replaced++; + } + } + } + } + + // Final fallback for gryphon/wyvern: try well-known skin texture names + if (replaced == 0 && !md->textures.empty()) { + std::string lowerMountPath = m2Path; + std::transform(lowerMountPath.begin(), lowerMountPath.end(), lowerMountPath.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lowerMountPath.find("gryphon") != std::string::npos) { + const char* gryphonSkins[] = { + "Creature\\Gryphon\\Gryphon_Skin.blp", + "Creature\\Gryphon\\Gryphon_Skin01.blp", + "Creature\\Gryphon\\GRYPHON_SKIN01.BLP", + nullptr + }; + for (const char** p = gryphonSkins; *p; ++p) { + rendering::VkTexture* texId = charRenderer->loadTexture(*p); + if (texId) { + charRenderer->setModelTexture(modelId, 0, texId); + LOG_INFO(" Forced gryphon skin fallback: ", *p); + replaced++; + break; + } + } + } else if (lowerMountPath.find("wyvern") != std::string::npos) { + const char* wyvernSkins[] = { + "Creature\\Wyvern\\Wyvern_Skin.blp", + "Creature\\Wyvern\\Wyvern_Skin01.blp", + nullptr + }; + for (const char** p = wyvernSkins; *p; ++p) { + rendering::VkTexture* texId = charRenderer->loadTexture(*p); + if (texId) { + charRenderer->setModelTexture(modelId, 0, texId); + LOG_INFO(" Forced wyvern skin fallback: ", *p); + replaced++; + break; + } + } + } + } + LOG_INFO("Mount texture setup: ", replaced, " textures applied"); + } + } + + mountModelId_ = modelId; + + // Create mount instance at player position + glm::vec3 mountPos = renderer_->getCharacterPosition(); + float yawRad = glm::radians(renderer_->getCharacterYaw()); + uint32_t instanceId = charRenderer->createInstance(modelId, mountPos, + glm::vec3(0.0f, 0.0f, yawRad), 1.0f); + + if (instanceId == 0) { + LOG_WARNING("Failed to create mount instance"); + return; + } + + mountInstanceId_ = instanceId; + + // Compute height offset — place player above mount's back + // Use tight bounds from actual vertices (M2 header bounds can be inaccurate) + const auto* modelData = charRenderer->getModelData(modelId); + float heightOffset = 1.8f; + if (modelData && !modelData->vertices.empty()) { + float minZ = std::numeric_limits::max(); + float maxZ = -std::numeric_limits::max(); + for (const auto& v : modelData->vertices) { + if (v.position.z < minZ) minZ = v.position.z; + if (v.position.z > maxZ) maxZ = v.position.z; + } + float extentZ = maxZ - minZ; + LOG_INFO("Mount tight bounds: minZ=", minZ, " maxZ=", maxZ, " extentZ=", extentZ); + if (extentZ > 0.5f) { + // Saddle point is roughly 75% up the model, measured from model origin + heightOffset = maxZ * 0.8f; + if (heightOffset < 1.0f) heightOffset = extentZ * 0.75f; + if (heightOffset < 1.0f) heightOffset = 1.8f; + } + } + + if (auto* ac = renderer_->getAnimationController()) ac->setMounted(instanceId, mountDisplayId, heightOffset, m2Path); + + // For taxi mounts, start with flying animation; for ground mounts, start with stand + bool isTaxi = gameHandler_ && gameHandler_->isOnTaxiFlight(); + uint32_t startAnim = rendering::anim::STAND; + if (isTaxi) { + // Try WotLK fly anims first, then Vanilla-friendly fallbacks + using namespace rendering::anim; + uint32_t taxiCandidates[] = {FLY_FORWARD, FLY_IDLE, FLY_RUN_2, FLY_SPELL, FLY_RISE, SPELL_KNEEL_LOOP, FLY_CUSTOM_SPELL_10, DEAD, RUN}; + for (uint32_t anim : taxiCandidates) { + if (charRenderer->hasAnimation(instanceId, anim)) { + startAnim = anim; + break; + } + } + // If none found, startAnim stays 0 (Stand/hover) which is fine for flying creatures + } + charRenderer->playAnimation(instanceId, startAnim, true); + + LOG_INFO("processPendingMount: DONE displayId=", mountDisplayId, " model=", m2Path, " heightOffset=", heightOffset); +} + +void EntitySpawner::despawnCreature(uint64_t guid) { + // If this guid is a PLAYER, it will be tracked in playerInstances_. + // Route to the correct despawn path so we don't leak instances. + if (playerInstances_.count(guid)) { + despawnPlayer(guid); + return; + } + + pendingCreatureSpawnGuids_.erase(guid); + creatureSpawnRetryCounts_.erase(guid); + creaturePermanentFailureGuids_.erase(guid); + deadCreatureGuids_.erase(guid); + + auto it = creatureInstances_.find(guid); + if (it == creatureInstances_.end()) return; + + if (renderer_ && renderer_->getCharacterRenderer()) { + renderer_->getCharacterRenderer()->removeInstance(it->second); + } + + creatureInstances_.erase(it); + creatureModelIds_.erase(guid); + creatureRenderPosCache_.erase(guid); + creatureWeaponsAttached_.erase(guid); + creatureWeaponAttachAttempts_.erase(guid); + creatureWasMoving_.erase(guid); + creatureWasSwimming_.erase(guid); + creatureWasFlying_.erase(guid); + creatureWasWalking_.erase(guid); + creatureSwimmingState_.erase(guid); + creatureWalkingState_.erase(guid); + creatureFlyingState_.erase(guid); + + LOG_DEBUG("Despawned creature: guid=0x", std::hex, guid, std::dec); +} + +void EntitySpawner::despawnGameObject(uint64_t guid) { + pendingTransportDoodadBatches_.erase( + std::remove_if(pendingTransportDoodadBatches_.begin(), pendingTransportDoodadBatches_.end(), + [guid](const PendingTransportDoodadBatch& b) { return b.guid == guid; }), + pendingTransportDoodadBatches_.end()); + + auto it = gameObjectInstances_.find(guid); + if (it == gameObjectInstances_.end()) return; + + if (renderer_) { + if (it->second.isWmo) { + if (auto* wmoRenderer = renderer_->getWMORenderer()) { + wmoRenderer->removeInstance(it->second.instanceId); + } + } else { + if (auto* m2Renderer = renderer_->getM2Renderer()) { + m2Renderer->removeInstance(it->second.instanceId); + } + } + } + + gameObjectInstances_.erase(it); + + LOG_DEBUG("Despawned gameobject: guid=0x", std::hex, guid, std::dec); +} + +bool EntitySpawner::loadWeaponM2(const std::string& m2Path, pipeline::M2Model& outModel) { + auto m2Data = assetManager_->readFile(m2Path); + if (m2Data.empty()) return false; + outModel = pipeline::M2Loader::load(m2Data); + // Load skin (WotLK+ M2 format): strip .m2, append 00.skin + std::string skinPath = m2Path; + size_t dotPos = skinPath.rfind('.'); + if (dotPos != std::string::npos) skinPath = skinPath.substr(0, dotPos); + skinPath += "00.skin"; + auto skinData = assetManager_->readFile(skinPath); + if (!skinData.empty() && outModel.version >= 264) + pipeline::M2Loader::loadSkin(skinData, outModel); + return outModel.isValid(); +} + + +} // namespace core +} // namespace wowee diff --git a/src/core/transport_callback_handler.cpp b/src/core/transport_callback_handler.cpp index 635f083b..7c7ea03c 100644 --- a/src/core/transport_callback_handler.cpp +++ b/src/core/transport_callback_handler.cpp @@ -4,6 +4,7 @@ #include "core/coordinates.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" #include "rendering/character_renderer.hpp" #include "rendering/camera_controller.hpp" #include "rendering/terrain_manager.hpp" @@ -37,7 +38,7 @@ void TransportCallbackHandler::setupCallbacks() { entitySpawner_.clearMountState(); } entitySpawner_.setMountDisplayId(0); - renderer_.clearMount(); + if (auto* ac = renderer_.getAnimationController()) ac->clearMount(); LOG_INFO("Dismounted"); return; } @@ -106,7 +107,7 @@ void TransportCallbackHandler::setupCallbacks() { renderer_.getCameraController()->setFacingYaw(yawDegrees); renderer_.setCharacterYaw(yawDegrees); // Set mount pitch and roll for realistic flight animation - renderer_.setMountPitchRoll(pitch, roll); + if (auto* ac = renderer_.getAnimationController()) ac->setMountPitchRoll(pitch, roll); } }); diff --git a/src/core/world_entry_callback_handler.cpp b/src/core/world_entry_callback_handler.cpp index fab21c52..e8e9c678 100644 --- a/src/core/world_entry_callback_handler.cpp +++ b/src/core/world_entry_callback_handler.cpp @@ -4,6 +4,7 @@ #include "core/world_loader.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" #include "rendering/camera_controller.hpp" #include "rendering/terrain_manager.hpp" #include "rendering/wmo_renderer.hpp" diff --git a/src/core/world_loader.cpp b/src/core/world_loader.cpp index 0ca7de64..2b624ed2 100644 --- a/src/core/world_loader.cpp +++ b/src/core/world_loader.cpp @@ -11,6 +11,7 @@ #include "core/coordinates.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" #include "rendering/vk_context.hpp" #include "rendering/camera.hpp" #include "rendering/camera_controller.hpp" @@ -290,7 +291,7 @@ void WorldLoader::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float if (auto* questMarkers = renderer_->getQuestMarkerRenderer()) { questMarkers->clear(); } - renderer_->clearMount(); + if (auto* ac = renderer_->getAnimationController()) ac->clearMount(); } // Clear application-level instance tracking (after renderer cleanup) @@ -416,7 +417,7 @@ void WorldLoader::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float uint32_t oldInst = renderer_->getCharacterInstanceId(); if (oldInst > 0) { renderer_->setCharacterFollow(0); - renderer_->clearMount(); + if (auto* ac = renderer_->getAnimationController()) ac->clearMount(); renderer_->getCharacterRenderer()->removeInstance(oldInst); } } diff --git a/src/game/chat_handler.cpp b/src/game/chat_handler.cpp index 5dfc2fd5..568cf00b 100644 --- a/src/game/chat_handler.cpp +++ b/src/game/chat_handler.cpp @@ -6,6 +6,7 @@ #include "game/opcode_table.hpp" #include "network/world_socket.hpp" #include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" #include "core/logger.hpp" #include @@ -54,8 +55,8 @@ void ChatHandler::registerOpcodes(DispatchTable& table) { if (!packet.hasRemaining(12)) return; uint32_t emoteAnim = packet.readUInt32(); uint64_t sourceGuid = packet.readUInt64(); - if (owner_.emoteAnimCallback_ && sourceGuid != 0) - owner_.emoteAnimCallback_(sourceGuid, emoteAnim); + if (owner_.emoteAnimCallbackRef() && sourceGuid != 0) + owner_.emoteAnimCallbackRef()(sourceGuid, emoteAnim); }; table[Opcode::SMSG_CHANNEL_NOTIFY] = [this](network::Packet& packet) { if (owner_.getState() == WorldState::IN_WORLD || @@ -125,7 +126,7 @@ void ChatHandler::registerOpcodes(DispatchTable& table) { if (!msg.empty()) { owner_.addUIError(msg); addSystemChatMessage(msg); - owner_.areaTriggerMsgs_.push_back(msg); + owner_.areaTriggerMsgsRef().push_back(msg); } } }; @@ -155,15 +156,15 @@ void ChatHandler::sendChatMessage(ChatType type, const std::string& message, con ChatLanguage language = isHorde ? ChatLanguage::ORCISH : ChatLanguage::COMMON; auto packet = MessageChatPacket::build(type, language, message, target); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); // Add local echo so the player sees their own message immediately MessageChatData echo; - echo.senderGuid = owner_.playerGuid; + echo.senderGuid = owner_.getPlayerGuid(); echo.language = language; echo.message = message; - auto nameIt = owner_.getPlayerNameCache().find(owner_.playerGuid); + auto nameIt = owner_.getPlayerNameCache().find(owner_.getPlayerGuid()); if (nameIt != owner_.getPlayerNameCache().end()) { echo.senderName = nameIt->second; } @@ -186,7 +187,7 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { LOG_DEBUG("Handling SMSG_MESSAGECHAT"); MessageChatData data; - if (!owner_.packetParsers_->parseMessageChat(packet, data)) { + if (!owner_.getPacketParsers()->parseMessageChat(packet, data)) { LOG_WARNING("Failed to parse SMSG_MESSAGECHAT, size=", packet.getSize()); return; } @@ -195,9 +196,9 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { " '", data.senderName, "' msg='", data.message.substr(0, 60), "'"); // Skip server echo of our own messages (we already added a local echo) - if (data.senderGuid == owner_.playerGuid && data.senderGuid != 0) { + if (data.senderGuid == owner_.getPlayerGuid() && data.senderGuid != 0) { if (data.type == ChatType::WHISPER && !data.senderName.empty()) { - owner_.lastWhisperSender_ = data.senderName; + owner_.lastWhisperSenderRef() = data.senderName; } return; } @@ -284,29 +285,29 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { // Always store GUID so getLastWhisperSender() can resolve the name // from the player name cache even if name wasn't available yet if (data.senderGuid != 0) - owner_.lastWhisperSenderGuid_ = data.senderGuid; + owner_.lastWhisperSenderGuidRef() = data.senderGuid; if (!data.senderName.empty()) - owner_.lastWhisperSender_ = data.senderName; + owner_.lastWhisperSenderRef() = data.senderName; if (!data.senderName.empty()) { // Only auto-reply once per sender per AFK/DND session to prevent loops - if (owner_.afkStatus_ && afkAutoRepliedSenders_.insert(data.senderName).second) { - std::string reply = owner_.afkMessage_.empty() ? "Away from Keyboard" : owner_.afkMessage_; + if (owner_.afkStatusRef() && afkAutoRepliedSenders_.insert(data.senderName).second) { + std::string reply = owner_.afkMessageRef().empty() ? "Away from Keyboard" : owner_.afkMessageRef(); sendChatMessage(ChatType::WHISPER, " " + reply, data.senderName); - } else if (owner_.dndStatus_ && afkAutoRepliedSenders_.insert(data.senderName).second) { - std::string reply = owner_.dndMessage_.empty() ? "Do Not Disturb" : owner_.dndMessage_; + } else if (owner_.dndStatusRef() && afkAutoRepliedSenders_.insert(data.senderName).second) { + std::string reply = owner_.dndMessageRef().empty() ? "Do Not Disturb" : owner_.dndMessageRef(); sendChatMessage(ChatType::WHISPER, " " + reply, data.senderName); } } } // Trigger chat bubble for SAY/YELL messages from others - if (owner_.chatBubbleCallback_ && data.senderGuid != 0) { + if (owner_.chatBubbleCallbackRef() && data.senderGuid != 0) { if (data.type == ChatType::SAY || data.type == ChatType::YELL || data.type == ChatType::MONSTER_SAY || data.type == ChatType::MONSTER_YELL || data.type == ChatType::MONSTER_PARTY) { bool isYell = (data.type == ChatType::YELL || data.type == ChatType::MONSTER_YELL); - owner_.chatBubbleCallback_(data.senderGuid, data.message, isYell); + owner_.chatBubbleCallbackRef()(data.senderGuid, data.message, isYell); } } @@ -328,7 +329,7 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { LOG_DEBUG("[", getChatTypeString(data.type), "] ", channelInfo, senderInfo, ": ", data.message); // Detect addon messages - if (owner_.addonEventCallback_ && + if (owner_.addonEventCallbackRef() && data.type != ChatType::SAY && data.type != ChatType::YELL && data.type != ChatType::EMOTE && data.type != ChatType::TEXT_EMOTE && data.type != ChatType::MONSTER_SAY && data.type != ChatType::MONSTER_YELL) { @@ -339,21 +340,21 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { if (prefix.find(' ') == std::string::npos) { std::string body = data.message.substr(tabPos + 1); std::string channel = getChatTypeString(data.type); - owner_.addonEventCallback_("CHAT_MSG_ADDON", {prefix, body, channel, data.senderName}); + owner_.addonEventCallbackRef()("CHAT_MSG_ADDON", {prefix, body, channel, data.senderName}); return; } } } // Fire CHAT_MSG_* addon events - if (owner_.addonChatCallback_) owner_.addonChatCallback_(data); - if (owner_.addonEventCallback_) { + if (owner_.addonChatCallbackRef()) owner_.addonChatCallbackRef()(data); + if (owner_.addonEventCallbackRef()) { std::string eventName = "CHAT_MSG_"; eventName += getChatTypeString(data.type); std::string lang = std::to_string(static_cast(data.language)); char guidBuf[32]; snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)data.senderGuid); - owner_.addonEventCallback_(eventName, { + owner_.addonEventCallbackRef()(eventName, { data.message, data.senderName, lang, @@ -371,9 +372,9 @@ void ChatHandler::handleMessageChat(network::Packet& packet) { } void ChatHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = TextEmotePacket::build(textEmoteId, targetGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void ChatHandler::handleTextEmote(network::Packet& packet) { @@ -384,7 +385,7 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { return; } - if (data.senderGuid == owner_.playerGuid && data.senderGuid != 0) { + if (data.senderGuid == owner_.getPlayerGuid() && data.senderGuid != 0) { return; } @@ -405,7 +406,7 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { } const std::string* targetPtr = data.targetName.empty() ? nullptr : &data.targetName; - std::string emoteText = rendering::Renderer::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr); + std::string emoteText = rendering::AnimationController::getEmoteTextByDbcId(data.textEmoteId, senderName, targetPtr); if (emoteText.empty()) { emoteText = data.targetName.empty() ? senderName + " performs an emote." @@ -421,29 +422,29 @@ void ChatHandler::handleTextEmote(network::Packet& packet) { addLocalChatMessage(chatMsg); - uint32_t animId = rendering::Renderer::getEmoteAnimByDbcId(data.textEmoteId); - if (animId != 0 && owner_.emoteAnimCallback_) { - owner_.emoteAnimCallback_(data.senderGuid, animId); + uint32_t animId = rendering::AnimationController::getEmoteAnimByDbcId(data.textEmoteId); + if (animId != 0 && owner_.emoteAnimCallbackRef()) { + owner_.emoteAnimCallbackRef()(data.senderGuid, animId); } LOG_INFO("TEXT_EMOTE from ", senderName, " (emoteId=", data.textEmoteId, ", anim=", animId, ")"); } void ChatHandler::joinChannel(const std::string& channelName, const std::string& password) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildJoinChannel(channelName, password) + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildJoinChannel(channelName, password) : JoinChannelPacket::build(channelName, password); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Requesting to join channel: ", channelName); } void ChatHandler::leaveChannel(const std::string& channelName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildLeaveChannel(channelName) + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildLeaveChannel(channelName) : LeaveChannelPacket::build(channelName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Requesting to leave channel: ", channelName); } @@ -601,9 +602,9 @@ void ChatHandler::addLocalChatMessage(const MessageChatData& msg) { if (chatHistory_.size() > maxChatHistory_) { chatHistory_.pop_front(); } - if (owner_.addonChatCallback_) owner_.addonChatCallback_(msg); + if (owner_.addonChatCallbackRef()) owner_.addonChatCallbackRef()(msg); - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string eventName = "CHAT_MSG_"; eventName += getChatTypeString(msg.type); const Character* ac = owner_.getActiveCharacter(); @@ -611,8 +612,8 @@ void ChatHandler::addLocalChatMessage(const MessageChatData& msg) { ? (ac ? ac->name : std::string{}) : msg.senderName; char guidBuf[32]; snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", - (unsigned long long)(msg.senderGuid != 0 ? msg.senderGuid : owner_.playerGuid)); - owner_.addonEventCallback_(eventName, { + (unsigned long long)(msg.senderGuid != 0 ? msg.senderGuid : owner_.getPlayerGuid())); + owner_.addonEventCallbackRef()(eventName, { msg.message, senderName, std::to_string(static_cast(msg.language)), msg.channelName, senderName, "", "0", "0", "", "0", "0", guidBuf @@ -630,51 +631,51 @@ void ChatHandler::addSystemChatMessage(const std::string& message) { } void ChatHandler::toggleAfk(const std::string& message) { - owner_.afkStatus_ = !owner_.afkStatus_; - owner_.afkMessage_ = message; + owner_.afkStatusRef() = !owner_.afkStatusRef(); + owner_.afkMessageRef() = message; - if (owner_.afkStatus_) { + if (owner_.afkStatusRef()) { if (message.empty()) { addSystemChatMessage("You are now AFK."); } else { addSystemChatMessage("You are now AFK: " + message); } // If DND was active, turn it off - if (owner_.dndStatus_) { - owner_.dndStatus_ = false; - owner_.dndMessage_.clear(); + if (owner_.dndStatusRef()) { + owner_.dndStatusRef() = false; + owner_.dndMessageRef().clear(); } } else { addSystemChatMessage("You are no longer AFK."); - owner_.afkMessage_.clear(); + owner_.afkMessageRef().clear(); afkAutoRepliedSenders_.clear(); } - LOG_INFO("AFK status: ", owner_.afkStatus_, ", message: ", message); + LOG_INFO("AFK status: ", owner_.afkStatusRef(), ", message: ", message); } void ChatHandler::toggleDnd(const std::string& message) { - owner_.dndStatus_ = !owner_.dndStatus_; - owner_.dndMessage_ = message; + owner_.dndStatusRef() = !owner_.dndStatusRef(); + owner_.dndMessageRef() = message; - if (owner_.dndStatus_) { + if (owner_.dndStatusRef()) { if (message.empty()) { addSystemChatMessage("You are now DND (Do Not Disturb)."); } else { addSystemChatMessage("You are now DND: " + message); } // If AFK was active, turn it off - if (owner_.afkStatus_) { - owner_.afkStatus_ = false; - owner_.afkMessage_.clear(); + if (owner_.afkStatusRef()) { + owner_.afkStatusRef() = false; + owner_.afkMessageRef().clear(); } } else { addSystemChatMessage("You are no longer DND."); - owner_.dndMessage_.clear(); + owner_.dndMessageRef().clear(); afkAutoRepliedSenders_.clear(); } - LOG_INFO("DND status: ", owner_.dndStatus_, ", message: ", message); + LOG_INFO("DND status: ", owner_.dndStatusRef(), ", message: ", message); } void ChatHandler::replyToLastWhisper(const std::string& message) { @@ -683,7 +684,7 @@ void ChatHandler::replyToLastWhisper(const std::string& message) { return; } - if (owner_.lastWhisperSender_.empty()) { + if (owner_.lastWhisperSenderRef().empty()) { addSystemChatMessage("No one has whispered you yet."); return; } @@ -694,8 +695,8 @@ void ChatHandler::replyToLastWhisper(const std::string& message) { } // Send whisper using the standard message chat function - sendChatMessage(ChatType::WHISPER, message, owner_.lastWhisperSender_); - LOG_INFO("Replied to ", owner_.lastWhisperSender_, ": ", message); + sendChatMessage(ChatType::WHISPER, message, owner_.lastWhisperSenderRef()); + LOG_INFO("Replied to ", owner_.lastWhisperSenderRef(), ": ", message); } // ============================================================ @@ -743,13 +744,13 @@ void ChatHandler::submitGmTicket(const std::string& text) { // uint8 need_response (1 = yes) network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_CREATE)); pkt.writeString(text); - pkt.writeFloat(owner_.movementInfo.x); - pkt.writeFloat(owner_.movementInfo.y); - pkt.writeFloat(owner_.movementInfo.z); - pkt.writeFloat(owner_.movementInfo.orientation); - pkt.writeUInt32(owner_.currentMapId_); + pkt.writeFloat(owner_.movementInfoRef().x); + pkt.writeFloat(owner_.movementInfoRef().y); + pkt.writeFloat(owner_.movementInfoRef().z); + pkt.writeFloat(owner_.movementInfoRef().orientation); + pkt.writeUInt32(owner_.currentMapIdRef()); pkt.writeUInt8(1); // need_response = yes - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("Submitted GM ticket: '", text, "'"); } diff --git a/src/game/combat_handler.cpp b/src/game/combat_handler.cpp index 48f0b2fd..47d36a0b 100644 --- a/src/game/combat_handler.cpp +++ b/src/game/combat_handler.cpp @@ -31,7 +31,7 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { }; table[Opcode::SMSG_THREAT_CLEAR] = [this](network::Packet& /*packet*/) { threatLists_.clear(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("UNIT_THREAT_LIST_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("UNIT_THREAT_LIST_UPDATE", {}); }; table[Opcode::SMSG_THREAT_REMOVE] = [this](network::Packet& packet) { if (!packet.hasRemaining(1)) return; @@ -67,10 +67,10 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { if (autoAttackRequested_ && autoAttackTarget_ != 0) { auto targetEntity = owner_.getEntityManager().getEntity(autoAttackTarget_); if (targetEntity) { - float toTargetX = targetEntity->getX() - owner_.movementInfo.x; - float toTargetY = targetEntity->getY() - owner_.movementInfo.y; + float toTargetX = targetEntity->getX() - owner_.movementInfoRef().x; + float toTargetY = targetEntity->getY() - owner_.movementInfoRef().y; if (std::abs(toTargetX) > 0.01f || std::abs(toTargetY) > 0.01f) { - owner_.movementInfo.orientation = std::atan2(-toTargetY, toTargetX); + owner_.movementInfoRef().orientation = std::atan2(-toTargetY, toTargetX); owner_.sendMovement(Opcode::MSG_MOVE_SET_FACING); } } @@ -96,10 +96,10 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { if (!packet.hasRemaining(12)) return; uint64_t guid = packet.readUInt64(); uint32_t reaction = packet.readUInt32(); - if (reaction == 2 && owner_.npcAggroCallback_) { + if (reaction == 2 && owner_.npcAggroCallbackRef()) { auto entity = owner_.getEntityManager().getEntity(guid); if (entity) - owner_.npcAggroCallback_(guid, glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + owner_.npcAggroCallbackRef()(guid, glm::vec3(entity->getX(), entity->getY(), entity->getZ())); } }; table[Opcode::SMSG_SPELLNONMELEEDAMAGELOG] = [this](network::Packet& packet) { handleSpellDamageLog(packet); }; @@ -115,7 +115,7 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { uint32_t dmg = packet.readUInt32(); uint32_t envAbs = packet.readUInt32(); uint32_t envRes = packet.readUInt32(); - if (victimGuid == owner_.playerGuid) { + if (victimGuid == owner_.getPlayerGuid()) { // Environmental damage: pass envType via powerType field for display differentiation if (dmg > 0) addCombatText(CombatTextEntry::ENVIRONMENTAL, static_cast(dmg), 0, false, envType, 0, victimGuid); @@ -124,8 +124,8 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { if (envRes > 0) addCombatText(CombatTextEntry::RESIST, static_cast(envRes), 0, false, 0, 0, victimGuid); // Drowning damage → play DROWN one-shot on player - if (envType == 1 && dmg > 0 && owner_.emoteAnimCallback_) - owner_.emoteAnimCallback_(victimGuid, 131); // anim::DROWN + if (envType == 1 && dmg > 0 && owner_.emoteAnimCallbackRef()) + owner_.emoteAnimCallbackRef()(victimGuid, 131); // anim::DROWN } packet.skipAll(); }; @@ -158,8 +158,8 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { std::sort(list.begin(), list.end(), [](const ThreatEntry& a, const ThreatEntry& b){ return a.threat > b.threat; }); threatLists_[unitGuid] = std::move(list); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("UNIT_THREAT_LIST_UPDATE", {}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("UNIT_THREAT_LIST_UPDATE", {}); }; } @@ -198,7 +198,7 @@ void CombatHandler::registerOpcodes(DispatchTable& table) { void CombatHandler::startAutoAttack(uint64_t targetGuid) { // Can't attack yourself - if (targetGuid == owner_.playerGuid) return; + if (targetGuid == owner_.getPlayerGuid()) return; if (targetGuid == 0) return; // Dismount when entering combat @@ -209,9 +209,9 @@ void CombatHandler::startAutoAttack(uint64_t targetGuid) { // Client-side melee range gate to avoid starting "swing forever" loops when // target is already clearly out of range. if (auto target = owner_.getEntityManager().getEntity(targetGuid)) { - float dx = owner_.movementInfo.x - target->getLatestX(); - float dy = owner_.movementInfo.y - target->getLatestY(); - float dz = owner_.movementInfo.z - target->getLatestZ(); + float dx = owner_.movementInfoRef().x - target->getLatestX(); + float dy = owner_.movementInfoRef().y - target->getLatestY(); + float dz = owner_.movementInfoRef().z - target->getLatestZ(); float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); if (dist3d > 8.0f) { if (autoAttackRangeWarnCooldown_ <= 0.0f) { @@ -232,9 +232,9 @@ void CombatHandler::startAutoAttack(uint64_t targetGuid) { autoAttackOutOfRangeTime_ = 0.0f; autoAttackResendTimer_ = 0.0f; autoAttackFacingSyncTimer_ = 0.0f; - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { auto packet = AttackSwingPacket::build(targetGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } LOG_INFO("Starting auto-attack on 0x", std::hex, targetGuid, std::dec); } @@ -249,13 +249,13 @@ void CombatHandler::stopAutoAttack() { autoAttackOutOfRangeTime_ = 0.0f; autoAttackResendTimer_ = 0.0f; autoAttackFacingSyncTimer_ = 0.0f; - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { auto packet = AttackStopPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } LOG_INFO("Stopping auto-attack"); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("PLAYER_LEAVE_COMBAT", {}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("PLAYER_LEAVE_COMBAT", {}); } // ============================================================ @@ -292,9 +292,9 @@ void CombatHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, ui // preserve "unknown/no source" (e.g. environmental damage) instead of // backfilling from current target. uint64_t effectiveSrc = (srcGuid != 0) ? srcGuid - : ((dstGuid != 0) ? 0 : (isPlayerSource ? owner_.playerGuid : owner_.targetGuid)); + : ((dstGuid != 0) ? 0 : (isPlayerSource ? owner_.getPlayerGuid() : owner_.getTargetGuid())); uint64_t effectiveDst = (dstGuid != 0) ? dstGuid - : (isPlayerSource ? owner_.targetGuid : owner_.playerGuid); + : (isPlayerSource ? owner_.getTargetGuid() : owner_.getPlayerGuid()); log.sourceName = owner_.lookupName(effectiveSrc); log.targetName = (effectiveDst != 0) ? owner_.lookupName(effectiveDst) : std::string{}; if (combatLog_.size() >= MAX_COMBAT_LOG) @@ -303,7 +303,7 @@ void CombatHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, ui // Fire COMBAT_LOG_EVENT_UNFILTERED for Lua addons // Args: subevent, sourceGUID, sourceName, 0 (sourceFlags), destGUID, destName, 0 (destFlags), spellId, spellName, amount - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { static const char* kSubevents[] = { "SWING_DAMAGE", "SPELL_DAMAGE", "SPELL_HEAL", "SWING_MISSED", "SWING_MISSED", "SWING_MISSED", "SWING_MISSED", "SWING_MISSED", "SPELL_DAMAGE", "SPELL_HEAL", @@ -320,7 +320,7 @@ void CombatHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, ui snprintf(dstBuf, sizeof(dstBuf), "0x%016llX", (unsigned long long)effectiveDst); std::string spellName = (spellId != 0) ? owner_.getSpellName(spellId) : std::string{}; std::string timestamp = std::to_string(static_cast(std::time(nullptr))); - owner_.addonEventCallback_("COMBAT_LOG_EVENT_UNFILTERED", { + owner_.addonEventCallbackRef()("COMBAT_LOG_EVENT_UNFILTERED", { timestamp, subevent, srcBuf, log.sourceName, "0", dstBuf, log.targetName, "0", @@ -370,8 +370,8 @@ void CombatHandler::updateCombatText(float deltaTime) { // ============================================================ void CombatHandler::autoTargetAttacker(uint64_t attackerGuid) { - if (attackerGuid == 0 || attackerGuid == owner_.playerGuid) return; - if (owner_.targetGuid != 0) return; + if (attackerGuid == 0 || attackerGuid == owner_.getPlayerGuid()) return; + if (owner_.getTargetGuid() != 0) return; if (!owner_.getEntityManager().hasEntity(attackerGuid)) return; owner_.setTarget(attackerGuid); } @@ -380,23 +380,23 @@ void CombatHandler::handleAttackStart(network::Packet& packet) { AttackStartData data; if (!AttackStartParser::parse(packet, data)) return; - if (data.attackerGuid == owner_.playerGuid) { + if (data.attackerGuid == owner_.getPlayerGuid()) { autoAttackRequested_ = true; autoAttacking_ = true; autoAttackRetryPending_ = false; autoAttackTarget_ = data.victimGuid; - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("PLAYER_ENTER_COMBAT", {}); - } else if (data.victimGuid == owner_.playerGuid && data.attackerGuid != 0) { + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("PLAYER_ENTER_COMBAT", {}); + } else if (data.victimGuid == owner_.getPlayerGuid() && data.attackerGuid != 0) { hostileAttackers_.insert(data.attackerGuid); autoTargetAttacker(data.attackerGuid); // Play aggro sound when NPC attacks player - if (owner_.npcAggroCallback_) { + if (owner_.npcAggroCallbackRef()) { auto entity = owner_.getEntityManager().getEntity(data.attackerGuid); if (entity && entity->getType() == ObjectType::UNIT) { glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ()); - owner_.npcAggroCallback_(data.attackerGuid, pos); + owner_.npcAggroCallbackRef()(data.attackerGuid, pos); } } } @@ -421,32 +421,32 @@ void CombatHandler::handleAttackStop(network::Packet& packet) { if (!AttackStopParser::parse(packet, data)) return; // Keep intent, but clear server-confirmed active state until ATTACKSTART resumes. - if (data.attackerGuid == owner_.playerGuid) { + if (data.attackerGuid == owner_.getPlayerGuid()) { autoAttacking_ = false; autoAttackRetryPending_ = autoAttackRequested_; autoAttackResendTimer_ = 0.0f; LOG_DEBUG("SMSG_ATTACKSTOP received (keeping auto-attack intent)"); - } else if (data.victimGuid == owner_.playerGuid) { + } else if (data.victimGuid == owner_.getPlayerGuid()) { hostileAttackers_.erase(data.attackerGuid); } } void CombatHandler::handleAttackerStateUpdate(network::Packet& packet) { AttackerStateUpdateData data; - if (!owner_.packetParsers_->parseAttackerStateUpdate(packet, data)) return; + if (!owner_.getPacketParsers()->parseAttackerStateUpdate(packet, data)) return; - bool isPlayerAttacker = (data.attackerGuid == owner_.playerGuid); - bool isPlayerTarget = (data.targetGuid == owner_.playerGuid); + bool isPlayerAttacker = (data.attackerGuid == owner_.getPlayerGuid()); + bool isPlayerTarget = (data.targetGuid == owner_.getPlayerGuid()); if (!isPlayerAttacker && !isPlayerTarget) return; // Not our combat if (isPlayerAttacker) { lastMeleeSwingMs_ = static_cast( std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count()); - if (owner_.meleeSwingCallback_) owner_.meleeSwingCallback_(0); + if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(0); } - if (!isPlayerAttacker && owner_.npcSwingCallback_) { - owner_.npcSwingCallback_(data.attackerGuid); + if (!isPlayerAttacker && owner_.npcSwingCallbackRef()) { + owner_.npcSwingCallbackRef()(data.attackerGuid); } if (isPlayerTarget && data.attackerGuid != 0) { @@ -524,24 +524,24 @@ void CombatHandler::handleAttackerStateUpdate(network::Packet& packet) { } // Fire hit reaction animation on the victim - if (owner_.hitReactionCallback_ && !data.isMiss()) { + if (owner_.hitReactionCallbackRef() && !data.isMiss()) { using HR = GameHandler::HitReaction; HR reaction = HR::WOUND; if (data.victimState == 1) reaction = HR::DODGE; else if (data.victimState == 2) reaction = HR::PARRY; else if (data.victimState == 4) reaction = HR::BLOCK; else if (data.isCrit()) reaction = HR::CRIT_WOUND; - owner_.hitReactionCallback_(data.targetGuid, reaction); + owner_.hitReactionCallbackRef()(data.targetGuid, reaction); } } void CombatHandler::handleSpellDamageLog(network::Packet& packet) { SpellDamageLogData data; - if (!owner_.packetParsers_->parseSpellDamageLog(packet, data)) return; + if (!owner_.getPacketParsers()->parseSpellDamageLog(packet, data)) return; - bool isPlayerSource = (data.attackerGuid == owner_.playerGuid); - bool isPlayerTarget = (data.targetGuid == owner_.playerGuid); + bool isPlayerSource = (data.attackerGuid == owner_.getPlayerGuid()); + bool isPlayerTarget = (data.targetGuid == owner_.getPlayerGuid()); if (!isPlayerSource && !isPlayerTarget) return; // Not our combat if (isPlayerTarget && data.attackerGuid != 0) { @@ -560,10 +560,10 @@ void CombatHandler::handleSpellDamageLog(network::Packet& packet) { void CombatHandler::handleSpellHealLog(network::Packet& packet) { SpellHealLogData data; - if (!owner_.packetParsers_->parseSpellHealLog(packet, data)) return; + if (!owner_.getPacketParsers()->parseSpellHealLog(packet, data)) return; - bool isPlayerSource = (data.casterGuid == owner_.playerGuid); - bool isPlayerTarget = (data.targetGuid == owner_.playerGuid); + bool isPlayerSource = (data.casterGuid == owner_.getPlayerGuid()); + bool isPlayerTarget = (data.targetGuid == owner_.getPlayerGuid()); if (!isPlayerSource && !isPlayerTarget) return; // Not our combat auto type = data.isCrit ? CombatTextEntry::CRIT_HEAL : CombatTextEntry::HEAL; @@ -608,9 +608,9 @@ void CombatHandler::updateAutoAttack(float deltaTime) { const float targetX = targetEntity->getLatestX(); const float targetY = targetEntity->getLatestY(); const float targetZ = targetEntity->getLatestZ(); - float dx = owner_.movementInfo.x - targetX; - float dy = owner_.movementInfo.y - targetY; - float dz = owner_.movementInfo.z - targetZ; + float dx = owner_.movementInfoRef().x - targetX; + float dy = owner_.movementInfoRef().y - targetY; + float dz = owner_.movementInfoRef().z - targetZ; float dist = std::sqrt(dx * dx + dy * dy); float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); const bool classicLike = isPreWotlk(); @@ -652,7 +652,7 @@ void CombatHandler::updateAutoAttack(float deltaTime) { autoAttackResendTimer_ = 0.0f; autoAttackRetryPending_ = false; auto pkt = AttackSwingPacket::build(autoAttackTarget_); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } // Keep server-facing aligned while trying to acquire melee. @@ -661,16 +661,16 @@ void CombatHandler::updateAutoAttack(float deltaTime) { if (allowPeriodicFacingSync && autoAttackFacingSyncTimer_ >= facingSyncInterval) { autoAttackFacingSyncTimer_ = 0.0f; - float toTargetX = targetX - owner_.movementInfo.x; - float toTargetY = targetY - owner_.movementInfo.y; + float toTargetX = targetX - owner_.movementInfoRef().x; + float toTargetY = targetY - owner_.movementInfoRef().y; if (std::abs(toTargetX) > 0.01f || std::abs(toTargetY) > 0.01f) { float desired = std::atan2(-toTargetY, toTargetX); - float diff = desired - owner_.movementInfo.orientation; + float diff = desired - owner_.movementInfoRef().orientation; while (diff > static_cast(M_PI)) diff -= 2.0f * static_cast(M_PI); while (diff < -static_cast(M_PI)) diff += 2.0f * static_cast(M_PI); const float facingThreshold = classicLike ? 0.035f : 0.12f; if (std::abs(diff) > facingThreshold) { - owner_.movementInfo.orientation = desired; + owner_.movementInfoRef().orientation = desired; owner_.sendMovement(Opcode::MSG_MOVE_SET_FACING); } } @@ -685,8 +685,8 @@ void CombatHandler::updateAutoAttack(float deltaTime) { for (uint64_t attackerGuid : hostileAttackers_) { auto attacker = owner_.getEntityManager().getEntity(attackerGuid); if (!attacker) continue; - float dx = owner_.movementInfo.x - attacker->getX(); - float dy = owner_.movementInfo.y - attacker->getY(); + float dx = owner_.movementInfoRef().x - attacker->getX(); + float dy = owner_.movementInfoRef().y - attacker->getY(); if (std::abs(dx) < 0.01f && std::abs(dy) < 0.01f) continue; attacker->setOrientation(std::atan2(-dy, dx)); } @@ -758,7 +758,7 @@ void CombatHandler::handlePowerUpdate(network::Packet& packet) { auto unitId = owner_.guidToUnitId(guid); if (!unitId.empty()) { owner_.fireAddonEvent("UNIT_POWER", {unitId}); - if (guid == owner_.playerGuid) { + if (guid == owner_.getPlayerGuid()) { owner_.fireAddonEvent("ACTIONBAR_UPDATE_USABLE", {}); owner_.fireAddonEvent("SPELL_UPDATE_USABLE", {}); } @@ -771,10 +771,10 @@ void CombatHandler::handleUpdateComboPoints(network::Packet& packet) { if (!packet.hasRemaining(cpTbc ? 8u : 2u) ) return; uint64_t target = cpTbc ? packet.readUInt64() : packet.readPackedGuid(); if (!packet.hasRemaining(1)) return; - owner_.comboPoints_ = packet.readUInt8(); - owner_.comboTarget_ = target; + owner_.comboPointsRef() = packet.readUInt8(); + owner_.comboTargetRef() = target; LOG_DEBUG("SMSG_UPDATE_COMBO_POINTS: target=0x", std::hex, target, - std::dec, " points=", static_cast(owner_.comboPoints_)); + std::dec, " points=", static_cast(owner_.comboPointsRef())); owner_.fireAddonEvent("PLAYER_COMBO_POINTS", {}); } @@ -787,7 +787,7 @@ void CombatHandler::handlePvpCredit(network::Packet& packet) { std::string msg = "You gain " + std::to_string(honor) + " honor points."; owner_.addSystemChatMessage(msg); if (honor > 0) addCombatText(CombatTextEntry::HONOR_GAIN, static_cast(honor), 0, true); - if (owner_.pvpHonorCallback_) owner_.pvpHonorCallback_(honor, victimGuid, rank); + if (owner_.pvpHonorCallbackRef()) owner_.pvpHonorCallbackRef()(honor, victimGuid, rank); owner_.fireAddonEvent("CHAT_MSG_COMBAT_HONOR_GAIN", {msg}); } } @@ -805,8 +805,8 @@ void CombatHandler::handleProcResist(network::Packet& packet) { uint64_t victim = readPrGuid(); if (!packet.hasRemaining(4)) return; uint32_t spellId = packet.readUInt32(); - if (victim == owner_.playerGuid) addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim); - else if (caster == owner_.playerGuid) addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, caster, victim); + if (victim == owner_.getPlayerGuid()) addCombatText(CombatTextEntry::RESIST, 0, spellId, false, 0, caster, victim); + else if (caster == owner_.getPlayerGuid()) addCombatText(CombatTextEntry::RESIST, 0, spellId, true, 0, caster, victim); packet.skipAll(); } @@ -846,10 +846,10 @@ void CombatHandler::handleSpellDamageShield(network::Packet& packet) { /*uint32_t absorbed =*/ packet.readUInt32(); /*uint32_t school =*/ packet.readUInt32(); // Show combat text: damage shield reflect - if (casterGuid == owner_.playerGuid) { + if (casterGuid == owner_.getPlayerGuid()) { // We have a damage shield that reflected damage addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, true, 0, casterGuid, victimGuid); - } else if (victimGuid == owner_.playerGuid) { + } else if (victimGuid == owner_.getPlayerGuid()) { // A damage shield hit us (e.g. target's Thorns) addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), shieldSpellId, false, 0, casterGuid, victimGuid); } @@ -878,9 +878,9 @@ void CombatHandler::handleSpellOrDamageImmune(network::Packet& packet) { /*uint8_t saveType =*/ packet.readUInt8(); // Show IMMUNE text when the player is the caster (we hit an immune target) // or the victim (we are immune) - if (casterGuid == owner_.playerGuid || victimGuid == owner_.playerGuid) { + if (casterGuid == owner_.getPlayerGuid() || victimGuid == owner_.getPlayerGuid()) { addCombatText(CombatTextEntry::IMMUNE, 0, immuneSpellId, - casterGuid == owner_.playerGuid, 0, casterGuid, victimGuid); + casterGuid == owner_.getPlayerGuid(), 0, casterGuid, victimGuid); } } @@ -916,9 +916,9 @@ void CombatHandler::handleResistLog(network::Packet& packet) { /*uint32_t targetRes =*/ packet.readUInt32(); int32_t resistedAmount = static_cast(packet.readUInt32()); // Show RESIST when the player is involved on either side. - if (resistedAmount > 0 && victimGuid == owner_.playerGuid) { + if (resistedAmount > 0 && victimGuid == owner_.getPlayerGuid()) { addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, false, 0, attackerGuid, victimGuid); - } else if (resistedAmount > 0 && attackerGuid == owner_.playerGuid) { + } else if (resistedAmount > 0 && attackerGuid == owner_.getPlayerGuid()) { addCombatText(CombatTextEntry::RESIST, resistedAmount, spellId, true, 0, attackerGuid, victimGuid); } packet.skipAll(); @@ -985,10 +985,10 @@ void CombatHandler::handlePetCastFailed(network::Packet& packet) { void CombatHandler::handlePetBroken(network::Packet& packet) { // Pet bond broken (died or forcibly dismissed) — clear pet state - owner_.petGuid_ = 0; - owner_.petSpellList_.clear(); - owner_.petAutocastSpells_.clear(); - memset(owner_.petActionSlots_, 0, sizeof(owner_.petActionSlots_)); + owner_.petGuidRef() = 0; + owner_.petSpellListRef().clear(); + owner_.petAutocastSpellsRef().clear(); + memset(owner_.petActionSlotsRef(), 0, sizeof(owner_.petActionSlotsRef())); owner_.addSystemChatMessage("Your pet has died."); LOG_INFO("SMSG_PET_BROKEN: pet bond broken"); packet.skipAll(); @@ -997,7 +997,7 @@ void CombatHandler::handlePetBroken(network::Packet& packet) { void CombatHandler::handlePetLearnedSpell(network::Packet& packet) { if (packet.hasRemaining(4)) { uint32_t spellId = packet.readUInt32(); - owner_.petSpellList_.push_back(spellId); + owner_.petSpellListRef().push_back(spellId); const std::string& sname = owner_.getSpellName(spellId); owner_.addSystemChatMessage("Your pet has learned " + (sname.empty() ? "a new ability." : sname + ".")); LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId); @@ -1009,10 +1009,10 @@ void CombatHandler::handlePetLearnedSpell(network::Packet& packet) { void CombatHandler::handlePetUnlearnedSpell(network::Packet& packet) { if (packet.hasRemaining(4)) { uint32_t spellId = packet.readUInt32(); - owner_.petSpellList_.erase( - std::remove(owner_.petSpellList_.begin(), owner_.petSpellList_.end(), spellId), - owner_.petSpellList_.end()); - owner_.petAutocastSpells_.erase(spellId); + owner_.petSpellListRef().erase( + std::remove(owner_.petSpellListRef().begin(), owner_.petSpellListRef().end(), spellId), + owner_.petSpellListRef().end()); + owner_.petAutocastSpellsRef().erase(spellId); LOG_DEBUG("SMSG_PET_UNLEARNED_SPELL: spellId=", spellId); } packet.skipAll(); @@ -1024,11 +1024,11 @@ void CombatHandler::handlePetMode(network::Packet& packet) { if (packet.hasRemaining(12)) { uint64_t modeGuid = packet.readUInt64(); uint32_t mode = packet.readUInt32(); - if (modeGuid == owner_.petGuid_) { - owner_.petCommand_ = static_cast(mode & 0xFF); - owner_.petReact_ = static_cast((mode >> 8) & 0xFF); - LOG_DEBUG("SMSG_PET_MODE: command=", static_cast(owner_.petCommand_), - " react=", static_cast(owner_.petReact_)); + if (modeGuid == owner_.petGuidRef()) { + owner_.petCommandRef() = static_cast(mode & 0xFF); + owner_.petReactRef() = static_cast((mode >> 8) & 0xFF); + LOG_DEBUG("SMSG_PET_MODE: command=", static_cast(owner_.petCommandRef()), + " react=", static_cast(owner_.petReactRef())); } } packet.skipAll(); @@ -1054,18 +1054,18 @@ void CombatHandler::handleResurrectFailed(network::Packet& packet) { // ============================================================ void CombatHandler::setTarget(uint64_t guid) { - if (guid == owner_.targetGuid) return; + if (guid == owner_.getTargetGuid()) return; // Save previous target - if (owner_.targetGuid != 0) { - owner_.lastTargetGuid = owner_.targetGuid; + if (owner_.getTargetGuid() != 0) { + owner_.lastTargetGuidRef() = owner_.getTargetGuid(); } - owner_.targetGuid = guid; + owner_.setTargetGuidRaw(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. - if (owner_.spellHandler_) for (auto& slot : owner_.spellHandler_->targetAuras_) slot = AuraSlot{}; + if (owner_.getSpellHandler()) for (auto& slot : owner_.getSpellHandler()->targetAuras_) slot = AuraSlot{}; // Clear previous target's cast bar on target change // (the new target's cast state is naturally fetched from spellHandler_->unitCastStates_ by GUID) @@ -1073,7 +1073,7 @@ void CombatHandler::setTarget(uint64_t guid) { // Inform server of target selection if (owner_.isInWorld()) { auto packet = SetSelectionPacket::build(guid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } if (guid != 0) { @@ -1083,27 +1083,27 @@ void CombatHandler::setTarget(uint64_t guid) { } void CombatHandler::clearTarget() { - if (owner_.targetGuid != 0) { + if (owner_.getTargetGuid() != 0) { LOG_INFO("Target cleared"); // Zero the GUID before firing the event so callbacks/addons that query // the current target see null (consistent with setTarget which updates // targetGuid before the event). - owner_.targetGuid = 0; + owner_.setTargetGuidRaw(0); owner_.fireAddonEvent("PLAYER_TARGET_CHANGED", {}); } else { - owner_.targetGuid = 0; + owner_.setTargetGuidRaw(0); } - owner_.tabCycleIndex = -1; - owner_.tabCycleStale = true; + owner_.tabCycleIndexRef() = -1; + owner_.tabCycleStaleRef() = true; } std::shared_ptr CombatHandler::getTarget() const { - if (owner_.targetGuid == 0) return nullptr; - return owner_.getEntityManager().getEntity(owner_.targetGuid); + if (owner_.getTargetGuid() == 0) return nullptr; + return owner_.getEntityManager().getEntity(owner_.getTargetGuid()); } void CombatHandler::setFocus(uint64_t guid) { - owner_.focusGuid = guid; + owner_.focusGuidRef() = guid; owner_.fireAddonEvent("PLAYER_FOCUS_CHANGED", {}); if (guid != 0) { auto entity = owner_.getEntityManager().getEntity(guid); @@ -1122,36 +1122,36 @@ void CombatHandler::setFocus(uint64_t guid) { } void CombatHandler::clearFocus() { - if (owner_.focusGuid != 0) { + if (owner_.focusGuidRef() != 0) { owner_.addSystemChatMessage("Focus cleared."); LOG_INFO("Focus cleared"); } - owner_.focusGuid = 0; + owner_.focusGuidRef() = 0; owner_.fireAddonEvent("PLAYER_FOCUS_CHANGED", {}); } std::shared_ptr CombatHandler::getFocus() const { - if (owner_.focusGuid == 0) return nullptr; - return owner_.getEntityManager().getEntity(owner_.focusGuid); + if (owner_.focusGuidRef() == 0) return nullptr; + return owner_.getEntityManager().getEntity(owner_.focusGuidRef()); } void CombatHandler::setMouseoverGuid(uint64_t guid) { - if (owner_.mouseoverGuid_ != guid) { - owner_.mouseoverGuid_ = guid; + if (owner_.mouseoverGuidRef() != guid) { + owner_.mouseoverGuidRef() = guid; owner_.fireAddonEvent("UPDATE_MOUSEOVER_UNIT", {}); } } void CombatHandler::targetLastTarget() { - if (owner_.lastTargetGuid == 0) { + if (owner_.lastTargetGuidRef() == 0) { owner_.addSystemChatMessage("No previous target."); return; } // Swap current and last target - uint64_t temp = owner_.targetGuid; - setTarget(owner_.lastTargetGuid); - owner_.lastTargetGuid = temp; + uint64_t temp = owner_.getTargetGuid(); + setTarget(owner_.lastTargetGuidRef()); + owner_.lastTargetGuidRef() = temp; } void CombatHandler::targetEnemy(bool reverse) { @@ -1162,7 +1162,7 @@ void CombatHandler::targetEnemy(bool reverse) { for (const auto& [guid, entity] : entities) { if (entity->getType() == ObjectType::UNIT) { auto unit = std::dynamic_pointer_cast(entity); - if (unit && guid != owner_.playerGuid && unit->isHostile()) { + if (unit && guid != owner_.getPlayerGuid() && unit->isHostile()) { hostiles.push_back(guid); } } @@ -1174,7 +1174,7 @@ void CombatHandler::targetEnemy(bool reverse) { } // Find current target in list - auto it = std::find(hostiles.begin(), hostiles.end(), owner_.targetGuid); + auto it = std::find(hostiles.begin(), hostiles.end(), owner_.getTargetGuid()); if (it == hostiles.end()) { // Not currently targeting a hostile, target first one @@ -1204,7 +1204,7 @@ void CombatHandler::targetFriend(bool reverse) { auto& entities = owner_.getEntityManager().getEntities(); for (const auto& [guid, entity] : entities) { - if (entity->getType() == ObjectType::PLAYER && guid != owner_.playerGuid) { + if (entity->getType() == ObjectType::PLAYER && guid != owner_.getPlayerGuid()) { friendlies.push_back(guid); } } @@ -1215,7 +1215,7 @@ void CombatHandler::targetFriend(bool reverse) { } // Find current target in list - auto it = std::find(friendlies.begin(), friendlies.end(), owner_.targetGuid); + auto it = std::find(friendlies.begin(), friendlies.end(), owner_.getTargetGuid()); if (it == friendlies.end()) { // Not currently targeting a friend, target first one @@ -1247,8 +1247,8 @@ void CombatHandler::tabTarget(float playerX, float playerY, float playerZ) { auto* unit = dynamic_cast(e.get()); if (!unit) return false; if (unit->getHealth() == 0) { - auto lootIt = owner_.localLootState_.find(guid); - if (lootIt == owner_.localLootState_.end() || lootIt->second.data.items.empty()) { + auto lootIt = owner_.localLootStateRef().find(guid); + if (lootIt == owner_.localLootStateRef().end() || lootIt->second.data.items.empty()) { return false; } return true; @@ -1260,9 +1260,9 @@ void CombatHandler::tabTarget(float playerX, float playerY, float playerZ) { }; // Rebuild cycle list if stale (entity added/removed since last tab press). - if (owner_.tabCycleStale) { - owner_.tabCycleList.clear(); - owner_.tabCycleIndex = -1; + if (owner_.tabCycleStaleRef()) { + owner_.tabCycleListRef().clear(); + owner_.tabCycleIndexRef() = -1; struct EntityDist { uint64_t guid; float distance; }; std::vector sortable; @@ -1270,7 +1270,7 @@ void CombatHandler::tabTarget(float playerX, float playerY, float playerZ) { for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) { auto t = entity->getType(); if (t != ObjectType::UNIT && t != ObjectType::PLAYER) continue; - if (guid == owner_.playerGuid) continue; + if (guid == owner_.getPlayerGuid()) continue; if (!isValidTabTarget(entity)) continue; float dx = entity->getX() - playerX; float dy = entity->getY() - playerY; @@ -1282,22 +1282,22 @@ void CombatHandler::tabTarget(float playerX, float playerY, float playerZ) { [](const EntityDist& a, const EntityDist& b) { return a.distance < b.distance; }); for (const auto& ed : sortable) { - owner_.tabCycleList.push_back(ed.guid); + owner_.tabCycleListRef().push_back(ed.guid); } - owner_.tabCycleStale = false; + owner_.tabCycleStaleRef() = false; } - if (owner_.tabCycleList.empty()) { + if (owner_.tabCycleListRef().empty()) { clearTarget(); return; } // Advance through the cycle, skipping any entry that has since died or // turned friendly (e.g. NPC killed between two tab presses). - int tries = static_cast(owner_.tabCycleList.size()); + int tries = static_cast(owner_.tabCycleListRef().size()); while (tries-- > 0) { - owner_.tabCycleIndex = (owner_.tabCycleIndex + 1) % static_cast(owner_.tabCycleList.size()); - uint64_t guid = owner_.tabCycleList[owner_.tabCycleIndex]; + owner_.tabCycleIndexRef() = (owner_.tabCycleIndexRef() + 1) % static_cast(owner_.tabCycleListRef().size()); + uint64_t guid = owner_.tabCycleListRef()[owner_.tabCycleIndexRef()]; auto entity = owner_.getEntityManager().getEntity(guid); if (isValidTabTarget(entity)) { setTarget(guid); @@ -1306,17 +1306,17 @@ void CombatHandler::tabTarget(float playerX, float playerY, float playerZ) { } // All cached entries are stale — clear target and force a fresh rebuild next time. - owner_.tabCycleStale = true; + owner_.tabCycleStaleRef() = true; clearTarget(); } void CombatHandler::assistTarget() { - if (owner_.state != WorldState::IN_WORLD) { + if (owner_.getState() != WorldState::IN_WORLD) { LOG_WARNING("Cannot assist: not in world"); return; } - if (owner_.targetGuid == 0) { + if (owner_.getTargetGuid() == 0) { owner_.addSystemChatMessage("You must target someone to assist."); return; } @@ -1373,8 +1373,8 @@ void CombatHandler::togglePvp() { } auto packet = TogglePvpPacket::build(); - owner_.socket->send(packet); - auto entity = owner_.getEntityManager().getEntity(owner_.playerGuid); + owner_.getSocket()->send(packet); + auto entity = owner_.getEntityManager().getEntity(owner_.getPlayerGuid()); bool currentlyPvp = false; if (entity) { // UNIT_FIELD_FLAGS (index 59), bit 0x1000 = UNIT_FLAG_PVP @@ -1393,93 +1393,93 @@ void CombatHandler::togglePvp() { // ============================================================ void CombatHandler::releaseSpirit() { - if (owner_.socket && owner_.state == WorldState::IN_WORLD) { + if (owner_.getSocket() && owner_.getState() == WorldState::IN_WORLD) { auto now = std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count(); - if (owner_.repopPending_ && now - static_cast(owner_.lastRepopRequestMs_) < 1000) { + if (owner_.repopPendingRef() && now - static_cast(owner_.lastRepopRequestMsRef()) < 1000) { return; } auto packet = RepopRequestPacket::build(); - owner_.socket->send(packet); - owner_.selfResAvailable_ = false; - owner_.repopPending_ = true; - owner_.lastRepopRequestMs_ = static_cast(now); + owner_.getSocket()->send(packet); + owner_.selfResAvailableRef() = false; + owner_.repopPendingRef() = true; + owner_.lastRepopRequestMsRef() = static_cast(now); LOG_INFO("Sent CMSG_REPOP_REQUEST (Release Spirit)"); network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY)); - owner_.socket->send(cq); + owner_.getSocket()->send(cq); } } bool CombatHandler::canReclaimCorpse() const { - if (!owner_.releasedSpirit_ || owner_.corpseGuid_ == 0 || owner_.corpseMapId_ == 0) return false; - if (owner_.currentMapId_ != owner_.corpseMapId_) return false; - float dx = owner_.movementInfo.x - owner_.corpseY_; - float dy = owner_.movementInfo.y - owner_.corpseX_; - float dz = owner_.movementInfo.z - owner_.corpseZ_; + if (!owner_.releasedSpiritRef() || owner_.corpseGuidRef() == 0 || owner_.corpseMapIdRef() == 0) return false; + if (owner_.currentMapIdRef() != owner_.corpseMapIdRef()) return false; + float dx = owner_.movementInfoRef().x - owner_.corpseYRef(); + float dy = owner_.movementInfoRef().y - owner_.corpseXRef(); + float dz = owner_.movementInfoRef().z - owner_.corpseZRef(); return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); } float CombatHandler::getCorpseReclaimDelaySec() const { - if (owner_.corpseReclaimAvailableMs_ == 0) return 0.0f; + if (owner_.corpseReclaimAvailableMsRef() == 0) return 0.0f; auto nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); - if (nowMs >= owner_.corpseReclaimAvailableMs_) return 0.0f; - return static_cast(owner_.corpseReclaimAvailableMs_ - nowMs) / 1000.0f; + if (nowMs >= owner_.corpseReclaimAvailableMsRef()) return 0.0f; + return static_cast(owner_.corpseReclaimAvailableMsRef() - nowMs) / 1000.0f; } void CombatHandler::reclaimCorpse() { - if (!canReclaimCorpse() || !owner_.socket) return; - if (owner_.corpseGuid_ == 0) { + if (!canReclaimCorpse() || !owner_.getSocket()) return; + if (owner_.corpseGuidRef() == 0) { LOG_WARNING("reclaimCorpse: corpse GUID not yet known (corpse object not received); cannot reclaim"); return; } - auto packet = ReclaimCorpsePacket::build(owner_.corpseGuid_); - owner_.socket->send(packet); - LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, owner_.corpseGuid_, std::dec); + auto packet = ReclaimCorpsePacket::build(owner_.corpseGuidRef()); + owner_.getSocket()->send(packet); + LOG_INFO("Sent CMSG_RECLAIM_CORPSE for corpse guid=0x", std::hex, owner_.corpseGuidRef(), std::dec); } void CombatHandler::useSelfRes() { - if (!owner_.selfResAvailable_ || !owner_.socket) return; + if (!owner_.selfResAvailableRef() || !owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_SELF_RES)); - owner_.socket->send(pkt); - owner_.selfResAvailable_ = false; + owner_.getSocket()->send(pkt); + owner_.selfResAvailableRef() = false; LOG_INFO("Sent CMSG_SELF_RES (Reincarnation / Twisting Nether)"); } void CombatHandler::activateSpiritHealer(uint64_t npcGuid) { if (!owner_.isInWorld()) return; - owner_.pendingSpiritHealerGuid_ = npcGuid; + owner_.pendingSpiritHealerGuidRef() = npcGuid; auto packet = SpiritHealerActivatePacket::build(npcGuid); - owner_.socket->send(packet); - owner_.resurrectPending_ = true; + owner_.getSocket()->send(packet); + owner_.resurrectPendingRef() = true; LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE for 0x", std::hex, npcGuid, std::dec); } void CombatHandler::acceptResurrect() { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || !owner_.resurrectRequestPending_) return; - if (owner_.resurrectIsSpiritHealer_) { - auto activate = SpiritHealerActivatePacket::build(owner_.resurrectCasterGuid_); - owner_.socket->send(activate); + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || !owner_.resurrectRequestPendingRef()) return; + if (owner_.resurrectIsSpiritHealerRef()) { + auto activate = SpiritHealerActivatePacket::build(owner_.resurrectCasterGuidRef()); + owner_.getSocket()->send(activate); LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE for 0x", - std::hex, owner_.resurrectCasterGuid_, std::dec); + std::hex, owner_.resurrectCasterGuidRef(), std::dec); } else { - auto resp = ResurrectResponsePacket::build(owner_.resurrectCasterGuid_, true); - owner_.socket->send(resp); + auto resp = ResurrectResponsePacket::build(owner_.resurrectCasterGuidRef(), true); + owner_.getSocket()->send(resp); LOG_INFO("Sent CMSG_RESURRECT_RESPONSE (accept) for 0x", - std::hex, owner_.resurrectCasterGuid_, std::dec); + std::hex, owner_.resurrectCasterGuidRef(), std::dec); } - owner_.resurrectRequestPending_ = false; - owner_.resurrectPending_ = true; + owner_.resurrectRequestPendingRef() = false; + owner_.resurrectPendingRef() = true; } void CombatHandler::declineResurrect() { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || !owner_.resurrectRequestPending_) return; - auto resp = ResurrectResponsePacket::build(owner_.resurrectCasterGuid_, false); - owner_.socket->send(resp); + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || !owner_.resurrectRequestPendingRef()) return; + auto resp = ResurrectResponsePacket::build(owner_.resurrectCasterGuidRef(), false); + owner_.getSocket()->send(resp); LOG_INFO("Sent CMSG_RESURRECT_RESPONSE (decline) for 0x", - std::hex, owner_.resurrectCasterGuid_, std::dec); - owner_.resurrectRequestPending_ = false; + std::hex, owner_.resurrectCasterGuidRef(), std::dec); + owner_.resurrectRequestPendingRef() = false; } // ============================================================ diff --git a/src/game/entity_controller.cpp b/src/game/entity_controller.cpp index 6eb3a307..ac67bef4 100644 --- a/src/game/entity_controller.cpp +++ b/src/game/entity_controller.cpp @@ -75,15 +75,15 @@ EntityController::EntityController(GameHandler& owner) void EntityController::registerOpcodes(DispatchTable& table) { // World object updates table[Opcode::SMSG_UPDATE_OBJECT] = [this](network::Packet& packet) { - LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast(owner_.state), " size=", packet.getSize()); - if (owner_.state == WorldState::IN_WORLD) handleUpdateObject(packet); + LOG_DEBUG("Received SMSG_UPDATE_OBJECT, state=", static_cast(owner_.getState()), " size=", packet.getSize()); + if (owner_.getState() == WorldState::IN_WORLD) handleUpdateObject(packet); }; table[Opcode::SMSG_COMPRESSED_UPDATE_OBJECT] = [this](network::Packet& packet) { - LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast(owner_.state), " size=", packet.getSize()); - if (owner_.state == WorldState::IN_WORLD) handleCompressedUpdateObject(packet); + LOG_DEBUG("Received SMSG_COMPRESSED_UPDATE_OBJECT, state=", static_cast(owner_.getState()), " size=", packet.getSize()); + if (owner_.getState() == WorldState::IN_WORLD) handleCompressedUpdateObject(packet); }; table[Opcode::SMSG_DESTROY_OBJECT] = [this](network::Packet& packet) { - if (owner_.state == WorldState::IN_WORLD) handleDestroyObject(packet); + if (owner_.getState() == WorldState::IN_WORLD) handleDestroyObject(packet); }; // Entity queries @@ -131,7 +131,7 @@ void EntityController::processPendingUpdateObjectWork(const std::chrono::steady_ return; } - const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(owner_.state); + const int maxBlocksThisUpdate = updateObjectBlocksBudgetPerUpdate(owner_.getState()); int processedBlocks = 0; while (!pendingUpdateObjectWork_.empty() && processedBlocks < maxBlocksThisUpdate) { @@ -191,12 +191,12 @@ void EntityController::processPendingUpdateObjectWork(const std::chrono::steady_ const auto& work = pendingUpdateObjectWork_.front(); LOG_DEBUG("GameHandler update-object budget reached (remainingBatches=", pendingUpdateObjectWork_.size(), ", nextBlockIndex=", work.nextBlockIndex, - "/", work.data.blocks.size(), ", owner_.state=", worldStateName(owner_.state), ")"); + "/", work.data.blocks.size(), ", owner_.getState()=", worldStateName(owner_.getState()), ")"); } } void EntityController::handleUpdateObject(network::Packet& packet) { UpdateObjectData data; - if (!owner_.packetParsers_->parseUpdateObject(packet, data)) { + if (!owner_.getPacketParsers()->parseUpdateObject(packet, data)) { static int updateObjErrors = 0; if (++updateObjErrors <= 5) LOG_WARNING("Failed to parse SMSG_UPDATE_OBJECT"); @@ -218,9 +218,9 @@ void EntityController::processOutOfRangeObjects(const std::vector& gui // Keep transports alive across out-of-range flapping. // Boats/zeppelins are global movers and removing them here can make // them disappear until a later movement snapshot happens to recreate them. - const bool playerAboardNow = (owner_.playerTransportGuid_ == guid); - const bool stickyAboard = (owner_.playerTransportStickyGuid_ == guid && owner_.playerTransportStickyTimer_ > 0.0f); - const bool movementSaysAboard = (owner_.movementInfo.transportGuid == guid); + const bool playerAboardNow = (owner_.playerTransportGuidRef() == guid); + const bool stickyAboard = (owner_.playerTransportStickyGuidRef() == guid && owner_.playerTransportStickyTimerRef() > 0.0f); + const bool movementSaysAboard = (owner_.movementInfoRef().transportGuid == guid); LOG_INFO("Preserving transport on out-of-range: 0x", std::hex, guid, std::dec, " now=", playerAboardNow, @@ -231,25 +231,25 @@ void EntityController::processOutOfRangeObjects(const std::vector& gui LOG_DEBUG("Entity went out of range: 0x", std::hex, guid, std::dec); // Trigger despawn callbacks before removing entity - if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) { - owner_.creatureDespawnCallback_(guid); - } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) { - owner_.playerDespawnCallback_(guid); - owner_.otherPlayerVisibleItemEntries_.erase(guid); - owner_.otherPlayerVisibleDirty_.erase(guid); - owner_.otherPlayerMoveTimeMs_.erase(guid); - owner_.inspectedPlayerItemEntries_.erase(guid); - owner_.pendingAutoInspect_.erase(guid); + if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallbackRef()) { + owner_.creatureDespawnCallbackRef()(guid); + } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallbackRef()) { + owner_.playerDespawnCallbackRef()(guid); + owner_.otherPlayerVisibleItemEntriesRef().erase(guid); + owner_.otherPlayerVisibleDirtyRef().erase(guid); + owner_.otherPlayerMoveTimeMsRef().erase(guid); + owner_.inspectedPlayerItemEntriesRef().erase(guid); + owner_.pendingAutoInspectRef().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 && owner_.gameObjectDespawnCallback_) { - owner_.gameObjectDespawnCallback_(guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallbackRef()) { + owner_.gameObjectDespawnCallbackRef()(guid); } transportGuids_.erase(guid); serverUpdatedTransportGuids_.erase(guid); owner_.clearTransportAttachment(guid); - if (owner_.playerTransportGuid_ == guid) { + if (owner_.playerTransportGuidRef() == guid) { owner_.clearPlayerTransport(); } entityManager.removeEntity(guid); @@ -344,7 +344,7 @@ bool EntityController::extractPlayerAppearance(const std::map& oldFields, const std::map& newFields) { - if (owner_.pendingMoneyDelta_ == 0 || owner_.pendingMoneyDeltaTimer_ <= 0.0f) return; + if (owner_.pendingMoneyDeltaRef() == 0 || owner_.pendingMoneyDeltaTimerRef() <= 0.0f) return; if (oldFields.empty() || newFields.empty()) return; constexpr uint32_t kMaxPlausibleCoinage = 2147483647u; @@ -357,7 +357,7 @@ void EntityController::maybeDetectCoinageIndex(const std::mapsecond; if (newVal < oldVal) continue; uint32_t delta = newVal - oldVal; - if (delta != owner_.pendingMoneyDelta_) continue; + if (delta != owner_.pendingMoneyDeltaRef()) continue; if (newVal > kMaxPlausibleCoinage) continue; candidates.push_back(idx); } @@ -374,12 +374,12 @@ void EntityController::maybeDetectCoinageIndex(const std::map& entity, ObjectType entityType) { - if (block.guid == owner_.playerGuid) return; + if (block.guid == owner_.getPlayerGuid()) return; if (entityType != ObjectType::UNIT && entityType != ObjectType::GAMEOBJECT) return; if (block.onTransport && block.transportGuid != 0) { @@ -421,8 +421,8 @@ void EntityController::updateNonPlayerTransportAttachment(const UpdateBlock& blo float localOriCanonical = core::coords::normalizeAngleRad(-block.transportO); owner_.setTransportAttachment(block.guid, entityType, block.transportGuid, localOffset, hasLocalOrientation, localOriCanonical); - if (owner_.transportManager_ && owner_.transportManager_->getTransport(block.transportGuid)) { - glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(block.transportGuid, localOffset); + if (owner_.getTransportManager() && owner_.getTransportManager()->getTransport(block.transportGuid)) { + glm::vec3 composed = owner_.getTransportManager()->getPlayerWorldPosition(block.transportGuid, localOffset); entity->setPosition(composed.x, composed.y, composed.z, entity->getOrientation()); } } else { @@ -436,7 +436,7 @@ void EntityController::updateNonPlayerTransportAttachment(const UpdateBlock& blo // Normalises Classic harmful bit (0x02) to WotLK debuff bit (0x80) so // downstream code checking for 0x80 works consistently across expansions. void EntityController::syncClassicAurasFromFields(const std::shared_ptr& entity) { - if (!isClassicLikeExpansion() || !owner_.spellHandler_) return; + if (!isClassicLikeExpansion() || !owner_.getSpellHandler()) return; const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); @@ -449,15 +449,15 @@ void EntityController::syncClassicAurasFromFields(const std::shared_ptr& } if (!hasAuraField) return; - owner_.spellHandler_->playerAuras_.clear(); - owner_.spellHandler_->playerAuras_.resize(48); + owner_.getSpellHandler()->playerAuras_.clear(); + owner_.getSpellHandler()->playerAuras_.resize(48); uint64_t nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); for (int slot = 0; slot < 48; ++slot) { auto it = allFields.find(static_cast(ufAuras + slot)); if (it != allFields.end() && it->second != 0) { - AuraSlot& a = owner_.spellHandler_->playerAuras_[slot]; + AuraSlot& a = owner_.getSpellHandler()->playerAuras_[slot]; a.spellId = it->second; // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags uint8_t aFlag = 0; @@ -473,7 +473,7 @@ void EntityController::syncClassicAurasFromFields(const std::shared_ptr& a.flags = aFlag; a.durationMs = -1; a.maxDurationMs = -1; - a.casterGuid = owner_.playerGuid; + a.casterGuid = owner_.getPlayerGuid(); a.receivedAtMs = nowMs; } } @@ -484,41 +484,41 @@ void EntityController::syncClassicAurasFromFields(const std::shared_ptr& // Detect player mount/dismount from UNIT_FIELD_MOUNTDISPLAYID changes void EntityController::detectPlayerMountChange(uint32_t newMountDisplayId, const std::map& blockFields) { - uint32_t old = owner_.currentMountDisplayId_; - owner_.currentMountDisplayId_ = newMountDisplayId; - if (newMountDisplayId != old && owner_.mountCallback_) owner_.mountCallback_(newMountDisplayId); + uint32_t old = owner_.currentMountDisplayIdRef(); + owner_.currentMountDisplayIdRef() = newMountDisplayId; + if (newMountDisplayId != old && owner_.mountCallbackRef()) owner_.mountCallbackRef()(newMountDisplayId); if (newMountDisplayId != old) pendingEvents_.emit("UNIT_MODEL_CHANGED", {"player"}); if (old == 0 && newMountDisplayId != 0) { // Just mounted — find the mount aura (indefinite duration, self-cast) - owner_.mountAuraSpellId_ = 0; - if (owner_.spellHandler_) for (const auto& a : owner_.spellHandler_->playerAuras_) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == owner_.playerGuid) { - owner_.mountAuraSpellId_ = a.spellId; + owner_.mountAuraSpellIdRef() = 0; + if (owner_.getSpellHandler()) for (const auto& a : owner_.getSpellHandler()->playerAuras_) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == owner_.getPlayerGuid()) { + owner_.mountAuraSpellIdRef() = a.spellId; } } // Classic/vanilla fallback: scan UNIT_FIELD_AURAS from same update block - if (owner_.mountAuraSpellId_ == 0) { + if (owner_.mountAuraSpellIdRef() == 0) { const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); if (ufAuras != 0xFFFF) { for (const auto& [fk, fv] : blockFields) { if (fk >= ufAuras && fk < ufAuras + 48 && fv != 0) { - owner_.mountAuraSpellId_ = fv; + owner_.mountAuraSpellIdRef() = fv; break; } } } } - LOG_INFO("Mount detected: displayId=", newMountDisplayId, " auraSpellId=", owner_.mountAuraSpellId_); + LOG_INFO("Mount detected: displayId=", newMountDisplayId, " auraSpellId=", owner_.mountAuraSpellIdRef()); } if (old != 0 && newMountDisplayId == 0) { // Only clear the specific mount aura, not all indefinite auras. // Previously this cleared every aura with maxDurationMs < 0, which // would strip racial passives, tracking, and zone buffs on dismount. - uint32_t mountSpell = owner_.mountAuraSpellId_; - owner_.mountAuraSpellId_ = 0; - if (mountSpell != 0 && owner_.spellHandler_) { - for (auto& a : owner_.spellHandler_->playerAuras_) { + uint32_t mountSpell = owner_.mountAuraSpellIdRef(); + owner_.mountAuraSpellIdRef() = 0; + if (mountSpell != 0 && owner_.getSpellHandler()) { + for (auto& a : owner_.getSpellHandler()->playerAuras_) { if (!a.isEmpty() && a.spellId == mountSpell) { a = AuraSlot{}; break; @@ -610,34 +610,34 @@ void EntityController::applyPlayerTransportState(const UpdateBlock& block, glm::vec3 serverOffset(block.transportX, block.transportY, block.transportZ); glm::vec3 canonicalOffset = core::coords::serverToCanonical(serverOffset); owner_.setPlayerOnTransport(block.transportGuid, canonicalOffset); - if (owner_.transportManager_ && owner_.transportManager_->getTransport(owner_.playerTransportGuid_)) { - glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_); + if (owner_.getTransportManager() && owner_.getTransportManager()->getTransport(owner_.playerTransportGuidRef())) { + glm::vec3 composed = owner_.getTransportManager()->getPlayerWorldPosition(owner_.playerTransportGuidRef(), owner_.playerTransportOffsetRef()); entity->setPosition(composed.x, composed.y, composed.z, oCanonical); - owner_.movementInfo.x = composed.x; - owner_.movementInfo.y = composed.y; - owner_.movementInfo.z = composed.z; + owner_.movementInfoRef().x = composed.x; + owner_.movementInfoRef().y = composed.y; + owner_.movementInfoRef().z = composed.z; } else if (updateMovementInfoPos) { - owner_.movementInfo.x = canonicalPos.x; - owner_.movementInfo.y = canonicalPos.y; - owner_.movementInfo.z = canonicalPos.z; + owner_.movementInfoRef().x = canonicalPos.x; + owner_.movementInfoRef().y = canonicalPos.y; + owner_.movementInfoRef().z = canonicalPos.z; } - LOG_INFO("Player on transport: 0x", std::hex, owner_.playerTransportGuid_, std::dec, - " offset=(", owner_.playerTransportOffset_.x, ", ", owner_.playerTransportOffset_.y, - ", ", owner_.playerTransportOffset_.z, ")"); + LOG_INFO("Player on transport: 0x", std::hex, owner_.playerTransportGuidRef(), std::dec, + " offset=(", owner_.playerTransportOffsetRef().x, ", ", owner_.playerTransportOffsetRef().y, + ", ", owner_.playerTransportOffsetRef().z, ")"); } else { if (updateMovementInfoPos) { - owner_.movementInfo.x = canonicalPos.x; - owner_.movementInfo.y = canonicalPos.y; - owner_.movementInfo.z = canonicalPos.z; + owner_.movementInfoRef().x = canonicalPos.x; + owner_.movementInfoRef().y = canonicalPos.y; + owner_.movementInfoRef().z = canonicalPos.z; } // Don't clear client-side M2 transport boarding (trams) — // the server doesn't know about client-detected transport attachment. bool isClientM2Transport = false; - if (owner_.playerTransportGuid_ != 0 && owner_.transportManager_) { - auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_); + if (owner_.playerTransportGuidRef() != 0 && owner_.getTransportManager()) { + auto* tr = owner_.getTransportManager()->getTransport(owner_.playerTransportGuidRef()); isClientM2Transport = (tr && tr->isM2); } - if (owner_.playerTransportGuid_ != 0 && !isClientM2Transport) { + if (owner_.playerTransportGuidRef() != 0 && !isClientM2Transport) { LOG_INFO("Player left transport"); owner_.clearPlayerTransport(); } @@ -664,8 +664,8 @@ bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block, if (block.objectType == ObjectType::UNIT && val == 0) { unitInitiallyDead = true; } - if (block.guid == owner_.playerGuid && val == 0) { - owner_.playerDead_ = true; + if (block.guid == owner_.getPlayerGuid() && val == 0) { + owner_.playerDeadRef() = true; LOG_INFO("Player logged in dead"); } } else if (key == ufi.maxHealth) { unit->setMaxHealth(val); } @@ -673,7 +673,7 @@ bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block, unit->setLevel(val); } else if (key == ufi.faction) { unit->setFactionTemplate(val); - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { auto uid = owner_.guidToUnitId(block.guid); if (!uid.empty()) pendingEvents_.emit("UNIT_FACTION", {uid}); @@ -681,7 +681,7 @@ bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block, } else if (key == ufi.flags) { unit->setUnitFlags(val); - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { auto uid = owner_.guidToUnitId(block.guid); if (!uid.empty()) pendingEvents_.emit("UNIT_FLAGS", {uid}); @@ -691,7 +691,7 @@ bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block, unit->setPowerType(static_cast((val >> 24) & 0xFF)); } else if (key == ufi.displayId) { unit->setDisplayId(val); - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { auto uid = owner_.guidToUnitId(block.guid); if (!uid.empty()) pendingEvents_.emit("UNIT_MODEL_CHANGED", {uid}); @@ -713,7 +713,7 @@ bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block, unit->setMaxPowerByType(static_cast(key - ufi.maxPowerBase), val); } else if (key == ufi.mountDisplayId) { - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { detectPlayerMountChange(val, block.fields); } unit->setMountDisplayId(val); @@ -727,17 +727,17 @@ bool EntityController::applyUnitFieldsOnCreate(const UpdateBlock& block, // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so this cached position // is the primary source for canReclaimCorpse(). void EntityController::markPlayerDead(const char* source) { - owner_.playerDead_ = true; - owner_.releasedSpirit_ = false; - // owner_.movementInfo is canonical (x=north, y=west); corpseX_/Y_ are + owner_.playerDeadRef() = true; + owner_.releasedSpiritRef() = false; + // owner_.movementInfoRef() is canonical (x=north, y=west); corpseX_/Y_ are // raw server coords (x=west, y=north) — swap axes. - owner_.corpseX_ = owner_.movementInfo.y; - owner_.corpseY_ = owner_.movementInfo.x; - owner_.corpseZ_ = owner_.movementInfo.z; - owner_.corpseMapId_ = owner_.currentMapId_; + owner_.corpseXRef() = owner_.movementInfoRef().y; + owner_.corpseYRef() = owner_.movementInfoRef().x; + owner_.corpseZRef() = owner_.movementInfoRef().z; + owner_.corpseMapIdRef() = owner_.currentMapIdRef(); LOG_INFO("Player died (", source, "). Corpse cached at server=(", - owner_.corpseX_, ",", owner_.corpseY_, ",", owner_.corpseZ_, - ") map=", owner_.corpseMapId_); + owner_.corpseXRef(), ",", owner_.corpseYRef(), ",", owner_.corpseZRef(), + ") map=", owner_.corpseMapIdRef()); } // 3c: Apply unit fields during VALUES update — tracks health/power/display changes @@ -755,34 +755,34 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat unit->setHealth(val); result.healthChanged = true; if (val == 0) { - if (owner_.combatHandler_ && block.guid == owner_.combatHandler_->getAutoAttackTargetGuid()) { + if (owner_.getCombatHandler() && block.guid == owner_.getCombatHandler()->getAutoAttackTargetGuid()) { owner_.stopAutoAttack(); } - if (owner_.combatHandler_) owner_.combatHandler_->removeHostileAttacker(block.guid); - if (block.guid == owner_.playerGuid) { + if (owner_.getCombatHandler()) owner_.getCombatHandler()->removeHostileAttacker(block.guid); + if (block.guid == owner_.getPlayerGuid()) { markPlayerDead("health=0"); owner_.stopAutoAttack(); pendingEvents_.emit("PLAYER_DEAD", {}); } - if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcDeathCallback_) { - owner_.npcDeathCallback_(block.guid); + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcDeathCallbackRef()) { + owner_.npcDeathCallbackRef()(block.guid); result.npcDeathNotified = true; } } else if (oldHealth == 0 && val > 0) { - if (block.guid == owner_.playerGuid) { - bool wasGhost = owner_.releasedSpirit_; - owner_.playerDead_ = false; + if (block.guid == owner_.getPlayerGuid()) { + bool wasGhost = owner_.releasedSpiritRef(); + owner_.playerDeadRef() = false; if (!wasGhost) { LOG_INFO("Player resurrected!"); pendingEvents_.emit("PLAYER_ALIVE", {}); } else { LOG_INFO("Player entered ghost form"); - owner_.releasedSpirit_ = false; + owner_.releasedSpiritRef() = false; pendingEvents_.emit("PLAYER_UNGHOST", {}); } } - if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcRespawnCallback_) { - owner_.npcRespawnCallback_(block.guid); + if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && owner_.npcRespawnCallbackRef()) { + owner_.npcRespawnCallbackRef()(block.guid); result.npcRespawnNotified = true; } } @@ -802,27 +802,27 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat unit->setUnitFlags(val); // Detect stun state change on local player constexpr uint32_t UNIT_FLAG_STUNNED = 0x00040000; - if (block.guid == owner_.playerGuid && owner_.stunStateCallback_) { + if (block.guid == owner_.getPlayerGuid() && owner_.stunStateCallbackRef()) { bool wasStunned = (oldFlags & UNIT_FLAG_STUNNED) != 0; bool nowStunned = (val & UNIT_FLAG_STUNNED) != 0; if (wasStunned != nowStunned) { - owner_.stunStateCallback_(nowStunned); + owner_.stunStateCallbackRef()(nowStunned); } } // Detect stealth state change on local player constexpr uint32_t UNIT_FLAG_SNEAKING = 0x02000000; - if (block.guid == owner_.playerGuid && owner_.stealthStateCallback_) { + if (block.guid == owner_.getPlayerGuid() && owner_.stealthStateCallbackRef()) { bool wasStealth = (oldFlags & UNIT_FLAG_SNEAKING) != 0; bool nowStealth = (val & UNIT_FLAG_SNEAKING) != 0; if (wasStealth != nowStealth) { - owner_.stealthStateCallback_(nowStealth); + owner_.stealthStateCallbackRef()(nowStealth); } } } - else if (ufi.bytes1 != 0xFFFF && key == ufi.bytes1 && block.guid == owner_.playerGuid) { + else if (ufi.bytes1 != 0xFFFF && key == ufi.bytes1 && block.guid == owner_.getPlayerGuid()) { uint8_t newForm = static_cast((val >> 24) & 0xFF); - if (newForm != owner_.shapeshiftFormId_) { - owner_.shapeshiftFormId_ = newForm; + if (newForm != owner_.shapeshiftFormIdRef()) { + owner_.shapeshiftFormIdRef() = newForm; LOG_INFO("Shapeshift form changed: ", static_cast(newForm)); pendingEvents_.emit("UPDATE_SHAPESHIFT_FORM", {}); pendingEvents_.emit("UPDATE_SHAPESHIFT_FORMS", {}); @@ -831,28 +831,28 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat else if (key == ufi.dynFlags) { uint32_t oldDyn = unit->getDynamicFlags(); unit->setDynamicFlags(val); - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; if (!wasDead && nowDead) { markPlayerDead("dynFlags"); } else if (wasDead && !nowDead) { - owner_.playerDead_ = false; - owner_.releasedSpirit_ = false; - owner_.selfResAvailable_ = false; + owner_.playerDeadRef() = false; + owner_.releasedSpiritRef() = false; + owner_.selfResAvailableRef() = false; LOG_INFO("Player resurrected (dynamic flags)"); } } else if (entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) { bool wasDead = (oldDyn & UNIT_DYNFLAG_DEAD) != 0; bool nowDead = (val & UNIT_DYNFLAG_DEAD) != 0; if (!wasDead && nowDead) { - if (!result.npcDeathNotified && owner_.npcDeathCallback_) { - owner_.npcDeathCallback_(block.guid); + if (!result.npcDeathNotified && owner_.npcDeathCallbackRef()) { + owner_.npcDeathCallbackRef()(block.guid); result.npcDeathNotified = true; } } else if (wasDead && !nowDead) { - if (!result.npcRespawnNotified && owner_.npcRespawnCallback_) { - owner_.npcRespawnCallback_(block.guid); + if (!result.npcRespawnNotified && owner_.npcRespawnCallbackRef()) { + owner_.npcRespawnCallbackRef()(block.guid); result.npcRespawnNotified = true; } } @@ -865,11 +865,11 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat if (!uid.empty()) pendingEvents_.emit("UNIT_LEVEL", {uid}); } - if (block.guid != owner_.playerGuid && + if (block.guid != owner_.getPlayerGuid() && entity->getType() == ObjectType::PLAYER && val > oldLvl && oldLvl > 0 && - owner_.otherPlayerLevelUpCallback_) { - owner_.otherPlayerLevelUpCallback_(block.guid, val); + owner_.otherPlayerLevelUpCallbackRef()) { + owner_.otherPlayerLevelUpCallbackRef()(block.guid, val); } } else if (key == ufi.faction) { @@ -881,7 +881,7 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat result.displayIdChanged = true; } } else if (key == ufi.mountDisplayId) { - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { detectPlayerMountChange(val, block.fields); } unit->setMountDisplayId(val); @@ -890,8 +890,8 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat uint32_t oldEmote = unit->getNpcEmoteState(); unit->setNpcEmoteState(val); // Fire emote animation callback so entity_spawner can update the NPC's idle anim - if (val != oldEmote && owner_.emoteAnimCallback_) { - owner_.emoteAnimCallback_(block.guid, val); + if (val != oldEmote && owner_.emoteAnimCallbackRef()) { + owner_.emoteAnimCallbackRef()(block.guid, val); } } // Power/maxpower range checks AFTER all specific fields @@ -912,7 +912,7 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat if (result.powerChanged) { pendingEvents_.emit("UNIT_POWER", {unitId}); // When player power changes, action bar usability may change - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { pendingEvents_.emit("ACTIONBAR_UPDATE_USABLE", {}); pendingEvents_.emit("SPELL_UPDATE_USABLE", {}); } @@ -921,8 +921,8 @@ EntityController::UnitFieldUpdateResult EntityController::applyUnitFieldsOnUpdat } // Fire player health callback for wounded-idle animation - if (result.healthChanged && block.guid == owner_.playerGuid && owner_.playerHealthCallback_) { - owner_.playerHealthCallback_(unit->getHealth(), unit->getMaxHealth()); + if (result.healthChanged && block.guid == owner_.getPlayerGuid() && owner_.playerHealthCallbackRef()) { + owner_.playerHealthCallbackRef()(unit->getHealth(), unit->getMaxHealth()); } return result; @@ -936,139 +936,139 @@ bool EntityController::applyPlayerStatFields(const std::map& bool slotsChanged = false; for (const auto& [key, val] : fields) { if (key == pfi.xp) { - owner_.playerXp_ = val; + owner_.playerXpRef() = val; if (!isCreate) { LOG_DEBUG("XP updated: ", val); pendingEvents_.emit("PLAYER_XP_UPDATE", {std::to_string(val)}); } } else if (key == pfi.nextXp) { - owner_.playerNextLevelXp_ = val; + owner_.playerNextLevelXpRef() = val; if (!isCreate) LOG_DEBUG("Next level XP updated: ", val); } else if (pfi.restedXp != 0xFFFF && key == pfi.restedXp) { - owner_.playerRestedXp_ = val; + owner_.playerRestedXpRef() = val; if (!isCreate) pendingEvents_.emit("UPDATE_EXHAUSTION", {}); } else if (key == pfi.level) { - owner_.serverPlayerLevel_ = val; + owner_.serverPlayerLevelRef() = val; if (!isCreate) LOG_DEBUG("Level updated: ", val); - for (auto& ch : owner_.characters) { - if (ch.guid == owner_.playerGuid) { ch.level = val; break; } + for (auto& ch : owner_.charactersRef()) { + if (ch.guid == owner_.getPlayerGuid()) { ch.level = val; break; } } } else if (key == pfi.coinage) { - uint64_t oldMoney = owner_.playerMoneyCopper_; - owner_.playerMoneyCopper_ = val; + uint64_t oldMoney = owner_.playerMoneyCopperRef(); + owner_.playerMoneyCopperRef() = val; LOG_DEBUG("Money ", isCreate ? "set from update fields: " : "updated via VALUES: ", val, " copper"); if (val != oldMoney) pendingEvents_.emit("PLAYER_MONEY", {}); } else if (pfi.honor != 0xFFFF && key == pfi.honor) { - owner_.playerHonorPoints_ = val; + owner_.playerHonorPointsRef() = val; LOG_DEBUG("Honor points ", isCreate ? "from update fields: " : "updated: ", val); } else if (pfi.arena != 0xFFFF && key == pfi.arena) { - owner_.playerArenaPoints_ = val; + owner_.playerArenaPointsRef() = val; LOG_DEBUG("Arena points ", isCreate ? "from update fields: " : "updated: ", val); } else if (pfi.armor != 0xFFFF && key == pfi.armor) { - owner_.playerArmorRating_ = static_cast(val); - if (isCreate) LOG_DEBUG("Armor rating from update fields: ", owner_.playerArmorRating_); + owner_.playerArmorRatingRef() = static_cast(val); + if (isCreate) LOG_DEBUG("Armor rating from update fields: ", owner_.playerArmorRatingRef()); } else if (pfi.armor != 0xFFFF && key > pfi.armor && key <= pfi.armor + 6) { - owner_.playerResistances_[key - pfi.armor - 1] = static_cast(val); + owner_.playerResistancesArr()[key - pfi.armor - 1] = static_cast(val); } else if (pfi.pBytes2 != 0xFFFF && key == pfi.pBytes2) { uint8_t bankBagSlots = static_cast((val >> 16) & 0xFF); - owner_.inventory.setPurchasedBankBagSlots(bankBagSlots); + owner_.inventoryRef().setPurchasedBankBagSlots(bankBagSlots); // Byte 3 (bits 24-31): REST_STATE // 0 = not resting, 1 = REST_TYPE_IN_TAVERN, 2 = REST_TYPE_IN_CITY uint8_t restStateByte = static_cast((val >> 24) & 0xFF); if (isCreate) { LOG_WARNING("PLAYER_BYTES_2 (CREATE): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots)); - bool wasResting = owner_.isResting_; - owner_.isResting_ = (restStateByte != 0); - if (owner_.isResting_ != wasResting) { + bool wasResting = owner_.isRestingRef(); + owner_.isRestingRef() = (restStateByte != 0); + if (owner_.isRestingRef() != wasResting) { pendingEvents_.emit("UPDATE_EXHAUSTION", {}); pendingEvents_.emit("PLAYER_UPDATE_RESTING", {}); } } else { // Byte 0 (bits 0-7): facial hair / piercings uint8_t facialHair = static_cast(val & 0xFF); - for (auto& ch : owner_.characters) { - if (ch.guid == owner_.playerGuid) { ch.facialFeatures = facialHair; break; } + for (auto& ch : owner_.charactersRef()) { + if (ch.guid == owner_.getPlayerGuid()) { ch.facialFeatures = facialHair; break; } } LOG_DEBUG("PLAYER_BYTES_2 (VALUES): raw=0x", std::hex, val, std::dec, " bankBagSlots=", static_cast(bankBagSlots), " facial=", static_cast(facialHair)); - owner_.isResting_ = (restStateByte != 0); - if (owner_.appearanceChangedCallback_) - owner_.appearanceChangedCallback_(); + owner_.isRestingRef() = (restStateByte != 0); + if (owner_.appearanceChangedCallbackRef()) + owner_.appearanceChangedCallbackRef()(); } } else if (pfi.chosenTitle != 0xFFFF && key == pfi.chosenTitle) { - owner_.chosenTitleBit_ = static_cast(val); + owner_.chosenTitleBitRef() = static_cast(val); LOG_DEBUG("PLAYER_CHOSEN_TITLE ", isCreate ? "from update fields: " : "updated: ", - owner_.chosenTitleBit_); + owner_.chosenTitleBitRef()); } // VALUES-only fields: PLAYER_BYTES (appearance) and PLAYER_FLAGS (ghost state) else if (!isCreate && pfi.pBytes != 0xFFFF && key == pfi.pBytes) { // PLAYER_BYTES changed (barber shop, polymorph, etc.) - for (auto& ch : owner_.characters) { - if (ch.guid == owner_.playerGuid) { ch.appearanceBytes = val; break; } + for (auto& ch : owner_.charactersRef()) { + if (ch.guid == owner_.getPlayerGuid()) { ch.appearanceBytes = val; break; } } - if (owner_.appearanceChangedCallback_) - owner_.appearanceChangedCallback_(); + if (owner_.appearanceChangedCallbackRef()) + owner_.appearanceChangedCallbackRef()(); } else if (!isCreate && key == pfi.playerFlags) { constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; - bool wasGhost = owner_.releasedSpirit_; + bool wasGhost = owner_.releasedSpiritRef(); bool nowGhost = (val & PLAYER_FLAGS_GHOST) != 0; if (!wasGhost && nowGhost) { - owner_.releasedSpirit_ = true; + owner_.releasedSpiritRef() = true; LOG_INFO("Player entered ghost form (PLAYER_FLAGS)"); - if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(true); + if (owner_.ghostStateCallbackRef()) owner_.ghostStateCallbackRef()(true); } else if (wasGhost && !nowGhost) { - owner_.releasedSpirit_ = false; - owner_.playerDead_ = false; - owner_.repopPending_ = false; - owner_.resurrectPending_ = false; - owner_.selfResAvailable_ = false; - owner_.corpseMapId_ = 0; // corpse reclaimed - owner_.corpseGuid_ = 0; - owner_.corpseReclaimAvailableMs_ = 0; + owner_.releasedSpiritRef() = false; + owner_.playerDeadRef() = false; + owner_.repopPendingRef() = false; + owner_.resurrectPendingRef() = false; + owner_.selfResAvailableRef() = false; + owner_.corpseMapIdRef() = 0; // corpse reclaimed + owner_.corpseGuidRef() = 0; + owner_.corpseReclaimAvailableMsRef() = 0; LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); pendingEvents_.emit("PLAYER_ALIVE", {}); - if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(false); + if (owner_.ghostStateCallbackRef()) owner_.ghostStateCallbackRef()(false); } pendingEvents_.emit("PLAYER_FLAGS_CHANGED", {}); } - else if (pfi.meleeAP != 0xFFFF && key == pfi.meleeAP) { owner_.playerMeleeAP_ = static_cast(val); } - else if (pfi.rangedAP != 0xFFFF && key == pfi.rangedAP) { owner_.playerRangedAP_ = static_cast(val); } + else if (pfi.meleeAP != 0xFFFF && key == pfi.meleeAP) { owner_.playerMeleeAPRef() = static_cast(val); } + else if (pfi.rangedAP != 0xFFFF && key == pfi.rangedAP) { owner_.playerRangedAPRef() = static_cast(val); } else if (pfi.spDmg1 != 0xFFFF && key >= pfi.spDmg1 && key < pfi.spDmg1 + 7) { - owner_.playerSpellDmgBonus_[key - pfi.spDmg1] = static_cast(val); + owner_.playerSpellDmgBonusArr()[key - pfi.spDmg1] = static_cast(val); } - else if (pfi.healBonus != 0xFFFF && key == pfi.healBonus) { owner_.playerHealBonus_ = static_cast(val); } + else if (pfi.healBonus != 0xFFFF && key == pfi.healBonus) { owner_.playerHealBonusRef() = static_cast(val); } // Percentage stats are stored as IEEE 754 floats packed into uint32 update fields. // memcpy reinterprets the bits; clamp to [0..100] to guard against NaN/Inf from // corrupted packets reaching the UI (display-only, no gameplay logic depends on these). - else if (pfi.blockPct != 0xFFFF && key == pfi.blockPct) { std::memcpy(&owner_.playerBlockPct_, &val, 4); owner_.playerBlockPct_ = std::clamp(owner_.playerBlockPct_, 0.0f, 100.0f); } - else if (pfi.dodgePct != 0xFFFF && key == pfi.dodgePct) { std::memcpy(&owner_.playerDodgePct_, &val, 4); owner_.playerDodgePct_ = std::clamp(owner_.playerDodgePct_, 0.0f, 100.0f); } - else if (pfi.parryPct != 0xFFFF && key == pfi.parryPct) { std::memcpy(&owner_.playerParryPct_, &val, 4); owner_.playerParryPct_ = std::clamp(owner_.playerParryPct_, 0.0f, 100.0f); } - else if (pfi.critPct != 0xFFFF && key == pfi.critPct) { std::memcpy(&owner_.playerCritPct_, &val, 4); owner_.playerCritPct_ = std::clamp(owner_.playerCritPct_, 0.0f, 100.0f); } - else if (pfi.rangedCritPct != 0xFFFF && key == pfi.rangedCritPct) { std::memcpy(&owner_.playerRangedCritPct_, &val, 4); owner_.playerRangedCritPct_ = std::clamp(owner_.playerRangedCritPct_, 0.0f, 100.0f); } + else if (pfi.blockPct != 0xFFFF && key == pfi.blockPct) { std::memcpy(&owner_.playerBlockPctRef(), &val, 4); owner_.playerBlockPctRef() = std::clamp(owner_.playerBlockPctRef(), 0.0f, 100.0f); } + else if (pfi.dodgePct != 0xFFFF && key == pfi.dodgePct) { std::memcpy(&owner_.playerDodgePctRef(), &val, 4); owner_.playerDodgePctRef() = std::clamp(owner_.playerDodgePctRef(), 0.0f, 100.0f); } + else if (pfi.parryPct != 0xFFFF && key == pfi.parryPct) { std::memcpy(&owner_.playerParryPctRef(), &val, 4); owner_.playerParryPctRef() = std::clamp(owner_.playerParryPctRef(), 0.0f, 100.0f); } + else if (pfi.critPct != 0xFFFF && key == pfi.critPct) { std::memcpy(&owner_.playerCritPctRef(), &val, 4); owner_.playerCritPctRef() = std::clamp(owner_.playerCritPctRef(), 0.0f, 100.0f); } + else if (pfi.rangedCritPct != 0xFFFF && key == pfi.rangedCritPct) { std::memcpy(&owner_.playerRangedCritPctRef(), &val, 4); owner_.playerRangedCritPctRef() = std::clamp(owner_.playerRangedCritPctRef(), 0.0f, 100.0f); } else if (pfi.sCrit1 != 0xFFFF && key >= pfi.sCrit1 && key < pfi.sCrit1 + 7) { - std::memcpy(&owner_.playerSpellCritPct_[key - pfi.sCrit1], &val, 4); + std::memcpy(&owner_.playerSpellCritPctArr()[key - pfi.sCrit1], &val, 4); } else if (pfi.rating1 != 0xFFFF && key >= pfi.rating1 && key < pfi.rating1 + 25) { - owner_.playerCombatRatings_[key - pfi.rating1] = static_cast(val); + owner_.playerCombatRatingsRef()[key - pfi.rating1] = static_cast(val); } else { for (int si = 0; si < 5; ++si) { if (pfi.stats[si] != 0xFFFF && key == pfi.stats[si]) { - owner_.playerStats_[si] = static_cast(val); + owner_.playerStatsArr()[si] = static_cast(val); break; } } @@ -1088,15 +1088,15 @@ void EntityController::dispatchEntitySpawn(uint64_t guid, ObjectType objectType, const std::shared_ptr& entity, const std::shared_ptr& unit, bool isDead) { - if (objectType == ObjectType::PLAYER && guid == owner_.playerGuid) { + if (objectType == ObjectType::PLAYER && guid == owner_.getPlayerGuid()) { return; // Skip local player — spawned separately via spawnPlayerCharacter() } if (objectType == ObjectType::PLAYER) { - if (owner_.playerSpawnCallback_) { + if (owner_.playerSpawnCallbackRef()) { uint8_t race = 0, gender = 0, facial = 0; uint32_t appearanceBytes = 0; if (extractPlayerAppearance(entity->getFields(), race, gender, appearanceBytes, facial)) { - owner_.playerSpawnCallback_(guid, unit->getDisplayId(), race, gender, + owner_.playerSpawnCallbackRef()(guid, unit->getDisplayId(), race, gender, appearanceBytes, facial, unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation()); } else { @@ -1104,7 +1104,7 @@ void EntityController::dispatchEntitySpawn(uint64_t guid, ObjectType objectType, " displayId=", unit->getDisplayId(), " appearance extraction failed — model will not render"); } } - } else if (owner_.creatureSpawnCallback_) { + } else if (owner_.creatureSpawnCallbackRef()) { LOG_DEBUG("[Spawn] UNIT guid=0x", std::hex, guid, std::dec, " displayId=", unit->getDisplayId(), " at (", unit->getX(), ",", unit->getY(), ",", unit->getZ(), ")"); @@ -1119,17 +1119,17 @@ void EntityController::dispatchEntitySpawn(uint64_t guid, ObjectType objectType, if (unitScale <= 0.01f || unitScale > 100.0f) unitScale = 1.0f; } } - owner_.creatureSpawnCallback_(guid, unit->getDisplayId(), + owner_.creatureSpawnCallbackRef()(guid, unit->getDisplayId(), unit->getX(), unit->getY(), unit->getZ(), unit->getOrientation(), unitScale); } - if (isDead && owner_.npcDeathCallback_) { - owner_.npcDeathCallback_(guid); + if (isDead && owner_.npcDeathCallbackRef()) { + owner_.npcDeathCallbackRef()(guid); } // Query quest giver status for NPCs with questgiver flag (0x02) - if (objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && owner_.socket) { + if (objectType == ObjectType::UNIT && (unit->getNpcFlags() & 0x02) && owner_.getSocket()) { network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); qsPkt.writeUInt64(guid); - owner_.socket->send(qsPkt); + owner_.getSocket()->send(qsPkt); } } @@ -1148,8 +1148,8 @@ void EntityController::trackItemOnCreate(const UpdateBlock& block, bool& newItem auto sock3EnchIt = (enchBase != 0xFFFF) ? block.fields.find(enchBase + 12u) : block.fields.end(); if (entryIt != block.fields.end() && entryIt->second != 0) { // Preserve existing info when doing partial updates - GameHandler::OnlineItemInfo info = owner_.onlineItems_.count(block.guid) - ? owner_.onlineItems_[block.guid] : GameHandler::OnlineItemInfo{}; + GameHandler::OnlineItemInfo info = owner_.onlineItemsRef().count(block.guid) + ? owner_.onlineItemsRef()[block.guid] : GameHandler::OnlineItemInfo{}; info.entry = entryIt->second; if (stackIt != block.fields.end()) info.stackCount = stackIt->second; if (durIt != block.fields.end()) info.curDurability = durIt->second; @@ -1159,7 +1159,7 @@ void EntityController::trackItemOnCreate(const UpdateBlock& block, bool& newItem if (sock1EnchIt != block.fields.end()) info.socketEnchantIds[0] = sock1EnchIt->second; if (sock2EnchIt != block.fields.end()) info.socketEnchantIds[1] = sock2EnchIt->second; if (sock3EnchIt != block.fields.end()) info.socketEnchantIds[2] = sock3EnchIt->second; - auto [itemIt, isNew] = owner_.onlineItems_.insert_or_assign(block.guid, info); + auto [itemIt, isNew] = owner_.onlineItemsRef().insert_or_assign(block.guid, info); if (isNew) newItemCreated = true; owner_.queryItemInfo(info.entry, block.guid); } @@ -1188,8 +1188,8 @@ void EntityController::updateItemOnValuesUpdate(const UpdateBlock& block, const uint16_t itemSock2EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 9u) : 0xFFFF; const uint16_t itemSock3EnchField= (itemEnchBase != 0xFFFF) ? (itemEnchBase + 12u) : 0xFFFF; - auto it = owner_.onlineItems_.find(block.guid); - bool isItemInInventory = (it != owner_.onlineItems_.end()); + auto it = owner_.onlineItemsRef().find(block.guid); + bool isItemInInventory = (it != owner_.onlineItemsRef().end()); for (const auto& [key, val] : block.fields) { if (key == itemStackField && isItemInInventory) { @@ -1209,7 +1209,7 @@ void EntityController::updateItemOnValuesUpdate(const UpdateBlock& block, if (maxDur > 0 && val < maxDur / 5u && prevDur >= maxDur / 5u) { // Check if this item is in an equip slot (not bag inventory). bool isEquipped = false; - for (uint64_t slotGuid : owner_.equipSlotGuids_) { + for (uint64_t slotGuid : owner_.equipSlotGuidsRef()) { if (slotGuid == block.guid) { isEquipped = true; break; } } if (isEquipped) { @@ -1376,9 +1376,9 @@ void EntityController::onCreateUnit(const UpdateBlock& block, std::shared_ptrgetDisplayId() != 0) { dispatchEntitySpawn(block.guid, block.objectType, entity, unit, unitInitiallyDead); - if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallback_ && - block.guid != owner_.playerGuid) { - owner_.unitMoveFlagsCallback_(block.guid, block.moveFlags); + if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallbackRef() && + block.guid != owner_.getPlayerGuid()) { + owner_.unitMoveFlagsCallbackRef()(block.guid, block.moveFlags); } } } @@ -1387,14 +1387,14 @@ void EntityController::onCreatePlayer(const UpdateBlock& block, std::shared_ptr< static const bool kVerboseUpdateObject = envFlagEnabled("WOWEE_LOG_UPDATE_OBJECT_VERBOSE", false); // For the local player, capture the full initial field state - if (block.guid == owner_.playerGuid) { - owner_.lastPlayerFields_ = entity->getFields(); + if (block.guid == owner_.getPlayerGuid()) { + owner_.lastPlayerFieldsRef() = entity->getFields(); owner_.maybeDetectVisibleItemLayout(); } // Name query + visible items queryPlayerName(block.guid); - if (block.guid != owner_.playerGuid) { + if (block.guid != owner_.getPlayerGuid()) { owner_.updateOtherPlayerVisibleItems(block.guid, entity->getFields()); } @@ -1404,38 +1404,38 @@ void EntityController::onCreatePlayer(const UpdateBlock& block, std::shared_ptr< bool unitInitiallyDead = applyUnitFieldsOnCreate(block, unit, ufi); // Self-player post-unit-field handling - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { constexpr uint32_t UNIT_FLAG_TAXI_FLIGHT = 0x00000100; - if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !owner_.onTaxiFlight_ && owner_.taxiLandingCooldown_ <= 0.0f) { - owner_.onTaxiFlight_ = true; - owner_.taxiStartGrace_ = std::max(owner_.taxiStartGrace_, 2.0f); + if ((unit->getUnitFlags() & UNIT_FLAG_TAXI_FLIGHT) != 0 && !owner_.onTaxiFlightRef() && owner_.taxiLandingCooldownRef() <= 0.0f) { + owner_.onTaxiFlightRef() = true; + owner_.taxiStartGraceRef() = std::max(owner_.taxiStartGraceRef(), 2.0f); owner_.sanitizeMovementForTaxi(); - if (owner_.movementHandler_) owner_.movementHandler_->applyTaxiMountForCurrentNode(); + if (owner_.getMovementHandler()) owner_.getMovementHandler()->applyTaxiMountForCurrentNode(); } } - if (block.guid == owner_.playerGuid && + if (block.guid == owner_.getPlayerGuid() && (unit->getDynamicFlags() & 0x0008 /*UNIT_DYNFLAG_DEAD*/) != 0) { - owner_.playerDead_ = true; + owner_.playerDeadRef() = true; LOG_INFO("Player logged in dead (dynamic flags)"); } // Detect ghost state on login via PLAYER_FLAGS - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; auto pfIt = block.fields.find(fieldIndex(UF::PLAYER_FLAGS)); if (pfIt != block.fields.end() && (pfIt->second & PLAYER_FLAGS_GHOST) != 0) { - owner_.releasedSpirit_ = true; - owner_.playerDead_ = true; + owner_.releasedSpiritRef() = true; + owner_.playerDeadRef() = true; LOG_INFO("Player logged in as ghost (PLAYER_FLAGS)"); - if (owner_.ghostStateCallback_) owner_.ghostStateCallback_(true); + if (owner_.ghostStateCallbackRef()) owner_.ghostStateCallbackRef()(true); // Query corpse position so minimap marker is accurate on reconnect - if (owner_.socket) { + if (owner_.getSocket()) { network::Packet cq(wireOpcode(Opcode::MSG_CORPSE_QUERY)); - owner_.socket->send(cq); + owner_.getSocket()->send(cq); } } } // Classic aura sync on initial object create - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { syncClassicAurasFromFields(entity); } @@ -1445,18 +1445,18 @@ void EntityController::onCreatePlayer(const UpdateBlock& block, std::shared_ptr< // Spawn dispatch if (unit->getDisplayId() != 0) { dispatchEntitySpawn(block.guid, block.objectType, entity, unit, unitInitiallyDead); - if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallback_ && - block.guid != owner_.playerGuid) { - owner_.unitMoveFlagsCallback_(block.guid, block.moveFlags); + if (block.hasMovement && block.moveFlags != 0 && owner_.unitMoveFlagsCallbackRef() && + block.guid != owner_.getPlayerGuid()) { + owner_.unitMoveFlagsCallbackRef()(block.guid, block.moveFlags); } } // Player stat fields (self only) - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { // Auto-detect coinage index using the previous snapshot vs this full snapshot. - maybeDetectCoinageIndex(owner_.lastPlayerFields_, block.fields); + maybeDetectCoinageIndex(owner_.lastPlayerFieldsRef(), block.fields); - owner_.lastPlayerFields_ = block.fields; + owner_.lastPlayerFieldsRef() = block.fields; owner_.detectInventorySlotBases(block.fields); if (kVerboseUpdateObject) { @@ -1472,9 +1472,9 @@ void EntityController::onCreatePlayer(const UpdateBlock& block, std::shared_ptr< bool slotsChanged = applyPlayerStatFields(block.fields, pfi, true); if (slotsChanged) owner_.rebuildOnlineInventory(); owner_.maybeDetectVisibleItemLayout(); - owner_.extractSkillFields(owner_.lastPlayerFields_); - owner_.extractExploredZoneFields(owner_.lastPlayerFields_); - owner_.applyQuestStateFromFields(owner_.lastPlayerFields_); + owner_.extractSkillFields(owner_.lastPlayerFieldsRef()); + owner_.extractExploredZoneFields(owner_.lastPlayerFieldsRef()); + owner_.applyQuestStateFromFields(owner_.lastPlayerFieldsRef()); } } @@ -1506,7 +1506,7 @@ void EntityController::onCreateGameObject(const UpdateBlock& block, std::shared_ " pos=(", go->getX(), ", ", go->getY(), ", ", go->getZ(), ")"); // Note: TransportSpawnCallback will be invoked from Application after WMO instance is created } - if (go->getDisplayId() != 0 && owner_.gameObjectSpawnCallback_) { + if (go->getDisplayId() != 0 && owner_.gameObjectSpawnCallbackRef()) { float goScale = 1.0f; { uint16_t scaleIdx = fieldIndex(UF::OBJECT_FIELD_SCALE_X); @@ -1518,13 +1518,13 @@ void EntityController::onCreateGameObject(const UpdateBlock& block, std::shared_ } } } - owner_.gameObjectSpawnCallback_(block.guid, go->getEntry(), go->getDisplayId(), + owner_.gameObjectSpawnCallbackRef()(block.guid, go->getEntry(), go->getDisplayId(), go->getX(), go->getY(), go->getZ(), go->getOrientation(), goScale); } // Fire transport move callback for transports (position update on re-creation) - if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) { + if (transportGuids_.count(block.guid) && owner_.transportMoveCallbackRef()) { serverUpdatedTransportGuids_.insert(block.guid); - owner_.transportMoveCallback_(block.guid, + owner_.transportMoveCallbackRef()(block.guid, go->getX(), go->getY(), go->getZ(), go->getOrientation()); } } @@ -1544,16 +1544,16 @@ void EntityController::onCreateCorpse(const UpdateBlock& block) { auto ownerHighIt = block.fields.find(ownerLowIdx + 1); uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; - if (ownerGuid == owner_.playerGuid || ownerLow == static_cast(owner_.playerGuid)) { + if (ownerGuid == owner_.getPlayerGuid() || ownerLow == static_cast(owner_.getPlayerGuid())) { // Server coords from movement block - owner_.corpseGuid_ = block.guid; - owner_.corpseX_ = block.x; - owner_.corpseY_ = block.y; - owner_.corpseZ_ = block.z; - owner_.corpseMapId_ = owner_.currentMapId_; - LOG_INFO("Corpse object detected: guid=0x", std::hex, owner_.corpseGuid_, std::dec, + owner_.corpseGuidRef() = block.guid; + owner_.corpseXRef() = block.x; + owner_.corpseYRef() = block.y; + owner_.corpseZRef() = block.z; + owner_.corpseMapIdRef() = owner_.currentMapIdRef(); + LOG_INFO("Corpse object detected: guid=0x", std::hex, owner_.corpseGuidRef(), std::dec, " server=(", block.x, ", ", block.y, ", ", block.z, - ") map=", owner_.corpseMapId_); + ") map=", owner_.corpseMapIdRef()); } } } @@ -1576,11 +1576,11 @@ void EntityController::handleDisplayIdChange(const UpdateBlock& block, ((unit->getDynamicFlags() & (UNIT_DYNFLAG_DEAD | UNIT_DYNFLAG_LOOTABLE)) != 0); dispatchEntitySpawn(block.guid, entity->getType(), entity, unit, isDeadNow && !result.npcDeathNotified); - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string uid; - if (block.guid == owner_.targetGuid) uid = "target"; - else if (block.guid == owner_.focusGuid) uid = "focus"; - else if (block.guid == owner_.petGuid_) uid = "pet"; + if (block.guid == owner_.getTargetGuid()) uid = "target"; + else if (block.guid == owner_.focusGuidRef()) uid = "focus"; + else if (block.guid == owner_.petGuidRef()) uid = "pet"; if (!uid.empty()) pendingEvents_.emit("UNIT_MODEL_CHANGED", {uid}); } @@ -1595,7 +1595,7 @@ void EntityController::onValuesUpdateUnit(const UpdateBlock& block, std::shared_ void EntityController::onValuesUpdatePlayer(const UpdateBlock& block, std::shared_ptr& entity) { // Other player visible items - if (block.guid != owner_.playerGuid) { + if (block.guid != owner_.getPlayerGuid()) { owner_.updateOtherPlayerVisibleItems(block.guid, entity->getFields()); } @@ -1605,7 +1605,7 @@ void EntityController::onValuesUpdatePlayer(const UpdateBlock& block, std::share UnitFieldUpdateResult result = applyUnitFieldsOnUpdate(block, entity, unit, ufi); // Classic aura sync from UNIT_FIELD_AURAS when those fields are updated - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { syncClassicAurasFromFields(entity); } @@ -1613,32 +1613,32 @@ void EntityController::onValuesUpdatePlayer(const UpdateBlock& block, std::share handleDisplayIdChange(block, entity, unit, result); // Self-player stat/inventory/quest field updates - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { const bool needCoinageDetectSnapshot = - (owner_.pendingMoneyDelta_ != 0 && owner_.pendingMoneyDeltaTimer_ > 0.0f); + (owner_.pendingMoneyDeltaRef() != 0 && owner_.pendingMoneyDeltaTimerRef() > 0.0f); std::map oldFieldsSnapshot; if (needCoinageDetectSnapshot) { - oldFieldsSnapshot = owner_.lastPlayerFields_; + oldFieldsSnapshot = owner_.lastPlayerFieldsRef(); } if (block.hasMovement && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - owner_.serverRunSpeed_ = block.runSpeed; + owner_.serverRunSpeedRef() = block.runSpeed; // Some server dismount paths update run speed without updating mount display field. - if (!owner_.onTaxiFlight_ && !owner_.taxiMountActive_ && - owner_.currentMountDisplayId_ != 0 && block.runSpeed <= 8.5f) { + if (!owner_.onTaxiFlightRef() && !owner_.taxiMountActiveRef() && + owner_.currentMountDisplayIdRef() != 0 && block.runSpeed <= 8.5f) { LOG_INFO("Auto-clearing mount from movement speed update: speed=", block.runSpeed, - " displayId=", owner_.currentMountDisplayId_); - owner_.currentMountDisplayId_ = 0; - if (owner_.mountCallback_) { - owner_.mountCallback_(0); + " displayId=", owner_.currentMountDisplayIdRef()); + owner_.currentMountDisplayIdRef() = 0; + if (owner_.mountCallbackRef()) { + owner_.mountCallbackRef()(0); } } } - auto mergeHint = owner_.lastPlayerFields_.end(); + auto mergeHint = owner_.lastPlayerFieldsRef().end(); for (const auto& [key, val] : block.fields) { - mergeHint = owner_.lastPlayerFields_.insert_or_assign(mergeHint, key, val); + mergeHint = owner_.lastPlayerFieldsRef().insert_or_assign(mergeHint, key, val); } if (needCoinageDetectSnapshot) { - maybeDetectCoinageIndex(oldFieldsSnapshot, owner_.lastPlayerFields_); + maybeDetectCoinageIndex(oldFieldsSnapshot, owner_.lastPlayerFieldsRef()); } owner_.maybeDetectVisibleItemLayout(); owner_.detectInventorySlotBases(block.fields); @@ -1649,9 +1649,9 @@ void EntityController::onValuesUpdatePlayer(const UpdateBlock& block, std::share owner_.rebuildOnlineInventory(); pendingEvents_.emit("PLAYER_EQUIPMENT_CHANGED", {}); } - owner_.extractSkillFields(owner_.lastPlayerFields_); - owner_.extractExploredZoneFields(owner_.lastPlayerFields_); - owner_.applyQuestStateFromFields(owner_.lastPlayerFields_); + owner_.extractSkillFields(owner_.lastPlayerFieldsRef()); + owner_.extractExploredZoneFields(owner_.lastPlayerFieldsRef()); + owner_.applyQuestStateFromFields(owner_.lastPlayerFieldsRef()); } } @@ -1661,12 +1661,12 @@ void EntityController::onValuesUpdateItem(const UpdateBlock& block, std::shared_ void EntityController::onValuesUpdateGameObject(const UpdateBlock& block, std::shared_ptr& entity) { if (block.hasMovement) { - if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) { + if (transportGuids_.count(block.guid) && owner_.transportMoveCallbackRef()) { serverUpdatedTransportGuids_.insert(block.guid); - owner_.transportMoveCallback_(block.guid, entity->getX(), entity->getY(), + owner_.transportMoveCallbackRef()(block.guid, entity->getX(), entity->getY(), entity->getZ(), entity->getOrientation()); - } else if (owner_.gameObjectMoveCallback_) { - owner_.gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + } else if (owner_.gameObjectMoveCallbackRef()) { + owner_.gameObjectMoveCallbackRef()(block.guid, entity->getX(), entity->getY(), entity->getZ(), entity->getOrientation()); } } @@ -1677,8 +1677,8 @@ void EntityController::onValuesUpdateGameObject(const UpdateBlock& block, std::s auto itB = block.fields.find(ufGoBytes1); if (itB != block.fields.end()) { uint8_t goState = static_cast(itB->second & 0xFF); - if (owner_.gameObjectStateCallback_) - owner_.gameObjectStateCallback_(block.guid, goState); + if (owner_.gameObjectStateCallbackRef()) + owner_.gameObjectStateCallbackRef()(block.guid, goState); } } } @@ -1699,11 +1699,11 @@ void EntityController::handleCreateObject(const UpdateBlock& block, bool& newIte float oCanonical = core::coords::serverToCanonicalYaw(block.orientation); entity->setPosition(pos.x, pos.y, pos.z, oCanonical); LOG_DEBUG(" Position: (", pos.x, ", ", pos.y, ", ", pos.z, ")"); - if (block.guid == owner_.playerGuid && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { - owner_.serverRunSpeed_ = block.runSpeed; + if (block.guid == owner_.getPlayerGuid() && block.runSpeed > 0.1f && block.runSpeed < 100.0f) { + owner_.serverRunSpeedRef() = block.runSpeed; } // 3b: Track player-on-transport state - if (block.guid == owner_.playerGuid) { + if (block.guid == owner_.getPlayerGuid()) { applyPlayerTransportState(block, entity, pos, oCanonical, false); } // 3i: Track transport-relative children so they follow parent transport motion. @@ -1731,7 +1731,7 @@ void EntityController::handleValuesUpdate(const UpdateBlock& block) { // Item/container entities may be absent from entityManager (e.g. server // only sent a partial update) but we still track them in onlineItems_. // Process field updates so durability/stack changes from repair aren't lost. - if (owner_.onlineItems_.count(block.guid)) { + if (owner_.onlineItemsRef().count(block.guid)) { pendingEvents_.clear(); updateItemOnValuesUpdate(block, entity); flushPendingEvents(); @@ -1780,32 +1780,32 @@ void EntityController::handleMovementUpdate(const UpdateBlock& block) { updateNonPlayerTransportAttachment(block, entity, entity->getType()); // 3b: Track player-on-transport state from MOVEMENT updates - if (block.guid == owner_.playerGuid) { - owner_.movementInfo.orientation = oCanonical; + if (block.guid == owner_.getPlayerGuid()) { + owner_.movementInfoRef().orientation = oCanonical; applyPlayerTransportState(block, entity, pos, oCanonical, true); } // Fire transport move callback if this is a known transport - if (transportGuids_.count(block.guid) && owner_.transportMoveCallback_) { + if (transportGuids_.count(block.guid) && owner_.transportMoveCallbackRef()) { serverUpdatedTransportGuids_.insert(block.guid); - owner_.transportMoveCallback_(block.guid, pos.x, pos.y, pos.z, oCanonical); + owner_.transportMoveCallbackRef()(block.guid, pos.x, pos.y, pos.z, oCanonical); } // Fire move callback for non-transport gameobjects. if (entity->getType() == ObjectType::GAMEOBJECT && transportGuids_.count(block.guid) == 0 && - owner_.gameObjectMoveCallback_) { - owner_.gameObjectMoveCallback_(block.guid, entity->getX(), entity->getY(), + owner_.gameObjectMoveCallbackRef()) { + owner_.gameObjectMoveCallbackRef()(block.guid, entity->getX(), entity->getY(), entity->getZ(), entity->getOrientation()); } // Fire move callback for non-player units (creatures). // SMSG_MONSTER_MOVE handles smooth interpolated movement, but many // servers (especially vanilla/Turtle WoW) communicate NPC positions // via MOVEMENT blocks instead. Use duration=0 for an instant snap. - if (block.guid != owner_.playerGuid && + if (block.guid != owner_.getPlayerGuid() && entity->getType() == ObjectType::UNIT && transportGuids_.count(block.guid) == 0 && - owner_.creatureMoveCallback_) { - owner_.creatureMoveCallback_(block.guid, pos.x, pos.y, pos.z, 0); + owner_.creatureMoveCallbackRef()) { + owner_.creatureMoveCallbackRef()(block.guid, pos.x, pos.y, pos.z, 0); } } else { LOG_WARNING("MOVEMENT update for unknown entity: 0x", std::hex, block.guid, std::dec); @@ -1813,20 +1813,20 @@ void EntityController::handleMovementUpdate(const UpdateBlock& block) { } void EntityController::finalizeUpdateObjectBatch(bool newItemCreated) { - owner_.tabCycleStale = true; + owner_.tabCycleStaleRef() = true; // Entity count logging disabled // Deferred rebuild: if new item objects were created in this packet, rebuild - // owner_.inventory so that slot GUIDs updated earlier in the same packet can resolve. + // owner_.inventoryRef() so that slot GUIDs updated earlier in the same packet can resolve. if (newItemCreated) { owner_.rebuildOnlineInventory(); } - // Late owner_.inventory base detection once items are known - if (owner_.playerGuid != 0 && owner_.invSlotBase_ < 0 && !owner_.lastPlayerFields_.empty() && !owner_.onlineItems_.empty()) { - owner_.detectInventorySlotBases(owner_.lastPlayerFields_); - if (owner_.invSlotBase_ >= 0) { - if (owner_.applyInventoryFields(owner_.lastPlayerFields_)) { + // Late owner_.inventoryRef() base detection once items are known + if (owner_.getPlayerGuid() != 0 && owner_.invSlotBaseRef() < 0 && !owner_.lastPlayerFieldsRef().empty() && !owner_.onlineItemsRef().empty()) { + owner_.detectInventorySlotBases(owner_.lastPlayerFieldsRef()); + if (owner_.invSlotBaseRef() >= 0) { + if (owner_.applyInventoryFields(owner_.lastPlayerFieldsRef())) { owner_.rebuildOnlineInventory(); } } @@ -1882,9 +1882,9 @@ void EntityController::handleDestroyObject(network::Packet& packet) { // Remove entity if (entityManager.hasEntity(data.guid)) { if (transportGuids_.count(data.guid) > 0) { - const bool playerAboardNow = (owner_.playerTransportGuid_ == data.guid); - const bool stickyAboard = (owner_.playerTransportStickyGuid_ == data.guid && owner_.playerTransportStickyTimer_ > 0.0f); - const bool movementSaysAboard = (owner_.movementInfo.transportGuid == data.guid); + const bool playerAboardNow = (owner_.playerTransportGuidRef() == data.guid); + const bool stickyAboard = (owner_.playerTransportStickyGuidRef() == data.guid && owner_.playerTransportStickyTimerRef() > 0.0f); + const bool movementSaysAboard = (owner_.movementInfoRef().transportGuid == data.guid); if (playerAboardNow || stickyAboard || movementSaysAboard) { serverUpdatedTransportGuids_.erase(data.guid); LOG_INFO("Preserving in-use transport on destroy: 0x", std::hex, data.guid, std::dec, @@ -1897,25 +1897,25 @@ void EntityController::handleDestroyObject(network::Packet& packet) { // Mirror out-of-range handling: invoke render-layer despawn callbacks before entity removal. auto entity = entityManager.getEntity(data.guid); if (entity) { - if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) { - owner_.creatureDespawnCallback_(data.guid); - } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) { + if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallbackRef()) { + owner_.creatureDespawnCallbackRef()(data.guid); + } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallbackRef()) { // Player entities also need renderer cleanup on DESTROY_OBJECT, not just out-of-range. - owner_.playerDespawnCallback_(data.guid); - owner_.otherPlayerVisibleItemEntries_.erase(data.guid); - owner_.otherPlayerVisibleDirty_.erase(data.guid); - owner_.otherPlayerMoveTimeMs_.erase(data.guid); - owner_.inspectedPlayerItemEntries_.erase(data.guid); - owner_.pendingAutoInspect_.erase(data.guid); + owner_.playerDespawnCallbackRef()(data.guid); + owner_.otherPlayerVisibleItemEntriesRef().erase(data.guid); + owner_.otherPlayerVisibleDirtyRef().erase(data.guid); + owner_.otherPlayerMoveTimeMsRef().erase(data.guid); + owner_.inspectedPlayerItemEntriesRef().erase(data.guid); + owner_.pendingAutoInspectRef().erase(data.guid); pendingNameQueries.erase(data.guid); - } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallback_) { - owner_.gameObjectDespawnCallback_(data.guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallbackRef()) { + owner_.gameObjectDespawnCallbackRef()(data.guid); } } if (transportGuids_.count(data.guid) > 0) { transportGuids_.erase(data.guid); serverUpdatedTransportGuids_.erase(data.guid); - if (owner_.playerTransportGuid_ == data.guid) { + if (owner_.playerTransportGuidRef() == data.guid) { owner_.clearPlayerTransport(); } } @@ -1928,33 +1928,33 @@ void EntityController::handleDestroyObject(network::Packet& packet) { } // Clean up auto-attack and target if destroyed entity was our target - if (owner_.combatHandler_ && data.guid == owner_.combatHandler_->getAutoAttackTargetGuid()) { + if (owner_.getCombatHandler() && data.guid == owner_.getCombatHandler()->getAutoAttackTargetGuid()) { owner_.stopAutoAttack(); } - if (data.guid == owner_.targetGuid) { - owner_.targetGuid = 0; + if (data.guid == owner_.getTargetGuid()) { + owner_.setTargetGuidRaw(0); } - if (owner_.combatHandler_) owner_.combatHandler_->removeHostileAttacker(data.guid); + if (owner_.getCombatHandler()) owner_.getCombatHandler()->removeHostileAttacker(data.guid); // Remove online item/container tracking - owner_.containerContents_.erase(data.guid); - if (owner_.onlineItems_.erase(data.guid)) { + owner_.containerContentsRef().erase(data.guid); + if (owner_.onlineItemsRef().erase(data.guid)) { owner_.rebuildOnlineInventory(); } // Clean up quest giver status - owner_.npcQuestStatus_.erase(data.guid); + owner_.npcQuestStatusRef().erase(data.guid); // Remove combat text entries referencing the destroyed entity so floating // damage numbers don't linger after the source/target despawns. - if (owner_.combatHandler_) owner_.combatHandler_->removeCombatTextForGuid(data.guid); + if (owner_.getCombatHandler()) owner_.getCombatHandler()->removeCombatTextForGuid(data.guid); - // Clean up unit cast owner_.state (cast bar) for the destroyed unit - if (owner_.spellHandler_) owner_.spellHandler_->unitCastStates_.erase(data.guid); + // Clean up unit cast owner_.getState() (cast bar) for the destroyed unit + if (owner_.getSpellHandler()) owner_.getSpellHandler()->unitCastStates_.erase(data.guid); // Clean up cached auras - if (owner_.spellHandler_) owner_.spellHandler_->unitAurasCache_.erase(data.guid); + if (owner_.getSpellHandler()) owner_.getSpellHandler()->unitAurasCache_.erase(data.guid); - owner_.tabCycleStale = true; + owner_.tabCycleStaleRef() = true; } // Name Queries @@ -1977,14 +1977,14 @@ void EntityController::queryPlayerName(uint64_t guid) { if (pendingNameQueries.count(guid)) return; if (!owner_.isInWorld()) { LOG_INFO("queryPlayerName: skipped guid=0x", std::hex, guid, std::dec, - " owner_.state=", worldStateName(owner_.state), " owner_.socket=", (owner_.socket ? "yes" : "no")); + " owner_.getState()=", worldStateName(owner_.getState()), " owner_.getSocket()=", (owner_.getSocket() ? "yes" : "no")); return; } LOG_INFO("queryPlayerName: sending CMSG_NAME_QUERY for guid=0x", std::hex, guid, std::dec); pendingNameQueries.insert(guid); auto packet = NameQueryPacket::build(guid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) { @@ -1993,7 +1993,7 @@ void EntityController::queryCreatureInfo(uint32_t entry, uint64_t guid) { pendingCreatureQueries.insert(entry); auto packet = CreatureQueryPacket::build(entry, guid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void EntityController::queryGameObjectInfo(uint32_t entry, uint64_t guid) { @@ -2002,7 +2002,7 @@ void EntityController::queryGameObjectInfo(uint32_t entry, uint64_t guid) { pendingGameObjectQueries_.insert(entry); auto packet = GameObjectQueryPacket::build(entry, guid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } std::string EntityController::getCachedPlayerName(uint64_t guid) const { @@ -2015,7 +2015,7 @@ std::string EntityController::getCachedCreatureName(uint32_t entry) const { } void EntityController::handleNameQueryResponse(network::Packet& packet) { NameQueryResponseData data; - if (!owner_.packetParsers_ || !owner_.packetParsers_->parseNameQueryResponse(packet, data)) { + if (!owner_.getPacketParsers() || !owner_.getPacketParsers()->parseNameQueryResponse(packet, data)) { LOG_WARNING("Failed to parse SMSG_NAME_QUERY_RESPONSE (size=", packet.getSize(), ")"); return; } @@ -2040,8 +2040,8 @@ void EntityController::handleNameQueryResponse(network::Packet& packet) { } // Backfill chat history entries that arrived before we knew the name. - if (owner_.chatHandler_) { - for (auto& msg : owner_.chatHandler_->getChatHistory()) { + if (owner_.getChatHandler()) { + for (auto& msg : owner_.getChatHandler()->getChatHistory()) { if (msg.senderGuid == data.guid && msg.senderName.empty()) { msg.senderName = data.name; } @@ -2049,35 +2049,35 @@ void EntityController::handleNameQueryResponse(network::Packet& packet) { } // Backfill whisper reply target if the name arrived after the whisper. - if (owner_.lastWhisperSenderGuid_ == data.guid && owner_.lastWhisperSender_.empty()) { - owner_.lastWhisperSender_ = data.name; + if (owner_.lastWhisperSenderGuidRef() == data.guid && owner_.lastWhisperSenderRef().empty()) { + owner_.lastWhisperSenderRef() = data.name; } // Backfill mail inbox sender names - for (auto& mail : owner_.mailInbox_) { + for (auto& mail : owner_.mailInboxRef()) { if (mail.messageType == 0 && mail.senderGuid == data.guid) { mail.senderName = data.name; } } // Backfill friend list: if this GUID came from a friend list packet, - // register the name in owner_.friendsCache now that we know it. - if (owner_.friendGuids_.count(data.guid)) { - owner_.friendsCache[data.name] = data.guid; + // register the name in owner_.friendsCacheRef() now that we know it. + if (owner_.friendGuidsRef().count(data.guid)) { + owner_.friendsCacheRef()[data.name] = data.guid; } // Backfill ignore list: SMSG_IGNORE_LIST only contains GUIDs, so // ignoreCache (name→guid for UI) is populated here once names resolve. - if (owner_.ignoreListGuids_.count(data.guid)) { - owner_.ignoreCache[data.name] = data.guid; + if (owner_.ignoreListGuidsRef().count(data.guid)) { + owner_.ignoreCacheRef()[data.name] = data.guid; } // Fire UNIT_NAME_UPDATE so nameplate/unit frame addons know the name is available - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string unitId; - if (data.guid == owner_.targetGuid) unitId = "target"; - else if (data.guid == owner_.focusGuid) unitId = "focus"; - else if (data.guid == owner_.playerGuid) unitId = "player"; + if (data.guid == owner_.getTargetGuid()) unitId = "target"; + else if (data.guid == owner_.focusGuidRef()) unitId = "focus"; + else if (data.guid == owner_.getPlayerGuid()) unitId = "player"; if (!unitId.empty()) owner_.fireAddonEvent("UNIT_NAME_UPDATE", {unitId}); } @@ -2086,7 +2086,7 @@ void EntityController::handleNameQueryResponse(network::Packet& packet) { void EntityController::handleCreatureQueryResponse(network::Packet& packet) { CreatureQueryResponseData data; - if (!owner_.packetParsers_->parseCreatureQueryResponse(packet, data)) return; + if (!owner_.getPacketParsers()->parseCreatureQueryResponse(packet, data)) return; pendingCreatureQueries.erase(data.entry); @@ -2110,7 +2110,7 @@ void EntityController::handleCreatureQueryResponse(network::Packet& packet) { void EntityController::handleGameObjectQueryResponse(network::Packet& packet) { GameObjectQueryResponseData data; - bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseGameObjectQueryResponse(packet, data) + bool ok = owner_.getPacketParsers() ? owner_.getPacketParsers()->parseGameObjectQueryResponse(packet, data) : GameObjectQueryResponseParser::parse(packet, data); if (!ok) return; @@ -2129,10 +2129,10 @@ void EntityController::handleGameObjectQueryResponse(network::Packet& packet) { } // MO_TRANSPORT (type 15): assign TaxiPathNode path if available - if (data.type == 15 && data.hasData && data.data[0] != 0 && owner_.transportManager_) { + if (data.type == 15 && data.hasData && data.data[0] != 0 && owner_.getTransportManager()) { uint32_t taxiPathId = data.data[0]; - if (owner_.transportManager_->hasTaxiPath(taxiPathId)) { - if (owner_.transportManager_->assignTaxiPathToTransport(data.entry, taxiPathId)) { + if (owner_.getTransportManager()->hasTaxiPath(taxiPathId)) { + if (owner_.getTransportManager()->assignTaxiPathToTransport(data.entry, taxiPathId)) { LOG_DEBUG("MO_TRANSPORT entry=", data.entry, " assigned TaxiPathNode path ", taxiPathId); } } else { @@ -2167,10 +2167,10 @@ void EntityController::handleGameObjectPageText(network::Packet& packet) { if (info.type == 9) pageId = info.data[0]; else if (info.type == 10) pageId = info.data[7]; - if (pageId != 0 && owner_.socket && owner_.state == WorldState::IN_WORLD) { - owner_.bookPages_.clear(); // start a fresh book for this interaction + if (pageId != 0 && owner_.getSocket() && owner_.getState() == WorldState::IN_WORLD) { + owner_.bookPagesRef().clear(); // start a fresh book for this interaction auto req = PageTextQueryPacket::build(pageId, guid); - owner_.socket->send(req); + owner_.getSocket()->send(req); return; } @@ -2187,27 +2187,27 @@ void EntityController::handlePageTextQueryResponse(network::Packet& packet) { // Append page if not already collected bool alreadyHave = false; - for (const auto& bp : owner_.bookPages_) { + for (const auto& bp : owner_.bookPagesRef()) { if (bp.pageId == data.pageId) { alreadyHave = true; break; } } if (!alreadyHave) { - owner_.bookPages_.push_back({data.pageId, data.text}); + owner_.bookPagesRef().push_back({data.pageId, data.text}); } // Follow the chain: if there's a next page we haven't fetched yet, request it if (data.nextPageId != 0) { bool nextHave = false; - for (const auto& bp : owner_.bookPages_) { + for (const auto& bp : owner_.bookPagesRef()) { if (bp.pageId == data.nextPageId) { nextHave = true; break; } } - if (!nextHave && owner_.socket && owner_.state == WorldState::IN_WORLD) { - auto req = PageTextQueryPacket::build(data.nextPageId, owner_.playerGuid); - owner_.socket->send(req); + if (!nextHave && owner_.getSocket() && owner_.getState() == WorldState::IN_WORLD) { + auto req = PageTextQueryPacket::build(data.nextPageId, owner_.getPlayerGuid()); + owner_.getSocket()->send(req); } } LOG_DEBUG("handlePageTextQueryResponse: pageId=", data.pageId, " nextPage=", data.nextPageId, - " totalPages=", owner_.bookPages_.size()); + " totalPages=", owner_.bookPagesRef().size()); } diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index 0c47c46f..14d510ba 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -17,6 +17,7 @@ #include "game/update_field_table.hpp" #include "game/expansion_profile.hpp" #include "rendering/renderer.hpp" +#include "rendering/spell_visual_system.hpp" #include "audio/audio_coordinator.hpp" #include "audio/activity_sound_manager.hpp" #include "audio/combat_sound_manager.hpp" @@ -75,59 +76,6 @@ const char* worldStateName(WorldState state) { return "UNKNOWN"; } -bool isAuthCharPipelineOpcode(LogicalOpcode op) { - switch (op) { - case Opcode::SMSG_AUTH_CHALLENGE: - case Opcode::SMSG_AUTH_RESPONSE: - case Opcode::SMSG_CLIENTCACHE_VERSION: - case Opcode::SMSG_TUTORIAL_FLAGS: - case Opcode::SMSG_WARDEN_DATA: - case Opcode::SMSG_CHAR_ENUM: - case Opcode::SMSG_CHAR_CREATE: - case Opcode::SMSG_CHAR_DELETE: - return true; - default: - return false; - } -} - -} // end anonymous namespace - -namespace { - -int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { - const char* raw = std::getenv(key); - if (!raw || !*raw) return defaultValue; - char* end = nullptr; - long parsed = std::strtol(raw, &end, 10); - if (end == raw) return defaultValue; - return static_cast(std::clamp(parsed, minValue, maxValue)); -} - -int incomingPacketsBudgetPerUpdate(WorldState state) { - static const int inWorldBudget = - parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS", 24, 1, 512); - static const int loginBudget = - parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS_LOGIN", 96, 1, 512); - return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; -} - -float incomingPacketBudgetMs(WorldState state) { - static const int inWorldBudgetMs = - parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS", 2, 1, 50); - static const int loginBudgetMs = - parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS_LOGIN", 8, 1, 50); - return static_cast(state == WorldState::IN_WORLD ? inWorldBudgetMs : loginBudgetMs); -} - -float slowPacketLogThresholdMs() { - static const int thresholdMs = - parseEnvIntClamped("WOWEE_NET_SLOW_PACKET_LOG_MS", 10, 1, 60000); - return static_cast(thresholdMs); -} - -constexpr size_t kMaxQueuedInboundPackets = 4096; - } // end anonymous namespace std::string formatCopperAmount(uint32_t amount) { @@ -153,13 +101,6 @@ std::string formatCopperAmount(uint32_t amount) { return oss.str(); } -template -void GameHandler::withSoundManager(ManagerGetter getter, Callback cb) { - if (auto* ac = services_.audioCoordinator) { - if (auto* mgr = (ac->*getter)()) cb(mgr); - } -} - // Registration helpers for common dispatch table patterns void GameHandler::registerSkipHandler(LogicalOpcode op) { dispatchTable_[op] = [](network::Packet& packet) { packet.skipAll(); }; @@ -968,5192 +909,6 @@ void GameHandler::update(float deltaTime) { } } -void GameHandler::registerOpcodeHandlers() { - // ----------------------------------------------------------------------- - // Auth / session / pre-world handshake - // ----------------------------------------------------------------------- - dispatchTable_[Opcode::SMSG_AUTH_CHALLENGE] = [this](network::Packet& packet) { - if (state == WorldState::CONNECTED) - handleAuthChallenge(packet); - else - LOG_WARNING("Unexpected SMSG_AUTH_CHALLENGE in state: ", worldStateName(state)); - }; - dispatchTable_[Opcode::SMSG_AUTH_RESPONSE] = [this](network::Packet& packet) { - if (state == WorldState::AUTH_SENT) - handleAuthResponse(packet); - else - LOG_WARNING("Unexpected SMSG_AUTH_RESPONSE in state: ", worldStateName(state)); - }; - dispatchTable_[Opcode::SMSG_CHAR_CREATE] = [this](network::Packet& packet) { - handleCharCreateResponse(packet); - }; - dispatchTable_[Opcode::SMSG_CHAR_DELETE] = [this](network::Packet& packet) { - uint8_t result = packet.readUInt8(); - lastCharDeleteResult_ = result; - bool success = (result == 0x00 || result == 0x47); - LOG_INFO("SMSG_CHAR_DELETE result: ", static_cast(result), success ? " (success)" : " (failed)"); - requestCharacterList(); - if (charDeleteCallback_) charDeleteCallback_(success); - }; - dispatchTable_[Opcode::SMSG_CHAR_ENUM] = [this](network::Packet& packet) { - if (state == WorldState::CHAR_LIST_REQUESTED) - handleCharEnum(packet); - else - LOG_WARNING("Unexpected SMSG_CHAR_ENUM in state: ", worldStateName(state)); - }; - registerHandler(Opcode::SMSG_CHARACTER_LOGIN_FAILED, &GameHandler::handleCharLoginFailed); - dispatchTable_[Opcode::SMSG_LOGIN_VERIFY_WORLD] = [this](network::Packet& packet) { - if (state == WorldState::ENTERING_WORLD || state == WorldState::IN_WORLD) - handleLoginVerifyWorld(packet); - else - LOG_WARNING("Unexpected SMSG_LOGIN_VERIFY_WORLD in state: ", worldStateName(state)); - }; - registerHandler(Opcode::SMSG_LOGIN_SETTIMESPEED, &GameHandler::handleLoginSetTimeSpeed); - registerHandler(Opcode::SMSG_CLIENTCACHE_VERSION, &GameHandler::handleClientCacheVersion); - registerHandler(Opcode::SMSG_TUTORIAL_FLAGS, &GameHandler::handleTutorialFlags); - registerHandler(Opcode::SMSG_ACCOUNT_DATA_TIMES, &GameHandler::handleAccountDataTimes); - registerHandler(Opcode::SMSG_MOTD, &GameHandler::handleMotd); - registerHandler(Opcode::SMSG_NOTIFICATION, &GameHandler::handleNotification); - registerHandler(Opcode::SMSG_PONG, &GameHandler::handlePong); - - // ----------------------------------------------------------------------- - // World object updates + entity queries (delegated to EntityController) - // ----------------------------------------------------------------------- - entityController_->registerOpcodes(dispatchTable_); - - // ----------------------------------------------------------------------- - // Item push / logout - // ----------------------------------------------------------------------- - registerSkipHandler(Opcode::SMSG_ADDON_INFO); - registerSkipHandler(Opcode::SMSG_EXPECTED_SPAM_RECORDS); - - // ----------------------------------------------------------------------- - // XP / exploration - // ----------------------------------------------------------------------- - registerHandler(Opcode::SMSG_LOG_XPGAIN, &GameHandler::handleXpGain); - dispatchTable_[Opcode::SMSG_EXPLORATION_EXPERIENCE] = [this](network::Packet& packet) { - if (packet.hasRemaining(8)) { - uint32_t areaId = packet.readUInt32(); - uint32_t xpGained = packet.readUInt32(); - if (xpGained > 0) { - 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); - addCombatText(CombatTextEntry::XP_GAIN, static_cast(xpGained), 0, true); - if (areaDiscoveryCallback_) areaDiscoveryCallback_(areaName, xpGained); - fireAddonEvent("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(xpGained)}); - } - } - }; - - registerSkipHandler(Opcode::SMSG_PET_NAME_QUERY_RESPONSE); - - // ----------------------------------------------------------------------- - // Entity delta updates: health / power / world state / combo / timers / PvP - // (SMSG_HEALTH_UPDATE, SMSG_POWER_UPDATE, SMSG_UPDATE_COMBO_POINTS, - // SMSG_PVP_CREDIT, SMSG_PROCRESIST → moved to CombatHandler) - // ----------------------------------------------------------------------- - dispatchTable_[Opcode::SMSG_UPDATE_WORLD_STATE] = [this](network::Packet& packet) { - if (!packet.hasRemaining(8)) return; - uint32_t field = packet.readUInt32(); - uint32_t value = packet.readUInt32(); - worldStates_[field] = value; - LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value); - fireAddonEvent("UPDATE_WORLD_STATES", {}); - }; - dispatchTable_[Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t serverTime = packet.readUInt32(); - LOG_DEBUG("SMSG_WORLD_STATE_UI_TIMER_UPDATE: serverTime=", serverTime); - } - }; - dispatchTable_[Opcode::SMSG_START_MIRROR_TIMER] = [this](network::Packet& packet) { - if (!packet.hasRemaining(21)) return; - uint32_t type = packet.readUInt32(); - int32_t value = static_cast(packet.readUInt32()); - int32_t maxV = static_cast(packet.readUInt32()); - int32_t scale = static_cast(packet.readUInt32()); - /*uint32_t tracker =*/ packet.readUInt32(); - uint8_t paused = packet.readUInt8(); - if (type < 3) { - mirrorTimers_[type].value = value; - mirrorTimers_[type].maxValue = maxV; - mirrorTimers_[type].scale = scale; - mirrorTimers_[type].paused = (paused != 0); - mirrorTimers_[type].active = true; - fireAddonEvent("MIRROR_TIMER_START", { - std::to_string(type), std::to_string(value), - std::to_string(maxV), std::to_string(scale), - paused ? "1" : "0"}); - } - }; - dispatchTable_[Opcode::SMSG_STOP_MIRROR_TIMER] = [this](network::Packet& packet) { - if (!packet.hasRemaining(4)) return; - uint32_t type = packet.readUInt32(); - if (type < 3) { - mirrorTimers_[type].active = false; - mirrorTimers_[type].value = 0; - fireAddonEvent("MIRROR_TIMER_STOP", {std::to_string(type)}); - } - }; - dispatchTable_[Opcode::SMSG_PAUSE_MIRROR_TIMER] = [this](network::Packet& packet) { - if (!packet.hasRemaining(5)) return; - uint32_t type = packet.readUInt32(); - uint8_t paused = packet.readUInt8(); - if (type < 3) { - mirrorTimers_[type].paused = (paused != 0); - fireAddonEvent("MIRROR_TIMER_PAUSE", {paused ? "1" : "0"}); - } - }; - - // ----------------------------------------------------------------------- - // Cast result / spell proc - // (SMSG_CAST_RESULT, SMSG_SPELL_FAILED_OTHER → moved to SpellHandler) - // (SMSG_PROCRESIST → moved to CombatHandler) - // ----------------------------------------------------------------------- - - // ----------------------------------------------------------------------- - // Pet stable - // ----------------------------------------------------------------------- - dispatchTable_[Opcode::MSG_LIST_STABLED_PETS] = [this](network::Packet& packet) { - if (state == WorldState::IN_WORLD) handleListStabledPets(packet); - }; - dispatchTable_[Opcode::SMSG_STABLE_RESULT] = [this](network::Packet& packet) { - if (!packet.hasRemaining(1)) return; - uint8_t result = packet.readUInt8(); - const char* msg = nullptr; - switch (result) { - case 0x01: msg = "Pet stored in stable."; break; - case 0x06: msg = "Pet retrieved from stable."; break; - case 0x07: msg = "Stable slot purchased."; break; - case 0x08: msg = "Stable list updated."; break; - case 0x09: msg = "Stable failed: not enough money or other error."; addUIError(msg); break; - default: break; - } - if (msg) addSystemChatMessage(msg); - LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast(result)); - if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) { - auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_); - socket->send(refreshPkt); - } - }; - - // ----------------------------------------------------------------------- - // Titles / achievements / character services - // ----------------------------------------------------------------------- - dispatchTable_[Opcode::SMSG_TITLE_EARNED] = [this](network::Packet& packet) { - if (!packet.hasRemaining(8)) return; - uint32_t titleBit = packet.readUInt32(); - uint32_t isLost = packet.readUInt32(); - loadTitleNameCache(); - std::string titleStr; - auto tit = titleNameCache_.find(titleBit); - if (tit != titleNameCache_.end() && !tit->second.empty()) { - const auto& ln = lookupName(playerGuid); - const std::string& pName = ln.empty() ? std::string("you") : ln; - const std::string& fmt = tit->second; - size_t pos = fmt.find("%s"); - if (pos != std::string::npos) - titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2); - else - titleStr = fmt; - } - std::string msg; - if (!titleStr.empty()) { - msg = isLost ? ("Title removed: " + titleStr + ".") : ("Title earned: " + titleStr + "!"); - } else { - char buf[64]; - std::snprintf(buf, sizeof(buf), isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!", titleBit); - msg = buf; - } - if (isLost) knownTitleBits_.erase(titleBit); - else knownTitleBits_.insert(titleBit); - addSystemChatMessage(msg); - LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, " title='", titleStr, "'"); - }; - dispatchTable_[Opcode::SMSG_LEARNED_DANCE_MOVES] = [this](network::Packet& packet) { - LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")"); - }; - dispatchTable_[Opcode::SMSG_CHAR_RENAME] = [this](network::Packet& packet) { - if (packet.hasRemaining(13)) { - uint32_t result = packet.readUInt32(); - /*uint64_t guid =*/ packet.readUInt64(); - std::string newName = packet.readString(); - if (result == 0) { - addSystemChatMessage("Character name changed to: " + newName); - } else { - static const char* kRenameErrors[] = { - nullptr, "Name already in use.", "Name too short.", "Name too long.", - "Name contains invalid characters.", "Name contains a profanity.", - "Name is reserved.", "Character name does not meet requirements.", - }; - const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr; - std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg : "Character rename failed."; - addUIError(renameErr); addSystemChatMessage(renameErr); - } - LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); - } - }; - - // ----------------------------------------------------------------------- - // Bind / heartstone / phase / barber / corpse - // ----------------------------------------------------------------------- - dispatchTable_[Opcode::SMSG_PLAYERBOUND] = [this](network::Packet& packet) { - if (!packet.hasRemaining(16)) return; - /*uint64_t binderGuid =*/ packet.readUInt64(); - uint32_t mapId = packet.readUInt32(); - uint32_t zoneId = packet.readUInt32(); - homeBindMapId_ = mapId; - homeBindZoneId_ = zoneId; - std::string pbMsg = "Your home location has been set"; - std::string zoneName = getAreaName(zoneId); - if (!zoneName.empty()) pbMsg += " to " + zoneName; - pbMsg += '.'; - addSystemChatMessage(pbMsg); - }; - registerSkipHandler(Opcode::SMSG_BINDER_CONFIRM); - registerSkipHandler(Opcode::SMSG_SET_PHASE_SHIFT); - dispatchTable_[Opcode::SMSG_TOGGLE_XP_GAIN] = [this](network::Packet& packet) { - if (!packet.hasRemaining(1)) return; - uint8_t enabled = packet.readUInt8(); - addSystemChatMessage(enabled ? "XP gain enabled." : "XP gain disabled."); - }; - dispatchTable_[Opcode::SMSG_BINDZONEREPLY] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t result = packet.readUInt32(); - if (result == 0) addSystemChatMessage("Your home is now set to this location."); - else { addUIError("You are too far from the innkeeper."); addSystemChatMessage("You are too far from the innkeeper."); } - } - }; - dispatchTable_[Opcode::SMSG_CHANGEPLAYER_DIFFICULTY_RESULT] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t result = packet.readUInt32(); - if (result == 0) { - addSystemChatMessage("Difficulty changed."); - } else { - static const char* reasons[] = { - "", "Error", "Too many members", "Already in dungeon", - "You are in a battleground", "Raid not allowed in heroic", - "You must be in a raid group", "Player not in group" - }; - const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed."; - addUIError(std::string("Cannot change difficulty: ") + msg); - addSystemChatMessage(std::string("Cannot change difficulty: ") + msg); - } - } - }; - dispatchTable_[Opcode::SMSG_CORPSE_NOT_IN_INSTANCE] = [this](network::Packet& /*packet*/) { - addUIError("Your corpse is outside this instance."); - addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it."); - }; - dispatchTable_[Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD] = [this](network::Packet& packet) { - if (packet.hasRemaining(12)) { - uint64_t guid = packet.readUInt64(); - uint32_t threshold = packet.readUInt32(); - if (guid == playerGuid && threshold > 0) addSystemChatMessage("You feel rather drunk."); - LOG_DEBUG("SMSG_CROSSED_INEBRIATION_THRESHOLD: guid=0x", std::hex, guid, std::dec, " threshold=", threshold); - } - }; - dispatchTable_[Opcode::SMSG_CLEAR_FAR_SIGHT_IMMEDIATE] = [this](network::Packet& /*packet*/) { - LOG_DEBUG("SMSG_CLEAR_FAR_SIGHT_IMMEDIATE"); - }; - registerSkipHandler(Opcode::SMSG_COMBAT_EVENT_FAILED); - dispatchTable_[Opcode::SMSG_FORCE_ANIM] = [this](network::Packet& packet) { - if (packet.hasRemaining(1)) { - uint64_t animGuid = packet.readPackedGuid(); - if (packet.hasRemaining(4)) { - uint32_t animId = packet.readUInt32(); - if (emoteAnimCallback_) emoteAnimCallback_(animGuid, animId); - } - } - }; - // Consume silently — opcodes we receive but don't need to act on - for (auto op : { - Opcode::SMSG_FLIGHT_SPLINE_SYNC, Opcode::SMSG_FORCE_DISPLAY_UPDATE, - Opcode::SMSG_FORCE_SEND_QUEUED_PACKETS, Opcode::SMSG_FORCE_SET_VEHICLE_REC_ID, - Opcode::SMSG_CORPSE_MAP_POSITION_QUERY_RESPONSE, Opcode::SMSG_DAMAGE_CALC_LOG, - Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT, Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE, - }) { registerSkipHandler(op); } - - // Game object despawn animation — reset state to closed before actual despawn - dispatchTable_[Opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM] = [this](network::Packet& packet) { - if (!packet.hasRemaining(8)) return; - uint64_t guid = packet.readUInt64(); - // Trigger a CLOSE animation / freeze before the object is removed - if (gameObjectStateCallback_) gameObjectStateCallback_(guid, 0); - }; - // Game object reset state — return to READY(closed) state - dispatchTable_[Opcode::SMSG_GAMEOBJECT_RESET_STATE] = [this](network::Packet& packet) { - if (!packet.hasRemaining(8)) return; - uint64_t guid = packet.readUInt64(); - if (gameObjectStateCallback_) gameObjectStateCallback_(guid, 0); - }; - dispatchTable_[Opcode::SMSG_FORCED_DEATH_UPDATE] = [this](network::Packet& packet) { - playerDead_ = true; - if (ghostStateCallback_) ghostStateCallback_(false); - fireAddonEvent("PLAYER_DEAD", {}); - addSystemChatMessage("You have been killed."); - LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed"); - packet.skipAll(); - }; - // SMSG_DEFENSE_MESSAGE — moved to ChatHandler::registerOpcodes - dispatchTable_[Opcode::SMSG_CORPSE_RECLAIM_DELAY] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t delayMs = packet.readUInt32(); - auto nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - corpseReclaimAvailableMs_ = nowMs + delayMs; - LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); - } - }; - dispatchTable_[Opcode::SMSG_DEATH_RELEASE_LOC] = [this](network::Packet& packet) { - if (packet.hasRemaining(16)) { - uint32_t relMapId = packet.readUInt32(); - float relX = packet.readFloat(), relY = packet.readFloat(), relZ = packet.readFloat(); - LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId, " x=", relX, " y=", relY, " z=", relZ); - } - }; - dispatchTable_[Opcode::SMSG_ENABLE_BARBER_SHOP] = [this](network::Packet& /*packet*/) { - LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); - barberShopOpen_ = true; - fireAddonEvent("BARBER_SHOP_OPEN", {}); - }; - - // ---- Batch 3: Corpse/gametime, combat clearing, mount, loot notify, - // movement/speed/flags, attack, spells, group ---- - - dispatchTable_[Opcode::MSG_CORPSE_QUERY] = [this](network::Packet& packet) { - if (!packet.hasRemaining(1)) return; - uint8_t found = packet.readUInt8(); - if (found && packet.hasRemaining(20)) { - /*uint32_t mapId =*/ packet.readUInt32(); - float cx = packet.readFloat(); - float cy = packet.readFloat(); - float cz = packet.readFloat(); - uint32_t corpseMapId = packet.readUInt32(); - corpseX_ = cx; - corpseY_ = cy; - corpseZ_ = cz; - corpseMapId_ = corpseMapId; - LOG_INFO("MSG_CORPSE_QUERY: corpse at (", cx, ",", cy, ",", cz, ") map=", corpseMapId); - } - }; - dispatchTable_[Opcode::SMSG_FEIGN_DEATH_RESISTED] = [this](network::Packet& /*packet*/) { - addUIError("Your Feign Death was resisted."); - addSystemChatMessage("Your Feign Death attempt was resisted."); - }; - dispatchTable_[Opcode::SMSG_CHANNEL_MEMBER_COUNT] = [this](network::Packet& packet) { - std::string chanName = packet.readString(); - if (packet.hasRemaining(5)) { - /*uint8_t flags =*/ packet.readUInt8(); - uint32_t count = packet.readUInt32(); - LOG_DEBUG("SMSG_CHANNEL_MEMBER_COUNT: channel=", chanName, " members=", count); - } - }; - for (auto op : { Opcode::SMSG_GAMETIME_SET, Opcode::SMSG_GAMETIME_UPDATE }) { - dispatchTable_[op] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t gameTimePacked = packet.readUInt32(); - gameTime_ = static_cast(gameTimePacked); - } - packet.skipAll(); - }; - } - dispatchTable_[Opcode::SMSG_GAMESPEED_SET] = [this](network::Packet& packet) { - if (packet.hasRemaining(8)) { - uint32_t gameTimePacked = packet.readUInt32(); - float timeSpeed = packet.readFloat(); - gameTime_ = static_cast(gameTimePacked); - timeSpeed_ = timeSpeed; - } - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_GAMETIMEBIAS_SET] = [this](network::Packet& packet) { - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_ACHIEVEMENT_DELETED] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t achId = packet.readUInt32(); - earnedAchievements_.erase(achId); - achievementDates_.erase(achId); - } - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_CRITERIA_DELETED] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t critId = packet.readUInt32(); - criteriaProgress_.erase(critId); - } - packet.skipAll(); - }; - - // Combat clearing - dispatchTable_[Opcode::SMSG_BREAK_TARGET] = [this](network::Packet& packet) { - if (packet.hasRemaining(8)) { - uint64_t bGuid = packet.readUInt64(); - if (bGuid == targetGuid) targetGuid = 0; - } - }; - dispatchTable_[Opcode::SMSG_CLEAR_TARGET] = [this](network::Packet& packet) { - if (packet.hasRemaining(8)) { - uint64_t cGuid = packet.readUInt64(); - if (cGuid == 0 || cGuid == targetGuid) targetGuid = 0; - } - }; - - // Mount/dismount - dispatchTable_[Opcode::SMSG_DISMOUNT] = [this](network::Packet& /*packet*/) { - currentMountDisplayId_ = 0; - if (mountCallback_) mountCallback_(0); - }; - dispatchTable_[Opcode::SMSG_MOUNTRESULT] = [this](network::Packet& packet) { - if (!packet.hasRemaining(4)) return; - uint32_t result = packet.readUInt32(); - if (result != 4) { - const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", - "Too far away to mount.", "Already mounted." }; - std::string mountErr = result < 4 ? msgs[result] : "Cannot mount."; - addUIError(mountErr); - addSystemChatMessage(mountErr); - } - }; - dispatchTable_[Opcode::SMSG_DISMOUNTRESULT] = [this](network::Packet& packet) { - if (!packet.hasRemaining(4)) return; - uint32_t result = packet.readUInt32(); - if (result != 0) { - addUIError("Cannot dismount here."); - addSystemChatMessage("Cannot dismount here."); - } - }; - - // Camera shake - dispatchTable_[Opcode::SMSG_CAMERA_SHAKE] = [this](network::Packet& packet) { - if (packet.hasRemaining(8)) { - uint32_t shakeId = packet.readUInt32(); - uint32_t shakeType = packet.readUInt32(); - (void)shakeType; - float magnitude = (shakeId < 50) ? 0.04f : 0.08f; - if (cameraShakeCallback_) - cameraShakeCallback_(magnitude, 18.0f, 0.5f); - } - }; - - // (SMSG_PLAY_SPELL_VISUAL, SMSG_CLEAR_COOLDOWN, SMSG_MODIFY_COOLDOWN → moved to SpellHandler) - - // ---- Batch 4: Ready check, duels, guild, loot/gossip/vendor, factions, spell mods ---- - - // Guild - registerHandler(Opcode::SMSG_PET_SPELLS, &GameHandler::handlePetSpells); - - // Loot/gossip/vendor delegates - registerHandler(Opcode::SMSG_SUMMON_REQUEST, &GameHandler::handleSummonRequest); - dispatchTable_[Opcode::SMSG_SUMMON_CANCEL] = [this](network::Packet& /*packet*/) { - pendingSummonRequest_ = false; - addSystemChatMessage("Summon cancelled."); - }; - - // Bind point - dispatchTable_[Opcode::SMSG_BINDPOINTUPDATE] = [this](network::Packet& packet) { - BindPointUpdateData data; - if (BindPointUpdateParser::parse(packet, data)) { - glm::vec3 canonical = core::coords::serverToCanonical( - glm::vec3(data.x, data.y, data.z)); - bool wasSet = hasHomeBind_; - hasHomeBind_ = true; - homeBindMapId_ = data.mapId; - homeBindZoneId_ = data.zoneId; - homeBindPos_ = canonical; - if (bindPointCallback_) - bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z); - if (wasSet) { - std::string bindMsg = "Your home has been set"; - std::string zoneName = getAreaName(data.zoneId); - if (!zoneName.empty()) bindMsg += " to " + zoneName; - bindMsg += '.'; - addSystemChatMessage(bindMsg); - } - } - }; - - // Spirit healer / resurrect - dispatchTable_[Opcode::SMSG_SPIRIT_HEALER_CONFIRM] = [this](network::Packet& packet) { - if (!packet.hasRemaining(8)) return; - uint64_t npcGuid = packet.readUInt64(); - if (npcGuid) { - resurrectCasterGuid_ = npcGuid; - resurrectCasterName_ = ""; - resurrectIsSpiritHealer_ = true; - resurrectRequestPending_ = true; - } - }; - dispatchTable_[Opcode::SMSG_RESURRECT_REQUEST] = [this](network::Packet& packet) { - if (!packet.hasRemaining(8)) return; - uint64_t casterGuid = packet.readUInt64(); - std::string casterName; - if (packet.hasData()) - casterName = packet.readString(); - if (casterGuid) { - resurrectCasterGuid_ = casterGuid; - resurrectIsSpiritHealer_ = false; - if (!casterName.empty()) { - resurrectCasterName_ = casterName; - } else { - resurrectCasterName_ = lookupName(casterGuid); - } - resurrectRequestPending_ = true; - fireAddonEvent("RESURRECT_REQUEST", {resurrectCasterName_}); - } - }; - - // Time sync - dispatchTable_[Opcode::SMSG_TIME_SYNC_REQ] = [this](network::Packet& packet) { - if (!packet.hasRemaining(4)) return; - uint32_t counter = packet.readUInt32(); - if (socket) { - network::Packet resp(wireOpcode(Opcode::CMSG_TIME_SYNC_RESP)); - resp.writeUInt32(counter); - resp.writeUInt32(nextMovementTimestampMs()); - socket->send(resp); - } - }; - - // (SMSG_TRAINER_BUY_SUCCEEDED, SMSG_TRAINER_BUY_FAILED → moved to InventoryHandler) - - // Minimap ping - dispatchTable_[Opcode::MSG_MINIMAP_PING] = [this](network::Packet& packet) { - const bool mmTbcLike = isPreWotlk(); - if (!packet.hasRemaining(mmTbcLike ? 8u : 1u) ) return; - uint64_t senderGuid = mmTbcLike - ? packet.readUInt64() : packet.readPackedGuid(); - if (!packet.hasRemaining(8)) return; - float pingX = packet.readFloat(); - float pingY = packet.readFloat(); - MinimapPing ping; - ping.senderGuid = senderGuid; - ping.wowX = pingY; - ping.wowY = pingX; - ping.age = 0.0f; - minimapPings_.push_back(ping); - if (senderGuid != playerGuid) { - withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playMinimapPing(); }); - } - }; - dispatchTable_[Opcode::SMSG_ZONE_UNDER_ATTACK] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t areaId = packet.readUInt32(); - std::string areaName = getAreaName(areaId); - std::string msg = areaName.empty() - ? std::string("A zone is under attack!") - : (areaName + " is under attack!"); - addUIError(msg); - addSystemChatMessage(msg); - } - }; - - // Spirit healer time / durability - dispatchTable_[Opcode::SMSG_AREA_SPIRIT_HEALER_TIME] = [this](network::Packet& packet) { - if (packet.hasRemaining(12)) { - /*uint64_t guid =*/ packet.readUInt64(); - uint32_t timeMs = packet.readUInt32(); - uint32_t secs = timeMs / 1000; - char buf[128]; - std::snprintf(buf, sizeof(buf), "You will be able to resurrect in %u seconds.", secs); - addSystemChatMessage(buf); - } - }; - dispatchTable_[Opcode::SMSG_DURABILITY_DAMAGE_DEATH] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t pct = packet.readUInt32(); - char buf[80]; - std::snprintf(buf, sizeof(buf), - "You have lost %u%% of your gear's durability due to death.", pct); - addUIError(buf); - addSystemChatMessage(buf); - } - }; - - // (SMSG_INITIALIZE_FACTIONS, SMSG_SET_FACTION_STANDING, - // SMSG_SET_FACTION_ATWAR, SMSG_SET_FACTION_VISIBLE → moved to SocialHandler) - dispatchTable_[Opcode::SMSG_FEATURE_SYSTEM_STATUS] = [this](network::Packet& packet) { - packet.skipAll(); - }; - - // (SMSG_SET_FLAT_SPELL_MODIFIER, SMSG_SET_PCT_SPELL_MODIFIER, SMSG_SPELL_DELAYED → moved to SpellHandler) - - // Proficiency - dispatchTable_[Opcode::SMSG_SET_PROFICIENCY] = [this](network::Packet& packet) { - if (!packet.hasRemaining(5)) return; - uint8_t itemClass = packet.readUInt8(); - uint32_t mask = packet.readUInt32(); - if (itemClass == 2) weaponProficiency_ = mask; - else if (itemClass == 4) armorProficiency_ = mask; - }; - - // Loot money / misc consume - for (auto op : { Opcode::SMSG_LOOT_CLEAR_MONEY, Opcode::SMSG_NPC_TEXT_UPDATE }) { - dispatchTable_[op] = [](network::Packet& /*packet*/) {}; - } - - // Play sound - dispatchTable_[Opcode::SMSG_PLAY_SOUND] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t soundId = packet.readUInt32(); - if (playSoundCallback_) playSoundCallback_(soundId); - } - }; - - // SMSG_SERVER_MESSAGE — moved to ChatHandler::registerOpcodes - // SMSG_CHAT_SERVER_MESSAGE — moved to ChatHandler::registerOpcodes - // SMSG_AREA_TRIGGER_MESSAGE — moved to ChatHandler::registerOpcodes - dispatchTable_[Opcode::SMSG_TRIGGER_CINEMATIC] = [this](network::Packet& packet) { - packet.skipAll(); - network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA)); - socket->send(ack); - }; - - // ---- Batch 5: Teleport, taxi, BG, LFG, arena, movement relay, mail, bank, auction, quests ---- - - // Teleport - dispatchTable_[Opcode::SMSG_TRANSFER_PENDING] = [this](network::Packet& packet) { - uint32_t pendingMapId = packet.readUInt32(); - if (packet.hasRemaining(8)) { - packet.readUInt32(); // transportEntry - packet.readUInt32(); // transportMapId - } - (void)pendingMapId; - }; - dispatchTable_[Opcode::SMSG_TRANSFER_ABORTED] = [this](network::Packet& packet) { - uint32_t mapId = packet.readUInt32(); - uint8_t reason = (packet.hasData()) ? packet.readUInt8() : 0; - (void)mapId; - const char* abortMsg = nullptr; - switch (reason) { - case 0x01: abortMsg = "Transfer aborted: difficulty unavailable."; break; - case 0x02: abortMsg = "Transfer aborted: expansion required."; break; - case 0x03: abortMsg = "Transfer aborted: instance not found."; break; - case 0x04: abortMsg = "Transfer aborted: too many instances. Please wait before entering a new instance."; break; - case 0x06: abortMsg = "Transfer aborted: instance is full."; break; - case 0x07: abortMsg = "Transfer aborted: zone is in combat."; break; - case 0x08: abortMsg = "Transfer aborted: you are already in this instance."; break; - case 0x09: abortMsg = "Transfer aborted: not enough players."; break; - default: abortMsg = "Transfer aborted."; break; - } - addUIError(abortMsg); - addSystemChatMessage(abortMsg); - }; - - // Taxi - dispatchTable_[Opcode::SMSG_STANDSTATE_UPDATE] = [this](network::Packet& packet) { - if (packet.hasRemaining(1)) { - standState_ = packet.readUInt8(); - if (standStateCallback_) standStateCallback_(standState_); - } - }; - dispatchTable_[Opcode::SMSG_NEW_TAXI_PATH] = [this](network::Packet& /*packet*/) { - addSystemChatMessage("New flight path discovered!"); - }; - - // Arena - dispatchTable_[Opcode::MSG_TALENT_WIPE_CONFIRM] = [this](network::Packet& packet) { - if (!packet.hasRemaining(12)) { packet.skipAll(); return; } - talentWipeNpcGuid_ = packet.readUInt64(); - talentWipeCost_ = packet.readUInt32(); - talentWipePending_ = true; - fireAddonEvent("CONFIRM_TALENT_WIPE", {std::to_string(talentWipeCost_)}); - }; - - // (SMSG_CHANNEL_LIST → moved to ChatHandler) - // (SMSG_GROUP_SET_LEADER → moved to SocialHandler) - - // Gameobject / page text (entity queries moved to EntityController::registerOpcodes) - dispatchTable_[Opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM] = [this](network::Packet& packet) { - if (packet.getSize() < 12) return; - uint64_t guid = packet.readUInt64(); - uint32_t animId = packet.readUInt32(); - if (gameObjectCustomAnimCallback_) - gameObjectCustomAnimCallback_(guid, animId); - if (animId == 0) { - auto goEnt = entityController_->getEntityManager().getEntity(guid); - if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(goEnt); - // Only show fishing message if the bobber belongs to us - // OBJECT_FIELD_CREATED_BY is a uint64 at field indices 6-7 - uint64_t createdBy = static_cast(go->getField(6)) - | (static_cast(go->getField(7)) << 32); - if (createdBy == playerGuid) { - auto* info = getCachedGameObjectInfo(go->getEntry()); - if (info && info->type == 17) { - addUIError("A fish is on your line!"); - addSystemChatMessage("A fish is on your line!"); - withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playQuestUpdate(); }); - } - } - } - } - }; - - // Item refund / socket gems / item time - dispatchTable_[Opcode::SMSG_ITEM_REFUND_RESULT] = [this](network::Packet& packet) { - if (packet.hasRemaining(12)) { - packet.readUInt64(); // itemGuid - uint32_t result = packet.readUInt32(); - addSystemChatMessage(result == 0 ? "Item returned. Refund processed." - : "Could not return item for refund."); - } - }; - dispatchTable_[Opcode::SMSG_SOCKET_GEMS_RESULT] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t result = packet.readUInt32(); - if (result == 0) addSystemChatMessage("Gems socketed successfully."); - else addSystemChatMessage("Failed to socket gems."); - } - }; - dispatchTable_[Opcode::SMSG_ITEM_TIME_UPDATE] = [this](network::Packet& packet) { - if (packet.hasRemaining(12)) { - packet.readUInt64(); // itemGuid - packet.readUInt32(); // durationMs - } - }; - - // ---- Batch 6: Spell miss / env damage / control / spell failure ---- - - - // ---- Achievement / fishing delegates ---- - dispatchTable_[Opcode::SMSG_ALL_ACHIEVEMENT_DATA] = [this](network::Packet& packet) { - handleAllAchievementData(packet); - }; - dispatchTable_[Opcode::SMSG_FISH_NOT_HOOKED] = [this](network::Packet& /*packet*/) { - addSystemChatMessage("Your fish got away."); - }; - dispatchTable_[Opcode::SMSG_FISH_ESCAPED] = [this](network::Packet& /*packet*/) { - addSystemChatMessage("Your fish escaped!"); - }; - - // ---- Auto-repeat / auras / dispel / totem ---- - dispatchTable_[Opcode::SMSG_CANCEL_AUTO_REPEAT] = [this](network::Packet& /*packet*/) { - // Server signals to stop a repeating spell (wand/shoot); no client action needed - }; - - - // ---- Batch 7: World states, action buttons, level-up, vendor, inventory ---- - - // ---- SMSG_INIT_WORLD_STATES ---- - dispatchTable_[Opcode::SMSG_INIT_WORLD_STATES] = [this](network::Packet& packet) { - // WotLK format: uint32 mapId, uint32 zoneId, uint32 areaId, uint16 count, N*(uint32 key, uint32 val) - // Classic/TBC format: uint32 mapId, uint32 zoneId, uint16 count, N*(uint32 key, uint32 val) - if (!packet.hasRemaining(10)) { - LOG_WARNING("SMSG_INIT_WORLD_STATES too short: ", packet.getSize(), " bytes"); - return; - } - worldStateMapId_ = packet.readUInt32(); - { - uint32_t newZoneId = packet.readUInt32(); - if (newZoneId != worldStateZoneId_ && newZoneId != 0) { - worldStateZoneId_ = newZoneId; - fireAddonEvent("ZONE_CHANGED_NEW_AREA", {}); - fireAddonEvent("ZONE_CHANGED", {}); - } else { - worldStateZoneId_ = newZoneId; - } - } - // WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format - size_t remaining = packet.getRemainingSize(); - bool isWotLKFormat = isActiveExpansion("wotlk"); - if (isWotLKFormat && remaining >= 6) { - packet.readUInt32(); // areaId (WotLK only) - } - uint16_t count = packet.readUInt16(); - size_t needed = static_cast(count) * 8; - size_t available = packet.getRemainingSize(); - if (available < needed) { - // Be tolerant across expansion/private-core variants: if packet shape - // still looks like N*(key,val) dwords, parse what is present. - if ((available % 8) == 0) { - uint16_t adjustedCount = static_cast(available / 8); - LOG_WARNING("SMSG_INIT_WORLD_STATES count mismatch: header=", count, - " adjusted=", adjustedCount, " (available=", available, ")"); - count = adjustedCount; - needed = available; - } else { - LOG_WARNING("SMSG_INIT_WORLD_STATES truncated: expected ", needed, - " bytes of state pairs, got ", available); - packet.skipAll(); - return; - } - } - worldStates_.clear(); - worldStates_.reserve(count); - for (uint16_t i = 0; i < count; ++i) { - uint32_t key = packet.readUInt32(); - uint32_t val = packet.readUInt32(); - worldStates_[key] = val; - } - }; - - // ---- SMSG_ACTION_BUTTONS ---- - dispatchTable_[Opcode::SMSG_ACTION_BUTTONS] = [this](network::Packet& packet) { - // Slot encoding differs by expansion: - // Classic/Turtle: uint16 actionId + uint8 type + uint8 misc - // type: 0=spell, 1=item, 64=macro - // TBC/WotLK: uint32 packed = actionId | (type << 24) - // type: 0x00=spell, 0x80=item, 0x40=macro - // Format differences: - // Classic 1.12: no mode byte, 120 slots (480 bytes) - // TBC 2.4.3: no mode byte, 132 slots (528 bytes) - // WotLK 3.3.5a: uint8 mode + 144 slots (577 bytes) - size_t rem = packet.getRemainingSize(); - const bool hasModeByteExp = isActiveExpansion("wotlk"); - int serverBarSlots; - if (isClassicLikeExpansion()) { - serverBarSlots = 120; - } else if (isActiveExpansion("tbc")) { - serverBarSlots = 132; - } else { - serverBarSlots = 144; - } - if (hasModeByteExp) { - if (rem < 1) return; - /*uint8_t mode =*/ packet.readUInt8(); - rem--; - } - for (int i = 0; i < serverBarSlots; ++i) { - if (rem < 4) return; - uint32_t packed = packet.readUInt32(); - rem -= 4; - 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. - continue; - } - uint8_t type = 0; - uint32_t id = 0; - if (isClassicLikeExpansion()) { - id = packed & 0x0000FFFFu; - type = static_cast((packed >> 16) & 0xFF); - } else { - type = static_cast((packed >> 24) & 0xFF); - id = packed & 0x00FFFFFFu; - } - if (id == 0) continue; - ActionBarSlot slot; - switch (type) { - case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; - case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item - case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item - case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions) - default: continue; // unknown — leave as-is - } - actionBar[i] = slot; - } - // Apply any pending cooldowns from spellHandler's cooldowns to newly populated slots. - // SMSG_SPELL_COOLDOWN often arrives before SMSG_ACTION_BUTTONS during login, - // so the per-slot cooldownRemaining would be 0 without this sync. - if (spellHandler_) { - for (auto& slot : actionBar) { - if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { - auto cdIt = spellHandler_->spellCooldowns_.find(slot.id); - if (cdIt != spellHandler_->spellCooldowns_.end() && cdIt->second > 0.0f) { - slot.cooldownRemaining = cdIt->second; - slot.cooldownTotal = cdIt->second; - } - } else if (slot.type == ActionBarSlot::ITEM && slot.id != 0) { - // Items (potions, trinkets): look up the item's on-use spell - // and check if that spell has a pending cooldown. - const auto* qi = getItemInfo(slot.id); - if (qi && qi->valid) { - for (const auto& sp : qi->spells) { - if (sp.spellId == 0) continue; - auto cdIt = spellHandler_->spellCooldowns_.find(sp.spellId); - if (cdIt != spellHandler_->spellCooldowns_.end() && cdIt->second > 0.0f) { - slot.cooldownRemaining = cdIt->second; - slot.cooldownTotal = cdIt->second; - break; - } - } - } - } - } - } - LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server"); - fireAddonEvent("ACTIONBAR_SLOT_CHANGED", {}); - packet.skipAll(); - }; - - // ---- SMSG_LEVELUP_INFO / SMSG_LEVELUP_INFO_ALT (shared body) ---- - for (auto op : {Opcode::SMSG_LEVELUP_INFO, Opcode::SMSG_LEVELUP_INFO_ALT}) { - dispatchTable_[op] = [this](network::Packet& packet) { - // Server-authoritative level-up event. - // WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas - if (packet.hasRemaining(4)) { - uint32_t newLevel = packet.readUInt32(); - if (newLevel > 0) { - // Parse stat deltas (WotLK layout has 7 more uint32s) - lastLevelUpDeltas_ = {}; - if (packet.hasRemaining(28)) { - lastLevelUpDeltas_.hp = packet.readUInt32(); - lastLevelUpDeltas_.mana = packet.readUInt32(); - lastLevelUpDeltas_.str = packet.readUInt32(); - lastLevelUpDeltas_.agi = packet.readUInt32(); - lastLevelUpDeltas_.sta = packet.readUInt32(); - lastLevelUpDeltas_.intel = packet.readUInt32(); - lastLevelUpDeltas_.spi = packet.readUInt32(); - } - uint32_t oldLevel = serverPlayerLevel_; - serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel); - // Update the character-list entry so the selection screen - // shows the correct level if the player logs out and back. - for (auto& ch : characters) { - if (ch.guid == playerGuid) { - ch.level = serverPlayerLevel_; - break; // was 'return' — must NOT exit here or level-up notification is skipped - } - } - if (newLevel > oldLevel) { - addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!"); - withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playLevelUp(); }); - if (levelUpCallback_) levelUpCallback_(newLevel); - fireAddonEvent("PLAYER_LEVEL_UP", {std::to_string(newLevel)}); - } - } - } - packet.skipAll(); - }; - } - - // ---- MSG_RAID_TARGET_UPDATE ---- - dispatchTable_[Opcode::MSG_RAID_TARGET_UPDATE] = [this](network::Packet& packet) { - // uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)), - // 1 = single update (uint8 icon + uint64 guid) - size_t remRTU = packet.getRemainingSize(); - if (remRTU < 1) return; - uint8_t rtuType = packet.readUInt8(); - if (rtuType == 0) { - // Full update: always 8 entries - for (uint32_t i = 0; i < kRaidMarkCount; ++i) { - if (!packet.hasRemaining(9)) return; - uint8_t icon = packet.readUInt8(); - uint64_t guid = packet.readUInt64(); - if (socialHandler_) - socialHandler_->setRaidTargetGuid(icon, guid); - } - } else { - // Single update - if (packet.hasRemaining(9)) { - uint8_t icon = packet.readUInt8(); - uint64_t guid = packet.readUInt64(); - if (socialHandler_) - socialHandler_->setRaidTargetGuid(icon, guid); - } - } - LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast(rtuType)); - fireAddonEvent("RAID_TARGET_UPDATE", {}); - }; - - // ---- SMSG_CRITERIA_UPDATE ---- - dispatchTable_[Opcode::SMSG_CRITERIA_UPDATE] = [this](network::Packet& packet) { - // uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime - if (packet.hasRemaining(20)) { - uint32_t criteriaId = packet.readUInt32(); - uint64_t progress = packet.readUInt64(); - packet.readUInt32(); // elapsedTime - packet.readUInt32(); // creationTime - uint64_t oldProgress = 0; - auto cpit = criteriaProgress_.find(criteriaId); - if (cpit != criteriaProgress_.end()) oldProgress = cpit->second; - criteriaProgress_[criteriaId] = progress; - LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); - // Fire addon event for achievement tracking addons - if (progress != oldProgress) - fireAddonEvent("CRITERIA_UPDATE", {std::to_string(criteriaId), std::to_string(progress)}); - } - }; - - // ---- SMSG_BARBER_SHOP_RESULT ---- - dispatchTable_[Opcode::SMSG_BARBER_SHOP_RESULT] = [this](network::Packet& packet) { - // uint32 result (0 = success, 1 = no money, 2 = not barber, 3 = sitting) - if (packet.hasRemaining(4)) { - uint32_t result = packet.readUInt32(); - if (result == 0) { - addSystemChatMessage("Hairstyle changed."); - barberShopOpen_ = false; - fireAddonEvent("BARBER_SHOP_CLOSE", {}); - } else { - const char* msg = (result == 1) ? "Not enough money for new hairstyle." - : (result == 2) ? "You are not at a barber shop." - : (result == 3) ? "You must stand up to use the barber shop." - : "Barber shop unavailable."; - addUIError(msg); - addSystemChatMessage(msg); - } - LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result); - } - }; - - // ----------------------------------------------------------------------- - // Batch 8-12: Remaining opcodes (inspects, quests, auctions, spells, - // calendars, battlefields, voice, misc consume-only) - // ----------------------------------------------------------------------- - // uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs - dispatchTable_[Opcode::SMSG_OVERRIDE_LIGHT] = [this](network::Packet& packet) { - // uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs - if (packet.hasRemaining(12)) { - uint32_t zoneLightId = packet.readUInt32(); - uint32_t overrideLightId = packet.readUInt32(); - uint32_t transitionMs = packet.readUInt32(); - overrideLightId_ = overrideLightId; - overrideLightTransMs_ = transitionMs; - LOG_DEBUG("SMSG_OVERRIDE_LIGHT: zone=", zoneLightId, - " override=", overrideLightId, " transition=", transitionMs, "ms"); - } - }; - // Classic 1.12: uint32 weatherType + float intensity (8 bytes, no isAbrupt) - // TBC 2.4.3 / WotLK 3.3.5a: uint32 weatherType + float intensity + uint8 isAbrupt (9 bytes) - dispatchTable_[Opcode::SMSG_WEATHER] = [this](network::Packet& packet) { - // Classic 1.12: uint32 weatherType + float intensity (8 bytes, no isAbrupt) - // TBC 2.4.3 / WotLK 3.3.5a: uint32 weatherType + float intensity + uint8 isAbrupt (9 bytes) - if (packet.hasRemaining(8)) { - uint32_t wType = packet.readUInt32(); - float wIntensity = packet.readFloat(); - if (packet.hasRemaining(1)) - /*uint8_t isAbrupt =*/ packet.readUInt8(); - uint32_t prevWeatherType = weatherType_; - weatherType_ = wType; - weatherIntensity_ = wIntensity; - const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear"; - LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity); - // Announce weather changes (including initial zone weather) - if (wType != prevWeatherType) { - const char* weatherMsg = nullptr; - if (wIntensity < 0.05f || wType == 0) { - if (prevWeatherType != 0) - weatherMsg = "The weather clears."; - } else if (wType == 1) { - weatherMsg = "It begins to rain."; - } else if (wType == 2) { - weatherMsg = "It begins to snow."; - } else if (wType == 3) { - weatherMsg = "A storm rolls in."; - } - if (weatherMsg) addSystemChatMessage(weatherMsg); - } - // Notify addons of weather change - fireAddonEvent("WEATHER_CHANGED", {std::to_string(wType), std::to_string(wIntensity)}); - // Storm transition: trigger a low-frequency thunder rumble shake - if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) { - float mag = 0.03f + wIntensity * 0.04f; // 0.03–0.07 units - cameraShakeCallback_(mag, 6.0f, 0.6f); - } - } - }; - // Server-script text message — display in system chat - dispatchTable_[Opcode::SMSG_SCRIPT_MESSAGE] = [this](network::Packet& packet) { - // Server-script text message — display in system chat - std::string msg = packet.readString(); - if (!msg.empty()) { - addSystemChatMessage(msg); - LOG_INFO("SMSG_SCRIPT_MESSAGE: ", msg); - } - }; - // uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType - dispatchTable_[Opcode::SMSG_ENCHANTMENTLOG] = [this](network::Packet& packet) { - // uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType - if (packet.hasRemaining(28)) { - uint64_t enchTargetGuid = packet.readUInt64(); - uint64_t enchCasterGuid = packet.readUInt64(); - uint32_t enchSpellId = packet.readUInt32(); - /*uint32_t displayId =*/ packet.readUInt32(); - /*uint32_t animType =*/ packet.readUInt32(); - LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", enchSpellId); - // Show enchant message if the player is involved - if (enchTargetGuid == playerGuid || enchCasterGuid == playerGuid) { - const std::string& enchName = getSpellName(enchSpellId); - std::string casterName = lookupName(enchCasterGuid); - if (!enchName.empty()) { - std::string msg; - if (enchCasterGuid == playerGuid) - msg = "You enchant with " + enchName + "."; - else if (!casterName.empty()) - msg = casterName + " enchants your item with " + enchName + "."; - else - msg = "Your item has been enchanted with " + enchName + "."; - addSystemChatMessage(msg); - } - } - } - }; - // WotLK: uint64 playerGuid + uint8 teamCount + per-team fields - dispatchTable_[Opcode::MSG_INSPECT_ARENA_TEAMS] = [this](network::Packet& packet) { - // WotLK: uint64 playerGuid + uint8 teamCount + per-team fields - if (!packet.hasRemaining(9)) { - packet.skipAll(); - return; - } - uint64_t inspGuid = packet.readUInt64(); - uint8_t teamCount = packet.readUInt8(); - if (teamCount > 3) teamCount = 3; // 2v2, 3v3, 5v5 - if (socialHandler_) { - auto& ir = socialHandler_->mutableInspectResult(); - if (inspGuid == ir.guid || ir.guid == 0) { - ir.guid = inspGuid; - ir.arenaTeams.clear(); - for (uint8_t t = 0; t < teamCount; ++t) { - if (!packet.hasRemaining(21)) break; - SocialHandler::InspectArenaTeam team; - team.teamId = packet.readUInt32(); - team.type = packet.readUInt8(); - team.weekGames = packet.readUInt32(); - team.weekWins = packet.readUInt32(); - team.seasonGames = packet.readUInt32(); - team.seasonWins = packet.readUInt32(); - team.name = packet.readString(); - if (!packet.hasRemaining(4)) break; - team.personalRating = packet.readUInt32(); - ir.arenaTeams.push_back(std::move(team)); - } - } - } - LOG_DEBUG("MSG_INSPECT_ARENA_TEAMS: guid=0x", std::hex, inspGuid, std::dec, - " teams=", static_cast(teamCount)); - }; - // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + randomPropertyId(u32) + ... - // action: 0=sold/won, 1=expired, 2=bid placed on your auction - // auctionHouseId(u32) + auctionId(u32) + bidderGuid(u64) + bidAmount(u32) + outbidAmount(u32) + itemEntry(u32) + randomPropertyId(u32) - // uint32 auctionId + uint32 itemEntry + uint32 itemRandom — auction expired/cancelled - // uint64 containerGuid — tells client to open this container - // The actual items come via update packets; we just log this. - // PackedGuid (player guid) + uint32 vehicleId - // vehicleId == 0 means the player left the vehicle - dispatchTable_[Opcode::SMSG_PLAYER_VEHICLE_DATA] = [this](network::Packet& packet) { - // PackedGuid (player guid) + uint32 vehicleId - // vehicleId == 0 means the player left the vehicle - if (packet.hasRemaining(1)) { - (void)packet.readPackedGuid(); // player guid (unused) - } - uint32_t newVehicleId = 0; - if (packet.hasRemaining(4)) { - newVehicleId = packet.readUInt32(); - } - bool wasInVehicle = vehicleId_ != 0; - bool nowInVehicle = newVehicleId != 0; - vehicleId_ = newVehicleId; - if (wasInVehicle != nowInVehicle && vehicleStateCallback_) { - vehicleStateCallback_(nowInVehicle, newVehicleId); - } - }; - // guid(8) + status(1): status 1 = NPC has available/new routes for this player - dispatchTable_[Opcode::SMSG_TAXINODE_STATUS] = [this](network::Packet& packet) { - // guid(8) + status(1): status 1 = NPC has available/new routes for this player - if (packet.hasRemaining(9)) { - uint64_t npcGuid = packet.readUInt64(); - uint8_t status = packet.readUInt8(); - taxiNpcHasRoutes_[npcGuid] = (status != 0); - } - }; - // SMSG_GUILD_DECLINE — moved to SocialHandler::registerOpcodes - // Clear cached talent data so the talent screen reflects the reset. - dispatchTable_[Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET] = [this](network::Packet& packet) { - // Clear cached talent data so the talent screen reflects the reset. - if (spellHandler_) spellHandler_->resetTalentState(); - addUIError("Your talents have been reset by the server."); - addSystemChatMessage("Your talents have been reset by the server."); - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_SET_REST_START] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t restTrigger = packet.readUInt32(); - isResting_ = (restTrigger > 0); - addSystemChatMessage(isResting_ ? "You are now resting." - : "You are no longer resting."); - fireAddonEvent("PLAYER_UPDATE_RESTING", {}); - } - }; - dispatchTable_[Opcode::SMSG_UPDATE_AURA_DURATION] = [this](network::Packet& packet) { - if (packet.hasRemaining(5)) { - uint8_t slot = packet.readUInt8(); - uint32_t durationMs = packet.readUInt32(); - handleUpdateAuraDuration(slot, durationMs); - } - }; - dispatchTable_[Opcode::SMSG_ITEM_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t itemId = packet.readUInt32(); - std::string name = packet.readString(); - if (!itemInfoCache_.count(itemId) && !name.empty()) { - ItemQueryResponseData stub; - stub.entry = itemId; - stub.name = std::move(name); - stub.valid = true; - itemInfoCache_[itemId] = std::move(stub); - } - } - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_MOUNTSPECIAL_ANIM] = [this](network::Packet& packet) { (void)packet.readPackedGuid(); }; - dispatchTable_[Opcode::SMSG_CHAR_CUSTOMIZE] = [this](network::Packet& packet) { - if (packet.hasRemaining(1)) { - uint8_t result = packet.readUInt8(); - addSystemChatMessage(result == 0 ? "Character customization complete." - : "Character customization failed."); - } - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_CHAR_FACTION_CHANGE] = [this](network::Packet& packet) { - if (packet.hasRemaining(1)) { - uint8_t result = packet.readUInt8(); - addSystemChatMessage(result == 0 ? "Faction change complete." - : "Faction change failed."); - } - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_INVALIDATE_PLAYER] = [this](network::Packet& packet) { - if (packet.hasRemaining(8)) { - uint64_t guid = packet.readUInt64(); - entityController_->invalidatePlayerName(guid); - } - }; - // uint32 movieId — we don't play movies; acknowledge immediately. - dispatchTable_[Opcode::SMSG_TRIGGER_MOVIE] = [this](network::Packet& packet) { - // uint32 movieId — we don't play movies; acknowledge immediately. - packet.skipAll(); - // WotLK servers expect CMSG_COMPLETE_MOVIE after the movie finishes; - // without it, the server may hang or disconnect the client. - uint16_t wire = wireOpcode(Opcode::CMSG_COMPLETE_MOVIE); - if (wire != 0xFFFF) { - network::Packet ack(wire); - socket->send(ack); - LOG_DEBUG("SMSG_TRIGGER_MOVIE: skipped, sent CMSG_COMPLETE_MOVIE"); - } - }; - // Server-side LFG invite timed out (no response within time limit) - dispatchTable_[Opcode::SMSG_LFG_TIMEDOUT] = [this](network::Packet& packet) { - // Server-side LFG invite timed out (no response within time limit) - addSystemChatMessage("Dungeon Finder: Invite timed out."); - if (openLfgCallback_) openLfgCallback_(); - packet.skipAll(); - }; - // Another party member failed to respond to a LFG role-check in time - dispatchTable_[Opcode::SMSG_LFG_OTHER_TIMEDOUT] = [this](network::Packet& packet) { - // Another party member failed to respond to a LFG role-check in time - addSystemChatMessage("Dungeon Finder: Another player's invite timed out."); - if (openLfgCallback_) openLfgCallback_(); - packet.skipAll(); - }; - // uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time) - dispatchTable_[Opcode::SMSG_LFG_AUTOJOIN_FAILED] = [this](network::Packet& packet) { - // uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time) - if (packet.hasRemaining(4)) { - uint32_t result = packet.readUInt32(); - (void)result; - } - addUIError("Dungeon Finder: Auto-join failed."); - addSystemChatMessage("Dungeon Finder: Auto-join failed."); - packet.skipAll(); - }; - // No eligible players found for auto-join - dispatchTable_[Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER] = [this](network::Packet& packet) { - // No eligible players found for auto-join - addUIError("Dungeon Finder: No players available for auto-join."); - addSystemChatMessage("Dungeon Finder: No players available for auto-join."); - packet.skipAll(); - }; - // Party leader is currently set to Looking for More (LFM) mode - dispatchTable_[Opcode::SMSG_LFG_LEADER_IS_LFM] = [this](network::Packet& packet) { - // Party leader is currently set to Looking for More (LFM) mode - addSystemChatMessage("Your party leader is currently Looking for More."); - packet.skipAll(); - }; - // uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone - dispatchTable_[Opcode::SMSG_MEETINGSTONE_SETQUEUE] = [this](network::Packet& packet) { - // uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone - if (packet.hasRemaining(6)) { - uint32_t zoneId = packet.readUInt32(); - uint8_t levelMin = packet.readUInt8(); - uint8_t levelMax = packet.readUInt8(); - char buf[128]; - std::string zoneName = getAreaName(zoneId); - if (!zoneName.empty()) - std::snprintf(buf, sizeof(buf), - "You are now in the Meeting Stone queue for %s (levels %u-%u).", - zoneName.c_str(), levelMin, levelMax); - else - std::snprintf(buf, sizeof(buf), - "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", - zoneId, levelMin, levelMax); - addSystemChatMessage(buf); - LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId, - " levels=", static_cast(levelMin), "-", static_cast(levelMax)); - } - packet.skipAll(); - }; - // Server confirms group found and teleport summon is ready - dispatchTable_[Opcode::SMSG_MEETINGSTONE_COMPLETE] = [this](network::Packet& packet) { - // Server confirms group found and teleport summon is ready - addSystemChatMessage("Meeting Stone: Your group is ready! Use the Meeting Stone to summon."); - LOG_INFO("SMSG_MEETINGSTONE_COMPLETE"); - packet.skipAll(); - }; - // Meeting stone search is still ongoing - dispatchTable_[Opcode::SMSG_MEETINGSTONE_IN_PROGRESS] = [this](network::Packet& packet) { - // Meeting stone search is still ongoing - addSystemChatMessage("Meeting Stone: Searching for group members..."); - LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS"); - packet.skipAll(); - }; - // uint64 memberGuid — a player was added to your group via meeting stone - dispatchTable_[Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED] = [this](network::Packet& packet) { - // uint64 memberGuid — a player was added to your group via meeting stone - if (packet.hasRemaining(8)) { - uint64_t memberGuid = packet.readUInt64(); - const auto& memberName = lookupName(memberGuid); - if (!memberName.empty()) { - addSystemChatMessage("Meeting Stone: " + memberName + - " has been added to your group."); - } else { - addSystemChatMessage("Meeting Stone: A new player has been added to your group."); - } - LOG_INFO("SMSG_MEETINGSTONE_MEMBER_ADDED: guid=0x", std::hex, memberGuid, std::dec); - } - }; - // uint8 reason — failed to join group via meeting stone - // 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available - dispatchTable_[Opcode::SMSG_MEETINGSTONE_JOINFAILED] = [this](network::Packet& packet) { - // uint8 reason — failed to join group via meeting stone - // 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available - static const char* kMeetingstoneErrors[] = { - "Target player is not using the Meeting Stone.", - "Target player is already in a group.", - "You are not in a valid zone for that Meeting Stone.", - "Target player is not available.", - }; - if (packet.hasRemaining(1)) { - uint8_t reason = packet.readUInt8(); - const char* msg = (reason < 4) ? kMeetingstoneErrors[reason] - : "Meeting Stone: Could not join group."; - addSystemChatMessage(msg); - LOG_INFO("SMSG_MEETINGSTONE_JOINFAILED: reason=", static_cast(reason)); - } - }; - // Player was removed from the meeting stone queue (left, or group disbanded) - dispatchTable_[Opcode::SMSG_MEETINGSTONE_LEAVE] = [this](network::Packet& packet) { - // Player was removed from the meeting stone queue (left, or group disbanded) - addSystemChatMessage("You have left the Meeting Stone queue."); - LOG_DEBUG("SMSG_MEETINGSTONE_LEAVE"); - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_GMTICKET_CREATE] = [this](network::Packet& packet) { - if (packet.hasRemaining(1)) { - uint8_t res = packet.readUInt8(); - addSystemChatMessage(res == 1 ? "GM ticket submitted." - : "Failed to submit GM ticket."); - } - }; - dispatchTable_[Opcode::SMSG_GMTICKET_UPDATETEXT] = [this](network::Packet& packet) { - if (packet.hasRemaining(1)) { - uint8_t res = packet.readUInt8(); - addSystemChatMessage(res == 1 ? "GM ticket updated." - : "Failed to update GM ticket."); - } - }; - dispatchTable_[Opcode::SMSG_GMTICKET_DELETETICKET] = [this](network::Packet& packet) { - if (packet.hasRemaining(1)) { - uint8_t res = packet.readUInt8(); - addSystemChatMessage(res == 9 ? "GM ticket deleted." - : "No ticket to delete."); - } - }; - // WotLK 3.3.5a format: - // uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended - // If status == 6 (GMTICKET_STATUS_HASTEXT): - // cstring ticketText - // uint32 ticketAge (seconds old) - // uint32 daysUntilOld (days remaining before escalation) - // float waitTimeHours (estimated GM wait time) - dispatchTable_[Opcode::SMSG_GMTICKET_GETTICKET] = [this](network::Packet& packet) { - // WotLK 3.3.5a format: - // uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended - // If status == 6 (GMTICKET_STATUS_HASTEXT): - // cstring ticketText - // uint32 ticketAge (seconds old) - // uint32 daysUntilOld (days remaining before escalation) - // float waitTimeHours (estimated GM wait time) - if (!packet.hasRemaining(1)) { packet.skipAll(); return; } - uint8_t gmStatus = packet.readUInt8(); - // Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text - if (gmStatus == 6 && packet.hasRemaining(1)) { - gmTicketText_ = packet.readString(); - uint32_t ageSec = (packet.hasRemaining(4)) ? packet.readUInt32() : 0; - /*uint32_t daysLeft =*/ (packet.hasRemaining(4)) ? packet.readUInt32() : 0; - gmTicketWaitHours_ = (packet.hasRemaining(4)) - ? packet.readFloat() : 0.0f; - gmTicketActive_ = true; - char buf[256]; - if (ageSec < 60) { - std::snprintf(buf, sizeof(buf), - "You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.", - ageSec, gmTicketWaitHours_); - } else { - uint32_t ageMin = ageSec / 60; - std::snprintf(buf, sizeof(buf), - "You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.", - ageMin, gmTicketWaitHours_); - } - addSystemChatMessage(buf); - LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec, - "s wait=", gmTicketWaitHours_, "h"); - } else if (gmStatus == 3) { - gmTicketActive_ = false; - gmTicketText_.clear(); - addSystemChatMessage("Your GM ticket has been closed."); - LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed"); - } else if (gmStatus == 10) { - gmTicketActive_ = false; - gmTicketText_.clear(); - addSystemChatMessage("Your GM ticket has been suspended."); - LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended"); - } else { - // Status 1 = no open ticket (default/no ticket) - gmTicketActive_ = false; - gmTicketText_.clear(); - LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", static_cast(gmStatus), ")"); - } - packet.skipAll(); - }; - // uint32 status: 1 = GM support available, 0 = offline/unavailable - dispatchTable_[Opcode::SMSG_GMTICKET_SYSTEMSTATUS] = [this](network::Packet& packet) { - // uint32 status: 1 = GM support available, 0 = offline/unavailable - if (packet.hasRemaining(4)) { - uint32_t sysStatus = packet.readUInt32(); - gmSupportAvailable_ = (sysStatus != 0); - addSystemChatMessage(gmSupportAvailable_ - ? "GM support is currently available." - : "GM support is currently unavailable."); - LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_); - } - packet.skipAll(); - }; - // uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death) - dispatchTable_[Opcode::SMSG_CONVERT_RUNE] = [this](network::Packet& packet) { - // uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death) - if (!packet.hasRemaining(2)) { - packet.skipAll(); - return; - } - uint8_t idx = packet.readUInt8(); - uint8_t type = packet.readUInt8(); - if (idx < 6) playerRunes_[idx].type = static_cast(type & 0x3); - }; - // uint8 runeReadyMask (bit i=1 → rune i is ready) - // uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255) - dispatchTable_[Opcode::SMSG_RESYNC_RUNES] = [this](network::Packet& packet) { - // uint8 runeReadyMask (bit i=1 → rune i is ready) - // uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255) - if (!packet.hasRemaining(7)) { - packet.skipAll(); - return; - } - uint8_t readyMask = packet.readUInt8(); - for (int i = 0; i < 6; i++) { - uint8_t cd = packet.readUInt8(); - playerRunes_[i].ready = (readyMask & (1u << i)) != 0; - playerRunes_[i].readyFraction = 1.0f - cd / 255.0f; - if (playerRunes_[i].ready) playerRunes_[i].readyFraction = 1.0f; - } - }; - // uint32 runeMask (bit i=1 → rune i just became ready) - dispatchTable_[Opcode::SMSG_ADD_RUNE_POWER] = [this](network::Packet& packet) { - // uint32 runeMask (bit i=1 → rune i just became ready) - if (!packet.hasRemaining(4)) { - packet.skipAll(); - return; - } - uint32_t runeMask = packet.readUInt32(); - for (int i = 0; i < 6; i++) { - if (runeMask & (1u << i)) { - playerRunes_[i].ready = true; - playerRunes_[i].readyFraction = 1.0f; - } - } - }; - - // uint8 result: 0=success, 1=failed, 2=disabled - dispatchTable_[Opcode::SMSG_COMPLAIN_RESULT] = [this](network::Packet& packet) { - // uint8 result: 0=success, 1=failed, 2=disabled - if (packet.hasRemaining(1)) { - uint8_t result = packet.readUInt8(); - if (result == 0) - addSystemChatMessage("Your complaint has been submitted."); - else if (result == 2) - addUIError("Report a Player is currently disabled."); - } - packet.skipAll(); - }; - // uint32 slot + packed_guid unit (0 packed = clear slot) - dispatchTable_[Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT] = [this](network::Packet& packet) { - // uint32 slot + packed_guid unit (0 packed = clear slot) - if (!packet.hasRemaining(5)) { - packet.skipAll(); - return; - } - uint32_t slot = packet.readUInt32(); - uint64_t unit = packet.readPackedGuid(); - if (socialHandler_) { - socialHandler_->setEncounterUnitGuid(slot, unit); - LOG_DEBUG("SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: slot=", slot, - " guid=0x", std::hex, unit, std::dec); - } - }; - // charName (cstring) + guid (uint64) + achievementId (uint32) + ... - dispatchTable_[Opcode::SMSG_SERVER_FIRST_ACHIEVEMENT] = [this](network::Packet& packet) { - // charName (cstring) + guid (uint64) + achievementId (uint32) + ... - if (packet.hasData()) { - std::string charName = packet.readString(); - if (packet.hasRemaining(12)) { - /*uint64_t guid =*/ packet.readUInt64(); - uint32_t achievementId = packet.readUInt32(); - loadAchievementNameCache(); - auto nit = achievementNameCache_.find(achievementId); - char buf[256]; - if (nit != achievementNameCache_.end() && !nit->second.empty()) { - std::snprintf(buf, sizeof(buf), - "%s is the first on the realm to earn: %s!", - charName.c_str(), nit->second.c_str()); - } else { - std::snprintf(buf, sizeof(buf), - "%s is the first on the realm to earn achievement #%u!", - charName.c_str(), achievementId); - } - addSystemChatMessage(buf); - } - } - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_SUSPEND_COMMS] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t seqIdx = packet.readUInt32(); - if (socket) { - network::Packet ack(wireOpcode(Opcode::CMSG_SUSPEND_COMMS_ACK)); - ack.writeUInt32(seqIdx); - socket->send(ack); - } - } - }; - // SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect. - // Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock), - // or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept. - dispatchTable_[Opcode::SMSG_PRE_RESURRECT] = [this](network::Packet& packet) { - // SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect. - // Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock), - // or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept. - uint64_t targetGuid = packet.readPackedGuid(); - if (targetGuid == playerGuid || targetGuid == 0) { - selfResAvailable_ = true; - LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x", - std::hex, targetGuid, std::dec, ")"); - } - }; - dispatchTable_[Opcode::SMSG_PLAYERBINDERROR] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t error = packet.readUInt32(); - if (error == 0) { - addUIError("Your hearthstone is not bound."); - addSystemChatMessage("Your hearthstone is not bound."); - } else { - addUIError("Hearthstone bind failed."); - addSystemChatMessage("Hearthstone bind failed."); - } - } - }; - dispatchTable_[Opcode::SMSG_RAID_GROUP_ONLY] = [this](network::Packet& packet) { - addUIError("You must be in a raid group to enter this instance."); - addSystemChatMessage("You must be in a raid group to enter this instance."); - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_RAID_READY_CHECK_ERROR] = [this](network::Packet& packet) { - if (packet.hasRemaining(1)) { - uint8_t err = packet.readUInt8(); - if (err == 0) { addUIError("Ready check failed: not in a group."); addSystemChatMessage("Ready check failed: not in a group."); } - else if (err == 1) { addUIError("Ready check failed: in instance."); addSystemChatMessage("Ready check failed: in instance."); } - else { addUIError("Ready check failed."); addSystemChatMessage("Ready check failed."); } - } - }; - dispatchTable_[Opcode::SMSG_RESET_FAILED_NOTIFY] = [this](network::Packet& packet) { - addUIError("Cannot reset instance: another player is still inside."); - addSystemChatMessage("Cannot reset instance: another player is still inside."); - packet.skipAll(); - }; - // uint32 splitType + uint32 deferTime + string realmName - // Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers. - dispatchTable_[Opcode::SMSG_REALM_SPLIT] = [this](network::Packet& packet) { - // uint32 splitType + uint32 deferTime + string realmName - // Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers. - uint32_t splitType = 0; - if (packet.hasRemaining(4)) - splitType = packet.readUInt32(); - packet.skipAll(); - if (socket) { - network::Packet resp(wireOpcode(Opcode::CMSG_REALM_SPLIT)); - resp.writeUInt32(splitType); - resp.writeString("3.3.5"); - socket->send(resp); - LOG_DEBUG("SMSG_REALM_SPLIT splitType=", splitType, " — sent CMSG_REALM_SPLIT ack"); - } - }; - dispatchTable_[Opcode::SMSG_REAL_GROUP_UPDATE] = [this](network::Packet& packet) { - auto rem = [&]() { return packet.getRemainingSize(); }; - if (rem() < 1) return; - uint8_t newGroupType = packet.readUInt8(); - if (rem() < 4) return; - uint32_t newMemberFlags = packet.readUInt32(); - if (rem() < 8) return; - uint64_t newLeaderGuid = packet.readUInt64(); - - if (socialHandler_) { - auto& pd = socialHandler_->mutablePartyData(); - pd.groupType = newGroupType; - pd.leaderGuid = newLeaderGuid; - - // Update local player's flags in the member list - uint64_t localGuid = playerGuid; - for (auto& m : pd.members) { - if (m.guid == localGuid) { - m.flags = static_cast(newMemberFlags & 0xFF); - break; - } - } - } - LOG_DEBUG("SMSG_REAL_GROUP_UPDATE groupType=", static_cast(newGroupType), - " memberFlags=0x", std::hex, newMemberFlags, std::dec, - " leaderGuid=", newLeaderGuid); - fireAddonEvent("PARTY_LEADER_CHANGED", {}); - fireAddonEvent("GROUP_ROSTER_UPDATE", {}); - }; - dispatchTable_[Opcode::SMSG_PLAY_MUSIC] = [this](network::Packet& packet) { - if (packet.hasRemaining(4)) { - uint32_t soundId = packet.readUInt32(); - if (playMusicCallback_) playMusicCallback_(soundId); - } - }; - dispatchTable_[Opcode::SMSG_PLAY_OBJECT_SOUND] = [this](network::Packet& packet) { - if (packet.hasRemaining(12)) { - // uint32 soundId + uint64 sourceGuid - uint32_t soundId = packet.readUInt32(); - uint64_t srcGuid = packet.readUInt64(); - LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND: id=", soundId, " src=0x", std::hex, srcGuid, std::dec); - if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid); - else if (playSoundCallback_) playSoundCallback_(soundId); - } else if (packet.hasRemaining(4)) { - uint32_t soundId = packet.readUInt32(); - if (playSoundCallback_) playSoundCallback_(soundId); - } - }; - // uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL) - dispatchTable_[Opcode::SMSG_PLAY_SPELL_IMPACT] = [this](network::Packet& packet) { - // uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL) - if (!packet.hasRemaining(12)) { - packet.skipAll(); return; - } - uint64_t impTargetGuid = packet.readUInt64(); - uint32_t impVisualId = packet.readUInt32(); - if (impVisualId == 0) return; - auto* renderer = services_.renderer; - if (!renderer) return; - glm::vec3 spawnPos; - if (impTargetGuid == playerGuid) { - spawnPos = renderer->getCharacterPosition(); - } else { - auto entity = entityController_->getEntityManager().getEntity(impTargetGuid); - if (!entity) return; - glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); - spawnPos = core::coords::canonicalToRender(canonical); - } - renderer->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true); - }; - // SMSG_READ_ITEM_OK — moved to InventoryHandler::registerOpcodes - // SMSG_READ_ITEM_FAILED — moved to InventoryHandler::registerOpcodes - // SMSG_QUERY_QUESTS_COMPLETED_RESPONSE — moved to QuestHandler::registerOpcodes - dispatchTable_[Opcode::SMSG_NPC_WONT_TALK] = [this](network::Packet& packet) { - addUIError("That creature can't talk to you right now."); - addSystemChatMessage("That creature can't talk to you right now."); - packet.skipAll(); - }; - - // SMSG_PET_UNLEARN_CONFIRM: uint64 petGuid + uint32 cost (copper). - // The other pet opcodes have different formats and must NOT set unlearn state. - dispatchTable_[Opcode::SMSG_PET_UNLEARN_CONFIRM] = [this](network::Packet& packet) { - if (packet.hasRemaining(12)) { - petUnlearnGuid_ = packet.readUInt64(); - petUnlearnCost_ = packet.readUInt32(); - petUnlearnPending_ = true; - } - packet.skipAll(); - }; - // These pet opcodes have incompatible formats — just consume the packet. - // Previously they shared the unlearn handler, which misinterpreted sound IDs - // or GUID lists as unlearn costs and could trigger a bogus unlearn dialog. - for (auto op : { Opcode::SMSG_PET_GUIDS, Opcode::SMSG_PET_DISMISS_SOUND, - Opcode::SMSG_PET_ACTION_SOUND }) { - dispatchTable_[op] = [](network::Packet& packet) { packet.skipAll(); }; - } - // Server signals that the pet can now be named (first tame) - dispatchTable_[Opcode::SMSG_PET_RENAMEABLE] = [this](network::Packet& packet) { - // Server signals that the pet can now be named (first tame) - petRenameablePending_ = true; - packet.skipAll(); - }; - dispatchTable_[Opcode::SMSG_PET_NAME_INVALID] = [this](network::Packet& packet) { - addUIError("That pet name is invalid. Please choose a different name."); - addSystemChatMessage("That pet name is invalid. Please choose a different name."); - packet.skipAll(); - }; - // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) - // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to - // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. - dispatchTable_[Opcode::SMSG_INSPECT] = [this](network::Packet& packet) { - // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) - // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to - // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. - if (!packet.hasRemaining(2)) { - packet.skipAll(); return; - } - uint64_t guid = packet.readPackedGuid(); - if (guid == 0) { packet.skipAll(); return; } - - constexpr int kGearSlots = 19; - size_t needed = kGearSlots * sizeof(uint32_t); - if (!packet.hasRemaining(needed)) { - packet.skipAll(); return; - } - - std::array items{}; - for (int s = 0; s < kGearSlots; ++s) - items[s] = packet.readUInt32(); - - // Resolve player name - auto ent = entityController_->getEntityManager().getEntity(guid); - std::string playerName = "Target"; - if (ent) { - auto pl = std::dynamic_pointer_cast(ent); - if (pl && !pl->getName().empty()) playerName = pl->getName(); - } - - // Populate inspect result immediately (no talent data in Classic SMSG_INSPECT) - if (socialHandler_) { - auto& ir = socialHandler_->mutableInspectResult(); - ir.guid = guid; - ir.playerName = playerName; - ir.totalTalents = 0; - ir.unspentTalents = 0; - ir.talentGroups = 0; - ir.activeTalentGroup = 0; - ir.itemEntries = items; - ir.enchantIds = {}; - } - - // Also cache for future talent-inspect cross-reference - inspectedPlayerItemEntries_[guid] = items; - - // Trigger item queries for non-empty slots - for (int s = 0; s < kGearSlots; ++s) { - if (items[s] != 0) queryItemInfo(items[s], 0); - } - - LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ", - std::count_if(items.begin(), items.end(), - [](uint32_t e) { return e != 0; }), "/19 slots"); - if (addonEventCallback_) { - char guidBuf[32]; - snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); - fireAddonEvent("INSPECT_READY", {guidBuf}); - } - }; - // Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[] - dispatchTable_[Opcode::SMSG_MULTIPLE_MOVES] = [this](network::Packet& packet) { - // Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[] - if (movementHandler_) movementHandler_->handleCompressedMoves(packet); - }; - // Each sub-packet uses the standard WotLK server wire format: - // uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2) - // uint16_le subOpcode - // payload (subSize - 2 bytes) - dispatchTable_[Opcode::SMSG_MULTIPLE_PACKETS] = [this](network::Packet& packet) { - // Each sub-packet uses the standard WotLK server wire format: - // uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2) - // uint16_le subOpcode - // payload (subSize - 2 bytes) - const auto& pdata = packet.getData(); - size_t dataLen = pdata.size(); - size_t pos = packet.getReadPos(); - static uint32_t multiPktWarnCount = 0; - std::vector subPackets; - while (pos + 4 <= dataLen) { - uint16_t subSize = static_cast( - (static_cast(pdata[pos]) << 8) | pdata[pos + 1]); - if (subSize < 2) break; - size_t payloadLen = subSize - 2; - if (pos + 4 + payloadLen > dataLen) { - if (++multiPktWarnCount <= 10) { - LOG_WARNING("SMSG_MULTIPLE_PACKETS: sub-packet overruns buffer at pos=", - pos, " subSize=", subSize, " dataLen=", dataLen); - } - break; - } - uint16_t subOpcode = static_cast(pdata[pos + 2]) | - (static_cast(pdata[pos + 3]) << 8); - std::vector subPayload(pdata.begin() + pos + 4, - pdata.begin() + pos + 4 + payloadLen); - subPackets.emplace_back(subOpcode, std::move(subPayload)); - pos += 4 + payloadLen; - } - for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) { - enqueueIncomingPacketFront(std::move(*it)); - } - packet.skipAll(); - }; - // Recruit-A-Friend: a mentor is offering to grant you a level - dispatchTable_[Opcode::SMSG_PROPOSE_LEVEL_GRANT] = [this](network::Packet& packet) { - // Recruit-A-Friend: a mentor is offering to grant you a level - if (packet.hasRemaining(8)) { - uint64_t mentorGuid = packet.readUInt64(); - std::string mentorName; - auto ent = entityController_->getEntityManager().getEntity(mentorGuid); - if (auto* unit = dynamic_cast(ent.get())) mentorName = unit->getName(); - if (mentorName.empty()) mentorName = lookupName(mentorGuid); - addSystemChatMessage(mentorName.empty() - ? "A player is offering to grant you a level." - : (mentorName + " is offering to grant you a level.")); - } - packet.skipAll(); - }; - // SMSG_REFER_A_FRIEND_EXPIRED — moved to SocialHandler::registerOpcodes - // SMSG_REFER_A_FRIEND_FAILURE — moved to SocialHandler::registerOpcodes - // SMSG_REPORT_PVP_AFK_RESULT — moved to SocialHandler::registerOpcodes - dispatchTable_[Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS] = [this](network::Packet& packet) { - loadAchievementNameCache(); - if (!packet.hasRemaining(1)) return; - uint64_t inspectedGuid = packet.readPackedGuid(); - if (inspectedGuid == 0) { packet.skipAll(); return; } - std::unordered_set achievements; - while (packet.hasRemaining(4)) { - uint32_t id = packet.readUInt32(); - if (id == 0xFFFFFFFF) break; - if (!packet.hasRemaining(4)) break; - /*date*/ packet.readUInt32(); - achievements.insert(id); - } - while (packet.hasRemaining(4)) { - uint32_t id = packet.readUInt32(); - if (id == 0xFFFFFFFF) break; - if (!packet.hasRemaining(16)) break; - packet.readUInt64(); packet.readUInt32(); packet.readUInt32(); - } - inspectedPlayerAchievements_[inspectedGuid] = std::move(achievements); - LOG_INFO("SMSG_RESPOND_INSPECT_ACHIEVEMENTS: guid=0x", std::hex, inspectedGuid, std::dec, - " achievements=", inspectedPlayerAchievements_[inspectedGuid].size()); - }; - dispatchTable_[Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA] = [this](network::Packet& packet) { - vehicleId_ = 0; // Vehicle ride cancelled; clear UI - if (vehicleStateCallback_) { - vehicleStateCallback_(false, 0); - } - packet.skipAll(); - }; - // uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played - dispatchTable_[Opcode::SMSG_PLAY_TIME_WARNING] = [this](network::Packet& packet) { - // uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played - if (packet.hasRemaining(4)) { - uint32_t warnType = packet.readUInt32(); - uint32_t minutesPlayed = (packet.hasRemaining(4)) - ? packet.readUInt32() : 0; - const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] "; - char buf[128]; - if (minutesPlayed > 0) { - uint32_t h = minutesPlayed / 60; - uint32_t m = minutesPlayed % 60; - if (h > 0) - std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m); - else - std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m); - } else { - std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity); - } - addSystemChatMessage(buf); - addUIError(buf); - } - }; - // WotLK 3.3.5a format: - // uint64 mirrorGuid — GUID of the mirror image unit - // uint32 displayId — display ID to render the image with - // uint8 raceId — race of caster - // uint8 genderFlag — gender of caster - // uint8 classId — class of caster - // uint64 casterGuid — GUID of the player who cast the spell - // Followed by equipped item display IDs (11 × uint32) if casterGuid != 0 - // Purpose: tells client how to render the image (same appearance as caster). - // We parse the GUIDs so units render correctly via their existing display IDs. - dispatchTable_[Opcode::SMSG_MIRRORIMAGE_DATA] = [this](network::Packet& packet) { - // WotLK 3.3.5a format: - // uint64 mirrorGuid — GUID of the mirror image unit - // uint32 displayId — display ID to render the image with - // uint8 raceId — race of caster - // uint8 genderFlag — gender of caster - // uint8 classId — class of caster - // uint64 casterGuid — GUID of the player who cast the spell - // Followed by equipped item display IDs (11 × uint32) if casterGuid != 0 - // Purpose: tells client how to render the image (same appearance as caster). - // We parse the GUIDs so units render correctly via their existing display IDs. - if (!packet.hasRemaining(8)) return; - uint64_t mirrorGuid = packet.readUInt64(); - if (!packet.hasRemaining(4)) return; - uint32_t displayId = packet.readUInt32(); - if (!packet.hasRemaining(3)) return; - /*uint8_t raceId =*/ packet.readUInt8(); - /*uint8_t gender =*/ packet.readUInt8(); - /*uint8_t classId =*/ packet.readUInt8(); - // Apply display ID to the mirror image unit so it renders correctly - if (mirrorGuid != 0 && displayId != 0) { - auto entity = entityController_->getEntityManager().getEntity(mirrorGuid); - if (entity) { - auto unit = std::dynamic_pointer_cast(entity); - if (unit && unit->getDisplayId() == 0) - unit->setDisplayId(displayId); - } - } - LOG_DEBUG("SMSG_MIRRORIMAGE_DATA: mirrorGuid=0x", std::hex, mirrorGuid, - " displayId=", std::dec, displayId); - packet.skipAll(); - }; - // uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds) - dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE] = [this](network::Packet& packet) { - // uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds) - if (!packet.hasRemaining(20)) { - packet.skipAll(); return; - } - uint64_t bfGuid = packet.readUInt64(); - uint32_t bfZoneId = packet.readUInt32(); - uint64_t expireTime = packet.readUInt64(); - (void)bfGuid; (void)expireTime; - // Store the invitation so the UI can show a prompt - bfMgrInvitePending_ = true; - bfMgrZoneId_ = bfZoneId; - char buf[128]; - std::string bfZoneName = getAreaName(bfZoneId); - if (!bfZoneName.empty()) - std::snprintf(buf, sizeof(buf), - "You are invited to the outdoor battlefield in %s. Click to enter.", - bfZoneName.c_str()); - else - std::snprintf(buf, sizeof(buf), - "You are invited to the outdoor battlefield in zone %u. Click to enter.", - bfZoneId); - addSystemChatMessage(buf); - LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId); - }; - // uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue - dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_ENTERED] = [this](network::Packet& packet) { - // uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue - if (packet.hasRemaining(8)) { - uint64_t bfGuid2 = packet.readUInt64(); - (void)bfGuid2; - uint8_t isSafe = (packet.hasRemaining(1)) ? packet.readUInt8() : 0; - uint8_t onQueue = (packet.hasRemaining(1)) ? packet.readUInt8() : 0; - bfMgrInvitePending_ = false; - bfMgrActive_ = true; - addSystemChatMessage(isSafe ? "You are in the battlefield zone (safe area)." - : "You have entered the battlefield!"); - if (onQueue) addSystemChatMessage("You are in the battlefield queue."); - LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTERED: isSafe=", static_cast(isSafe), " onQueue=", static_cast(onQueue)); - } - packet.skipAll(); - }; - // uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime - dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE] = [this](network::Packet& packet) { - // uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime - if (!packet.hasRemaining(20)) { - packet.skipAll(); return; - } - uint64_t bfGuid3 = packet.readUInt64(); - uint32_t bfId = packet.readUInt32(); - uint64_t expTime = packet.readUInt64(); - (void)bfGuid3; (void)expTime; - bfMgrInvitePending_ = true; - bfMgrZoneId_ = bfId; - char buf[128]; - std::snprintf(buf, sizeof(buf), - "A spot has opened in the battlefield queue (battlefield %u).", bfId); - addSystemChatMessage(buf); - LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: bfId=", bfId); - }; - // uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result - // result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level, - // 4=in_cooldown, 5=queued_other_bf, 6=bf_full - dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE] = [this](network::Packet& packet) { - // uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result - // result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level, - // 4=in_cooldown, 5=queued_other_bf, 6=bf_full - if (!packet.hasRemaining(11)) { - packet.skipAll(); return; - } - uint32_t bfId2 = packet.readUInt32(); - /*uint32_t teamId =*/ packet.readUInt32(); - uint8_t accepted = packet.readUInt8(); - /*uint8_t logging =*/ packet.readUInt8(); - uint8_t result = packet.readUInt8(); - (void)bfId2; - if (accepted) { - addSystemChatMessage("You have joined the battlefield queue."); - } else { - static const char* kBfQueueErrors[] = { - "Queued for battlefield.", "Not in a group.", "Level too high.", - "Level too low.", "Battlefield in cooldown.", "Already queued for another battlefield.", - "Battlefield is full." - }; - const char* msg = (result < 7) ? kBfQueueErrors[result] - : "Battlefield queue request failed."; - addSystemChatMessage(std::string("Battlefield: ") + msg); - } - LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: accepted=", static_cast(accepted), - " result=", static_cast(result)); - packet.skipAll(); - }; - // uint64 battlefieldGuid + uint8 remove - dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING] = [this](network::Packet& packet) { - // uint64 battlefieldGuid + uint8 remove - if (packet.hasRemaining(9)) { - uint64_t bfGuid4 = packet.readUInt64(); - uint8_t remove = packet.readUInt8(); - (void)bfGuid4; - if (remove) { - addSystemChatMessage("You will be removed from the battlefield shortly."); - } - LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECT_PENDING: remove=", static_cast(remove)); - } - packet.skipAll(); - }; - // uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated - dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_EJECTED] = [this](network::Packet& packet) { - // uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated - if (packet.hasRemaining(17)) { - uint64_t bfGuid5 = packet.readUInt64(); - uint32_t reason = packet.readUInt32(); - /*uint32_t status =*/ packet.readUInt32(); - uint8_t relocated = packet.readUInt8(); - (void)bfGuid5; - static const char* kEjectReasons[] = { - "Removed from battlefield.", "Transported from battlefield.", - "Left battlefield voluntarily.", "Offline.", - }; - const char* msg = (reason < 4) ? kEjectReasons[reason] - : "You have been ejected from the battlefield."; - addSystemChatMessage(msg); - if (relocated) addSystemChatMessage("You have been relocated outside the battlefield."); - LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECTED: reason=", reason, " relocated=", static_cast(relocated)); - } - bfMgrActive_ = false; - bfMgrInvitePending_ = false; - packet.skipAll(); - }; - // uint32 oldState + uint32 newState - // States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown - dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE] = [this](network::Packet& packet) { - // uint32 oldState + uint32 newState - // States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown - if (packet.hasRemaining(8)) { - /*uint32_t oldState =*/ packet.readUInt32(); - uint32_t newState = packet.readUInt32(); - static const char* kBfStates[] = { - "waiting", "starting", "in progress", "ending", "in cooldown" - }; - const char* stateStr = (newState < 5) ? kBfStates[newState] : "unknown state"; - char buf[128]; - std::snprintf(buf, sizeof(buf), "Battlefield is now %s.", stateStr); - addSystemChatMessage(buf); - LOG_INFO("SMSG_BATTLEFIELD_MGR_STATE_CHANGE: newState=", newState); - } - packet.skipAll(); - }; - // uint32 numPending — number of unacknowledged calendar invites - dispatchTable_[Opcode::SMSG_CALENDAR_SEND_NUM_PENDING] = [this](network::Packet& packet) { - // uint32 numPending — number of unacknowledged calendar invites - if (packet.hasRemaining(4)) { - uint32_t numPending = packet.readUInt32(); - calendarPendingInvites_ = numPending; - if (numPending > 0) { - char buf[64]; - std::snprintf(buf, sizeof(buf), - "You have %u pending calendar invite%s.", - numPending, numPending == 1 ? "" : "s"); - addSystemChatMessage(buf); - } - LOG_DEBUG("SMSG_CALENDAR_SEND_NUM_PENDING: ", numPending, " pending invites"); - } - }; - // uint32 command + uint8 result + cstring info - // result 0 = success; non-zero = error code - // command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove, - // 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status - dispatchTable_[Opcode::SMSG_CALENDAR_COMMAND_RESULT] = [this](network::Packet& packet) { - // uint32 command + uint8 result + cstring info - // result 0 = success; non-zero = error code - // command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove, - // 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status - if (!packet.hasRemaining(5)) { - packet.skipAll(); return; - } - /*uint32_t command =*/ packet.readUInt32(); - uint8_t result = packet.readUInt8(); - std::string info = (packet.hasData()) ? packet.readString() : ""; - if (result != 0) { - // Map common calendar error codes to friendly strings - static const char* kCalendarErrors[] = { - "", - "Calendar: Internal error.", // 1 = CALENDAR_ERROR_INTERNAL - "Calendar: Guild event limit reached.",// 2 - "Calendar: Event limit reached.", // 3 - "Calendar: You cannot invite that player.", // 4 - "Calendar: No invites remaining.", // 5 - "Calendar: Invalid date.", // 6 - "Calendar: Cannot invite yourself.", // 7 - "Calendar: Cannot modify this event.", // 8 - "Calendar: Not invited.", // 9 - "Calendar: Already invited.", // 10 - "Calendar: Player not found.", // 11 - "Calendar: Not enough focus.", // 12 - "Calendar: Event locked.", // 13 - "Calendar: Event deleted.", // 14 - "Calendar: Not a moderator.", // 15 - }; - const char* errMsg = (result < 16) ? kCalendarErrors[result] - : "Calendar: Command failed."; - if (errMsg && errMsg[0] != '\0') addSystemChatMessage(errMsg); - else if (!info.empty()) addSystemChatMessage("Calendar: " + info); - } - packet.skipAll(); - }; - // Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) + - // eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) + - // isGuildEvent(1) + inviterGuid(8) - dispatchTable_[Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT] = [this](network::Packet& packet) { - // Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) + - // eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) + - // isGuildEvent(1) + inviterGuid(8) - if (!packet.hasRemaining(9)) { - packet.skipAll(); return; - } - /*uint64_t eventId =*/ packet.readUInt64(); - std::string title = (packet.hasData()) ? packet.readString() : ""; - packet.skipAll(); // consume remaining fields - if (!title.empty()) { - addSystemChatMessage("Calendar invite: " + title); - } else { - addSystemChatMessage("You have a new calendar invite."); - } - if (calendarPendingInvites_ < 255) ++calendarPendingInvites_; - LOG_INFO("SMSG_CALENDAR_EVENT_INVITE_ALERT: title='", title, "'"); - }; - // Sent when an event invite's RSVP status changes for the local player - // Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) + - // inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring) - dispatchTable_[Opcode::SMSG_CALENDAR_EVENT_STATUS] = [this](network::Packet& packet) { - // Sent when an event invite's RSVP status changes for the local player - // Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) + - // inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring) - if (!packet.hasRemaining(31)) { - packet.skipAll(); return; - } - /*uint64_t inviteId =*/ packet.readUInt64(); - /*uint64_t eventId =*/ packet.readUInt64(); - /*uint8_t evType =*/ packet.readUInt8(); - /*uint32_t flags =*/ packet.readUInt32(); - /*uint64_t invTime =*/ packet.readUInt64(); - uint8_t status = packet.readUInt8(); - /*uint8_t rank =*/ packet.readUInt8(); - /*uint8_t isGuild =*/ packet.readUInt8(); - std::string evTitle = (packet.hasData()) ? packet.readString() : ""; - // status: 0=Invited,1=Accepted,2=Declined,3=Confirmed,4=Out,5=Standby,6=SignedUp,7=Not Signed Up,8=Tentative - static const char* kRsvpStatus[] = { - "invited", "accepted", "declined", "confirmed", - "out", "on standby", "signed up", "not signed up", "tentative" - }; - const char* statusStr = (status < 9) ? kRsvpStatus[status] : "unknown"; - if (!evTitle.empty()) { - char buf[256]; - std::snprintf(buf, sizeof(buf), "Calendar event '%s': your RSVP is %s.", - evTitle.c_str(), statusStr); - addSystemChatMessage(buf); - } - packet.skipAll(); - }; - // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime - dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED] = [this](network::Packet& packet) { - // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime - if (packet.hasRemaining(28)) { - /*uint64_t inviteId =*/ packet.readUInt64(); - /*uint64_t eventId =*/ packet.readUInt64(); - uint32_t mapId = packet.readUInt32(); - uint32_t difficulty = packet.readUInt32(); - /*uint64_t resetTime =*/ packet.readUInt64(); - std::string mapLabel = getMapName(mapId); - if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); - static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; - const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; - std::string msg = "Calendar: Raid lockout added for " + mapLabel; - if (diffStr) msg += std::string(" (") + diffStr + ")"; - msg += '.'; - addSystemChatMessage(msg); - LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty); - } - packet.skipAll(); - }; - // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty - dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED] = [this](network::Packet& packet) { - // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty - if (packet.hasRemaining(20)) { - /*uint64_t inviteId =*/ packet.readUInt64(); - /*uint64_t eventId =*/ packet.readUInt64(); - uint32_t mapId = packet.readUInt32(); - uint32_t difficulty = packet.readUInt32(); - std::string mapLabel = getMapName(mapId); - if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); - static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; - const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; - std::string msg = "Calendar: Raid lockout removed for " + mapLabel; - if (diffStr) msg += std::string(" (") + diffStr + ")"; - msg += '.'; - addSystemChatMessage(msg); - LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId, - " difficulty=", difficulty); - } - packet.skipAll(); - }; - // uint32 unixTime — server's current unix timestamp; use to sync gameTime_ - dispatchTable_[Opcode::SMSG_SERVERTIME] = [this](network::Packet& packet) { - // uint32 unixTime — server's current unix timestamp; use to sync gameTime_ - if (packet.hasRemaining(4)) { - uint32_t srvTime = packet.readUInt32(); - if (srvTime > 0) { - gameTime_ = static_cast(srvTime); - LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime); - } - } - }; - // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string - // kickReasonType: 0=other, 1=afk, 2=vote kick - dispatchTable_[Opcode::SMSG_KICK_REASON] = [this](network::Packet& packet) { - // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string - // kickReasonType: 0=other, 1=afk, 2=vote kick - if (!packet.hasRemaining(12)) { - packet.skipAll(); - return; - } - uint64_t kickerGuid = packet.readUInt64(); - uint32_t reasonType = packet.readUInt32(); - std::string reason; - if (packet.hasData()) - reason = packet.readString(); - (void)kickerGuid; // not displayed; reasonType IS used below - std::string msg = "You have been removed from the group."; - if (!reason.empty()) - msg = "You have been removed from the group: " + reason; - else if (reasonType == 1) - msg = "You have been removed from the group for being AFK."; - else if (reasonType == 2) - msg = "You have been removed from the group by vote."; - addSystemChatMessage(msg); - addUIError(msg); - LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType, - " reason='", reason, "'"); - }; - // uint32 throttleMs — rate-limited group action; notify the player - dispatchTable_[Opcode::SMSG_GROUPACTION_THROTTLED] = [this](network::Packet& packet) { - // uint32 throttleMs — rate-limited group action; notify the player - if (packet.hasRemaining(4)) { - uint32_t throttleMs = packet.readUInt32(); - char buf[128]; - if (throttleMs > 0) { - std::snprintf(buf, sizeof(buf), - "Group action throttled. Please wait %.1f seconds.", - throttleMs / 1000.0f); - } else { - std::snprintf(buf, sizeof(buf), "Group action throttled."); - } - addSystemChatMessage(buf); - LOG_DEBUG("SMSG_GROUPACTION_THROTTLED: throttleMs=", throttleMs); - } - }; - // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count - // per count: string responseText - dispatchTable_[Opcode::SMSG_GMRESPONSE_RECEIVED] = [this](network::Packet& packet) { - // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count - // per count: string responseText - if (!packet.hasRemaining(4)) { - packet.skipAll(); - return; - } - uint32_t ticketId = packet.readUInt32(); - std::string subject; - std::string body; - if (packet.hasData()) subject = packet.readString(); - if (packet.hasData()) body = packet.readString(); - uint32_t responseCount = 0; - if (packet.hasRemaining(4)) - responseCount = packet.readUInt32(); - std::string responseText; - for (uint32_t i = 0; i < responseCount && i < 10; ++i) { - if (packet.hasData()) { - std::string t = packet.readString(); - if (i == 0) responseText = t; - } - } - (void)ticketId; - std::string msg; - if (!responseText.empty()) - msg = "[GM Response] " + responseText; - else if (!body.empty()) - msg = "[GM Response] " + body; - else if (!subject.empty()) - msg = "[GM Response] " + subject; - else - msg = "[GM Response] Your ticket has been answered."; - addSystemChatMessage(msg); - addUIError(msg); - LOG_INFO("SMSG_GMRESPONSE_RECEIVED: ticketId=", ticketId, - " subject='", subject, "'"); - }; - // uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help) - dispatchTable_[Opcode::SMSG_GMRESPONSE_STATUS_UPDATE] = [this](network::Packet& packet) { - // uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help) - if (packet.hasRemaining(5)) { - uint32_t ticketId = packet.readUInt32(); - uint8_t status = packet.readUInt8(); - const char* statusStr = (status == 1) ? "open" - : (status == 2) ? "answered" - : (status == 3) ? "needs more info" - : "updated"; - char buf[128]; - std::snprintf(buf, sizeof(buf), - "[GM Ticket #%u] Status: %s.", ticketId, statusStr); - addSystemChatMessage(buf); - LOG_DEBUG("SMSG_GMRESPONSE_STATUS_UPDATE: ticketId=", ticketId, - " status=", static_cast(status)); - } - }; - // GM ticket status (new/updated); no ticket UI yet - registerSkipHandler(Opcode::SMSG_GM_TICKET_STATUS_UPDATE); - // Client uses this outbound; treat inbound variant as no-op for robustness. - registerSkipHandler(Opcode::MSG_MOVE_WORLDPORT_ACK); - // Observed custom server packet (8 bytes). Safe-consume for now. - registerSkipHandler(Opcode::MSG_MOVE_TIME_SKIPPED); - // loggingOut_ already cleared by cancelLogout(); this is server's confirmation - registerSkipHandler(Opcode::SMSG_LOGOUT_CANCEL_ACK); - // These packets are not damage-shield events. Consume them without - // synthesizing reflected damage entries or misattributing GUIDs. - registerSkipHandler(Opcode::SMSG_AURACASTLOG); - // These packets are not damage-shield events. Consume them without - // synthesizing reflected damage entries or misattributing GUIDs. - registerSkipHandler(Opcode::SMSG_SPELLBREAKLOG); - // Consume silently — informational, no UI action needed - registerSkipHandler(Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE); - // Consume silently — informational, no UI action needed - registerSkipHandler(Opcode::SMSG_LOOT_LIST); - // Same format as LOCKOUT_ADDED; consume - registerSkipHandler(Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED); - // Consume — remaining server notifications not yet parsed - for (auto op : { - Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE, - Opcode::SMSG_AUCTION_LIST_PENDING_SALES, - Opcode::SMSG_AVAILABLE_VOICE_CHANNEL, - Opcode::SMSG_CALENDAR_ARENA_TEAM, - Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION, - Opcode::SMSG_CALENDAR_EVENT_INVITE, - Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES, - Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT, - Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED, - Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT, - Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT, - Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT, - Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT, - Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT, - Opcode::SMSG_CALENDAR_FILTER_GUILD, - Opcode::SMSG_CALENDAR_SEND_CALENDAR, - Opcode::SMSG_CALENDAR_SEND_EVENT, - Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE, - Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE, - Opcode::SMSG_CHEAT_PLAYER_LOOKUP, - Opcode::SMSG_CHECK_FOR_BOTS, - Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO, - Opcode::SMSG_COMMENTATOR_MAP_INFO, - Opcode::SMSG_COMMENTATOR_PLAYER_INFO, - Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1, - Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2, - Opcode::SMSG_COMMENTATOR_STATE_CHANGED, - Opcode::SMSG_COOLDOWN_CHEAT, - Opcode::SMSG_DANCE_QUERY_RESPONSE, - Opcode::SMSG_DBLOOKUP, - Opcode::SMSG_DEBUGAURAPROC, - Opcode::SMSG_DEBUG_AISTATE, - Opcode::SMSG_DEBUG_LIST_TARGETS, - Opcode::SMSG_DEBUG_SERVER_GEO, - Opcode::SMSG_DUMP_OBJECTS_DATA, - Opcode::SMSG_FORCEACTIONSHOW, - Opcode::SMSG_GM_PLAYER_INFO, - Opcode::SMSG_GODMODE, - Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT, - Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT, - Opcode::SMSG_INVALIDATE_DANCE, - Opcode::SMSG_LFG_PENDING_INVITE, - Opcode::SMSG_LFG_PENDING_MATCH, - Opcode::SMSG_LFG_PENDING_MATCH_DONE, - Opcode::SMSG_LFG_UPDATE, - Opcode::SMSG_LFG_UPDATE_LFG, - Opcode::SMSG_LFG_UPDATE_LFM, - Opcode::SMSG_LFG_UPDATE_QUEUED, - Opcode::SMSG_MOVE_CHARACTER_CHEAT, - Opcode::SMSG_NOTIFY_DANCE, - Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST, - Opcode::SMSG_PETGODMODE, - Opcode::SMSG_PET_UPDATE_COMBO_POINTS, - Opcode::SMSG_PLAYER_SKINNED, - Opcode::SMSG_PLAY_DANCE, - Opcode::SMSG_PROFILEDATA_RESPONSE, - Opcode::SMSG_PVP_QUEUE_STATS, - Opcode::SMSG_QUERY_OBJECT_POSITION, - Opcode::SMSG_QUERY_OBJECT_ROTATION, - Opcode::SMSG_REDIRECT_CLIENT, - Opcode::SMSG_RESET_RANGED_COMBAT_TIMER, - Opcode::SMSG_SEND_ALL_COMBAT_LOG, - Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE, - Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT, - Opcode::SMSG_SET_PROJECTILE_POSITION, - Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK, - Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, - Opcode::SMSG_STOP_DANCE, - Opcode::SMSG_TEST_DROP_RATE_RESULT, - Opcode::SMSG_UPDATE_ACCOUNT_DATA, - Opcode::SMSG_UPDATE_ACCOUNT_DATA_COMPLETE, - Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP, - Opcode::SMSG_UPDATE_LAST_INSTANCE, - Opcode::SMSG_VOICESESSION_FULL, - Opcode::SMSG_VOICE_CHAT_STATUS, - Opcode::SMSG_VOICE_PARENTAL_CONTROLS, - Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY, - Opcode::SMSG_VOICE_SESSION_ENABLE, - Opcode::SMSG_VOICE_SESSION_LEAVE, - Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE, - Opcode::SMSG_VOICE_SET_TALKER_MUTED - }) { registerSkipHandler(op); } - - // ----------------------------------------------------------------------- - // Domain handler registrations (override duplicate entries above) - // ----------------------------------------------------------------------- - chatHandler_->registerOpcodes(dispatchTable_); - movementHandler_->registerOpcodes(dispatchTable_); - combatHandler_->registerOpcodes(dispatchTable_); - spellHandler_->registerOpcodes(dispatchTable_); - inventoryHandler_->registerOpcodes(dispatchTable_); - socialHandler_->registerOpcodes(dispatchTable_); - questHandler_->registerOpcodes(dispatchTable_); - wardenHandler_->registerOpcodes(dispatchTable_); -} - -void GameHandler::handlePacket(network::Packet& packet) { - if (packet.getSize() < 1) { - LOG_DEBUG("Received empty world packet (ignored)"); - return; - } - - uint16_t opcode = packet.getOpcode(); - - try { - - const bool allowVanillaAliases = isPreWotlk(); - - // Vanilla compatibility aliases: - // - 0x006B: can be SMSG_COMPRESSED_MOVES on some vanilla-family servers - // and SMSG_WEATHER on others - // - 0x0103: SMSG_PLAY_MUSIC (some vanilla-family servers) - // - // We gate these by payload shape so expansion-native mappings remain intact. - if (allowVanillaAliases && opcode == 0x006B) { - // Try compressed movement batch first: - // [u8 subSize][u16 subOpcode][subPayload...] ... - // where subOpcode is typically SMSG_MONSTER_MOVE / SMSG_MONSTER_MOVE_TRANSPORT. - const auto& data = packet.getData(); - if (packet.getReadPos() + 3 <= data.size()) { - size_t pos = packet.getReadPos(); - uint8_t subSize = data[pos]; - if (subSize >= 2 && pos + 1 + subSize <= data.size()) { - uint16_t subOpcode = static_cast(data[pos + 1]) | - (static_cast(data[pos + 2]) << 8); - uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE); - uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT); - if ((monsterMoveWire != 0xFFFF && subOpcode == monsterMoveWire) || - (monsterMoveTransportWire != 0xFFFF && subOpcode == monsterMoveTransportWire)) { - LOG_INFO("Opcode 0x006B interpreted as SMSG_COMPRESSED_MOVES (subOpcode=0x", - std::hex, subOpcode, std::dec, ")"); - if (movementHandler_) movementHandler_->handleCompressedMoves(packet); - return; - } - } - } - - // Expected weather payload: uint32 weatherType, float intensity, uint8 abrupt - if (packet.hasRemaining(9)) { - uint32_t wType = packet.readUInt32(); - float wIntensity = packet.readFloat(); - uint8_t abrupt = packet.readUInt8(); - bool plausibleWeather = - (wType <= 3) && - std::isfinite(wIntensity) && - (wIntensity >= 0.0f && wIntensity <= 1.5f) && - (abrupt <= 1); - if (plausibleWeather) { - weatherType_ = wType; - weatherIntensity_ = wIntensity; - const char* typeName = - (wType == 1) ? "Rain" : - (wType == 2) ? "Snow" : - (wType == 3) ? "Storm" : "Clear"; - LOG_INFO("Weather changed (0x006B alias): type=", wType, - " (", typeName, "), intensity=", wIntensity, - ", abrupt=", static_cast(abrupt)); - return; - } - // Not weather-shaped: rewind and fall through to normal opcode table handling. - packet.setReadPos(0); - } - } else if (allowVanillaAliases && opcode == 0x0103) { - // Expected play-music payload: uint32 sound/music id - if (packet.getRemainingSize() == 4) { - uint32_t soundId = packet.readUInt32(); - LOG_INFO("SMSG_PLAY_MUSIC (0x0103 alias): soundId=", soundId); - if (playMusicCallback_) playMusicCallback_(soundId); - return; - } - } else if (opcode == 0x0480) { - // Observed on this WotLK profile immediately after CMSG_BUYBACK_ITEM. - // Treat as vendor/buyback transaction result (7-byte payload on this core). - if (packet.hasRemaining(7)) { - uint8_t opType = packet.readUInt8(); - uint8_t resultCode = packet.readUInt8(); - uint8_t slotOrCount = packet.readUInt8(); - uint32_t itemId = packet.readUInt32(); - LOG_INFO("Vendor txn result (0x480): opType=", static_cast(opType), - " result=", static_cast(resultCode), - " slot/count=", static_cast(slotOrCount), - " itemId=", itemId, - " pendingBuybackSlot=", pendingBuybackSlot_, - " pendingBuyItemId=", pendingBuyItemId_, - " pendingBuyItemSlot=", pendingBuyItemSlot_); - - if (pendingBuybackSlot_ >= 0) { - if (resultCode == 0) { - // Success: remove the bought-back slot from our local UI cache. - if (pendingBuybackSlot_ < static_cast(buybackItems_.size())) { - buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_); - } - } else { - const char* msg = "Buyback failed."; - // Best-effort mapping; keep raw code visible for unknowns. - switch (resultCode) { - case 2: msg = "Buyback failed: not enough money."; break; - case 4: msg = "Buyback failed: vendor too far away."; break; - case 5: msg = "Buyback failed: item unavailable."; break; - case 6: msg = "Buyback failed: inventory full."; break; - case 8: msg = "Buyback failed: requirements not met."; break; - default: break; - } - addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")"); - } - pendingBuybackSlot_ = -1; - pendingBuybackWireSlot_ = 0; - - // Refresh vendor list so UI state stays in sync after buyback result. - if (getVendorItems().vendorGuid != 0 && socket && state == WorldState::IN_WORLD) { - auto pkt = ListInventoryPacket::build(getVendorItems().vendorGuid); - socket->send(pkt); - } - } else if (pendingBuyItemId_ != 0) { - if (resultCode != 0) { - const char* msg = "Purchase failed."; - switch (resultCode) { - case 2: msg = "Purchase failed: not enough money."; break; - case 4: msg = "Purchase failed: vendor too far away."; break; - case 5: msg = "Purchase failed: item sold out."; break; - case 6: msg = "Purchase failed: inventory full."; break; - case 8: msg = "Purchase failed: requirements not met."; break; - default: break; - } - addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")"); - } - pendingBuyItemId_ = 0; - pendingBuyItemSlot_ = 0; - } - return; - } - } else if (opcode == 0x046A) { - // Server-specific vendor/buyback state packet (observed 25-byte records). - // Consume to keep stream aligned; currently not used for gameplay logic. - if (packet.hasRemaining(25)) { - packet.setReadPos(packet.getReadPos() + 25); - return; - } - } - - auto preLogicalOp = opcodeTable_.fromWire(opcode); - if (wardenGateSeen_ && (!preLogicalOp || *preLogicalOp != Opcode::SMSG_WARDEN_DATA)) { - ++wardenPacketsAfterGate_; - } - if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) { - LOG_WARNING("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec, - " logical=", static_cast(*preLogicalOp), - " state=", worldStateName(state), - " size=", packet.getSize()); - } - - LOG_DEBUG("Received world packet: opcode=0x", std::hex, opcode, std::dec, - " size=", packet.getSize(), " bytes"); - - // Translate wire opcode to logical opcode via expansion table - auto logicalOp = opcodeTable_.fromWire(opcode); - - if (!logicalOp) { - static std::unordered_set loggedUnknownWireOpcodes; - if (loggedUnknownWireOpcodes.insert(opcode).second) { - LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec, - " state=", static_cast(state), - " size=", packet.getSize()); - } - return; - } - - // Dispatch via the opcode handler table - auto it = dispatchTable_.find(*logicalOp); - if (it != dispatchTable_.end()) { - it->second(packet); - } else { - // In pre-world states we need full visibility (char create/login handshakes). - // In-world we keep de-duplication to avoid heavy log I/O in busy areas. - if (state != WorldState::IN_WORLD) { - static std::unordered_set loggedUnhandledByState; - const uint32_t key = (static_cast(static_cast(state)) << 16) | - static_cast(opcode); - if (loggedUnhandledByState.insert(key).second) { - LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec, - " state=", static_cast(state), - " size=", packet.getSize()); - const auto& data = packet.getData(); - std::string hex; - size_t limit = std::min(data.size(), 48); - hex.reserve(limit * 3); - for (size_t i = 0; i < limit; ++i) { - char b[4]; - snprintf(b, sizeof(b), "%02x ", data[i]); - hex += b; - } - LOG_INFO("Unhandled opcode payload hex (first ", limit, " bytes): ", hex); - } - } else { - static std::unordered_set loggedUnhandledOpcodes; - if (loggedUnhandledOpcodes.insert(static_cast(opcode)).second) { - LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec); - } - } - } - } catch (const std::bad_alloc& e) { - LOG_ERROR("OOM while handling world opcode=0x", std::hex, opcode, std::dec, - " state=", worldStateName(state), - " size=", packet.getSize(), - " readPos=", packet.getReadPos(), - " what=", e.what()); - if (socket && state == WorldState::IN_WORLD) { - disconnect(); - fail("Out of memory while parsing world packet"); - } - } catch (const std::exception& e) { - LOG_ERROR("Exception while handling world opcode=0x", std::hex, opcode, std::dec, - " state=", worldStateName(state), - " size=", packet.getSize(), - " readPos=", packet.getReadPos(), - " what=", e.what()); - } -} - -void GameHandler::enqueueIncomingPacket(const network::Packet& packet) { - if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { - LOG_ERROR("Inbound packet queue overflow (", pendingIncomingPackets_.size(), - " packets); dropping oldest packet to preserve responsiveness"); - pendingIncomingPackets_.pop_front(); - } - pendingIncomingPackets_.push_back(packet); - lastRxTime_ = std::chrono::steady_clock::now(); - rxSilenceLogged_ = false; - rxSilence15sLogged_ = false; -} - -void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) { - if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { - LOG_ERROR("Inbound packet queue overflow while prepending (", pendingIncomingPackets_.size(), - " packets); dropping newest queued packet to preserve ordering"); - pendingIncomingPackets_.pop_back(); - } - pendingIncomingPackets_.emplace_front(std::move(packet)); -} - -// enqueueUpdateObjectWork and processPendingUpdateObjectWork moved to EntityController - -void GameHandler::processQueuedIncomingPackets() { - if (pendingIncomingPackets_.empty() && !entityController_->hasPendingUpdateObjectWork()) { - return; - } - - const int maxPacketsThisUpdate = incomingPacketsBudgetPerUpdate(state); - const float budgetMs = incomingPacketBudgetMs(state); - const auto start = std::chrono::steady_clock::now(); - int processed = 0; - - while (processed < maxPacketsThisUpdate) { - float elapsedMs = std::chrono::duration( - std::chrono::steady_clock::now() - start).count(); - if (elapsedMs >= budgetMs) { - break; - } - - if (entityController_->hasPendingUpdateObjectWork()) { - entityController_->processPendingUpdateObjectWork(start, budgetMs); - if (entityController_->hasPendingUpdateObjectWork()) { - break; - } - continue; - } - - if (pendingIncomingPackets_.empty()) { - break; - } - - network::Packet packet = std::move(pendingIncomingPackets_.front()); - pendingIncomingPackets_.pop_front(); - const uint16_t wireOp = packet.getOpcode(); - const auto logicalOp = opcodeTable_.fromWire(wireOp); - auto packetHandleStart = std::chrono::steady_clock::now(); - handlePacket(packet); - float packetMs = std::chrono::duration( - std::chrono::steady_clock::now() - packetHandleStart).count(); - if (packetMs > slowPacketLogThresholdMs()) { - const char* logicalName = logicalOp - ? OpcodeTable::logicalToName(*logicalOp) - : "UNKNOWN"; - LOG_WARNING("SLOW packet handler: ", packetMs, - "ms wire=0x", std::hex, wireOp, std::dec, - " logical=", logicalName, - " size=", packet.getSize(), - " state=", worldStateName(state)); - } - ++processed; - } - - if (entityController_->hasPendingUpdateObjectWork()) { - return; - } - - if (!pendingIncomingPackets_.empty()) { - LOG_DEBUG("GameHandler packet budget reached (processed=", processed, - ", remaining=", pendingIncomingPackets_.size(), - ", state=", worldStateName(state), ")"); - } -} - -void GameHandler::handleAuthChallenge(network::Packet& packet) { - LOG_INFO("Handling SMSG_AUTH_CHALLENGE"); - - AuthChallengeData challenge; - if (!AuthChallengeParser::parse(packet, challenge)) { - fail("Failed to parse SMSG_AUTH_CHALLENGE"); - return; - } - - if (!challenge.isValid()) { - fail("Invalid auth challenge data"); - return; - } - - // Store server seed - serverSeed = challenge.serverSeed; - LOG_DEBUG("Server seed: 0x", std::hex, serverSeed, std::dec); - - setState(WorldState::CHALLENGE_RECEIVED); - - // Send authentication session - sendAuthSession(); -} - -void GameHandler::sendAuthSession() { - LOG_INFO("Sending CMSG_AUTH_SESSION"); - - // Build authentication packet - auto packet = AuthSessionPacket::build( - build, - accountName, - clientSeed, - sessionKey, - serverSeed, - realmId_ - ); - - LOG_DEBUG("CMSG_AUTH_SESSION packet size: ", packet.getSize(), " bytes"); - - // Send packet (unencrypted - this is the last unencrypted packet) - socket->send(packet); - - // Enable encryption IMMEDIATELY after sending AUTH_SESSION - // AzerothCore enables encryption before sending AUTH_RESPONSE, - // so we need to be ready to decrypt the response - LOG_INFO("Enabling encryption immediately after AUTH_SESSION"); - socket->initEncryption(sessionKey, build); - - setState(WorldState::AUTH_SENT); - LOG_INFO("CMSG_AUTH_SESSION sent, encryption enabled, waiting for AUTH_RESPONSE..."); -} - -void GameHandler::handleAuthResponse(network::Packet& packet) { - LOG_WARNING("Handling SMSG_AUTH_RESPONSE, size=", packet.getSize()); - - AuthResponseData response; - if (!AuthResponseParser::parse(packet, response)) { - fail("Failed to parse SMSG_AUTH_RESPONSE"); - return; - } - - if (!response.isSuccess()) { - std::string reason = std::string("Authentication failed: ") + - getAuthResultString(response.result); - fail(reason); - return; - } - - // Encryption was already enabled after sending AUTH_SESSION - LOG_INFO("AUTH_RESPONSE OK - world authentication successful"); - - setState(WorldState::AUTHENTICATED); - - LOG_INFO("========================================"); - LOG_INFO(" WORLD AUTHENTICATION SUCCESSFUL!"); - LOG_INFO("========================================"); - LOG_INFO("Connected to world server"); - LOG_INFO("Ready for character operations"); - - setState(WorldState::READY); - - // Request character list automatically - requestCharacterList(); - - // Call success callback - if (onSuccess) { - onSuccess(); - } -} - -void GameHandler::requestCharacterList() { - if (requiresWarden_) { - // Gate already surfaced via failure callback/chat; avoid per-frame warning spam. - wardenCharEnumBlockedLogged_ = true; - return; - } - - if (state == WorldState::FAILED || !socket || !socket->isConnected()) { - return; - } - - if (state != WorldState::READY && state != WorldState::AUTHENTICATED && - state != WorldState::CHAR_LIST_RECEIVED) { - LOG_WARNING("Cannot request character list in state: ", worldStateName(state)); - return; - } - - LOG_INFO("Requesting character list from server..."); - - // Prevent the UI from showing/selecting stale characters while we wait for the new SMSG_CHAR_ENUM. - // This matters after character create/delete where the old list can linger for a few frames. - characters.clear(); - - // Build CMSG_CHAR_ENUM packet (no body, just opcode) - auto packet = CharEnumPacket::build(); - - // Send packet - socket->send(packet); - - setState(WorldState::CHAR_LIST_REQUESTED); - LOG_INFO("CMSG_CHAR_ENUM sent, waiting for character list..."); -} - -void GameHandler::handleCharEnum(network::Packet& packet) { - LOG_INFO("Handling SMSG_CHAR_ENUM"); - - CharEnumResponse response; - // IMPORTANT: Do not infer packet formats from numeric build alone. - // Turtle WoW uses a "high" build but classic-era world packet formats. - bool parsed = packetParsers_ ? packetParsers_->parseCharEnum(packet, response) - : CharEnumParser::parse(packet, response); - if (!parsed) { - fail("Failed to parse SMSG_CHAR_ENUM"); - return; - } - - // Store characters - characters = response.characters; - - setState(WorldState::CHAR_LIST_RECEIVED); - - LOG_INFO("========================================"); - LOG_INFO(" CHARACTER LIST RECEIVED"); - LOG_INFO("========================================"); - LOG_INFO("Found ", characters.size(), " character(s)"); - - if (characters.empty()) { - LOG_INFO("No characters on this account"); - } else { - LOG_INFO("Characters:"); - for (size_t i = 0; i < characters.size(); ++i) { - const auto& character = characters[i]; - LOG_INFO(" [", i + 1, "] ", character.name); - LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec); - LOG_INFO(" ", getRaceName(character.race), " ", - getClassName(character.characterClass)); - LOG_INFO(" Level ", static_cast(character.level)); - } - } - - LOG_INFO("Ready to select character"); -} - -void GameHandler::createCharacter(const CharCreateData& data) { - - // Online mode: send packet to server - if (!socket) { - LOG_WARNING("Cannot create character: not connected"); - if (charCreateCallback_) { - charCreateCallback_(false, "Not connected to server"); - } - return; - } - - if (requiresWarden_) { - std::string msg = "Server requires anti-cheat/Warden; character creation blocked."; - LOG_WARNING("Blocking CMSG_CHAR_CREATE while Warden gate is active"); - if (charCreateCallback_) { - charCreateCallback_(false, msg); - } - return; - } - - if (state != WorldState::CHAR_LIST_RECEIVED) { - std::string msg = "Character list not ready yet. Wait for SMSG_CHAR_ENUM."; - LOG_WARNING("Blocking CMSG_CHAR_CREATE in state=", worldStateName(state), - " (awaiting CHAR_LIST_RECEIVED)"); - if (charCreateCallback_) { - charCreateCallback_(false, msg); - } - return; - } - - auto packet = CharCreatePacket::build(data); - socket->send(packet); - LOG_INFO("CMSG_CHAR_CREATE sent for: ", data.name); -} - -void GameHandler::handleCharCreateResponse(network::Packet& packet) { - CharCreateResponseData data; - if (!CharCreateResponseParser::parse(packet, data)) { - LOG_ERROR("Failed to parse SMSG_CHAR_CREATE"); - return; - } - - if (data.result == CharCreateResult::SUCCESS || data.result == CharCreateResult::IN_PROGRESS) { - LOG_INFO("Character created successfully (code=", static_cast(data.result), ")"); - requestCharacterList(); - if (charCreateCallback_) { - charCreateCallback_(true, "Character created!"); - } - } else { - std::string msg; - switch (data.result) { - case CharCreateResult::CHAR_ERROR: msg = "Server error"; break; - case CharCreateResult::FAILED: msg = "Creation failed"; break; - case CharCreateResult::NAME_IN_USE: msg = "Name already in use"; break; - case CharCreateResult::DISABLED: msg = "Character creation disabled"; break; - case CharCreateResult::PVP_TEAMS_VIOLATION: msg = "PvP faction violation"; break; - case CharCreateResult::SERVER_LIMIT: msg = "Server character limit reached"; break; - case CharCreateResult::ACCOUNT_LIMIT: msg = "Account character limit reached"; break; - case CharCreateResult::SERVER_QUEUE: msg = "Server is queued"; break; - case CharCreateResult::ONLY_EXISTING: msg = "Only existing characters allowed"; break; - case CharCreateResult::EXPANSION: msg = "Expansion required"; break; - case CharCreateResult::EXPANSION_CLASS: msg = "Expansion required for this class"; break; - case CharCreateResult::LEVEL_REQUIREMENT: msg = "Level requirement not met"; break; - case CharCreateResult::UNIQUE_CLASS_LIMIT: msg = "Unique class limit reached"; break; - case CharCreateResult::RESTRICTED_RACECLASS: msg = "Race/class combination not allowed"; break; - case CharCreateResult::IN_PROGRESS: msg = "Character creation in progress..."; break; - case CharCreateResult::CHARACTER_CHOOSE_RACE: msg = "Please choose a different race"; break; - case CharCreateResult::CHARACTER_ARENA_LEADER: msg = "Arena team leader restriction"; break; - case CharCreateResult::CHARACTER_DELETE_MAIL: msg = "Character has mail"; break; - case CharCreateResult::CHARACTER_SWAP_FACTION: msg = "Faction swap restriction"; break; - case CharCreateResult::CHARACTER_RACE_ONLY: msg = "Race-only restriction"; break; - case CharCreateResult::CHARACTER_GOLD_LIMIT: msg = "Gold limit reached"; break; - case CharCreateResult::FORCE_LOGIN: msg = "Force login required"; break; - case CharCreateResult::CHARACTER_IN_GUILD: msg = "Character is in a guild"; break; - // Name validation errors - case CharCreateResult::NAME_FAILURE: msg = "Invalid name"; break; - case CharCreateResult::NAME_NO_NAME: msg = "Please enter a name"; break; - case CharCreateResult::NAME_TOO_SHORT: msg = "Name is too short"; break; - case CharCreateResult::NAME_TOO_LONG: msg = "Name is too long"; break; - case CharCreateResult::NAME_INVALID_CHARACTER: msg = "Name contains invalid characters"; break; - case CharCreateResult::NAME_MIXED_LANGUAGES: msg = "Name mixes languages"; break; - case CharCreateResult::NAME_PROFANE: msg = "Name contains profanity"; break; - case CharCreateResult::NAME_RESERVED: msg = "Name is reserved"; break; - case CharCreateResult::NAME_INVALID_APOSTROPHE: msg = "Invalid apostrophe in name"; break; - case CharCreateResult::NAME_MULTIPLE_APOSTROPHES: msg = "Name has multiple apostrophes"; break; - case CharCreateResult::NAME_THREE_CONSECUTIVE: msg = "Name has 3+ consecutive same letters"; break; - case CharCreateResult::NAME_INVALID_SPACE: msg = "Invalid space in name"; break; - case CharCreateResult::NAME_CONSECUTIVE_SPACES: msg = "Name has consecutive spaces"; break; - default: msg = "Unknown error (code " + std::to_string(static_cast(data.result)) + ")"; break; - } - LOG_WARNING("Character creation failed: ", msg, " (code=", static_cast(data.result), ")"); - if (charCreateCallback_) { - charCreateCallback_(false, msg); - } - } -} - -void GameHandler::deleteCharacter(uint64_t characterGuid) { - if (!socket) { - if (charDeleteCallback_) charDeleteCallback_(false); - return; - } - - network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE)); - packet.writeUInt64(characterGuid); - socket->send(packet); - LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec); -} - -const Character* GameHandler::getActiveCharacter() const { - if (activeCharacterGuid_ == 0) return nullptr; - for (const auto& ch : characters) { - if (ch.guid == activeCharacterGuid_) return &ch; - } - return nullptr; -} - -const Character* GameHandler::getFirstCharacter() const { - if (characters.empty()) return nullptr; - return &characters.front(); -} - -void GameHandler::handleCharLoginFailed(network::Packet& packet) { - uint8_t reason = packet.readUInt8(); - - static const char* reasonNames[] = { - "Login failed", // 0 - "World server is down", // 1 - "Duplicate character", // 2 (session still active) - "No instance servers", // 3 - "Login disabled", // 4 - "Character not found", // 5 - "Locked for transfer", // 6 - "Locked by billing", // 7 - "Using remote", // 8 - }; - const char* msg = (reason < 9) ? reasonNames[reason] : "Unknown reason"; - - LOG_ERROR("SMSG_CHARACTER_LOGIN_FAILED: reason=", static_cast(reason), " (", msg, ")"); - - // Allow the player to re-select a character - setState(WorldState::CHAR_LIST_RECEIVED); - - if (charLoginFailCallback_) { - charLoginFailCallback_(msg); - } -} - -void GameHandler::selectCharacter(uint64_t characterGuid) { - if (state != WorldState::CHAR_LIST_RECEIVED) { - LOG_WARNING("Cannot select character in state: ", static_cast(state)); - return; - } - - // Make the selected character authoritative in GameHandler. - // This avoids relying on UI/Application ordering for appearance-dependent logic. - activeCharacterGuid_ = characterGuid; - - LOG_INFO("========================================"); - LOG_INFO(" ENTERING WORLD"); - LOG_INFO("========================================"); - LOG_INFO("Character GUID: 0x", std::hex, characterGuid, std::dec); - - // Find character name for logging - for (const auto& character : characters) { - if (character.guid == characterGuid) { - LOG_INFO("Character: ", character.name); - LOG_INFO("Level ", static_cast(character.level), " ", - getRaceName(character.race), " ", - getClassName(character.characterClass)); - playerRace_ = character.race; - break; - } - } - - // Store player GUID - playerGuid = characterGuid; - - // Reset per-character state so previous character data doesn't bleed through - inventory = Inventory(); - onlineItems_.clear(); - itemInfoCache_.clear(); - pendingItemQueries_.clear(); - equipSlotGuids_ = {}; - backpackSlotGuids_ = {}; - keyringSlotGuids_ = {}; - invSlotBase_ = -1; - packSlotBase_ = -1; - lastPlayerFields_.clear(); - onlineEquipDirty_ = false; - playerMoneyCopper_ = 0; - playerArmorRating_ = 0; - std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0); - std::fill(std::begin(playerStats_), std::end(playerStats_), -1); - playerMeleeAP_ = -1; - playerRangedAP_ = -1; - std::fill(std::begin(playerSpellDmgBonus_), std::end(playerSpellDmgBonus_), -1); - playerHealBonus_ = -1; - playerDodgePct_ = -1.0f; - playerParryPct_ = -1.0f; - playerBlockPct_ = -1.0f; - playerCritPct_ = -1.0f; - playerRangedCritPct_ = -1.0f; - std::fill(std::begin(playerSpellCritPct_), std::end(playerSpellCritPct_), -1.0f); - std::fill(std::begin(playerCombatRatings_), std::end(playerCombatRatings_), -1); - if (spellHandler_) spellHandler_->resetAllState(); - spellFlatMods_.clear(); - spellPctMods_.clear(); - actionBar = {}; - petGuid_ = 0; - stableWindowOpen_ = false; - stableMasterGuid_ = 0; - stableNumSlots_ = 0; - stabledPets_.clear(); - playerXp_ = 0; - playerNextLevelXp_ = 0; - serverPlayerLevel_ = 1; - std::fill(playerExploredZones_.begin(), playerExploredZones_.end(), 0u); - hasPlayerExploredZones_ = false; - playerSkills_.clear(); - questLog_.clear(); - pendingQuestQueryIds_.clear(); - pendingLoginQuestResync_ = false; - pendingLoginQuestResyncTimeout_ = 0.0f; - pendingQuestAcceptTimeouts_.clear(); - pendingQuestAcceptNpcGuids_.clear(); - npcQuestStatus_.clear(); - if (combatHandler_) combatHandler_->resetAllCombatState(); - // resetCastState() already called inside resetAllState() above - pendingGameObjectInteractGuid_ = 0; - lastInteractedGoGuid_ = 0; - playerDead_ = false; - releasedSpirit_ = false; - corpseGuid_ = 0; - corpseReclaimAvailableMs_ = 0; - targetGuid = 0; - focusGuid = 0; - lastTargetGuid = 0; - tabCycleStale = true; - entityController_->clearAll(); - - // Build CMSG_PLAYER_LOGIN packet - auto packet = PlayerLoginPacket::build(characterGuid); - - // Send packet - socket->send(packet); - - setState(WorldState::ENTERING_WORLD); - LOG_INFO("CMSG_PLAYER_LOGIN sent, entering world..."); -} - -void GameHandler::handleLoginSetTimeSpeed(network::Packet& packet) { - // SMSG_LOGIN_SETTIMESPEED (0x042) - // Structure: uint32 gameTime, float timeScale - // gameTime: Game time in seconds since epoch - // timeScale: Time speed multiplier (typically 0.0166 for 1 day = 1 hour) - - if (packet.getSize() < 8) { - LOG_WARNING("SMSG_LOGIN_SETTIMESPEED: packet too small (", packet.getSize(), " bytes)"); - return; - } - - uint32_t gameTimePacked = packet.readUInt32(); - float timeScale = packet.readFloat(); - - // Store for celestial/sky system use - gameTime_ = static_cast(gameTimePacked); - timeSpeed_ = timeScale; - - LOG_INFO("Server time: gameTime=", gameTime_, "s, timeSpeed=", timeSpeed_); - LOG_INFO(" (1 game day = ", (1.0f / timeSpeed_) / 60.0f, " real minutes)"); -} - -void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { - LOG_INFO("Handling SMSG_LOGIN_VERIFY_WORLD"); - const bool initialWorldEntry = (state == WorldState::ENTERING_WORLD); - - LoginVerifyWorldData data; - if (!LoginVerifyWorldParser::parse(packet, data)) { - fail("Failed to parse SMSG_LOGIN_VERIFY_WORLD"); - return; - } - - if (!data.isValid()) { - fail("Invalid world entry data"); - return; - } - - glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); - const bool alreadyInWorld = (state == WorldState::IN_WORLD); - const bool sameMap = alreadyInWorld && (currentMapId_ == data.mapId); - const float dxCurrent = movementInfo.x - canonical.x; - const float dyCurrent = movementInfo.y - canonical.y; - const float dzCurrent = movementInfo.z - canonical.z; - const float distSqCurrent = dxCurrent * dxCurrent + dyCurrent * dyCurrent + dzCurrent * dzCurrent; - - // Some realms emit a late duplicate LOGIN_VERIFY_WORLD after the client is already - // in-world. Re-running full world-entry handling here can trigger an expensive - // same-map reload/reset path and starve networking for tens of seconds. - if (!initialWorldEntry && sameMap && distSqCurrent <= (5.0f * 5.0f)) { - LOG_INFO("Ignoring duplicate SMSG_LOGIN_VERIFY_WORLD while already in world: mapId=", - data.mapId, " dist=", std::sqrt(distSqCurrent)); - return; - } - - // Successfully entered the world (or teleported) - currentMapId_ = data.mapId; - setState(WorldState::IN_WORLD); - if (socket) { - socket->tracePacketsFor(std::chrono::seconds(12), "login_verify_world"); - } - - LOG_INFO("========================================"); - LOG_INFO(" SUCCESSFULLY ENTERED WORLD!"); - LOG_INFO("========================================"); - LOG_INFO("Map ID: ", data.mapId); - LOG_INFO("Position: (", data.x, ", ", data.y, ", ", data.z, ")"); - LOG_INFO("Orientation: ", data.orientation, " radians"); - LOG_INFO("Player is now in the game world"); - - // Initialize movement info with world entry position (server → canonical) - 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; - movementInfo.z = canonical.z; - movementInfo.orientation = core::coords::serverToCanonicalYaw(data.orientation); - movementInfo.flags = 0; - movementInfo.flags2 = 0; - if (movementHandler_) { - movementHandler_->movementClockStart_ = std::chrono::steady_clock::now(); - movementHandler_->lastMovementTimestampMs_ = 0; - } - movementInfo.time = nextMovementTimestampMs(); - if (movementHandler_) { - movementHandler_->isFalling_ = false; - movementHandler_->fallStartMs_ = 0; - } - movementInfo.fallTime = 0; - movementInfo.jumpVelocity = 0.0f; - movementInfo.jumpSinAngle = 0.0f; - movementInfo.jumpCosAngle = 0.0f; - movementInfo.jumpXYSpeed = 0.0f; - resurrectPending_ = false; - resurrectRequestPending_ = false; - selfResAvailable_ = false; - onTaxiFlight_ = false; - taxiMountActive_ = false; - taxiActivatePending_ = false; - taxiClientActive_ = false; - taxiClientPath_.clear(); - // taxiRecoverPending_ is NOT cleared here — it must survive the general - // state reset so the recovery check below can detect a mid-flight reconnect. - taxiStartGrace_ = 0.0f; - currentMountDisplayId_ = 0; - taxiMountDisplayId_ = 0; - vehicleId_ = 0; - if (mountCallback_) { - mountCallback_(0); - } - - // Clear boss encounter unit slots and raid marks on world transfer - if (socialHandler_) socialHandler_->resetTransferState(); - - // Suppress area triggers on initial login — prevents exit portals from - // immediately firing when spawning inside a dungeon/instance. - activeAreaTriggers_.clear(); - areaTriggerCheckTimer_ = -5.0f; - areaTriggerSuppressFirst_ = true; - - // Notify application to load terrain for this map/position (online mode) - if (worldEntryCallback_) { - worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); - } - - // Send CMSG_SET_ACTIVE_MOVER on initial world entry and world transfers. - if (playerGuid != 0 && socket) { - auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid); - socket->send(activeMoverPacket); - LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec); - } - - // Kick the first keepalive immediately on world entry. Classic-like realms - // can close the session before our default 30s ping cadence fires. - timeSinceLastPing = 0.0f; - if (socket) { - LOG_DEBUG("World entry keepalive: sending immediate ping after LOGIN_VERIFY_WORLD"); - sendPing(); - } - - // If we disconnected mid-taxi, attempt to recover to destination after login. - if (taxiRecoverPending_ && taxiRecoverMapId_ == data.mapId) { - float dx = movementInfo.x - taxiRecoverPos_.x; - float dy = movementInfo.y - taxiRecoverPos_.y; - float dz = movementInfo.z - taxiRecoverPos_.z; - float dist = std::sqrt(dx * dx + dy * dy + dz * dz); - if (dist > 5.0f) { - // Keep pending until player entity exists; update() will apply. - LOG_INFO("Taxi recovery pending: dist=", dist); - } else { - taxiRecoverPending_ = false; - } - } - - if (initialWorldEntry) { - // Clear inspect caches on world entry to avoid showing stale data. - inspectedPlayerAchievements_.clear(); - - // Reset talent initialization so the first SMSG_TALENTS_INFO after login - // correctly sets the active spec (static locals don't reset across logins). - if (spellHandler_) spellHandler_->resetTalentState(); - - // Auto-join default chat channels only on first world entry. - autoJoinDefaultChannels(); - - // Auto-query guild info on login. - const Character* activeChar = getActiveCharacter(); - if (activeChar && activeChar->hasGuild() && socket) { - auto gqPacket = GuildQueryPacket::build(activeChar->guildId); - socket->send(gqPacket); - auto grPacket = GuildRosterPacket::build(); - socket->send(grPacket); - LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); - } - - pendingQuestAcceptTimeouts_.clear(); - pendingQuestAcceptNpcGuids_.clear(); - pendingQuestQueryIds_.clear(); - pendingLoginQuestResync_ = true; - pendingLoginQuestResyncTimeout_ = 10.0f; - completedQuests_.clear(); - LOG_INFO("Queued quest log resync for login (from server quest slots)"); - - // Request completed quest IDs when the expansion supports it. Classic-like - // opcode tables do not define this packet, and sending 0xFFFF during world - // entry can desync the early session handshake. - if (socket) { - const uint16_t queryCompletedWire = wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED); - if (queryCompletedWire != 0xFFFF) { - network::Packet cqcPkt(queryCompletedWire); - socket->send(cqcPkt); - LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); - } else { - LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion"); - } - } - - // Auto-request played time on login so the character Stats tab is - // populated immediately without requiring /played. - if (socket) { - auto ptPkt = RequestPlayedTimePacket::build(false); // false = don't show in chat - socket->send(ptPkt); - LOG_INFO("Auto-requested played time on login"); - } - } - - // Pre-load DBC name caches during world entry so the first packet that - // needs spell/title/achievement data doesn't stall mid-gameplay (the - // Spell.dbc cache alone is ~170ms on a cold load). - if (initialWorldEntry) { - preloadDBCCaches(); - } - - // Fire PLAYER_ENTERING_WORLD — THE most important event for addon initialization. - // Fires on initial login, teleports, instance transitions, and zone changes. - if (addonEventCallback_) { - fireAddonEvent("PLAYER_ENTERING_WORLD", {initialWorldEntry ? "1" : "0"}); - // Also fire ZONE_CHANGED_NEW_AREA and UPDATE_WORLD_STATES so map/BG addons refresh - fireAddonEvent("ZONE_CHANGED_NEW_AREA", {}); - fireAddonEvent("UPDATE_WORLD_STATES", {}); - // PLAYER_LOGIN fires only on initial login (not teleports) - if (initialWorldEntry) { - fireAddonEvent("PLAYER_LOGIN", {}); - } - } -} - -void GameHandler::handleClientCacheVersion(network::Packet& packet) { - if (packet.getSize() < 4) { - LOG_WARNING("SMSG_CLIENTCACHE_VERSION too short: ", packet.getSize(), " bytes"); - return; - } - - uint32_t version = packet.readUInt32(); - LOG_INFO("SMSG_CLIENTCACHE_VERSION: ", version); -} - -void GameHandler::handleTutorialFlags(network::Packet& packet) { - if (packet.getSize() < 32) { - LOG_WARNING("SMSG_TUTORIAL_FLAGS too short: ", packet.getSize(), " bytes"); - return; - } - - std::array flags{}; - for (uint32_t& v : flags) { - v = packet.readUInt32(); - } - - LOG_INFO("SMSG_TUTORIAL_FLAGS: [", - flags[0], ", ", flags[1], ", ", flags[2], ", ", flags[3], ", ", - flags[4], ", ", flags[5], ", ", flags[6], ", ", flags[7], "]"); -} - -void GameHandler::handleAccountDataTimes(network::Packet& packet) { - LOG_DEBUG("Handling SMSG_ACCOUNT_DATA_TIMES"); - - AccountDataTimesData data; - if (!AccountDataTimesParser::parse(packet, data)) { - LOG_WARNING("Failed to parse SMSG_ACCOUNT_DATA_TIMES"); - return; - } - - LOG_DEBUG("Account data times received (server time: ", data.serverTime, ")"); -} - -void GameHandler::handleMotd(network::Packet& packet) { - if (chatHandler_) chatHandler_->handleMotd(packet); -} - -void GameHandler::handleNotification(network::Packet& packet) { - // SMSG_NOTIFICATION: single null-terminated string - std::string message = packet.readString(); - if (!message.empty()) { - LOG_INFO("Server notification: ", message); - addSystemChatMessage(message); - } -} - -void GameHandler::sendPing() { - if (state != WorldState::IN_WORLD) { - return; - } - - // Increment sequence number - pingSequence++; - - LOG_DEBUG("Sending CMSG_PING: sequence=", pingSequence, - " latencyHintMs=", lastLatency); - - // Record send time for RTT measurement - pingTimestamp_ = std::chrono::steady_clock::now(); - - // Build and send ping packet - auto packet = PingPacket::build(pingSequence, lastLatency); - socket->send(packet); -} - -void GameHandler::sendRequestVehicleExit() { - if (state != WorldState::IN_WORLD || vehicleId_ == 0) return; - // CMSG_REQUEST_VEHICLE_EXIT has no payload — opcode only - network::Packet pkt(wireOpcode(Opcode::CMSG_REQUEST_VEHICLE_EXIT)); - socket->send(pkt); - vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0) -} - -const std::vector& GameHandler::getEquipmentSets() const { - if (inventoryHandler_) return inventoryHandler_->getEquipmentSets(); - static const std::vector empty; - return empty; -} - -// Trade state delegation to InventoryHandler (which owns the canonical trade state) -GameHandler::TradeStatus GameHandler::getTradeStatus() const { - if (inventoryHandler_) return static_cast(inventoryHandler_->getTradeStatus()); - return tradeStatus_; -} -bool GameHandler::hasPendingTradeRequest() const { - return inventoryHandler_ ? inventoryHandler_->hasPendingTradeRequest() : false; -} -bool GameHandler::isTradeOpen() const { - return inventoryHandler_ ? inventoryHandler_->isTradeOpen() : false; -} -const std::string& GameHandler::getTradePeerName() const { - if (inventoryHandler_) return inventoryHandler_->getTradePeerName(); - return tradePeerName_; -} -const std::array& GameHandler::getMyTradeSlots() const { - if (inventoryHandler_) { - // Convert InventoryHandler::TradeSlot → GameHandler::TradeSlot (different struct layouts) - static std::array converted{}; - const auto& src = inventoryHandler_->getMyTradeSlots(); - for (size_t i = 0; i < TRADE_SLOT_COUNT; i++) { - converted[i].itemId = src[i].itemId; - converted[i].displayId = src[i].displayId; - converted[i].stackCount = src[i].stackCount; - converted[i].itemGuid = src[i].itemGuid; - } - return converted; - } - return myTradeSlots_; -} -const std::array& GameHandler::getPeerTradeSlots() const { - if (inventoryHandler_) { - static std::array converted{}; - const auto& src = inventoryHandler_->getPeerTradeSlots(); - for (size_t i = 0; i < TRADE_SLOT_COUNT; i++) { - converted[i].itemId = src[i].itemId; - converted[i].displayId = src[i].displayId; - converted[i].stackCount = src[i].stackCount; - converted[i].itemGuid = src[i].itemGuid; - } - return converted; - } - return peerTradeSlots_; -} -uint64_t GameHandler::getMyTradeGold() const { - return inventoryHandler_ ? inventoryHandler_->getMyTradeGold() : myTradeGold_; -} -uint64_t GameHandler::getPeerTradeGold() const { - return inventoryHandler_ ? inventoryHandler_->getPeerTradeGold() : peerTradeGold_; -} - -bool GameHandler::supportsEquipmentSets() const { - return inventoryHandler_ && inventoryHandler_->supportsEquipmentSets(); -} - -void GameHandler::useEquipmentSet(uint32_t setId) { - if (inventoryHandler_) inventoryHandler_->useEquipmentSet(setId); -} - -void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName, - uint64_t existingGuid, uint32_t setIndex) { - if (inventoryHandler_) inventoryHandler_->saveEquipmentSet(name, iconName, existingGuid, setIndex); -} - -void GameHandler::deleteEquipmentSet(uint64_t setGuid) { - if (inventoryHandler_) inventoryHandler_->deleteEquipmentSet(setGuid); -} - -// --- Inventory state delegation (canonical state lives in InventoryHandler) --- - -// Item text -bool GameHandler::isItemTextOpen() const { - return inventoryHandler_ ? inventoryHandler_->isItemTextOpen() : itemTextOpen_; -} -const std::string& GameHandler::getItemText() const { - if (inventoryHandler_) return inventoryHandler_->getItemText(); - return itemText_; -} -void GameHandler::closeItemText() { - if (inventoryHandler_) inventoryHandler_->closeItemText(); - else itemTextOpen_ = false; -} - -// Loot -bool GameHandler::isLootWindowOpen() const { - return inventoryHandler_ ? inventoryHandler_->isLootWindowOpen() : lootWindowOpen; -} -const LootResponseData& GameHandler::getCurrentLoot() const { - if (inventoryHandler_) return inventoryHandler_->getCurrentLoot(); - return currentLoot; -} -void GameHandler::setAutoLoot(bool enabled) { - if (inventoryHandler_) inventoryHandler_->setAutoLoot(enabled); - else autoLoot_ = enabled; -} -bool GameHandler::isAutoLoot() const { - return inventoryHandler_ ? inventoryHandler_->isAutoLoot() : autoLoot_; -} -void GameHandler::setAutoSellGrey(bool enabled) { - if (inventoryHandler_) inventoryHandler_->setAutoSellGrey(enabled); - else autoSellGrey_ = enabled; -} -bool GameHandler::isAutoSellGrey() const { - return inventoryHandler_ ? inventoryHandler_->isAutoSellGrey() : autoSellGrey_; -} -void GameHandler::setAutoRepair(bool enabled) { - if (inventoryHandler_) inventoryHandler_->setAutoRepair(enabled); - else autoRepair_ = enabled; -} -bool GameHandler::isAutoRepair() const { - return inventoryHandler_ ? inventoryHandler_->isAutoRepair() : autoRepair_; -} -const std::vector& GameHandler::getMasterLootCandidates() const { - if (inventoryHandler_) return inventoryHandler_->getMasterLootCandidates(); - return masterLootCandidates_; -} -bool GameHandler::hasMasterLootCandidates() const { - return inventoryHandler_ ? inventoryHandler_->hasMasterLootCandidates() : !masterLootCandidates_.empty(); -} -bool GameHandler::hasPendingLootRoll() const { - return inventoryHandler_ ? inventoryHandler_->hasPendingLootRoll() : pendingLootRollActive_; -} -const LootRollEntry& GameHandler::getPendingLootRoll() const { - if (inventoryHandler_) return inventoryHandler_->getPendingLootRoll(); - return pendingLootRoll_; -} - -// Vendor -bool GameHandler::isVendorWindowOpen() const { - return inventoryHandler_ ? inventoryHandler_->isVendorWindowOpen() : vendorWindowOpen; -} -const ListInventoryData& GameHandler::getVendorItems() const { - if (inventoryHandler_) return inventoryHandler_->getVendorItems(); - return currentVendorItems; -} -void GameHandler::setVendorCanRepair(bool v) { - if (inventoryHandler_) inventoryHandler_->setVendorCanRepair(v); - else currentVendorItems.canRepair = v; -} -const std::deque& GameHandler::getBuybackItems() const { - if (inventoryHandler_) { - // Layout-identical structs (InventoryHandler::BuybackItem == GameHandler::BuybackItem) - return reinterpret_cast&>(inventoryHandler_->getBuybackItems()); - } - return buybackItems_; -} -uint64_t GameHandler::getVendorGuid() const { - if (inventoryHandler_) return inventoryHandler_->getVendorGuid(); - return currentVendorItems.vendorGuid; -} - -// Mail -bool GameHandler::isMailboxOpen() const { - return inventoryHandler_ ? inventoryHandler_->isMailboxOpen() : mailboxOpen_; -} -const std::vector& GameHandler::getMailInbox() const { - if (inventoryHandler_) return inventoryHandler_->getMailInbox(); - return mailInbox_; -} -int GameHandler::getSelectedMailIndex() const { - return inventoryHandler_ ? inventoryHandler_->getSelectedMailIndex() : selectedMailIndex_; -} -void GameHandler::setSelectedMailIndex(int idx) { - if (inventoryHandler_) inventoryHandler_->setSelectedMailIndex(idx); - else selectedMailIndex_ = idx; -} -bool GameHandler::isMailComposeOpen() const { - return inventoryHandler_ ? inventoryHandler_->isMailComposeOpen() : showMailCompose_; -} -void GameHandler::openMailCompose() { - if (inventoryHandler_) inventoryHandler_->openMailCompose(); - else { showMailCompose_ = true; clearMailAttachments(); } -} -void GameHandler::closeMailCompose() { - if (inventoryHandler_) inventoryHandler_->closeMailCompose(); - else { showMailCompose_ = false; clearMailAttachments(); } -} -bool GameHandler::hasNewMail() const { - return inventoryHandler_ ? inventoryHandler_->hasNewMail() : hasNewMail_; -} -const std::array& GameHandler::getMailAttachments() const { - if (inventoryHandler_) { - // Layout-identical structs (InventoryHandler::MailAttachSlot == GameHandler::MailAttachSlot) - return reinterpret_cast&>(inventoryHandler_->getMailAttachments()); - } - return mailAttachments_; -} - -// Bank -bool GameHandler::isBankOpen() const { - return inventoryHandler_ ? inventoryHandler_->isBankOpen() : bankOpen_; -} -uint64_t GameHandler::getBankerGuid() const { - return inventoryHandler_ ? inventoryHandler_->getBankerGuid() : bankerGuid_; -} -int GameHandler::getEffectiveBankSlots() const { - return inventoryHandler_ ? inventoryHandler_->getEffectiveBankSlots() : effectiveBankSlots_; -} -int GameHandler::getEffectiveBankBagSlots() const { - return inventoryHandler_ ? inventoryHandler_->getEffectiveBankBagSlots() : effectiveBankBagSlots_; -} - -// Guild Bank -bool GameHandler::isGuildBankOpen() const { - return inventoryHandler_ ? inventoryHandler_->isGuildBankOpen() : guildBankOpen_; -} -const GuildBankData& GameHandler::getGuildBankData() const { - if (inventoryHandler_) return inventoryHandler_->getGuildBankData(); - return guildBankData_; -} -uint8_t GameHandler::getGuildBankActiveTab() const { - return inventoryHandler_ ? inventoryHandler_->getGuildBankActiveTab() : guildBankActiveTab_; -} -void GameHandler::setGuildBankActiveTab(uint8_t tab) { - if (inventoryHandler_) inventoryHandler_->setGuildBankActiveTab(tab); - else guildBankActiveTab_ = tab; -} - -// Auction House -bool GameHandler::isAuctionHouseOpen() const { - return inventoryHandler_ ? inventoryHandler_->isAuctionHouseOpen() : auctionOpen_; -} -uint64_t GameHandler::getAuctioneerGuid() const { - return inventoryHandler_ ? inventoryHandler_->getAuctioneerGuid() : auctioneerGuid_; -} -const AuctionListResult& GameHandler::getAuctionBrowseResults() const { - if (inventoryHandler_) return inventoryHandler_->getAuctionBrowseResults(); - return auctionBrowseResults_; -} -const AuctionListResult& GameHandler::getAuctionOwnerResults() const { - if (inventoryHandler_) return inventoryHandler_->getAuctionOwnerResults(); - return auctionOwnerResults_; -} -const AuctionListResult& GameHandler::getAuctionBidderResults() const { - if (inventoryHandler_) return inventoryHandler_->getAuctionBidderResults(); - return auctionBidderResults_; -} -int GameHandler::getAuctionActiveTab() const { - return inventoryHandler_ ? inventoryHandler_->getAuctionActiveTab() : auctionActiveTab_; -} -void GameHandler::setAuctionActiveTab(int tab) { - if (inventoryHandler_) inventoryHandler_->setAuctionActiveTab(tab); - else auctionActiveTab_ = tab; -} -float GameHandler::getAuctionSearchDelay() const { - return inventoryHandler_ ? inventoryHandler_->getAuctionSearchDelay() : auctionSearchDelayTimer_; -} - -// Trainer -bool GameHandler::isTrainerWindowOpen() const { - return inventoryHandler_ ? inventoryHandler_->isTrainerWindowOpen() : trainerWindowOpen_; -} -const TrainerListData& GameHandler::getTrainerSpells() const { - if (inventoryHandler_) return inventoryHandler_->getTrainerSpells(); - return currentTrainerList_; -} -const std::vector& GameHandler::getTrainerTabs() const { - if (inventoryHandler_) { - // Layout-identical structs (InventoryHandler::TrainerTab == GameHandler::TrainerTab) - return reinterpret_cast&>(inventoryHandler_->getTrainerTabs()); - } - return trainerTabs_; -} - -void GameHandler::sendMinimapPing(float wowX, float wowY) { - if (socialHandler_) socialHandler_->sendMinimapPing(wowX, wowY); -} - -void GameHandler::handlePong(network::Packet& packet) { - LOG_DEBUG("Handling SMSG_PONG"); - - PongData data; - if (!PongParser::parse(packet, data)) { - LOG_WARNING("Failed to parse SMSG_PONG"); - return; - } - - // Verify sequence matches - if (data.sequence != pingSequence) { - LOG_WARNING("SMSG_PONG sequence mismatch: expected ", pingSequence, - ", got ", data.sequence); - return; - } - - // Measure round-trip time - auto rtt = std::chrono::steady_clock::now() - pingTimestamp_; - lastLatency = static_cast( - std::chrono::duration_cast(rtt).count()); - - LOG_DEBUG("SMSG_PONG acknowledged: sequence=", data.sequence, - " latencyMs=", lastLatency); -} - -bool GameHandler::isServerMovementAllowed() const { - return movementHandler_ ? movementHandler_->isServerMovementAllowed() : true; -} - -uint32_t GameHandler::nextMovementTimestampMs() { - if (movementHandler_) return movementHandler_->nextMovementTimestampMs(); - return 0; -} - -void GameHandler::sendMovement(Opcode opcode) { - if (movementHandler_) movementHandler_->sendMovement(opcode); -} - -void GameHandler::sanitizeMovementForTaxi() { - if (movementHandler_) movementHandler_->sanitizeMovementForTaxi(); -} - -void GameHandler::forceClearTaxiAndMovementState() { - if (movementHandler_) movementHandler_->forceClearTaxiAndMovementState(); -} - -void GameHandler::setPosition(float x, float y, float z) { - if (movementHandler_) movementHandler_->setPosition(x, y, z); -} - -void GameHandler::setOrientation(float orientation) { - if (movementHandler_) movementHandler_->setOrientation(orientation); -} - -// Entity lifecycle methods (handleUpdateObject, processOutOfRangeObjects, -// applyUpdateObjectBlock, finalizeUpdateObjectBatch, handleCompressedUpdateObject, -// handleDestroyObject) moved to EntityController — see entity_controller.cpp - -void GameHandler::sendChatMessage(ChatType type, const std::string& message, const std::string& target) { - if (chatHandler_) chatHandler_->sendChatMessage(type, message, target); -} - -void GameHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) { - if (chatHandler_) chatHandler_->sendTextEmote(textEmoteId, targetGuid); -} - -void GameHandler::joinChannel(const std::string& channelName, const std::string& password) { - if (chatHandler_) chatHandler_->joinChannel(channelName, password); -} - -void GameHandler::leaveChannel(const std::string& channelName) { - if (chatHandler_) chatHandler_->leaveChannel(channelName); -} - -std::string GameHandler::getChannelByIndex(int index) const { - return chatHandler_ ? chatHandler_->getChannelByIndex(index) : ""; -} - -int GameHandler::getChannelIndex(const std::string& channelName) const { - return chatHandler_ ? chatHandler_->getChannelIndex(channelName) : 0; -} - -void GameHandler::autoJoinDefaultChannels() { - if (chatHandler_) { - chatHandler_->chatAutoJoin.general = chatAutoJoin.general; - chatHandler_->chatAutoJoin.trade = chatAutoJoin.trade; - chatHandler_->chatAutoJoin.localDefense = chatAutoJoin.localDefense; - chatHandler_->chatAutoJoin.lfg = chatAutoJoin.lfg; - chatHandler_->chatAutoJoin.local = chatAutoJoin.local; - chatHandler_->autoJoinDefaultChannels(); - } -} - -void GameHandler::setTarget(uint64_t guid) { - if (combatHandler_) combatHandler_->setTarget(guid); -} - -void GameHandler::clearTarget() { - if (combatHandler_) combatHandler_->clearTarget(); -} - -std::shared_ptr GameHandler::getTarget() const { - return combatHandler_ ? combatHandler_->getTarget() : nullptr; -} - -void GameHandler::setFocus(uint64_t guid) { - if (combatHandler_) combatHandler_->setFocus(guid); -} - -void GameHandler::clearFocus() { - if (combatHandler_) combatHandler_->clearFocus(); -} - -void GameHandler::setMouseoverGuid(uint64_t guid) { - if (combatHandler_) combatHandler_->setMouseoverGuid(guid); -} - -std::shared_ptr GameHandler::getFocus() const { - return combatHandler_ ? combatHandler_->getFocus() : nullptr; -} - -void GameHandler::targetLastTarget() { - if (combatHandler_) combatHandler_->targetLastTarget(); -} - -void GameHandler::targetEnemy(bool reverse) { - if (combatHandler_) combatHandler_->targetEnemy(reverse); -} - -void GameHandler::targetFriend(bool reverse) { - if (combatHandler_) combatHandler_->targetFriend(reverse); -} - -void GameHandler::inspectTarget() { - if (socialHandler_) socialHandler_->inspectTarget(); -} - -void GameHandler::queryServerTime() { - if (socialHandler_) socialHandler_->queryServerTime(); -} - -void GameHandler::requestPlayedTime() { - if (socialHandler_) socialHandler_->requestPlayedTime(); -} - -void GameHandler::queryWho(const std::string& playerName) { - if (socialHandler_) socialHandler_->queryWho(playerName); -} - -void GameHandler::addFriend(const std::string& playerName, const std::string& note) { - if (socialHandler_) socialHandler_->addFriend(playerName, note); -} - -void GameHandler::removeFriend(const std::string& playerName) { - if (socialHandler_) socialHandler_->removeFriend(playerName); -} - -void GameHandler::setFriendNote(const std::string& playerName, const std::string& note) { - if (socialHandler_) socialHandler_->setFriendNote(playerName, note); -} - -void GameHandler::randomRoll(uint32_t minRoll, uint32_t maxRoll) { - if (socialHandler_) socialHandler_->randomRoll(minRoll, maxRoll); -} - -void GameHandler::addIgnore(const std::string& playerName) { - if (socialHandler_) socialHandler_->addIgnore(playerName); -} - -void GameHandler::removeIgnore(const std::string& playerName) { - if (socialHandler_) socialHandler_->removeIgnore(playerName); -} - -void GameHandler::requestLogout() { - if (socialHandler_) socialHandler_->requestLogout(); -} - -void GameHandler::cancelLogout() { - if (socialHandler_) socialHandler_->cancelLogout(); -} - -void GameHandler::sendSetDifficulty(uint32_t difficulty) { - if (socialHandler_) socialHandler_->sendSetDifficulty(difficulty); -} - -void GameHandler::setStandState(uint8_t standState) { - if (socialHandler_) socialHandler_->setStandState(standState); -} - -void GameHandler::toggleHelm() { - if (socialHandler_) socialHandler_->toggleHelm(); -} - -void GameHandler::toggleCloak() { - if (socialHandler_) socialHandler_->toggleCloak(); -} - -void GameHandler::followTarget() { - if (movementHandler_) movementHandler_->followTarget(); -} - -void GameHandler::cancelFollow() { - if (movementHandler_) movementHandler_->cancelFollow(); -} - -void GameHandler::assistTarget() { - if (combatHandler_) combatHandler_->assistTarget(); -} - -void GameHandler::togglePvp() { - if (combatHandler_) combatHandler_->togglePvp(); -} - -void GameHandler::requestGuildInfo() { - if (socialHandler_) socialHandler_->requestGuildInfo(); -} - -void GameHandler::requestGuildRoster() { - if (socialHandler_) socialHandler_->requestGuildRoster(); -} - -void GameHandler::setGuildMotd(const std::string& motd) { - if (socialHandler_) socialHandler_->setGuildMotd(motd); -} - -void GameHandler::promoteGuildMember(const std::string& playerName) { - if (socialHandler_) socialHandler_->promoteGuildMember(playerName); -} - -void GameHandler::demoteGuildMember(const std::string& playerName) { - if (socialHandler_) socialHandler_->demoteGuildMember(playerName); -} - -void GameHandler::leaveGuild() { - if (socialHandler_) socialHandler_->leaveGuild(); -} - -void GameHandler::inviteToGuild(const std::string& playerName) { - if (socialHandler_) socialHandler_->inviteToGuild(playerName); -} - -void GameHandler::initiateReadyCheck() { - if (socialHandler_) socialHandler_->initiateReadyCheck(); -} - -void GameHandler::respondToReadyCheck(bool ready) { - if (socialHandler_) socialHandler_->respondToReadyCheck(ready); -} - -void GameHandler::acceptDuel() { - if (socialHandler_) socialHandler_->acceptDuel(); -} - -void GameHandler::forfeitDuel() { - if (socialHandler_) socialHandler_->forfeitDuel(); -} - -void GameHandler::toggleAfk(const std::string& message) { - if (chatHandler_) chatHandler_->toggleAfk(message); -} - -void GameHandler::toggleDnd(const std::string& message) { - if (chatHandler_) chatHandler_->toggleDnd(message); -} - -void GameHandler::replyToLastWhisper(const std::string& message) { - if (chatHandler_) chatHandler_->replyToLastWhisper(message); -} - -void GameHandler::uninvitePlayer(const std::string& playerName) { - if (socialHandler_) socialHandler_->uninvitePlayer(playerName); -} - -void GameHandler::leaveParty() { - if (socialHandler_) socialHandler_->leaveParty(); -} - -void GameHandler::setMainTank(uint64_t targetGuid) { - if (socialHandler_) socialHandler_->setMainTank(targetGuid); -} - -void GameHandler::setMainAssist(uint64_t targetGuid) { - if (socialHandler_) socialHandler_->setMainAssist(targetGuid); -} - -void GameHandler::clearMainTank() { - if (socialHandler_) socialHandler_->clearMainTank(); -} - -void GameHandler::clearMainAssist() { - if (socialHandler_) socialHandler_->clearMainAssist(); -} - -void GameHandler::setRaidMark(uint64_t guid, uint8_t icon) { - if (socialHandler_) socialHandler_->setRaidMark(guid, icon); -} - -void GameHandler::requestRaidInfo() { - if (socialHandler_) socialHandler_->requestRaidInfo(); -} - -void GameHandler::proposeDuel(uint64_t targetGuid) { - if (socialHandler_) socialHandler_->proposeDuel(targetGuid); -} - -void GameHandler::initiateTrade(uint64_t targetGuid) { - if (inventoryHandler_) inventoryHandler_->initiateTrade(targetGuid); -} - -void GameHandler::reportPlayer(uint64_t targetGuid, const std::string& reason) { - if (socialHandler_) socialHandler_->reportPlayer(targetGuid, reason); -} - -void GameHandler::stopCasting() { - if (spellHandler_) spellHandler_->stopCasting(); -} - -void GameHandler::resetCastState() { - if (spellHandler_) spellHandler_->resetCastState(); -} - -void GameHandler::clearUnitCaches() { - if (spellHandler_) spellHandler_->clearUnitCaches(); -} - -void GameHandler::releaseSpirit() { - if (combatHandler_) combatHandler_->releaseSpirit(); -} - -bool GameHandler::canReclaimCorpse() const { - return combatHandler_ ? combatHandler_->canReclaimCorpse() : false; -} - -float GameHandler::getCorpseReclaimDelaySec() const { - return combatHandler_ ? combatHandler_->getCorpseReclaimDelaySec() : 0.0f; -} - -void GameHandler::reclaimCorpse() { - if (combatHandler_) combatHandler_->reclaimCorpse(); -} - -void GameHandler::useSelfRes() { - if (combatHandler_) combatHandler_->useSelfRes(); -} - -void GameHandler::activateSpiritHealer(uint64_t npcGuid) { - if (combatHandler_) combatHandler_->activateSpiritHealer(npcGuid); -} - -void GameHandler::acceptResurrect() { - if (combatHandler_) combatHandler_->acceptResurrect(); -} - -void GameHandler::declineResurrect() { - if (combatHandler_) combatHandler_->declineResurrect(); -} - -void GameHandler::tabTarget(float playerX, float playerY, float playerZ) { - if (combatHandler_) combatHandler_->tabTarget(playerX, playerY, playerZ); -} - -void GameHandler::addLocalChatMessage(const MessageChatData& msg) { - if (chatHandler_) chatHandler_->addLocalChatMessage(msg); -} - -const std::deque& GameHandler::getChatHistory() const { - if (chatHandler_) return chatHandler_->getChatHistory(); - static const std::deque kEmpty; - return kEmpty; -} - -void GameHandler::clearChatHistory() { - if (chatHandler_) chatHandler_->getChatHistory().clear(); -} - -const std::vector& GameHandler::getJoinedChannels() const { - if (chatHandler_) return chatHandler_->getJoinedChannels(); - static const std::vector kEmpty; - return kEmpty; -} - -// ============================================================ -// Name Queries (delegated to EntityController) -// ============================================================ - -void GameHandler::queryPlayerName(uint64_t guid) { - if (entityController_) entityController_->queryPlayerName(guid); -} - -void GameHandler::queryCreatureInfo(uint32_t entry, uint64_t guid) { - if (entityController_) entityController_->queryCreatureInfo(entry, guid); -} - -void GameHandler::queryGameObjectInfo(uint32_t entry, uint64_t guid) { - if (entityController_) entityController_->queryGameObjectInfo(entry, guid); -} - -std::string GameHandler::getCachedPlayerName(uint64_t guid) const { - return entityController_ ? entityController_->getCachedPlayerName(guid) : ""; -} - -std::string GameHandler::getCachedCreatureName(uint32_t entry) const { - return entityController_ ? entityController_->getCachedCreatureName(entry) : ""; -} - -// ============================================================ -// Item Query (forwarded to InventoryHandler) -// ============================================================ - -void GameHandler::queryItemInfo(uint32_t entry, uint64_t guid) { - if (inventoryHandler_) inventoryHandler_->queryItemInfo(entry, guid); -} - -void GameHandler::handleItemQueryResponse(network::Packet& packet) { - if (inventoryHandler_) inventoryHandler_->handleItemQueryResponse(packet); -} - -uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const { - return inventoryHandler_ ? inventoryHandler_->resolveOnlineItemGuid(itemId) : 0; -} - -void GameHandler::detectInventorySlotBases(const std::map& fields) { - if (inventoryHandler_) inventoryHandler_->detectInventorySlotBases(fields); -} - -bool GameHandler::applyInventoryFields(const std::map& fields) { - return inventoryHandler_ ? inventoryHandler_->applyInventoryFields(fields) : false; -} - -void GameHandler::extractContainerFields(uint64_t containerGuid, const std::map& fields) { - if (inventoryHandler_) inventoryHandler_->extractContainerFields(containerGuid, fields); -} - -void GameHandler::rebuildOnlineInventory() { - if (inventoryHandler_) inventoryHandler_->rebuildOnlineInventory(); -} - -void GameHandler::maybeDetectVisibleItemLayout() { - if (inventoryHandler_) inventoryHandler_->maybeDetectVisibleItemLayout(); -} - -void GameHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::map& fields) { - if (inventoryHandler_) inventoryHandler_->updateOtherPlayerVisibleItems(guid, fields); -} - -void GameHandler::emitOtherPlayerEquipment(uint64_t guid) { - if (inventoryHandler_) inventoryHandler_->emitOtherPlayerEquipment(guid); -} - -void GameHandler::emitAllOtherPlayerEquipment() { - if (inventoryHandler_) inventoryHandler_->emitAllOtherPlayerEquipment(); -} - -// ============================================================ -// Combat (delegated to CombatHandler) -// ============================================================ - -void GameHandler::startAutoAttack(uint64_t targetGuid) { - if (combatHandler_) combatHandler_->startAutoAttack(targetGuid); -} - -void GameHandler::stopAutoAttack() { - if (combatHandler_) combatHandler_->stopAutoAttack(); -} - -void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType, - uint64_t srcGuid, uint64_t dstGuid) { - if (combatHandler_) combatHandler_->addCombatText(type, amount, spellId, isPlayerSource, powerType, srcGuid, dstGuid); -} - -bool GameHandler::shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId) { - return combatHandler_ ? combatHandler_->shouldLogSpellstealAura(casterGuid, victimGuid, spellId) : false; -} - -void GameHandler::updateCombatText(float deltaTime) { - if (combatHandler_) combatHandler_->updateCombatText(deltaTime); -} - -bool GameHandler::isAutoAttacking() const { - return combatHandler_ ? combatHandler_->isAutoAttacking() : false; -} - -bool GameHandler::hasAutoAttackIntent() const { - return combatHandler_ ? combatHandler_->hasAutoAttackIntent() : false; -} - -bool GameHandler::isInCombat() const { - return combatHandler_ ? combatHandler_->isInCombat() : false; -} - -bool GameHandler::isInCombatWith(uint64_t guid) const { - return combatHandler_ ? combatHandler_->isInCombatWith(guid) : false; -} - -uint64_t GameHandler::getAutoAttackTargetGuid() const { - return combatHandler_ ? combatHandler_->getAutoAttackTargetGuid() : 0; -} - -bool GameHandler::isAggressiveTowardPlayer(uint64_t guid) const { - return combatHandler_ ? combatHandler_->isAggressiveTowardPlayer(guid) : false; -} - -uint64_t GameHandler::getLastMeleeSwingMs() const { - return combatHandler_ ? combatHandler_->getLastMeleeSwingMs() : 0; -} - -const std::vector& GameHandler::getCombatText() const { - static const std::vector empty; - return combatHandler_ ? combatHandler_->getCombatText() : empty; -} - -const std::deque& GameHandler::getCombatLog() const { - static const std::deque empty; - return combatHandler_ ? combatHandler_->getCombatLog() : empty; -} - -void GameHandler::clearCombatLog() { - if (combatHandler_) combatHandler_->clearCombatLog(); -} - -void GameHandler::clearCombatText() { - if (combatHandler_) combatHandler_->clearCombatText(); -} - -void GameHandler::clearHostileAttackers() { - if (combatHandler_) combatHandler_->clearHostileAttackers(); -} - -const std::vector* GameHandler::getThreatList(uint64_t unitGuid) const { - return combatHandler_ ? combatHandler_->getThreatList(unitGuid) : nullptr; -} - -const std::vector* GameHandler::getTargetThreatList() const { - return targetGuid ? getThreatList(targetGuid) : nullptr; -} - -bool GameHandler::isHostileAttacker(uint64_t guid) const { - return combatHandler_ ? combatHandler_->isHostileAttacker(guid) : false; -} - -void GameHandler::dismount() { - if (movementHandler_) movementHandler_->dismount(); -} - -// ============================================================ -// Arena / Battleground Handlers -// ============================================================ - -void GameHandler::declineBattlefield(uint32_t queueSlot) { - if (socialHandler_) socialHandler_->declineBattlefield(queueSlot); -} - -bool GameHandler::hasPendingBgInvite() const { - return socialHandler_ && socialHandler_->hasPendingBgInvite(); -} - -void GameHandler::acceptBattlefield(uint32_t queueSlot) { - if (socialHandler_) socialHandler_->acceptBattlefield(queueSlot); -} - -// --------------------------------------------------------------------------- -// LFG / Dungeon Finder handlers (WotLK 3.3.5a) -// --------------------------------------------------------------------------- - -// --------------------------------------------------------------------------- -// LFG outgoing packets -// --------------------------------------------------------------------------- - -void GameHandler::lfgJoin(uint32_t dungeonId, uint8_t roles) { - if (socialHandler_) socialHandler_->lfgJoin(dungeonId, roles); -} - -void GameHandler::lfgLeave() { - if (socialHandler_) socialHandler_->lfgLeave(); -} - -void GameHandler::lfgSetRoles(uint8_t roles) { - if (socialHandler_) socialHandler_->lfgSetRoles(roles); -} - -void GameHandler::lfgAcceptProposal(uint32_t proposalId, bool accept) { - if (socialHandler_) socialHandler_->lfgAcceptProposal(proposalId, accept); -} - -void GameHandler::lfgTeleport(bool toLfgDungeon) { - if (socialHandler_) socialHandler_->lfgTeleport(toLfgDungeon); -} - -void GameHandler::lfgSetBootVote(bool vote) { - if (socialHandler_) socialHandler_->lfgSetBootVote(vote); -} - -void GameHandler::loadAreaTriggerDbc() { - if (movementHandler_) movementHandler_->loadAreaTriggerDbc(); -} - -void GameHandler::checkAreaTriggers() { - if (movementHandler_) movementHandler_->checkAreaTriggers(); -} - -void GameHandler::requestArenaTeamRoster(uint32_t teamId) { - if (socialHandler_) socialHandler_->requestArenaTeamRoster(teamId); -} - -void GameHandler::requestPvpLog() { - if (socialHandler_) socialHandler_->requestPvpLog(); -} - -// ============================================================ -// Spells -// ============================================================ - -void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { - if (spellHandler_) spellHandler_->castSpell(spellId, targetGuid); -} - -void GameHandler::cancelCast() { - if (spellHandler_) spellHandler_->cancelCast(); -} - -void GameHandler::startCraftQueue(uint32_t spellId, int count) { - if (spellHandler_) spellHandler_->startCraftQueue(spellId, count); -} - -void GameHandler::cancelCraftQueue() { - if (spellHandler_) spellHandler_->cancelCraftQueue(); -} - -void GameHandler::cancelAura(uint32_t spellId) { - if (spellHandler_) spellHandler_->cancelAura(spellId); -} - -uint32_t GameHandler::getTempEnchantRemainingMs(uint32_t slot) const { - return inventoryHandler_ ? inventoryHandler_->getTempEnchantRemainingMs(slot) : 0u; -} - -void GameHandler::handlePetSpells(network::Packet& packet) { - if (spellHandler_) spellHandler_->handlePetSpells(packet); -} - -void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { - if (spellHandler_) spellHandler_->sendPetAction(action, targetGuid); -} - -void GameHandler::dismissPet() { - if (spellHandler_) spellHandler_->dismissPet(); -} - -void GameHandler::togglePetSpellAutocast(uint32_t spellId) { - if (spellHandler_) spellHandler_->togglePetSpellAutocast(spellId); -} - -void GameHandler::renamePet(const std::string& newName) { - if (spellHandler_) spellHandler_->renamePet(newName); -} - -void GameHandler::requestStabledPetList() { - if (spellHandler_) spellHandler_->requestStabledPetList(); -} - -void GameHandler::stablePet(uint8_t slot) { - if (spellHandler_) spellHandler_->stablePet(slot); -} - -void GameHandler::unstablePet(uint32_t petNumber) { - if (spellHandler_) spellHandler_->unstablePet(petNumber); -} - -void GameHandler::handleListStabledPets(network::Packet& packet) { - if (spellHandler_) spellHandler_->handleListStabledPets(packet); -} - -void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) { - if (slot < 0 || slot >= ACTION_BAR_SLOTS) return; - actionBar[slot].type = type; - actionBar[slot].id = id; - // Pre-query item information so action bar displays item name instead of "Item" placeholder - if (type == ActionBarSlot::ITEM && id != 0) { - queryItemInfo(id, 0); - } - saveCharacterConfig(); - // Notify Lua addons that the action bar changed - fireAddonEvent("ACTIONBAR_SLOT_CHANGED", {std::to_string(slot + 1)}); - fireAddonEvent("ACTIONBAR_UPDATE_STATE", {}); - // Notify the server so the action bar persists across relogs. - if (isInWorld()) { - const bool classic = isClassicLikeExpansion(); - auto pkt = SetActionButtonPacket::build( - static_cast(slot), - static_cast(type), - id, - classic); - socket->send(pkt); - } -} - -float GameHandler::getSpellCooldown(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSpellCooldown(spellId); - return 0; -} - -// ============================================================ -// Talents -// ============================================================ - -void GameHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) { - if (spellHandler_) spellHandler_->learnTalent(talentId, requestedRank); -} - -void GameHandler::switchTalentSpec(uint8_t newSpec) { - if (spellHandler_) spellHandler_->switchTalentSpec(newSpec); -} - -void GameHandler::confirmPetUnlearn() { - if (spellHandler_) spellHandler_->confirmPetUnlearn(); -} - -void GameHandler::confirmTalentWipe() { - if (spellHandler_) spellHandler_->confirmTalentWipe(); -} - -void GameHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { - if (socialHandler_) socialHandler_->sendAlterAppearance(hairStyle, hairColor, facialHair); -} - -// ============================================================ -// Group/Party -// ============================================================ - -void GameHandler::inviteToGroup(const std::string& playerName) { - if (socialHandler_) socialHandler_->inviteToGroup(playerName); -} - -void GameHandler::acceptGroupInvite() { - if (socialHandler_) socialHandler_->acceptGroupInvite(); -} - -void GameHandler::declineGroupInvite() { - if (socialHandler_) socialHandler_->declineGroupInvite(); -} - -void GameHandler::leaveGroup() { - if (socialHandler_) socialHandler_->leaveGroup(); -} - -void GameHandler::convertToRaid() { - if (socialHandler_) socialHandler_->convertToRaid(); -} - -void GameHandler::sendSetLootMethod(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid) { - if (socialHandler_) socialHandler_->sendSetLootMethod(method, threshold, masterLooterGuid); -} - -// ============================================================ -// Guild Handlers -// ============================================================ - -void GameHandler::kickGuildMember(const std::string& playerName) { - if (socialHandler_) socialHandler_->kickGuildMember(playerName); -} - -void GameHandler::disbandGuild() { - if (socialHandler_) socialHandler_->disbandGuild(); -} - -void GameHandler::setGuildLeader(const std::string& name) { - if (socialHandler_) socialHandler_->setGuildLeader(name); -} - -void GameHandler::setGuildPublicNote(const std::string& name, const std::string& note) { - if (socialHandler_) socialHandler_->setGuildPublicNote(name, note); -} - -void GameHandler::setGuildOfficerNote(const std::string& name, const std::string& note) { - if (socialHandler_) socialHandler_->setGuildOfficerNote(name, note); -} - -void GameHandler::acceptGuildInvite() { - if (socialHandler_) socialHandler_->acceptGuildInvite(); -} - -void GameHandler::declineGuildInvite() { - if (socialHandler_) socialHandler_->declineGuildInvite(); -} - -void GameHandler::submitGmTicket(const std::string& text) { - if (chatHandler_) chatHandler_->submitGmTicket(text); -} - -void GameHandler::deleteGmTicket() { - if (socialHandler_) socialHandler_->deleteGmTicket(); -} - -void GameHandler::requestGmTicket() { - if (socialHandler_) socialHandler_->requestGmTicket(); -} - -void GameHandler::queryGuildInfo(uint32_t guildId) { - if (socialHandler_) socialHandler_->queryGuildInfo(guildId); -} - -static const std::string kEmptyString; - -const std::string& GameHandler::lookupGuildName(uint32_t guildId) { - static const std::string kEmpty; - if (socialHandler_) return socialHandler_->lookupGuildName(guildId); - return kEmpty; -} - -uint32_t GameHandler::getEntityGuildId(uint64_t guid) const { - if (socialHandler_) return socialHandler_->getEntityGuildId(guid); - return 0; -} - -void GameHandler::createGuild(const std::string& guildName) { - if (socialHandler_) socialHandler_->createGuild(guildName); -} - -void GameHandler::addGuildRank(const std::string& rankName) { - if (socialHandler_) socialHandler_->addGuildRank(rankName); -} - -void GameHandler::deleteGuildRank() { - if (socialHandler_) socialHandler_->deleteGuildRank(); -} - -void GameHandler::requestPetitionShowlist(uint64_t npcGuid) { - if (socialHandler_) socialHandler_->requestPetitionShowlist(npcGuid); -} - -void GameHandler::buyPetition(uint64_t npcGuid, const std::string& guildName) { - if (socialHandler_) socialHandler_->buyPetition(npcGuid, guildName); -} - -void GameHandler::signPetition(uint64_t petitionGuid) { - if (socialHandler_) socialHandler_->signPetition(petitionGuid); -} - -void GameHandler::turnInPetition(uint64_t petitionGuid) { - if (socialHandler_) socialHandler_->turnInPetition(petitionGuid); -} - -// ============================================================ -// Loot, Gossip, Vendor -// ============================================================ - -void GameHandler::lootTarget(uint64_t guid) { - if (inventoryHandler_) inventoryHandler_->lootTarget(guid); -} - -void GameHandler::lootItem(uint8_t slotIndex) { - if (inventoryHandler_) inventoryHandler_->lootItem(slotIndex); -} - -void GameHandler::closeLoot() { - if (inventoryHandler_) inventoryHandler_->closeLoot(); -} - -void GameHandler::lootMasterGive(uint8_t lootSlot, uint64_t targetGuid) { - if (inventoryHandler_) inventoryHandler_->lootMasterGive(lootSlot, targetGuid); -} - -void GameHandler::interactWithNpc(uint64_t guid) { - if (!isInWorld()) return; - auto packet = GossipHelloPacket::build(guid); - socket->send(packet); -} - -void GameHandler::interactWithGameObject(uint64_t guid) { - LOG_WARNING("[GO-DIAG] interactWithGameObject called: guid=0x", std::hex, guid, std::dec); - if (guid == 0) { LOG_WARNING("[GO-DIAG] BLOCKED: guid==0"); return; } - if (!isInWorld()) { LOG_WARNING("[GO-DIAG] BLOCKED: not in world"); return; } - // Do not overlap an actual spell cast. - if (spellHandler_ && spellHandler_->casting_ && spellHandler_->currentCastSpellId_ != 0) { - LOG_WARNING("[GO-DIAG] BLOCKED: already casting spellId=", spellHandler_->currentCastSpellId_); - return; - } - // Always clear melee intent before GO interactions. - stopAutoAttack(); - // Set the pending GO guid so that: - // 1. cancelCast() won't send CMSG_CANCEL_CAST for GO-triggered casts - // (e.g., "Opening" on a quest chest) — without this, any movement - // during the cast cancels it server-side and quest credit is lost. - // 2. The cast-completion fallback in update() can call - // performGameObjectInteractionNow after the cast timer expires. - // 3. isGameObjectInteractionCasting() returns true during GO casts. - pendingGameObjectInteractGuid_ = guid; - performGameObjectInteractionNow(guid); -} - -void GameHandler::performGameObjectInteractionNow(uint64_t guid) { - if (guid == 0) return; - if (!isInWorld()) return; - // Rate-limit to prevent spamming the server - static uint64_t lastInteractGuid = 0; - static std::chrono::steady_clock::time_point lastInteractTime{}; - auto now = std::chrono::steady_clock::now(); - // Keep duplicate suppression, but allow quick retry clicks. - constexpr int64_t minRepeatMs = 150; - if (guid == lastInteractGuid && - std::chrono::duration_cast(now - lastInteractTime).count() < minRepeatMs) { - return; - } - lastInteractGuid = guid; - lastInteractTime = now; - - // Ensure GO interaction isn't blocked by stale or active melee state. - stopAutoAttack(); - auto entity = entityController_->getEntityManager().getEntity(guid); - uint32_t goEntry = 0; - uint32_t goType = 0; - std::string goName; - - if (entity) { - if (entity->getType() == ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - goEntry = go->getEntry(); - goName = go->getName(); - if (auto* info = getCachedGameObjectInfo(goEntry)) goType = info->type; - if (goType == 5 && !goName.empty()) { - std::string lower = goName; - std::transform(lower.begin(), lower.end(), lower.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - if (lower.rfind("doodad_", 0) != 0) { - addSystemChatMessage(goName); - } - } - } - // Face object and send heartbeat before use so strict servers don't require - // a nudge movement to accept interaction. - float dx = entity->getX() - movementInfo.x; - float dy = entity->getY() - movementInfo.y; - float dz = entity->getZ() - movementInfo.z; - float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); - if (dist3d > 10.0f) { - addSystemChatMessage("Too far away."); - return; - } - // Stop movement before interacting — servers may reject GO use or - // immediately cancel the resulting spell cast if the player is moving. - const uint32_t moveFlags = movementInfo.flags; - const bool isMoving = (moveFlags & 0x00000001u) || // FORWARD - (moveFlags & 0x00000002u) || // BACKWARD - (moveFlags & 0x00000004u) || // STRAFE_LEFT - (moveFlags & 0x00000008u); // STRAFE_RIGHT - if (isMoving) { - movementInfo.flags &= ~0x0000000Fu; // clear directional movement flags - sendMovement(Opcode::MSG_MOVE_STOP); - } - if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { - movementInfo.orientation = std::atan2(-dy, dx); - sendMovement(Opcode::MSG_MOVE_SET_FACING); - } - sendMovement(Opcode::MSG_MOVE_HEARTBEAT); - } - - // Determine GO type for interaction strategy - bool isMailbox = false; - bool chestLike = false; - if (entity && entity->getType() == ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - auto* info = getCachedGameObjectInfo(go->getEntry()); - if (info && info->type == 19) { - isMailbox = true; - } else if (info && info->type == 3) { - chestLike = true; - } - } - if (!chestLike && !goName.empty()) { - std::string lower = goName; - std::transform(lower.begin(), lower.end(), lower.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - chestLike = (lower.find("chest") != std::string::npos || - lower.find("lockbox") != std::string::npos || - lower.find("strongbox") != std::string::npos || - lower.find("coffer") != std::string::npos || - lower.find("cache") != std::string::npos || - lower.find("bundle") != std::string::npos); - } - - LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec, - " entry=", goEntry, " type=", goType, - " name='", goName, "' chestLike=", chestLike, " isMailbox=", isMailbox); - - // Always send CMSG_GAMEOBJ_USE first — this triggers the server-side - // GameObject::Use() handler for all GO types. - auto usePacket = GameObjectUsePacket::build(guid); - socket->send(usePacket); - lastInteractedGoGuid_ = guid; - - if (chestLike) { - // Don't send CMSG_LOOT immediately — the server may start a timed cast - // (e.g., "Opening") and the GO isn't lootable until the cast finishes. - // Sending LOOT prematurely gets an empty response or is silently dropped, - // which can interfere with the server's loot state machine. - // Instead, handleSpellGo will send LOOT after the cast completes - // (using lastInteractedGoGuid_ set above). For instant-open chests - // (no cast), the server sends SMSG_LOOT_RESPONSE directly after USE. - } else if (isMailbox) { - openMailbox(guid); - } - - // CMSG_GAMEOBJ_REPORT_USE triggers GO AI scripts (SmartAI, ScriptAI) which - // is where many quest objectives grant credit. Previously this was only sent - // for non-chest GOs, so chest-type quest objectives (Bundle of Wood, etc.) - // never triggered the server-side quest credit script. - if (!isMailbox) { - const auto* table = getActiveOpcodeTable(); - if (table && table->hasOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)) { - network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); - reportUse.writeUInt64(guid); - socket->send(reportUse); - } - } -} - -void GameHandler::selectGossipOption(uint32_t optionId) { - if (questHandler_) questHandler_->selectGossipOption(optionId); -} - -void GameHandler::selectGossipQuest(uint32_t questId) { - if (questHandler_) questHandler_->selectGossipQuest(questId); -} - -bool GameHandler::requestQuestQuery(uint32_t questId, bool force) { - return questHandler_ && questHandler_->requestQuestQuery(questId, force); -} - -bool GameHandler::hasQuestInLog(uint32_t questId) const { - return questHandler_ && questHandler_->hasQuestInLog(questId); -} - -Unit* GameHandler::getUnitByGuid(uint64_t guid) { - auto entity = entityController_->getEntityManager().getEntity(guid); - return entity ? dynamic_cast(entity.get()) : nullptr; -} - -std::string GameHandler::guidToUnitId(uint64_t guid) const { - if (guid == playerGuid) return "player"; - if (guid == targetGuid) return "target"; - if (guid == focusGuid) return "focus"; - if (guid == petGuid_) return "pet"; - return {}; -} - -std::string GameHandler::getQuestTitle(uint32_t questId) const { - for (const auto& q : questLog_) - if (q.questId == questId && !q.title.empty()) return q.title; - return {}; -} - -const GameHandler::QuestLogEntry* GameHandler::findQuestLogEntry(uint32_t questId) const { - for (const auto& q : questLog_) - if (q.questId == questId) return &q; - return nullptr; -} - -int GameHandler::findQuestLogSlotIndexFromServer(uint32_t questId) const { - if (questHandler_) return questHandler_->findQuestLogSlotIndexFromServer(questId); - return 0; -} - -void GameHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::string& title, const std::string& objectives) { - if (questHandler_) questHandler_->addQuestToLocalLogIfMissing(questId, title, objectives); -} - -bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { - return questHandler_ && questHandler_->resyncQuestLogFromServerSlots(forceQueryMetadata); -} - -// Apply quest completion state from player update fields to already-tracked local quests. -// Called from VALUES update handler so quests that complete mid-session (or that were -// complete on login) get quest.complete=true without waiting for SMSG_QUESTUPDATE_COMPLETE. -void GameHandler::applyQuestStateFromFields(const std::map& fields) { - if (questHandler_) questHandler_->applyQuestStateFromFields(fields); -} - -// Extract packed 6-bit kill/objective counts from WotLK/TBC/Classic quest-log update fields -// and populate quest.killCounts + quest.itemCounts using the structured objectives obtained -// from a prior SMSG_QUEST_QUERY_RESPONSE. Silently does nothing if objectives are absent. -void GameHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { - if (questHandler_) questHandler_->applyPackedKillCountsFromFields(quest); -} - -void GameHandler::clearPendingQuestAccept(uint32_t questId) { - if (questHandler_) questHandler_->clearPendingQuestAccept(questId); -} - -void GameHandler::triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason) { - if (questHandler_) questHandler_->triggerQuestAcceptResync(questId, npcGuid, reason); -} - -void GameHandler::acceptQuest() { - if (questHandler_) questHandler_->acceptQuest(); -} - -void GameHandler::declineQuest() { - if (questHandler_) questHandler_->declineQuest(); -} - -void GameHandler::abandonQuest(uint32_t questId) { - if (questHandler_) questHandler_->abandonQuest(questId); -} - -void GameHandler::shareQuestWithParty(uint32_t questId) { - if (questHandler_) questHandler_->shareQuestWithParty(questId); -} - -void GameHandler::completeQuest() { - if (questHandler_) questHandler_->completeQuest(); -} - -void GameHandler::closeQuestRequestItems() { - if (questHandler_) questHandler_->closeQuestRequestItems(); -} - -void GameHandler::chooseQuestReward(uint32_t rewardIndex) { - if (questHandler_) questHandler_->chooseQuestReward(rewardIndex); -} - -void GameHandler::closeQuestOfferReward() { - if (questHandler_) questHandler_->closeQuestOfferReward(); -} - -void GameHandler::closeGossip() { - if (questHandler_) questHandler_->closeGossip(); -} - -void GameHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) { - if (questHandler_) questHandler_->offerQuestFromItem(itemGuid, questId); -} - -uint64_t GameHandler::getBagItemGuid(int bagIndex, int slotIndex) const { - if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return 0; - if (slotIndex < 0) return 0; - uint64_t bagGuid = equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; - if (bagGuid == 0) return 0; - auto it = containerContents_.find(bagGuid); - if (it == containerContents_.end()) return 0; - if (slotIndex >= static_cast(it->second.numSlots)) return 0; - return it->second.slotGuids[slotIndex]; -} - -void GameHandler::openVendor(uint64_t npcGuid) { - if (inventoryHandler_) inventoryHandler_->openVendor(npcGuid); -} - -void GameHandler::closeVendor() { - if (inventoryHandler_) inventoryHandler_->closeVendor(); -} - -void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { - if (inventoryHandler_) inventoryHandler_->buyItem(vendorGuid, itemId, slot, count); -} - -void GameHandler::buyBackItem(uint32_t buybackSlot) { - if (inventoryHandler_) inventoryHandler_->buyBackItem(buybackSlot); -} - -void GameHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { - if (inventoryHandler_) inventoryHandler_->repairItem(vendorGuid, itemGuid); -} - -void GameHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { - if (inventoryHandler_) inventoryHandler_->repairAll(vendorGuid, useGuildBank); -} - -uint32_t GameHandler::estimateRepairAllCost() const { - if (inventoryHandler_) return inventoryHandler_->estimateRepairAllCost(); - return 0; -} - -void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { - if (inventoryHandler_) inventoryHandler_->sellItem(vendorGuid, itemGuid, count); -} - -void GameHandler::sellItemBySlot(int backpackIndex) { - if (inventoryHandler_) inventoryHandler_->sellItemBySlot(backpackIndex); -} - -void GameHandler::autoEquipItemBySlot(int backpackIndex) { - if (inventoryHandler_) inventoryHandler_->autoEquipItemBySlot(backpackIndex); -} - -void GameHandler::autoEquipItemInBag(int bagIndex, int slotIndex) { - if (inventoryHandler_) inventoryHandler_->autoEquipItemInBag(bagIndex, slotIndex); -} - -void GameHandler::sellItemInBag(int bagIndex, int slotIndex) { - if (inventoryHandler_) inventoryHandler_->sellItemInBag(bagIndex, slotIndex); -} - -void GameHandler::unequipToBackpack(EquipSlot equipSlot) { - if (inventoryHandler_) inventoryHandler_->unequipToBackpack(equipSlot); -} - -void GameHandler::swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot) { - if (inventoryHandler_) inventoryHandler_->swapContainerItems(srcBag, srcSlot, dstBag, dstSlot); -} - -void GameHandler::swapBagSlots(int srcBagIndex, int dstBagIndex) { - if (inventoryHandler_) inventoryHandler_->swapBagSlots(srcBagIndex, dstBagIndex); -} - -void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) { - if (inventoryHandler_) inventoryHandler_->destroyItem(bag, slot, count); -} - -void GameHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) { - if (inventoryHandler_) inventoryHandler_->splitItem(srcBag, srcSlot, count); -} - -void GameHandler::useItemBySlot(int backpackIndex) { - if (inventoryHandler_) inventoryHandler_->useItemBySlot(backpackIndex); -} - -void GameHandler::useItemInBag(int bagIndex, int slotIndex) { - if (inventoryHandler_) inventoryHandler_->useItemInBag(bagIndex, slotIndex); -} - -void GameHandler::openItemBySlot(int backpackIndex) { - if (inventoryHandler_) inventoryHandler_->openItemBySlot(backpackIndex); -} - -void GameHandler::openItemInBag(int bagIndex, int slotIndex) { - if (inventoryHandler_) inventoryHandler_->openItemInBag(bagIndex, slotIndex); -} - -void GameHandler::useItemById(uint32_t itemId) { - if (inventoryHandler_) inventoryHandler_->useItemById(itemId); -} - -uint32_t GameHandler::getItemIdForSpell(uint32_t spellId) const { - if (spellId == 0) return 0; - // Search backpack and bags for an item whose on-use spell matches - for (int i = 0; i < inventory.getBackpackSize(); i++) { - const auto& slot = inventory.getBackpackSlot(i); - if (slot.empty()) continue; - auto* info = getItemInfo(slot.item.itemId); - if (!info || !info->valid) continue; - for (const auto& sp : info->spells) { - if (sp.spellId == spellId && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) - return slot.item.itemId; - } - } - for (int bag = 0; bag < inventory.NUM_BAG_SLOTS; bag++) { - for (int s = 0; s < inventory.getBagSize(bag); s++) { - const auto& slot = inventory.getBagSlot(bag, s); - if (slot.empty()) continue; - auto* info = getItemInfo(slot.item.itemId); - if (!info || !info->valid) continue; - for (const auto& sp : info->spells) { - if (sp.spellId == spellId && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) - return slot.item.itemId; - } - } - } - return 0; -} - -void GameHandler::unstuck() { - if (unstuckCallback_) { - unstuckCallback_(); - addSystemChatMessage("Unstuck: snapped upward. Use /unstuckgy for full teleport."); - } -} - -void GameHandler::unstuckGy() { - if (unstuckGyCallback_) { - unstuckGyCallback_(); - addSystemChatMessage("Unstuck: teleported to safe location."); - } -} - -void GameHandler::unstuckHearth() { - if (unstuckHearthCallback_) { - unstuckHearthCallback_(); - addSystemChatMessage("Unstuck: teleported to hearthstone location."); - } else { - addSystemChatMessage("No hearthstone bind point set."); - } -} - -// ============================================================ -// Trainer -// ============================================================ - -void GameHandler::trainSpell(uint32_t spellId) { - if (inventoryHandler_) inventoryHandler_->trainSpell(spellId); -} - -void GameHandler::closeTrainer() { - if (inventoryHandler_) inventoryHandler_->closeTrainer(); -} - -void GameHandler::preloadDBCCaches() const { - LOG_INFO("Pre-loading DBC caches during world entry..."); - auto t0 = std::chrono::steady_clock::now(); - - loadSpellNameCache(); // Spell.dbc — largest, ~170ms cold - loadTitleNameCache(); // CharTitles.dbc - loadFactionNameCache(); // Faction.dbc - loadAreaNameCache(); // WorldMapArea.dbc - loadMapNameCache(); // Map.dbc - loadLfgDungeonDbc(); // LFGDungeons.dbc - - // Validate animation constants against AnimationData.dbc - if (auto* am = services_.assetManager) { - auto animDbc = am->loadDBC("AnimationData.dbc"); - rendering::anim::validateAgainstDBC(animDbc); - } - - auto elapsed = std::chrono::duration_cast( - std::chrono::steady_clock::now() - t0).count(); - LOG_INFO("DBC cache pre-load complete in ", elapsed, " ms"); -} - -void GameHandler::loadSpellNameCache() const { - if (spellHandler_) spellHandler_->loadSpellNameCache(); -} - -void GameHandler::loadSkillLineAbilityDbc() { - if (spellHandler_) spellHandler_->loadSkillLineAbilityDbc(); -} - -const std::vector& GameHandler::getSpellBookTabs() { - static const std::vector kEmpty; - if (spellHandler_) return spellHandler_->getSpellBookTabs(); - return kEmpty; -} - -void GameHandler::categorizeTrainerSpells() { - if (spellHandler_) spellHandler_->categorizeTrainerSpells(); -} - -void GameHandler::loadTalentDbc() { - if (spellHandler_) spellHandler_->loadTalentDbc(); -} - -static const std::string EMPTY_STRING; - -const int32_t* GameHandler::getSpellEffectBasePoints(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSpellEffectBasePoints(spellId); - return nullptr; -} - -float GameHandler::getSpellDuration(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSpellDuration(spellId); - return 0.0f; -} - -const std::string& GameHandler::getSpellName(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSpellName(spellId); - return EMPTY_STRING; -} - -const std::string& GameHandler::getSpellRank(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSpellRank(spellId); - return EMPTY_STRING; -} - -const std::string& GameHandler::getSpellDescription(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSpellDescription(spellId); - return EMPTY_STRING; -} - -std::string GameHandler::getEnchantName(uint32_t enchantId) const { - if (spellHandler_) return spellHandler_->getEnchantName(enchantId); - return {}; -} - -uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSpellDispelType(spellId); - return 0; -} - -bool GameHandler::isSpellInterruptible(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->isSpellInterruptible(spellId); - return true; -} - -uint32_t GameHandler::getSpellSchoolMask(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSpellSchoolMask(spellId); - return 0; -} - -const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { - if (spellHandler_) return spellHandler_->getSkillLineName(spellId); - return EMPTY_STRING; -} - // ============================================================ // Single-player local combat // ============================================================ diff --git a/src/game/game_handler_callbacks.cpp b/src/game/game_handler_callbacks.cpp new file mode 100644 index 00000000..bd97e42a --- /dev/null +++ b/src/game/game_handler_callbacks.cpp @@ -0,0 +1,2468 @@ +#include "game/game_handler.hpp" +#include "game/game_utils.hpp" +#include "game/chat_handler.hpp" +#include "game/movement_handler.hpp" +#include "game/combat_handler.hpp" +#include "game/spell_handler.hpp" +#include "game/inventory_handler.hpp" +#include "game/social_handler.hpp" +#include "game/quest_handler.hpp" +#include "game/warden_handler.hpp" +#include "game/packet_parsers.hpp" +#include "game/transport_manager.hpp" +#include "game/warden_crypto.hpp" +#include "game/warden_memory.hpp" +#include "game/warden_module.hpp" +#include "game/opcodes.hpp" +#include "game/update_field_table.hpp" +#include "game/expansion_profile.hpp" +#include "rendering/renderer.hpp" +#include "rendering/spell_visual_system.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/activity_sound_manager.hpp" +#include "audio/combat_sound_manager.hpp" +#include "audio/spell_sound_manager.hpp" +#include "audio/ui_sound_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "network/world_socket.hpp" +#include "network/packet.hpp" +#include "auth/crypto.hpp" +#include "core/coordinates.hpp" +#include "core/application.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "core/logger.hpp" +#include "rendering/animation/animation_ids.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace wowee { +namespace game { + +namespace { + +const char* worldStateName(WorldState state) { + switch (state) { + case WorldState::DISCONNECTED: return "DISCONNECTED"; + case WorldState::CONNECTING: return "CONNECTING"; + case WorldState::CONNECTED: return "CONNECTED"; + case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED"; + case WorldState::AUTH_SENT: return "AUTH_SENT"; + case WorldState::AUTHENTICATED: return "AUTHENTICATED"; + case WorldState::READY: return "READY"; + case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED"; + case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED"; + case WorldState::ENTERING_WORLD: return "ENTERING_WORLD"; + case WorldState::IN_WORLD: return "IN_WORLD"; + case WorldState::FAILED: return "FAILED"; + } + return "UNKNOWN"; +} + +} // end anonymous namespace + +void GameHandler::handleAuthChallenge(network::Packet& packet) { + LOG_INFO("Handling SMSG_AUTH_CHALLENGE"); + + AuthChallengeData challenge; + if (!AuthChallengeParser::parse(packet, challenge)) { + fail("Failed to parse SMSG_AUTH_CHALLENGE"); + return; + } + + if (!challenge.isValid()) { + fail("Invalid auth challenge data"); + return; + } + + // Store server seed + serverSeed = challenge.serverSeed; + LOG_DEBUG("Server seed: 0x", std::hex, serverSeed, std::dec); + + setState(WorldState::CHALLENGE_RECEIVED); + + // Send authentication session + sendAuthSession(); +} + +void GameHandler::sendAuthSession() { + LOG_INFO("Sending CMSG_AUTH_SESSION"); + + // Build authentication packet + auto packet = AuthSessionPacket::build( + build, + accountName, + clientSeed, + sessionKey, + serverSeed, + realmId_ + ); + + LOG_DEBUG("CMSG_AUTH_SESSION packet size: ", packet.getSize(), " bytes"); + + // Send packet (unencrypted - this is the last unencrypted packet) + socket->send(packet); + + // Enable encryption IMMEDIATELY after sending AUTH_SESSION + // AzerothCore enables encryption before sending AUTH_RESPONSE, + // so we need to be ready to decrypt the response + LOG_INFO("Enabling encryption immediately after AUTH_SESSION"); + socket->initEncryption(sessionKey, build); + + setState(WorldState::AUTH_SENT); + LOG_INFO("CMSG_AUTH_SESSION sent, encryption enabled, waiting for AUTH_RESPONSE..."); +} + +void GameHandler::handleAuthResponse(network::Packet& packet) { + LOG_WARNING("Handling SMSG_AUTH_RESPONSE, size=", packet.getSize()); + + AuthResponseData response; + if (!AuthResponseParser::parse(packet, response)) { + fail("Failed to parse SMSG_AUTH_RESPONSE"); + return; + } + + if (!response.isSuccess()) { + std::string reason = std::string("Authentication failed: ") + + getAuthResultString(response.result); + fail(reason); + return; + } + + // Encryption was already enabled after sending AUTH_SESSION + LOG_INFO("AUTH_RESPONSE OK - world authentication successful"); + + setState(WorldState::AUTHENTICATED); + + LOG_INFO("========================================"); + LOG_INFO(" WORLD AUTHENTICATION SUCCESSFUL!"); + LOG_INFO("========================================"); + LOG_INFO("Connected to world server"); + LOG_INFO("Ready for character operations"); + + setState(WorldState::READY); + + // Request character list automatically + requestCharacterList(); + + // Call success callback + if (onSuccess) { + onSuccess(); + } +} + +void GameHandler::requestCharacterList() { + if (requiresWarden_) { + // Gate already surfaced via failure callback/chat; avoid per-frame warning spam. + wardenCharEnumBlockedLogged_ = true; + return; + } + + if (state == WorldState::FAILED || !socket || !socket->isConnected()) { + return; + } + + if (state != WorldState::READY && state != WorldState::AUTHENTICATED && + state != WorldState::CHAR_LIST_RECEIVED) { + LOG_WARNING("Cannot request character list in state: ", worldStateName(state)); + return; + } + + LOG_INFO("Requesting character list from server..."); + + // Prevent the UI from showing/selecting stale characters while we wait for the new SMSG_CHAR_ENUM. + // This matters after character create/delete where the old list can linger for a few frames. + characters.clear(); + + // Build CMSG_CHAR_ENUM packet (no body, just opcode) + auto packet = CharEnumPacket::build(); + + // Send packet + socket->send(packet); + + setState(WorldState::CHAR_LIST_REQUESTED); + LOG_INFO("CMSG_CHAR_ENUM sent, waiting for character list..."); +} + +void GameHandler::handleCharEnum(network::Packet& packet) { + LOG_INFO("Handling SMSG_CHAR_ENUM"); + + CharEnumResponse response; + // IMPORTANT: Do not infer packet formats from numeric build alone. + // Turtle WoW uses a "high" build but classic-era world packet formats. + bool parsed = packetParsers_ ? packetParsers_->parseCharEnum(packet, response) + : CharEnumParser::parse(packet, response); + if (!parsed) { + fail("Failed to parse SMSG_CHAR_ENUM"); + return; + } + + // Store characters + characters = response.characters; + + setState(WorldState::CHAR_LIST_RECEIVED); + + LOG_INFO("========================================"); + LOG_INFO(" CHARACTER LIST RECEIVED"); + LOG_INFO("========================================"); + LOG_INFO("Found ", characters.size(), " character(s)"); + + if (characters.empty()) { + LOG_INFO("No characters on this account"); + } else { + LOG_INFO("Characters:"); + for (size_t i = 0; i < characters.size(); ++i) { + const auto& character = characters[i]; + LOG_INFO(" [", i + 1, "] ", character.name); + LOG_INFO(" GUID: 0x", std::hex, character.guid, std::dec); + LOG_INFO(" ", getRaceName(character.race), " ", + getClassName(character.characterClass)); + LOG_INFO(" Level ", static_cast(character.level)); + } + } + + LOG_INFO("Ready to select character"); +} + +void GameHandler::createCharacter(const CharCreateData& data) { + + // Online mode: send packet to server + if (!socket) { + LOG_WARNING("Cannot create character: not connected"); + if (charCreateCallback_) { + charCreateCallback_(false, "Not connected to server"); + } + return; + } + + if (requiresWarden_) { + std::string msg = "Server requires anti-cheat/Warden; character creation blocked."; + LOG_WARNING("Blocking CMSG_CHAR_CREATE while Warden gate is active"); + if (charCreateCallback_) { + charCreateCallback_(false, msg); + } + return; + } + + if (state != WorldState::CHAR_LIST_RECEIVED) { + std::string msg = "Character list not ready yet. Wait for SMSG_CHAR_ENUM."; + LOG_WARNING("Blocking CMSG_CHAR_CREATE in state=", worldStateName(state), + " (awaiting CHAR_LIST_RECEIVED)"); + if (charCreateCallback_) { + charCreateCallback_(false, msg); + } + return; + } + + auto packet = CharCreatePacket::build(data); + socket->send(packet); + LOG_INFO("CMSG_CHAR_CREATE sent for: ", data.name); +} + +void GameHandler::handleCharCreateResponse(network::Packet& packet) { + CharCreateResponseData data; + if (!CharCreateResponseParser::parse(packet, data)) { + LOG_ERROR("Failed to parse SMSG_CHAR_CREATE"); + return; + } + + if (data.result == CharCreateResult::SUCCESS || data.result == CharCreateResult::IN_PROGRESS) { + LOG_INFO("Character created successfully (code=", static_cast(data.result), ")"); + requestCharacterList(); + if (charCreateCallback_) { + charCreateCallback_(true, "Character created!"); + } + } else { + std::string msg; + switch (data.result) { + case CharCreateResult::CHAR_ERROR: msg = "Server error"; break; + case CharCreateResult::FAILED: msg = "Creation failed"; break; + case CharCreateResult::NAME_IN_USE: msg = "Name already in use"; break; + case CharCreateResult::DISABLED: msg = "Character creation disabled"; break; + case CharCreateResult::PVP_TEAMS_VIOLATION: msg = "PvP faction violation"; break; + case CharCreateResult::SERVER_LIMIT: msg = "Server character limit reached"; break; + case CharCreateResult::ACCOUNT_LIMIT: msg = "Account character limit reached"; break; + case CharCreateResult::SERVER_QUEUE: msg = "Server is queued"; break; + case CharCreateResult::ONLY_EXISTING: msg = "Only existing characters allowed"; break; + case CharCreateResult::EXPANSION: msg = "Expansion required"; break; + case CharCreateResult::EXPANSION_CLASS: msg = "Expansion required for this class"; break; + case CharCreateResult::LEVEL_REQUIREMENT: msg = "Level requirement not met"; break; + case CharCreateResult::UNIQUE_CLASS_LIMIT: msg = "Unique class limit reached"; break; + case CharCreateResult::RESTRICTED_RACECLASS: msg = "Race/class combination not allowed"; break; + case CharCreateResult::IN_PROGRESS: msg = "Character creation in progress..."; break; + case CharCreateResult::CHARACTER_CHOOSE_RACE: msg = "Please choose a different race"; break; + case CharCreateResult::CHARACTER_ARENA_LEADER: msg = "Arena team leader restriction"; break; + case CharCreateResult::CHARACTER_DELETE_MAIL: msg = "Character has mail"; break; + case CharCreateResult::CHARACTER_SWAP_FACTION: msg = "Faction swap restriction"; break; + case CharCreateResult::CHARACTER_RACE_ONLY: msg = "Race-only restriction"; break; + case CharCreateResult::CHARACTER_GOLD_LIMIT: msg = "Gold limit reached"; break; + case CharCreateResult::FORCE_LOGIN: msg = "Force login required"; break; + case CharCreateResult::CHARACTER_IN_GUILD: msg = "Character is in a guild"; break; + // Name validation errors + case CharCreateResult::NAME_FAILURE: msg = "Invalid name"; break; + case CharCreateResult::NAME_NO_NAME: msg = "Please enter a name"; break; + case CharCreateResult::NAME_TOO_SHORT: msg = "Name is too short"; break; + case CharCreateResult::NAME_TOO_LONG: msg = "Name is too long"; break; + case CharCreateResult::NAME_INVALID_CHARACTER: msg = "Name contains invalid characters"; break; + case CharCreateResult::NAME_MIXED_LANGUAGES: msg = "Name mixes languages"; break; + case CharCreateResult::NAME_PROFANE: msg = "Name contains profanity"; break; + case CharCreateResult::NAME_RESERVED: msg = "Name is reserved"; break; + case CharCreateResult::NAME_INVALID_APOSTROPHE: msg = "Invalid apostrophe in name"; break; + case CharCreateResult::NAME_MULTIPLE_APOSTROPHES: msg = "Name has multiple apostrophes"; break; + case CharCreateResult::NAME_THREE_CONSECUTIVE: msg = "Name has 3+ consecutive same letters"; break; + case CharCreateResult::NAME_INVALID_SPACE: msg = "Invalid space in name"; break; + case CharCreateResult::NAME_CONSECUTIVE_SPACES: msg = "Name has consecutive spaces"; break; + default: msg = "Unknown error (code " + std::to_string(static_cast(data.result)) + ")"; break; + } + LOG_WARNING("Character creation failed: ", msg, " (code=", static_cast(data.result), ")"); + if (charCreateCallback_) { + charCreateCallback_(false, msg); + } + } +} + +void GameHandler::deleteCharacter(uint64_t characterGuid) { + if (!socket) { + if (charDeleteCallback_) charDeleteCallback_(false); + return; + } + + network::Packet packet(wireOpcode(Opcode::CMSG_CHAR_DELETE)); + packet.writeUInt64(characterGuid); + socket->send(packet); + LOG_INFO("CMSG_CHAR_DELETE sent for GUID: 0x", std::hex, characterGuid, std::dec); +} + +const Character* GameHandler::getActiveCharacter() const { + if (activeCharacterGuid_ == 0) return nullptr; + for (const auto& ch : characters) { + if (ch.guid == activeCharacterGuid_) return &ch; + } + return nullptr; +} + +const Character* GameHandler::getFirstCharacter() const { + if (characters.empty()) return nullptr; + return &characters.front(); +} + +void GameHandler::handleCharLoginFailed(network::Packet& packet) { + uint8_t reason = packet.readUInt8(); + + static const char* reasonNames[] = { + "Login failed", // 0 + "World server is down", // 1 + "Duplicate character", // 2 (session still active) + "No instance servers", // 3 + "Login disabled", // 4 + "Character not found", // 5 + "Locked for transfer", // 6 + "Locked by billing", // 7 + "Using remote", // 8 + }; + const char* msg = (reason < 9) ? reasonNames[reason] : "Unknown reason"; + + LOG_ERROR("SMSG_CHARACTER_LOGIN_FAILED: reason=", static_cast(reason), " (", msg, ")"); + + // Allow the player to re-select a character + setState(WorldState::CHAR_LIST_RECEIVED); + + if (charLoginFailCallback_) { + charLoginFailCallback_(msg); + } +} + +void GameHandler::selectCharacter(uint64_t characterGuid) { + if (state != WorldState::CHAR_LIST_RECEIVED) { + LOG_WARNING("Cannot select character in state: ", static_cast(state)); + return; + } + + // Make the selected character authoritative in GameHandler. + // This avoids relying on UI/Application ordering for appearance-dependent logic. + activeCharacterGuid_ = characterGuid; + + LOG_INFO("========================================"); + LOG_INFO(" ENTERING WORLD"); + LOG_INFO("========================================"); + LOG_INFO("Character GUID: 0x", std::hex, characterGuid, std::dec); + + // Find character name for logging + for (const auto& character : characters) { + if (character.guid == characterGuid) { + LOG_INFO("Character: ", character.name); + LOG_INFO("Level ", static_cast(character.level), " ", + getRaceName(character.race), " ", + getClassName(character.characterClass)); + playerRace_ = character.race; + break; + } + } + + // Store player GUID + playerGuid = characterGuid; + + // Reset per-character state so previous character data doesn't bleed through + inventory = Inventory(); + onlineItems_.clear(); + itemInfoCache_.clear(); + pendingItemQueries_.clear(); + equipSlotGuids_ = {}; + backpackSlotGuids_ = {}; + keyringSlotGuids_ = {}; + invSlotBase_ = -1; + packSlotBase_ = -1; + lastPlayerFields_.clear(); + onlineEquipDirty_ = false; + playerMoneyCopper_ = 0; + playerArmorRating_ = 0; + std::fill(std::begin(playerResistances_), std::end(playerResistances_), 0); + std::fill(std::begin(playerStats_), std::end(playerStats_), -1); + playerMeleeAP_ = -1; + playerRangedAP_ = -1; + std::fill(std::begin(playerSpellDmgBonus_), std::end(playerSpellDmgBonus_), -1); + playerHealBonus_ = -1; + playerDodgePct_ = -1.0f; + playerParryPct_ = -1.0f; + playerBlockPct_ = -1.0f; + playerCritPct_ = -1.0f; + playerRangedCritPct_ = -1.0f; + std::fill(std::begin(playerSpellCritPct_), std::end(playerSpellCritPct_), -1.0f); + std::fill(std::begin(playerCombatRatings_), std::end(playerCombatRatings_), -1); + if (spellHandler_) spellHandler_->resetAllState(); + spellFlatMods_.clear(); + spellPctMods_.clear(); + actionBar = {}; + petGuid_ = 0; + stableWindowOpen_ = false; + stableMasterGuid_ = 0; + stableNumSlots_ = 0; + stabledPets_.clear(); + playerXp_ = 0; + playerNextLevelXp_ = 0; + serverPlayerLevel_ = 1; + std::fill(playerExploredZones_.begin(), playerExploredZones_.end(), 0u); + hasPlayerExploredZones_ = false; + playerSkills_.clear(); + questLog_.clear(); + pendingQuestQueryIds_.clear(); + pendingLoginQuestResync_ = false; + pendingLoginQuestResyncTimeout_ = 0.0f; + pendingQuestAcceptTimeouts_.clear(); + pendingQuestAcceptNpcGuids_.clear(); + npcQuestStatus_.clear(); + if (combatHandler_) combatHandler_->resetAllCombatState(); + // resetCastState() already called inside resetAllState() above + pendingGameObjectInteractGuid_ = 0; + lastInteractedGoGuid_ = 0; + playerDead_ = false; + releasedSpirit_ = false; + corpseGuid_ = 0; + corpseReclaimAvailableMs_ = 0; + targetGuid = 0; + focusGuid = 0; + lastTargetGuid = 0; + tabCycleStale = true; + entityController_->clearAll(); + + // Build CMSG_PLAYER_LOGIN packet + auto packet = PlayerLoginPacket::build(characterGuid); + + // Send packet + socket->send(packet); + + setState(WorldState::ENTERING_WORLD); + LOG_INFO("CMSG_PLAYER_LOGIN sent, entering world..."); +} + +void GameHandler::handleLoginSetTimeSpeed(network::Packet& packet) { + // SMSG_LOGIN_SETTIMESPEED (0x042) + // Structure: uint32 gameTime, float timeScale + // gameTime: Game time in seconds since epoch + // timeScale: Time speed multiplier (typically 0.0166 for 1 day = 1 hour) + + if (packet.getSize() < 8) { + LOG_WARNING("SMSG_LOGIN_SETTIMESPEED: packet too small (", packet.getSize(), " bytes)"); + return; + } + + uint32_t gameTimePacked = packet.readUInt32(); + float timeScale = packet.readFloat(); + + // Store for celestial/sky system use + gameTime_ = static_cast(gameTimePacked); + timeSpeed_ = timeScale; + + LOG_INFO("Server time: gameTime=", gameTime_, "s, timeSpeed=", timeSpeed_); + LOG_INFO(" (1 game day = ", (1.0f / timeSpeed_) / 60.0f, " real minutes)"); +} + +void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { + LOG_INFO("Handling SMSG_LOGIN_VERIFY_WORLD"); + const bool initialWorldEntry = (state == WorldState::ENTERING_WORLD); + + LoginVerifyWorldData data; + if (!LoginVerifyWorldParser::parse(packet, data)) { + fail("Failed to parse SMSG_LOGIN_VERIFY_WORLD"); + return; + } + + if (!data.isValid()) { + fail("Invalid world entry data"); + return; + } + + glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(data.x, data.y, data.z)); + const bool alreadyInWorld = (state == WorldState::IN_WORLD); + const bool sameMap = alreadyInWorld && (currentMapId_ == data.mapId); + const float dxCurrent = movementInfo.x - canonical.x; + const float dyCurrent = movementInfo.y - canonical.y; + const float dzCurrent = movementInfo.z - canonical.z; + const float distSqCurrent = dxCurrent * dxCurrent + dyCurrent * dyCurrent + dzCurrent * dzCurrent; + + // Some realms emit a late duplicate LOGIN_VERIFY_WORLD after the client is already + // in-world. Re-running full world-entry handling here can trigger an expensive + // same-map reload/reset path and starve networking for tens of seconds. + if (!initialWorldEntry && sameMap && distSqCurrent <= (5.0f * 5.0f)) { + LOG_INFO("Ignoring duplicate SMSG_LOGIN_VERIFY_WORLD while already in world: mapId=", + data.mapId, " dist=", std::sqrt(distSqCurrent)); + return; + } + + // Successfully entered the world (or teleported) + currentMapId_ = data.mapId; + setState(WorldState::IN_WORLD); + if (socket) { + socket->tracePacketsFor(std::chrono::seconds(12), "login_verify_world"); + } + + LOG_INFO("========================================"); + LOG_INFO(" SUCCESSFULLY ENTERED WORLD!"); + LOG_INFO("========================================"); + LOG_INFO("Map ID: ", data.mapId); + LOG_INFO("Position: (", data.x, ", ", data.y, ", ", data.z, ")"); + LOG_INFO("Orientation: ", data.orientation, " radians"); + LOG_INFO("Player is now in the game world"); + + // Initialize movement info with world entry position (server → canonical) + 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; + movementInfo.z = canonical.z; + movementInfo.orientation = core::coords::serverToCanonicalYaw(data.orientation); + movementInfo.flags = 0; + movementInfo.flags2 = 0; + if (movementHandler_) { + movementHandler_->movementClockStart_ = std::chrono::steady_clock::now(); + movementHandler_->lastMovementTimestampMs_ = 0; + } + movementInfo.time = nextMovementTimestampMs(); + if (movementHandler_) { + movementHandler_->isFalling_ = false; + movementHandler_->fallStartMs_ = 0; + } + movementInfo.fallTime = 0; + movementInfo.jumpVelocity = 0.0f; + movementInfo.jumpSinAngle = 0.0f; + movementInfo.jumpCosAngle = 0.0f; + movementInfo.jumpXYSpeed = 0.0f; + resurrectPending_ = false; + resurrectRequestPending_ = false; + selfResAvailable_ = false; + onTaxiFlight_ = false; + taxiMountActive_ = false; + taxiActivatePending_ = false; + taxiClientActive_ = false; + taxiClientPath_.clear(); + // taxiRecoverPending_ is NOT cleared here — it must survive the general + // state reset so the recovery check below can detect a mid-flight reconnect. + taxiStartGrace_ = 0.0f; + currentMountDisplayId_ = 0; + taxiMountDisplayId_ = 0; + vehicleId_ = 0; + if (mountCallback_) { + mountCallback_(0); + } + + // Clear boss encounter unit slots and raid marks on world transfer + if (socialHandler_) socialHandler_->resetTransferState(); + + // Suppress area triggers on initial login — prevents exit portals from + // immediately firing when spawning inside a dungeon/instance. + activeAreaTriggers_.clear(); + areaTriggerCheckTimer_ = -5.0f; + areaTriggerSuppressFirst_ = true; + + // Notify application to load terrain for this map/position (online mode) + if (worldEntryCallback_) { + worldEntryCallback_(data.mapId, data.x, data.y, data.z, initialWorldEntry); + } + + // Send CMSG_SET_ACTIVE_MOVER on initial world entry and world transfers. + if (playerGuid != 0 && socket) { + auto activeMoverPacket = SetActiveMoverPacket::build(playerGuid); + socket->send(activeMoverPacket); + LOG_INFO("Sent CMSG_SET_ACTIVE_MOVER for player 0x", std::hex, playerGuid, std::dec); + } + + // Kick the first keepalive immediately on world entry. Classic-like realms + // can close the session before our default 30s ping cadence fires. + timeSinceLastPing = 0.0f; + if (socket) { + LOG_DEBUG("World entry keepalive: sending immediate ping after LOGIN_VERIFY_WORLD"); + sendPing(); + } + + // If we disconnected mid-taxi, attempt to recover to destination after login. + if (taxiRecoverPending_ && taxiRecoverMapId_ == data.mapId) { + float dx = movementInfo.x - taxiRecoverPos_.x; + float dy = movementInfo.y - taxiRecoverPos_.y; + float dz = movementInfo.z - taxiRecoverPos_.z; + float dist = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist > 5.0f) { + // Keep pending until player entity exists; update() will apply. + LOG_INFO("Taxi recovery pending: dist=", dist); + } else { + taxiRecoverPending_ = false; + } + } + + if (initialWorldEntry) { + // Clear inspect caches on world entry to avoid showing stale data. + inspectedPlayerAchievements_.clear(); + + // Reset talent initialization so the first SMSG_TALENTS_INFO after login + // correctly sets the active spec (static locals don't reset across logins). + if (spellHandler_) spellHandler_->resetTalentState(); + + // Auto-join default chat channels only on first world entry. + autoJoinDefaultChannels(); + + // Auto-query guild info on login. + const Character* activeChar = getActiveCharacter(); + if (activeChar && activeChar->hasGuild() && socket) { + auto gqPacket = GuildQueryPacket::build(activeChar->guildId); + socket->send(gqPacket); + auto grPacket = GuildRosterPacket::build(); + socket->send(grPacket); + LOG_INFO("Auto-queried guild info (guildId=", activeChar->guildId, ")"); + } + + pendingQuestAcceptTimeouts_.clear(); + pendingQuestAcceptNpcGuids_.clear(); + pendingQuestQueryIds_.clear(); + pendingLoginQuestResync_ = true; + pendingLoginQuestResyncTimeout_ = 10.0f; + completedQuests_.clear(); + LOG_INFO("Queued quest log resync for login (from server quest slots)"); + + // Request completed quest IDs when the expansion supports it. Classic-like + // opcode tables do not define this packet, and sending 0xFFFF during world + // entry can desync the early session handshake. + if (socket) { + const uint16_t queryCompletedWire = wireOpcode(Opcode::CMSG_QUERY_QUESTS_COMPLETED); + if (queryCompletedWire != 0xFFFF) { + network::Packet cqcPkt(queryCompletedWire); + socket->send(cqcPkt); + LOG_INFO("Sent CMSG_QUERY_QUESTS_COMPLETED"); + } else { + LOG_INFO("Skipping CMSG_QUERY_QUESTS_COMPLETED: opcode not mapped for current expansion"); + } + } + + // Auto-request played time on login so the character Stats tab is + // populated immediately without requiring /played. + if (socket) { + auto ptPkt = RequestPlayedTimePacket::build(false); // false = don't show in chat + socket->send(ptPkt); + LOG_INFO("Auto-requested played time on login"); + } + } + + // Pre-load DBC name caches during world entry so the first packet that + // needs spell/title/achievement data doesn't stall mid-gameplay (the + // Spell.dbc cache alone is ~170ms on a cold load). + if (initialWorldEntry) { + preloadDBCCaches(); + } + + // Fire PLAYER_ENTERING_WORLD — THE most important event for addon initialization. + // Fires on initial login, teleports, instance transitions, and zone changes. + if (addonEventCallback_) { + fireAddonEvent("PLAYER_ENTERING_WORLD", {initialWorldEntry ? "1" : "0"}); + // Also fire ZONE_CHANGED_NEW_AREA and UPDATE_WORLD_STATES so map/BG addons refresh + fireAddonEvent("ZONE_CHANGED_NEW_AREA", {}); + fireAddonEvent("UPDATE_WORLD_STATES", {}); + // PLAYER_LOGIN fires only on initial login (not teleports) + if (initialWorldEntry) { + fireAddonEvent("PLAYER_LOGIN", {}); + } + } +} + +void GameHandler::handleClientCacheVersion(network::Packet& packet) { + if (packet.getSize() < 4) { + LOG_WARNING("SMSG_CLIENTCACHE_VERSION too short: ", packet.getSize(), " bytes"); + return; + } + + uint32_t version = packet.readUInt32(); + LOG_INFO("SMSG_CLIENTCACHE_VERSION: ", version); +} + +void GameHandler::handleTutorialFlags(network::Packet& packet) { + if (packet.getSize() < 32) { + LOG_WARNING("SMSG_TUTORIAL_FLAGS too short: ", packet.getSize(), " bytes"); + return; + } + + std::array flags{}; + for (uint32_t& v : flags) { + v = packet.readUInt32(); + } + + LOG_INFO("SMSG_TUTORIAL_FLAGS: [", + flags[0], ", ", flags[1], ", ", flags[2], ", ", flags[3], ", ", + flags[4], ", ", flags[5], ", ", flags[6], ", ", flags[7], "]"); +} + +void GameHandler::handleAccountDataTimes(network::Packet& packet) { + LOG_DEBUG("Handling SMSG_ACCOUNT_DATA_TIMES"); + + AccountDataTimesData data; + if (!AccountDataTimesParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_ACCOUNT_DATA_TIMES"); + return; + } + + LOG_DEBUG("Account data times received (server time: ", data.serverTime, ")"); +} + +void GameHandler::handleMotd(network::Packet& packet) { + if (chatHandler_) chatHandler_->handleMotd(packet); +} + +void GameHandler::handleNotification(network::Packet& packet) { + // SMSG_NOTIFICATION: single null-terminated string + std::string message = packet.readString(); + if (!message.empty()) { + LOG_INFO("Server notification: ", message); + addSystemChatMessage(message); + } +} + +void GameHandler::sendPing() { + if (state != WorldState::IN_WORLD) { + return; + } + + // Increment sequence number + pingSequence++; + + LOG_DEBUG("Sending CMSG_PING: sequence=", pingSequence, + " latencyHintMs=", lastLatency); + + // Record send time for RTT measurement + pingTimestamp_ = std::chrono::steady_clock::now(); + + // Build and send ping packet + auto packet = PingPacket::build(pingSequence, lastLatency); + socket->send(packet); +} + +void GameHandler::sendRequestVehicleExit() { + if (state != WorldState::IN_WORLD || vehicleId_ == 0) return; + // CMSG_REQUEST_VEHICLE_EXIT has no payload — opcode only + network::Packet pkt(wireOpcode(Opcode::CMSG_REQUEST_VEHICLE_EXIT)); + socket->send(pkt); + vehicleId_ = 0; // Optimistically clear; server will confirm via SMSG_PLAYER_VEHICLE_DATA(0) +} + +const std::vector& GameHandler::getEquipmentSets() const { + if (inventoryHandler_) return inventoryHandler_->getEquipmentSets(); + static const std::vector empty; + return empty; +} + +// Trade state delegation to InventoryHandler (which owns the canonical trade state) +GameHandler::TradeStatus GameHandler::getTradeStatus() const { + if (inventoryHandler_) return static_cast(inventoryHandler_->getTradeStatus()); + return tradeStatus_; +} +bool GameHandler::hasPendingTradeRequest() const { + return inventoryHandler_ ? inventoryHandler_->hasPendingTradeRequest() : false; +} +bool GameHandler::isTradeOpen() const { + return inventoryHandler_ ? inventoryHandler_->isTradeOpen() : false; +} +const std::string& GameHandler::getTradePeerName() const { + if (inventoryHandler_) return inventoryHandler_->getTradePeerName(); + return tradePeerName_; +} +const std::array& GameHandler::getMyTradeSlots() const { + if (inventoryHandler_) { + // Convert InventoryHandler::TradeSlot → GameHandler::TradeSlot (different struct layouts) + static std::array converted{}; + const auto& src = inventoryHandler_->getMyTradeSlots(); + for (size_t i = 0; i < TRADE_SLOT_COUNT; i++) { + converted[i].itemId = src[i].itemId; + converted[i].displayId = src[i].displayId; + converted[i].stackCount = src[i].stackCount; + converted[i].itemGuid = src[i].itemGuid; + } + return converted; + } + return myTradeSlots_; +} +const std::array& GameHandler::getPeerTradeSlots() const { + if (inventoryHandler_) { + static std::array converted{}; + const auto& src = inventoryHandler_->getPeerTradeSlots(); + for (size_t i = 0; i < TRADE_SLOT_COUNT; i++) { + converted[i].itemId = src[i].itemId; + converted[i].displayId = src[i].displayId; + converted[i].stackCount = src[i].stackCount; + converted[i].itemGuid = src[i].itemGuid; + } + return converted; + } + return peerTradeSlots_; +} +uint64_t GameHandler::getMyTradeGold() const { + return inventoryHandler_ ? inventoryHandler_->getMyTradeGold() : myTradeGold_; +} +uint64_t GameHandler::getPeerTradeGold() const { + return inventoryHandler_ ? inventoryHandler_->getPeerTradeGold() : peerTradeGold_; +} + +bool GameHandler::supportsEquipmentSets() const { + return inventoryHandler_ && inventoryHandler_->supportsEquipmentSets(); +} + +void GameHandler::useEquipmentSet(uint32_t setId) { + if (inventoryHandler_) inventoryHandler_->useEquipmentSet(setId); +} + +void GameHandler::saveEquipmentSet(const std::string& name, const std::string& iconName, + uint64_t existingGuid, uint32_t setIndex) { + if (inventoryHandler_) inventoryHandler_->saveEquipmentSet(name, iconName, existingGuid, setIndex); +} + +void GameHandler::deleteEquipmentSet(uint64_t setGuid) { + if (inventoryHandler_) inventoryHandler_->deleteEquipmentSet(setGuid); +} + +// --- Inventory state delegation (canonical state lives in InventoryHandler) --- + +// Item text +bool GameHandler::isItemTextOpen() const { + return inventoryHandler_ ? inventoryHandler_->isItemTextOpen() : itemTextOpen_; +} +const std::string& GameHandler::getItemText() const { + if (inventoryHandler_) return inventoryHandler_->getItemText(); + return itemText_; +} +void GameHandler::closeItemText() { + if (inventoryHandler_) inventoryHandler_->closeItemText(); + else itemTextOpen_ = false; +} + +// Loot +bool GameHandler::isLootWindowOpen() const { + return inventoryHandler_ ? inventoryHandler_->isLootWindowOpen() : lootWindowOpen; +} +const LootResponseData& GameHandler::getCurrentLoot() const { + if (inventoryHandler_) return inventoryHandler_->getCurrentLoot(); + return currentLoot; +} +void GameHandler::setAutoLoot(bool enabled) { + if (inventoryHandler_) inventoryHandler_->setAutoLoot(enabled); + else autoLoot_ = enabled; +} +bool GameHandler::isAutoLoot() const { + return inventoryHandler_ ? inventoryHandler_->isAutoLoot() : autoLoot_; +} +void GameHandler::setAutoSellGrey(bool enabled) { + if (inventoryHandler_) inventoryHandler_->setAutoSellGrey(enabled); + else autoSellGrey_ = enabled; +} +bool GameHandler::isAutoSellGrey() const { + return inventoryHandler_ ? inventoryHandler_->isAutoSellGrey() : autoSellGrey_; +} +void GameHandler::setAutoRepair(bool enabled) { + if (inventoryHandler_) inventoryHandler_->setAutoRepair(enabled); + else autoRepair_ = enabled; +} +bool GameHandler::isAutoRepair() const { + return inventoryHandler_ ? inventoryHandler_->isAutoRepair() : autoRepair_; +} +const std::vector& GameHandler::getMasterLootCandidates() const { + if (inventoryHandler_) return inventoryHandler_->getMasterLootCandidates(); + return masterLootCandidates_; +} +bool GameHandler::hasMasterLootCandidates() const { + return inventoryHandler_ ? inventoryHandler_->hasMasterLootCandidates() : !masterLootCandidates_.empty(); +} +bool GameHandler::hasPendingLootRoll() const { + return inventoryHandler_ ? inventoryHandler_->hasPendingLootRoll() : pendingLootRollActive_; +} +const LootRollEntry& GameHandler::getPendingLootRoll() const { + if (inventoryHandler_) return inventoryHandler_->getPendingLootRoll(); + return pendingLootRoll_; +} + +// Vendor +bool GameHandler::isVendorWindowOpen() const { + return inventoryHandler_ ? inventoryHandler_->isVendorWindowOpen() : vendorWindowOpen; +} +const ListInventoryData& GameHandler::getVendorItems() const { + if (inventoryHandler_) return inventoryHandler_->getVendorItems(); + return currentVendorItems; +} +void GameHandler::setVendorCanRepair(bool v) { + if (inventoryHandler_) inventoryHandler_->setVendorCanRepair(v); + else currentVendorItems.canRepair = v; +} +const std::deque& GameHandler::getBuybackItems() const { + if (inventoryHandler_) { + // Layout-identical structs (InventoryHandler::BuybackItem == GameHandler::BuybackItem) + return reinterpret_cast&>(inventoryHandler_->getBuybackItems()); + } + return buybackItems_; +} +uint64_t GameHandler::getVendorGuid() const { + if (inventoryHandler_) return inventoryHandler_->getVendorGuid(); + return currentVendorItems.vendorGuid; +} + +// Mail +bool GameHandler::isMailboxOpen() const { + return inventoryHandler_ ? inventoryHandler_->isMailboxOpen() : mailboxOpen_; +} +const std::vector& GameHandler::getMailInbox() const { + if (inventoryHandler_) return inventoryHandler_->getMailInbox(); + return mailInbox_; +} +int GameHandler::getSelectedMailIndex() const { + return inventoryHandler_ ? inventoryHandler_->getSelectedMailIndex() : selectedMailIndex_; +} +void GameHandler::setSelectedMailIndex(int idx) { + if (inventoryHandler_) inventoryHandler_->setSelectedMailIndex(idx); + else selectedMailIndex_ = idx; +} +bool GameHandler::isMailComposeOpen() const { + return inventoryHandler_ ? inventoryHandler_->isMailComposeOpen() : showMailCompose_; +} +void GameHandler::openMailCompose() { + if (inventoryHandler_) inventoryHandler_->openMailCompose(); + else { showMailCompose_ = true; clearMailAttachments(); } +} +void GameHandler::closeMailCompose() { + if (inventoryHandler_) inventoryHandler_->closeMailCompose(); + else { showMailCompose_ = false; clearMailAttachments(); } +} +bool GameHandler::hasNewMail() const { + return inventoryHandler_ ? inventoryHandler_->hasNewMail() : hasNewMail_; +} +const std::array& GameHandler::getMailAttachments() const { + if (inventoryHandler_) { + // Layout-identical structs (InventoryHandler::MailAttachSlot == GameHandler::MailAttachSlot) + return reinterpret_cast&>(inventoryHandler_->getMailAttachments()); + } + return mailAttachments_; +} + +// Bank +bool GameHandler::isBankOpen() const { + return inventoryHandler_ ? inventoryHandler_->isBankOpen() : bankOpen_; +} +uint64_t GameHandler::getBankerGuid() const { + return inventoryHandler_ ? inventoryHandler_->getBankerGuid() : bankerGuid_; +} +int GameHandler::getEffectiveBankSlots() const { + return inventoryHandler_ ? inventoryHandler_->getEffectiveBankSlots() : effectiveBankSlots_; +} +int GameHandler::getEffectiveBankBagSlots() const { + return inventoryHandler_ ? inventoryHandler_->getEffectiveBankBagSlots() : effectiveBankBagSlots_; +} + +// Guild Bank +bool GameHandler::isGuildBankOpen() const { + return inventoryHandler_ ? inventoryHandler_->isGuildBankOpen() : guildBankOpen_; +} +const GuildBankData& GameHandler::getGuildBankData() const { + if (inventoryHandler_) return inventoryHandler_->getGuildBankData(); + return guildBankData_; +} +uint8_t GameHandler::getGuildBankActiveTab() const { + return inventoryHandler_ ? inventoryHandler_->getGuildBankActiveTab() : guildBankActiveTab_; +} +void GameHandler::setGuildBankActiveTab(uint8_t tab) { + if (inventoryHandler_) inventoryHandler_->setGuildBankActiveTab(tab); + else guildBankActiveTab_ = tab; +} + +// Auction House +bool GameHandler::isAuctionHouseOpen() const { + return inventoryHandler_ ? inventoryHandler_->isAuctionHouseOpen() : auctionOpen_; +} +uint64_t GameHandler::getAuctioneerGuid() const { + return inventoryHandler_ ? inventoryHandler_->getAuctioneerGuid() : auctioneerGuid_; +} +const AuctionListResult& GameHandler::getAuctionBrowseResults() const { + if (inventoryHandler_) return inventoryHandler_->getAuctionBrowseResults(); + return auctionBrowseResults_; +} +const AuctionListResult& GameHandler::getAuctionOwnerResults() const { + if (inventoryHandler_) return inventoryHandler_->getAuctionOwnerResults(); + return auctionOwnerResults_; +} +const AuctionListResult& GameHandler::getAuctionBidderResults() const { + if (inventoryHandler_) return inventoryHandler_->getAuctionBidderResults(); + return auctionBidderResults_; +} +int GameHandler::getAuctionActiveTab() const { + return inventoryHandler_ ? inventoryHandler_->getAuctionActiveTab() : auctionActiveTab_; +} +void GameHandler::setAuctionActiveTab(int tab) { + if (inventoryHandler_) inventoryHandler_->setAuctionActiveTab(tab); + else auctionActiveTab_ = tab; +} +float GameHandler::getAuctionSearchDelay() const { + return inventoryHandler_ ? inventoryHandler_->getAuctionSearchDelay() : auctionSearchDelayTimer_; +} + +// Trainer +bool GameHandler::isTrainerWindowOpen() const { + return inventoryHandler_ ? inventoryHandler_->isTrainerWindowOpen() : trainerWindowOpen_; +} +const TrainerListData& GameHandler::getTrainerSpells() const { + if (inventoryHandler_) return inventoryHandler_->getTrainerSpells(); + return currentTrainerList_; +} +const std::vector& GameHandler::getTrainerTabs() const { + if (inventoryHandler_) { + // Layout-identical structs (InventoryHandler::TrainerTab == GameHandler::TrainerTab) + return reinterpret_cast&>(inventoryHandler_->getTrainerTabs()); + } + return trainerTabs_; +} + +void GameHandler::sendMinimapPing(float wowX, float wowY) { + if (socialHandler_) socialHandler_->sendMinimapPing(wowX, wowY); +} + +void GameHandler::handlePong(network::Packet& packet) { + LOG_DEBUG("Handling SMSG_PONG"); + + PongData data; + if (!PongParser::parse(packet, data)) { + LOG_WARNING("Failed to parse SMSG_PONG"); + return; + } + + // Verify sequence matches + if (data.sequence != pingSequence) { + LOG_WARNING("SMSG_PONG sequence mismatch: expected ", pingSequence, + ", got ", data.sequence); + return; + } + + // Measure round-trip time + auto rtt = std::chrono::steady_clock::now() - pingTimestamp_; + lastLatency = static_cast( + std::chrono::duration_cast(rtt).count()); + + LOG_DEBUG("SMSG_PONG acknowledged: sequence=", data.sequence, + " latencyMs=", lastLatency); +} + +bool GameHandler::isServerMovementAllowed() const { + return movementHandler_ ? movementHandler_->isServerMovementAllowed() : true; +} + +uint32_t GameHandler::nextMovementTimestampMs() { + if (movementHandler_) return movementHandler_->nextMovementTimestampMs(); + return 0; +} + +void GameHandler::sendMovement(Opcode opcode) { + if (movementHandler_) movementHandler_->sendMovement(opcode); +} + +void GameHandler::sanitizeMovementForTaxi() { + if (movementHandler_) movementHandler_->sanitizeMovementForTaxi(); +} + +void GameHandler::forceClearTaxiAndMovementState() { + if (movementHandler_) movementHandler_->forceClearTaxiAndMovementState(); +} + +void GameHandler::setPosition(float x, float y, float z) { + if (movementHandler_) movementHandler_->setPosition(x, y, z); +} + +void GameHandler::setOrientation(float orientation) { + if (movementHandler_) movementHandler_->setOrientation(orientation); +} + +// Entity lifecycle methods (handleUpdateObject, processOutOfRangeObjects, +// applyUpdateObjectBlock, finalizeUpdateObjectBatch, handleCompressedUpdateObject, +// handleDestroyObject) moved to EntityController — see entity_controller.cpp + +void GameHandler::sendChatMessage(ChatType type, const std::string& message, const std::string& target) { + if (chatHandler_) chatHandler_->sendChatMessage(type, message, target); +} + +void GameHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) { + if (chatHandler_) chatHandler_->sendTextEmote(textEmoteId, targetGuid); +} + +void GameHandler::joinChannel(const std::string& channelName, const std::string& password) { + if (chatHandler_) chatHandler_->joinChannel(channelName, password); +} + +void GameHandler::leaveChannel(const std::string& channelName) { + if (chatHandler_) chatHandler_->leaveChannel(channelName); +} + +std::string GameHandler::getChannelByIndex(int index) const { + return chatHandler_ ? chatHandler_->getChannelByIndex(index) : ""; +} + +int GameHandler::getChannelIndex(const std::string& channelName) const { + return chatHandler_ ? chatHandler_->getChannelIndex(channelName) : 0; +} + +void GameHandler::autoJoinDefaultChannels() { + if (chatHandler_) { + chatHandler_->chatAutoJoin.general = chatAutoJoin.general; + chatHandler_->chatAutoJoin.trade = chatAutoJoin.trade; + chatHandler_->chatAutoJoin.localDefense = chatAutoJoin.localDefense; + chatHandler_->chatAutoJoin.lfg = chatAutoJoin.lfg; + chatHandler_->chatAutoJoin.local = chatAutoJoin.local; + chatHandler_->autoJoinDefaultChannels(); + } +} + +void GameHandler::setTarget(uint64_t guid) { + if (combatHandler_) combatHandler_->setTarget(guid); +} + +void GameHandler::clearTarget() { + if (combatHandler_) combatHandler_->clearTarget(); +} + +std::shared_ptr GameHandler::getTarget() const { + return combatHandler_ ? combatHandler_->getTarget() : nullptr; +} + +void GameHandler::setFocus(uint64_t guid) { + if (combatHandler_) combatHandler_->setFocus(guid); +} + +void GameHandler::clearFocus() { + if (combatHandler_) combatHandler_->clearFocus(); +} + +void GameHandler::setMouseoverGuid(uint64_t guid) { + if (combatHandler_) combatHandler_->setMouseoverGuid(guid); +} + +std::shared_ptr GameHandler::getFocus() const { + return combatHandler_ ? combatHandler_->getFocus() : nullptr; +} + +void GameHandler::targetLastTarget() { + if (combatHandler_) combatHandler_->targetLastTarget(); +} + +void GameHandler::targetEnemy(bool reverse) { + if (combatHandler_) combatHandler_->targetEnemy(reverse); +} + +void GameHandler::targetFriend(bool reverse) { + if (combatHandler_) combatHandler_->targetFriend(reverse); +} + +void GameHandler::inspectTarget() { + if (socialHandler_) socialHandler_->inspectTarget(); +} + +void GameHandler::queryServerTime() { + if (socialHandler_) socialHandler_->queryServerTime(); +} + +void GameHandler::requestPlayedTime() { + if (socialHandler_) socialHandler_->requestPlayedTime(); +} + +void GameHandler::queryWho(const std::string& playerName) { + if (socialHandler_) socialHandler_->queryWho(playerName); +} + +void GameHandler::addFriend(const std::string& playerName, const std::string& note) { + if (socialHandler_) socialHandler_->addFriend(playerName, note); +} + +void GameHandler::removeFriend(const std::string& playerName) { + if (socialHandler_) socialHandler_->removeFriend(playerName); +} + +void GameHandler::setFriendNote(const std::string& playerName, const std::string& note) { + if (socialHandler_) socialHandler_->setFriendNote(playerName, note); +} + +void GameHandler::randomRoll(uint32_t minRoll, uint32_t maxRoll) { + if (socialHandler_) socialHandler_->randomRoll(minRoll, maxRoll); +} + +void GameHandler::addIgnore(const std::string& playerName) { + if (socialHandler_) socialHandler_->addIgnore(playerName); +} + +void GameHandler::removeIgnore(const std::string& playerName) { + if (socialHandler_) socialHandler_->removeIgnore(playerName); +} + +void GameHandler::requestLogout() { + if (socialHandler_) socialHandler_->requestLogout(); +} + +void GameHandler::cancelLogout() { + if (socialHandler_) socialHandler_->cancelLogout(); +} + +void GameHandler::sendSetDifficulty(uint32_t difficulty) { + if (socialHandler_) socialHandler_->sendSetDifficulty(difficulty); +} + +void GameHandler::setStandState(uint8_t standState) { + if (socialHandler_) socialHandler_->setStandState(standState); +} + +void GameHandler::toggleHelm() { + if (socialHandler_) socialHandler_->toggleHelm(); +} + +void GameHandler::toggleCloak() { + if (socialHandler_) socialHandler_->toggleCloak(); +} + +void GameHandler::followTarget() { + if (movementHandler_) movementHandler_->followTarget(); +} + +void GameHandler::cancelFollow() { + if (movementHandler_) movementHandler_->cancelFollow(); +} + +void GameHandler::assistTarget() { + if (combatHandler_) combatHandler_->assistTarget(); +} + +void GameHandler::togglePvp() { + if (combatHandler_) combatHandler_->togglePvp(); +} + +void GameHandler::requestGuildInfo() { + if (socialHandler_) socialHandler_->requestGuildInfo(); +} + +void GameHandler::requestGuildRoster() { + if (socialHandler_) socialHandler_->requestGuildRoster(); +} + +void GameHandler::setGuildMotd(const std::string& motd) { + if (socialHandler_) socialHandler_->setGuildMotd(motd); +} + +void GameHandler::promoteGuildMember(const std::string& playerName) { + if (socialHandler_) socialHandler_->promoteGuildMember(playerName); +} + +void GameHandler::demoteGuildMember(const std::string& playerName) { + if (socialHandler_) socialHandler_->demoteGuildMember(playerName); +} + +void GameHandler::leaveGuild() { + if (socialHandler_) socialHandler_->leaveGuild(); +} + +void GameHandler::inviteToGuild(const std::string& playerName) { + if (socialHandler_) socialHandler_->inviteToGuild(playerName); +} + +void GameHandler::initiateReadyCheck() { + if (socialHandler_) socialHandler_->initiateReadyCheck(); +} + +void GameHandler::respondToReadyCheck(bool ready) { + if (socialHandler_) socialHandler_->respondToReadyCheck(ready); +} + +void GameHandler::acceptDuel() { + if (socialHandler_) socialHandler_->acceptDuel(); +} + +void GameHandler::forfeitDuel() { + if (socialHandler_) socialHandler_->forfeitDuel(); +} + +void GameHandler::toggleAfk(const std::string& message) { + if (chatHandler_) chatHandler_->toggleAfk(message); +} + +void GameHandler::toggleDnd(const std::string& message) { + if (chatHandler_) chatHandler_->toggleDnd(message); +} + +void GameHandler::replyToLastWhisper(const std::string& message) { + if (chatHandler_) chatHandler_->replyToLastWhisper(message); +} + +void GameHandler::uninvitePlayer(const std::string& playerName) { + if (socialHandler_) socialHandler_->uninvitePlayer(playerName); +} + +void GameHandler::leaveParty() { + if (socialHandler_) socialHandler_->leaveParty(); +} + +void GameHandler::setMainTank(uint64_t targetGuid) { + if (socialHandler_) socialHandler_->setMainTank(targetGuid); +} + +void GameHandler::setMainAssist(uint64_t targetGuid) { + if (socialHandler_) socialHandler_->setMainAssist(targetGuid); +} + +void GameHandler::clearMainTank() { + if (socialHandler_) socialHandler_->clearMainTank(); +} + +void GameHandler::clearMainAssist() { + if (socialHandler_) socialHandler_->clearMainAssist(); +} + +void GameHandler::setRaidMark(uint64_t guid, uint8_t icon) { + if (socialHandler_) socialHandler_->setRaidMark(guid, icon); +} + +void GameHandler::requestRaidInfo() { + if (socialHandler_) socialHandler_->requestRaidInfo(); +} + +void GameHandler::proposeDuel(uint64_t targetGuid) { + if (socialHandler_) socialHandler_->proposeDuel(targetGuid); +} + +void GameHandler::initiateTrade(uint64_t targetGuid) { + if (inventoryHandler_) inventoryHandler_->initiateTrade(targetGuid); +} + +void GameHandler::reportPlayer(uint64_t targetGuid, const std::string& reason) { + if (socialHandler_) socialHandler_->reportPlayer(targetGuid, reason); +} + +void GameHandler::stopCasting() { + if (spellHandler_) spellHandler_->stopCasting(); +} + +void GameHandler::resetCastState() { + if (spellHandler_) spellHandler_->resetCastState(); +} + +void GameHandler::clearUnitCaches() { + if (spellHandler_) spellHandler_->clearUnitCaches(); +} + +void GameHandler::releaseSpirit() { + if (combatHandler_) combatHandler_->releaseSpirit(); +} + +bool GameHandler::canReclaimCorpse() const { + return combatHandler_ ? combatHandler_->canReclaimCorpse() : false; +} + +float GameHandler::getCorpseReclaimDelaySec() const { + return combatHandler_ ? combatHandler_->getCorpseReclaimDelaySec() : 0.0f; +} + +void GameHandler::reclaimCorpse() { + if (combatHandler_) combatHandler_->reclaimCorpse(); +} + +void GameHandler::useSelfRes() { + if (combatHandler_) combatHandler_->useSelfRes(); +} + +void GameHandler::activateSpiritHealer(uint64_t npcGuid) { + if (combatHandler_) combatHandler_->activateSpiritHealer(npcGuid); +} + +void GameHandler::acceptResurrect() { + if (combatHandler_) combatHandler_->acceptResurrect(); +} + +void GameHandler::declineResurrect() { + if (combatHandler_) combatHandler_->declineResurrect(); +} + +void GameHandler::tabTarget(float playerX, float playerY, float playerZ) { + if (combatHandler_) combatHandler_->tabTarget(playerX, playerY, playerZ); +} + +void GameHandler::addLocalChatMessage(const MessageChatData& msg) { + if (chatHandler_) chatHandler_->addLocalChatMessage(msg); +} + +const std::deque& GameHandler::getChatHistory() const { + if (chatHandler_) return chatHandler_->getChatHistory(); + static const std::deque kEmpty; + return kEmpty; +} + +void GameHandler::clearChatHistory() { + if (chatHandler_) chatHandler_->getChatHistory().clear(); +} + +const std::vector& GameHandler::getJoinedChannels() const { + if (chatHandler_) return chatHandler_->getJoinedChannels(); + static const std::vector kEmpty; + return kEmpty; +} + +// ============================================================ +// Name Queries (delegated to EntityController) +// ============================================================ + +void GameHandler::queryPlayerName(uint64_t guid) { + if (entityController_) entityController_->queryPlayerName(guid); +} + +void GameHandler::queryCreatureInfo(uint32_t entry, uint64_t guid) { + if (entityController_) entityController_->queryCreatureInfo(entry, guid); +} + +void GameHandler::queryGameObjectInfo(uint32_t entry, uint64_t guid) { + if (entityController_) entityController_->queryGameObjectInfo(entry, guid); +} + +std::string GameHandler::getCachedPlayerName(uint64_t guid) const { + return entityController_ ? entityController_->getCachedPlayerName(guid) : ""; +} + +std::string GameHandler::getCachedCreatureName(uint32_t entry) const { + return entityController_ ? entityController_->getCachedCreatureName(entry) : ""; +} + +// ============================================================ +// Item Query (forwarded to InventoryHandler) +// ============================================================ + +void GameHandler::queryItemInfo(uint32_t entry, uint64_t guid) { + if (inventoryHandler_) inventoryHandler_->queryItemInfo(entry, guid); +} + +void GameHandler::handleItemQueryResponse(network::Packet& packet) { + if (inventoryHandler_) inventoryHandler_->handleItemQueryResponse(packet); +} + +uint64_t GameHandler::resolveOnlineItemGuid(uint32_t itemId) const { + return inventoryHandler_ ? inventoryHandler_->resolveOnlineItemGuid(itemId) : 0; +} + +void GameHandler::detectInventorySlotBases(const std::map& fields) { + if (inventoryHandler_) inventoryHandler_->detectInventorySlotBases(fields); +} + +bool GameHandler::applyInventoryFields(const std::map& fields) { + return inventoryHandler_ ? inventoryHandler_->applyInventoryFields(fields) : false; +} + +void GameHandler::extractContainerFields(uint64_t containerGuid, const std::map& fields) { + if (inventoryHandler_) inventoryHandler_->extractContainerFields(containerGuid, fields); +} + +void GameHandler::rebuildOnlineInventory() { + if (inventoryHandler_) inventoryHandler_->rebuildOnlineInventory(); +} + +void GameHandler::maybeDetectVisibleItemLayout() { + if (inventoryHandler_) inventoryHandler_->maybeDetectVisibleItemLayout(); +} + +void GameHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::map& fields) { + if (inventoryHandler_) inventoryHandler_->updateOtherPlayerVisibleItems(guid, fields); +} + +void GameHandler::emitOtherPlayerEquipment(uint64_t guid) { + if (inventoryHandler_) inventoryHandler_->emitOtherPlayerEquipment(guid); +} + +void GameHandler::emitAllOtherPlayerEquipment() { + if (inventoryHandler_) inventoryHandler_->emitAllOtherPlayerEquipment(); +} + +// ============================================================ +// Combat (delegated to CombatHandler) +// ============================================================ + +void GameHandler::startAutoAttack(uint64_t targetGuid) { + if (combatHandler_) combatHandler_->startAutoAttack(targetGuid); +} + +void GameHandler::stopAutoAttack() { + if (combatHandler_) combatHandler_->stopAutoAttack(); +} + +void GameHandler::addCombatText(CombatTextEntry::Type type, int32_t amount, uint32_t spellId, bool isPlayerSource, uint8_t powerType, + uint64_t srcGuid, uint64_t dstGuid) { + if (combatHandler_) combatHandler_->addCombatText(type, amount, spellId, isPlayerSource, powerType, srcGuid, dstGuid); +} + +bool GameHandler::shouldLogSpellstealAura(uint64_t casterGuid, uint64_t victimGuid, uint32_t spellId) { + return combatHandler_ ? combatHandler_->shouldLogSpellstealAura(casterGuid, victimGuid, spellId) : false; +} + +void GameHandler::updateCombatText(float deltaTime) { + if (combatHandler_) combatHandler_->updateCombatText(deltaTime); +} + +bool GameHandler::isAutoAttacking() const { + return combatHandler_ ? combatHandler_->isAutoAttacking() : false; +} + +bool GameHandler::hasAutoAttackIntent() const { + return combatHandler_ ? combatHandler_->hasAutoAttackIntent() : false; +} + +bool GameHandler::isInCombat() const { + return combatHandler_ ? combatHandler_->isInCombat() : false; +} + +bool GameHandler::isInCombatWith(uint64_t guid) const { + return combatHandler_ ? combatHandler_->isInCombatWith(guid) : false; +} + +uint64_t GameHandler::getAutoAttackTargetGuid() const { + return combatHandler_ ? combatHandler_->getAutoAttackTargetGuid() : 0; +} + +bool GameHandler::isAggressiveTowardPlayer(uint64_t guid) const { + return combatHandler_ ? combatHandler_->isAggressiveTowardPlayer(guid) : false; +} + +uint64_t GameHandler::getLastMeleeSwingMs() const { + return combatHandler_ ? combatHandler_->getLastMeleeSwingMs() : 0; +} + +const std::vector& GameHandler::getCombatText() const { + static const std::vector empty; + return combatHandler_ ? combatHandler_->getCombatText() : empty; +} + +const std::deque& GameHandler::getCombatLog() const { + static const std::deque empty; + return combatHandler_ ? combatHandler_->getCombatLog() : empty; +} + +void GameHandler::clearCombatLog() { + if (combatHandler_) combatHandler_->clearCombatLog(); +} + +void GameHandler::clearCombatText() { + if (combatHandler_) combatHandler_->clearCombatText(); +} + +void GameHandler::clearHostileAttackers() { + if (combatHandler_) combatHandler_->clearHostileAttackers(); +} + +const std::vector* GameHandler::getThreatList(uint64_t unitGuid) const { + return combatHandler_ ? combatHandler_->getThreatList(unitGuid) : nullptr; +} + +const std::vector* GameHandler::getTargetThreatList() const { + return targetGuid ? getThreatList(targetGuid) : nullptr; +} + +bool GameHandler::isHostileAttacker(uint64_t guid) const { + return combatHandler_ ? combatHandler_->isHostileAttacker(guid) : false; +} + +void GameHandler::dismount() { + if (movementHandler_) movementHandler_->dismount(); +} + +// ============================================================ +// Arena / Battleground Handlers +// ============================================================ + +void GameHandler::declineBattlefield(uint32_t queueSlot) { + if (socialHandler_) socialHandler_->declineBattlefield(queueSlot); +} + +bool GameHandler::hasPendingBgInvite() const { + return socialHandler_ && socialHandler_->hasPendingBgInvite(); +} + +void GameHandler::acceptBattlefield(uint32_t queueSlot) { + if (socialHandler_) socialHandler_->acceptBattlefield(queueSlot); +} + +// --------------------------------------------------------------------------- +// LFG / Dungeon Finder handlers (WotLK 3.3.5a) +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// LFG outgoing packets +// --------------------------------------------------------------------------- + +void GameHandler::lfgJoin(uint32_t dungeonId, uint8_t roles) { + if (socialHandler_) socialHandler_->lfgJoin(dungeonId, roles); +} + +void GameHandler::lfgLeave() { + if (socialHandler_) socialHandler_->lfgLeave(); +} + +void GameHandler::lfgSetRoles(uint8_t roles) { + if (socialHandler_) socialHandler_->lfgSetRoles(roles); +} + +void GameHandler::lfgAcceptProposal(uint32_t proposalId, bool accept) { + if (socialHandler_) socialHandler_->lfgAcceptProposal(proposalId, accept); +} + +void GameHandler::lfgTeleport(bool toLfgDungeon) { + if (socialHandler_) socialHandler_->lfgTeleport(toLfgDungeon); +} + +void GameHandler::lfgSetBootVote(bool vote) { + if (socialHandler_) socialHandler_->lfgSetBootVote(vote); +} + +void GameHandler::loadAreaTriggerDbc() { + if (movementHandler_) movementHandler_->loadAreaTriggerDbc(); +} + +void GameHandler::checkAreaTriggers() { + if (movementHandler_) movementHandler_->checkAreaTriggers(); +} + +void GameHandler::requestArenaTeamRoster(uint32_t teamId) { + if (socialHandler_) socialHandler_->requestArenaTeamRoster(teamId); +} + +void GameHandler::requestPvpLog() { + if (socialHandler_) socialHandler_->requestPvpLog(); +} + +// ============================================================ +// Spells +// ============================================================ + +void GameHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { + if (spellHandler_) spellHandler_->castSpell(spellId, targetGuid); +} + +void GameHandler::cancelCast() { + if (spellHandler_) spellHandler_->cancelCast(); +} + +void GameHandler::startCraftQueue(uint32_t spellId, int count) { + if (spellHandler_) spellHandler_->startCraftQueue(spellId, count); +} + +void GameHandler::cancelCraftQueue() { + if (spellHandler_) spellHandler_->cancelCraftQueue(); +} + +void GameHandler::cancelAura(uint32_t spellId) { + if (spellHandler_) spellHandler_->cancelAura(spellId); +} + +uint32_t GameHandler::getTempEnchantRemainingMs(uint32_t slot) const { + return inventoryHandler_ ? inventoryHandler_->getTempEnchantRemainingMs(slot) : 0u; +} + +void GameHandler::handlePetSpells(network::Packet& packet) { + if (spellHandler_) spellHandler_->handlePetSpells(packet); +} + +void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { + if (spellHandler_) spellHandler_->sendPetAction(action, targetGuid); +} + +void GameHandler::dismissPet() { + if (spellHandler_) spellHandler_->dismissPet(); +} + +void GameHandler::togglePetSpellAutocast(uint32_t spellId) { + if (spellHandler_) spellHandler_->togglePetSpellAutocast(spellId); +} + +void GameHandler::renamePet(const std::string& newName) { + if (spellHandler_) spellHandler_->renamePet(newName); +} + +void GameHandler::requestStabledPetList() { + if (spellHandler_) spellHandler_->requestStabledPetList(); +} + +void GameHandler::stablePet(uint8_t slot) { + if (spellHandler_) spellHandler_->stablePet(slot); +} + +void GameHandler::unstablePet(uint32_t petNumber) { + if (spellHandler_) spellHandler_->unstablePet(petNumber); +} + +void GameHandler::handleListStabledPets(network::Packet& packet) { + if (spellHandler_) spellHandler_->handleListStabledPets(packet); +} + +void GameHandler::setActionBarSlot(int slot, ActionBarSlot::Type type, uint32_t id) { + if (slot < 0 || slot >= ACTION_BAR_SLOTS) return; + actionBar[slot].type = type; + actionBar[slot].id = id; + // Pre-query item information so action bar displays item name instead of "Item" placeholder + if (type == ActionBarSlot::ITEM && id != 0) { + queryItemInfo(id, 0); + } + saveCharacterConfig(); + // Notify Lua addons that the action bar changed + fireAddonEvent("ACTIONBAR_SLOT_CHANGED", {std::to_string(slot + 1)}); + fireAddonEvent("ACTIONBAR_UPDATE_STATE", {}); + // Notify the server so the action bar persists across relogs. + if (isInWorld()) { + const bool classic = isClassicLikeExpansion(); + auto pkt = SetActionButtonPacket::build( + static_cast(slot), + static_cast(type), + id, + classic); + socket->send(pkt); + } +} + +float GameHandler::getSpellCooldown(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSpellCooldown(spellId); + return 0; +} + +// ============================================================ +// Talents +// ============================================================ + +void GameHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) { + if (spellHandler_) spellHandler_->learnTalent(talentId, requestedRank); +} + +void GameHandler::switchTalentSpec(uint8_t newSpec) { + if (spellHandler_) spellHandler_->switchTalentSpec(newSpec); +} + +void GameHandler::confirmPetUnlearn() { + if (spellHandler_) spellHandler_->confirmPetUnlearn(); +} + +void GameHandler::confirmTalentWipe() { + if (spellHandler_) spellHandler_->confirmTalentWipe(); +} + +void GameHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { + if (socialHandler_) socialHandler_->sendAlterAppearance(hairStyle, hairColor, facialHair); +} + +// ============================================================ +// Group/Party +// ============================================================ + +void GameHandler::inviteToGroup(const std::string& playerName) { + if (socialHandler_) socialHandler_->inviteToGroup(playerName); +} + +void GameHandler::acceptGroupInvite() { + if (socialHandler_) socialHandler_->acceptGroupInvite(); +} + +void GameHandler::declineGroupInvite() { + if (socialHandler_) socialHandler_->declineGroupInvite(); +} + +void GameHandler::leaveGroup() { + if (socialHandler_) socialHandler_->leaveGroup(); +} + +void GameHandler::convertToRaid() { + if (socialHandler_) socialHandler_->convertToRaid(); +} + +void GameHandler::sendSetLootMethod(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid) { + if (socialHandler_) socialHandler_->sendSetLootMethod(method, threshold, masterLooterGuid); +} + +// ============================================================ +// Guild Handlers +// ============================================================ + +void GameHandler::kickGuildMember(const std::string& playerName) { + if (socialHandler_) socialHandler_->kickGuildMember(playerName); +} + +void GameHandler::disbandGuild() { + if (socialHandler_) socialHandler_->disbandGuild(); +} + +void GameHandler::setGuildLeader(const std::string& name) { + if (socialHandler_) socialHandler_->setGuildLeader(name); +} + +void GameHandler::setGuildPublicNote(const std::string& name, const std::string& note) { + if (socialHandler_) socialHandler_->setGuildPublicNote(name, note); +} + +void GameHandler::setGuildOfficerNote(const std::string& name, const std::string& note) { + if (socialHandler_) socialHandler_->setGuildOfficerNote(name, note); +} + +void GameHandler::acceptGuildInvite() { + if (socialHandler_) socialHandler_->acceptGuildInvite(); +} + +void GameHandler::declineGuildInvite() { + if (socialHandler_) socialHandler_->declineGuildInvite(); +} + +void GameHandler::submitGmTicket(const std::string& text) { + if (chatHandler_) chatHandler_->submitGmTicket(text); +} + +void GameHandler::deleteGmTicket() { + if (socialHandler_) socialHandler_->deleteGmTicket(); +} + +void GameHandler::requestGmTicket() { + if (socialHandler_) socialHandler_->requestGmTicket(); +} + +void GameHandler::queryGuildInfo(uint32_t guildId) { + if (socialHandler_) socialHandler_->queryGuildInfo(guildId); +} + +static const std::string kEmptyString; + +const std::string& GameHandler::lookupGuildName(uint32_t guildId) { + static const std::string kEmpty; + if (socialHandler_) return socialHandler_->lookupGuildName(guildId); + return kEmpty; +} + +uint32_t GameHandler::getEntityGuildId(uint64_t guid) const { + if (socialHandler_) return socialHandler_->getEntityGuildId(guid); + return 0; +} + +void GameHandler::createGuild(const std::string& guildName) { + if (socialHandler_) socialHandler_->createGuild(guildName); +} + +void GameHandler::addGuildRank(const std::string& rankName) { + if (socialHandler_) socialHandler_->addGuildRank(rankName); +} + +void GameHandler::deleteGuildRank() { + if (socialHandler_) socialHandler_->deleteGuildRank(); +} + +void GameHandler::requestPetitionShowlist(uint64_t npcGuid) { + if (socialHandler_) socialHandler_->requestPetitionShowlist(npcGuid); +} + +void GameHandler::buyPetition(uint64_t npcGuid, const std::string& guildName) { + if (socialHandler_) socialHandler_->buyPetition(npcGuid, guildName); +} + +void GameHandler::signPetition(uint64_t petitionGuid) { + if (socialHandler_) socialHandler_->signPetition(petitionGuid); +} + +void GameHandler::turnInPetition(uint64_t petitionGuid) { + if (socialHandler_) socialHandler_->turnInPetition(petitionGuid); +} + +// ============================================================ +// Loot, Gossip, Vendor +// ============================================================ + +void GameHandler::lootTarget(uint64_t guid) { + if (inventoryHandler_) inventoryHandler_->lootTarget(guid); +} + +void GameHandler::lootItem(uint8_t slotIndex) { + if (inventoryHandler_) inventoryHandler_->lootItem(slotIndex); +} + +void GameHandler::closeLoot() { + if (inventoryHandler_) inventoryHandler_->closeLoot(); +} + +void GameHandler::lootMasterGive(uint8_t lootSlot, uint64_t targetGuid) { + if (inventoryHandler_) inventoryHandler_->lootMasterGive(lootSlot, targetGuid); +} + +void GameHandler::interactWithNpc(uint64_t guid) { + if (!isInWorld()) return; + auto packet = GossipHelloPacket::build(guid); + socket->send(packet); +} + +void GameHandler::interactWithGameObject(uint64_t guid) { + LOG_WARNING("[GO-DIAG] interactWithGameObject called: guid=0x", std::hex, guid, std::dec); + if (guid == 0) { LOG_WARNING("[GO-DIAG] BLOCKED: guid==0"); return; } + if (!isInWorld()) { LOG_WARNING("[GO-DIAG] BLOCKED: not in world"); return; } + // Do not overlap an actual spell cast. + if (spellHandler_ && spellHandler_->casting_ && spellHandler_->currentCastSpellId_ != 0) { + LOG_WARNING("[GO-DIAG] BLOCKED: already casting spellId=", spellHandler_->currentCastSpellId_); + return; + } + // Always clear melee intent before GO interactions. + stopAutoAttack(); + // Set the pending GO guid so that: + // 1. cancelCast() won't send CMSG_CANCEL_CAST for GO-triggered casts + // (e.g., "Opening" on a quest chest) — without this, any movement + // during the cast cancels it server-side and quest credit is lost. + // 2. The cast-completion fallback in update() can call + // performGameObjectInteractionNow after the cast timer expires. + // 3. isGameObjectInteractionCasting() returns true during GO casts. + pendingGameObjectInteractGuid_ = guid; + performGameObjectInteractionNow(guid); +} + +void GameHandler::performGameObjectInteractionNow(uint64_t guid) { + if (guid == 0) return; + if (!isInWorld()) return; + // Rate-limit to prevent spamming the server + static uint64_t lastInteractGuid = 0; + static std::chrono::steady_clock::time_point lastInteractTime{}; + auto now = std::chrono::steady_clock::now(); + // Keep duplicate suppression, but allow quick retry clicks. + constexpr int64_t minRepeatMs = 150; + if (guid == lastInteractGuid && + std::chrono::duration_cast(now - lastInteractTime).count() < minRepeatMs) { + return; + } + lastInteractGuid = guid; + lastInteractTime = now; + + // Ensure GO interaction isn't blocked by stale or active melee state. + stopAutoAttack(); + auto entity = entityController_->getEntityManager().getEntity(guid); + uint32_t goEntry = 0; + uint32_t goType = 0; + std::string goName; + + if (entity) { + if (entity->getType() == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + goEntry = go->getEntry(); + goName = go->getName(); + if (auto* info = getCachedGameObjectInfo(goEntry)) goType = info->type; + if (goType == 5 && !goName.empty()) { + std::string lower = goName; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (lower.rfind("doodad_", 0) != 0) { + addSystemChatMessage(goName); + } + } + } + // Face object and send heartbeat before use so strict servers don't require + // a nudge movement to accept interaction. + float dx = entity->getX() - movementInfo.x; + float dy = entity->getY() - movementInfo.y; + float dz = entity->getZ() - movementInfo.z; + float dist3d = std::sqrt(dx * dx + dy * dy + dz * dz); + if (dist3d > 10.0f) { + addSystemChatMessage("Too far away."); + return; + } + // Stop movement before interacting — servers may reject GO use or + // immediately cancel the resulting spell cast if the player is moving. + const uint32_t moveFlags = movementInfo.flags; + const bool isMoving = (moveFlags & 0x00000001u) || // FORWARD + (moveFlags & 0x00000002u) || // BACKWARD + (moveFlags & 0x00000004u) || // STRAFE_LEFT + (moveFlags & 0x00000008u); // STRAFE_RIGHT + if (isMoving) { + movementInfo.flags &= ~0x0000000Fu; // clear directional movement flags + sendMovement(Opcode::MSG_MOVE_STOP); + } + if (std::abs(dx) > 0.01f || std::abs(dy) > 0.01f) { + movementInfo.orientation = std::atan2(-dy, dx); + sendMovement(Opcode::MSG_MOVE_SET_FACING); + } + sendMovement(Opcode::MSG_MOVE_HEARTBEAT); + } + + // Determine GO type for interaction strategy + bool isMailbox = false; + bool chestLike = false; + if (entity && entity->getType() == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + auto* info = getCachedGameObjectInfo(go->getEntry()); + if (info && info->type == 19) { + isMailbox = true; + } else if (info && info->type == 3) { + chestLike = true; + } + } + if (!chestLike && !goName.empty()) { + std::string lower = goName; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + chestLike = (lower.find("chest") != std::string::npos || + lower.find("lockbox") != std::string::npos || + lower.find("strongbox") != std::string::npos || + lower.find("coffer") != std::string::npos || + lower.find("cache") != std::string::npos || + lower.find("bundle") != std::string::npos); + } + + LOG_INFO("GO interaction: guid=0x", std::hex, guid, std::dec, + " entry=", goEntry, " type=", goType, + " name='", goName, "' chestLike=", chestLike, " isMailbox=", isMailbox); + + // Always send CMSG_GAMEOBJ_USE first — this triggers the server-side + // GameObject::Use() handler for all GO types. + auto usePacket = GameObjectUsePacket::build(guid); + socket->send(usePacket); + lastInteractedGoGuid_ = guid; + + if (chestLike) { + // Don't send CMSG_LOOT immediately — the server may start a timed cast + // (e.g., "Opening") and the GO isn't lootable until the cast finishes. + // Sending LOOT prematurely gets an empty response or is silently dropped, + // which can interfere with the server's loot state machine. + // Instead, handleSpellGo will send LOOT after the cast completes + // (using lastInteractedGoGuid_ set above). For instant-open chests + // (no cast), the server sends SMSG_LOOT_RESPONSE directly after USE. + } else if (isMailbox) { + openMailbox(guid); + } + + // CMSG_GAMEOBJ_REPORT_USE triggers GO AI scripts (SmartAI, ScriptAI) which + // is where many quest objectives grant credit. Previously this was only sent + // for non-chest GOs, so chest-type quest objectives (Bundle of Wood, etc.) + // never triggered the server-side quest credit script. + if (!isMailbox) { + const auto* table = getActiveOpcodeTable(); + if (table && table->hasOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)) { + network::Packet reportUse(wireOpcode(Opcode::CMSG_GAMEOBJ_REPORT_USE)); + reportUse.writeUInt64(guid); + socket->send(reportUse); + } + } +} + +void GameHandler::selectGossipOption(uint32_t optionId) { + if (questHandler_) questHandler_->selectGossipOption(optionId); +} + +void GameHandler::selectGossipQuest(uint32_t questId) { + if (questHandler_) questHandler_->selectGossipQuest(questId); +} + +bool GameHandler::requestQuestQuery(uint32_t questId, bool force) { + return questHandler_ && questHandler_->requestQuestQuery(questId, force); +} + +bool GameHandler::hasQuestInLog(uint32_t questId) const { + return questHandler_ && questHandler_->hasQuestInLog(questId); +} + +Unit* GameHandler::getUnitByGuid(uint64_t guid) { + auto entity = entityController_->getEntityManager().getEntity(guid); + return entity ? dynamic_cast(entity.get()) : nullptr; +} + +std::string GameHandler::guidToUnitId(uint64_t guid) const { + if (guid == playerGuid) return "player"; + if (guid == targetGuid) return "target"; + if (guid == focusGuid) return "focus"; + if (guid == petGuid_) return "pet"; + return {}; +} + +std::string GameHandler::getQuestTitle(uint32_t questId) const { + for (const auto& q : questLog_) + if (q.questId == questId && !q.title.empty()) return q.title; + return {}; +} + +const GameHandler::QuestLogEntry* GameHandler::findQuestLogEntry(uint32_t questId) const { + for (const auto& q : questLog_) + if (q.questId == questId) return &q; + return nullptr; +} + +int GameHandler::findQuestLogSlotIndexFromServer(uint32_t questId) const { + if (questHandler_) return questHandler_->findQuestLogSlotIndexFromServer(questId); + return 0; +} + +void GameHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::string& title, const std::string& objectives) { + if (questHandler_) questHandler_->addQuestToLocalLogIfMissing(questId, title, objectives); +} + +bool GameHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { + return questHandler_ && questHandler_->resyncQuestLogFromServerSlots(forceQueryMetadata); +} + +// Apply quest completion state from player update fields to already-tracked local quests. +// Called from VALUES update handler so quests that complete mid-session (or that were +// complete on login) get quest.complete=true without waiting for SMSG_QUESTUPDATE_COMPLETE. +void GameHandler::applyQuestStateFromFields(const std::map& fields) { + if (questHandler_) questHandler_->applyQuestStateFromFields(fields); +} + +// Extract packed 6-bit kill/objective counts from WotLK/TBC/Classic quest-log update fields +// and populate quest.killCounts + quest.itemCounts using the structured objectives obtained +// from a prior SMSG_QUEST_QUERY_RESPONSE. Silently does nothing if objectives are absent. +void GameHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { + if (questHandler_) questHandler_->applyPackedKillCountsFromFields(quest); +} + +void GameHandler::clearPendingQuestAccept(uint32_t questId) { + if (questHandler_) questHandler_->clearPendingQuestAccept(questId); +} + +void GameHandler::triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason) { + if (questHandler_) questHandler_->triggerQuestAcceptResync(questId, npcGuid, reason); +} + +void GameHandler::acceptQuest() { + if (questHandler_) questHandler_->acceptQuest(); +} + +void GameHandler::declineQuest() { + if (questHandler_) questHandler_->declineQuest(); +} + +void GameHandler::abandonQuest(uint32_t questId) { + if (questHandler_) questHandler_->abandonQuest(questId); +} + +void GameHandler::shareQuestWithParty(uint32_t questId) { + if (questHandler_) questHandler_->shareQuestWithParty(questId); +} + +void GameHandler::completeQuest() { + if (questHandler_) questHandler_->completeQuest(); +} + +void GameHandler::closeQuestRequestItems() { + if (questHandler_) questHandler_->closeQuestRequestItems(); +} + +void GameHandler::chooseQuestReward(uint32_t rewardIndex) { + if (questHandler_) questHandler_->chooseQuestReward(rewardIndex); +} + +void GameHandler::closeQuestOfferReward() { + if (questHandler_) questHandler_->closeQuestOfferReward(); +} + +void GameHandler::closeGossip() { + if (questHandler_) questHandler_->closeGossip(); +} + +void GameHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) { + if (questHandler_) questHandler_->offerQuestFromItem(itemGuid, questId); +} + +uint64_t GameHandler::getBagItemGuid(int bagIndex, int slotIndex) const { + if (bagIndex < 0 || bagIndex >= inventory.NUM_BAG_SLOTS) return 0; + if (slotIndex < 0) return 0; + uint64_t bagGuid = equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; + if (bagGuid == 0) return 0; + auto it = containerContents_.find(bagGuid); + if (it == containerContents_.end()) return 0; + if (slotIndex >= static_cast(it->second.numSlots)) return 0; + return it->second.slotGuids[slotIndex]; +} + +void GameHandler::openVendor(uint64_t npcGuid) { + if (inventoryHandler_) inventoryHandler_->openVendor(npcGuid); +} + +void GameHandler::closeVendor() { + if (inventoryHandler_) inventoryHandler_->closeVendor(); +} + +void GameHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { + if (inventoryHandler_) inventoryHandler_->buyItem(vendorGuid, itemId, slot, count); +} + +void GameHandler::buyBackItem(uint32_t buybackSlot) { + if (inventoryHandler_) inventoryHandler_->buyBackItem(buybackSlot); +} + +void GameHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { + if (inventoryHandler_) inventoryHandler_->repairItem(vendorGuid, itemGuid); +} + +void GameHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { + if (inventoryHandler_) inventoryHandler_->repairAll(vendorGuid, useGuildBank); +} + +uint32_t GameHandler::estimateRepairAllCost() const { + if (inventoryHandler_) return inventoryHandler_->estimateRepairAllCost(); + return 0; +} + +void GameHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { + if (inventoryHandler_) inventoryHandler_->sellItem(vendorGuid, itemGuid, count); +} + +void GameHandler::sellItemBySlot(int backpackIndex) { + if (inventoryHandler_) inventoryHandler_->sellItemBySlot(backpackIndex); +} + +void GameHandler::autoEquipItemBySlot(int backpackIndex) { + if (inventoryHandler_) inventoryHandler_->autoEquipItemBySlot(backpackIndex); +} + +void GameHandler::autoEquipItemInBag(int bagIndex, int slotIndex) { + if (inventoryHandler_) inventoryHandler_->autoEquipItemInBag(bagIndex, slotIndex); +} + +void GameHandler::sellItemInBag(int bagIndex, int slotIndex) { + if (inventoryHandler_) inventoryHandler_->sellItemInBag(bagIndex, slotIndex); +} + +void GameHandler::unequipToBackpack(EquipSlot equipSlot) { + if (inventoryHandler_) inventoryHandler_->unequipToBackpack(equipSlot); +} + +void GameHandler::swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot) { + if (inventoryHandler_) inventoryHandler_->swapContainerItems(srcBag, srcSlot, dstBag, dstSlot); +} + +void GameHandler::swapBagSlots(int srcBagIndex, int dstBagIndex) { + if (inventoryHandler_) inventoryHandler_->swapBagSlots(srcBagIndex, dstBagIndex); +} + +void GameHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) { + if (inventoryHandler_) inventoryHandler_->destroyItem(bag, slot, count); +} + +void GameHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) { + if (inventoryHandler_) inventoryHandler_->splitItem(srcBag, srcSlot, count); +} + +void GameHandler::useItemBySlot(int backpackIndex) { + if (inventoryHandler_) inventoryHandler_->useItemBySlot(backpackIndex); +} + +void GameHandler::useItemInBag(int bagIndex, int slotIndex) { + if (inventoryHandler_) inventoryHandler_->useItemInBag(bagIndex, slotIndex); +} + +void GameHandler::openItemBySlot(int backpackIndex) { + if (inventoryHandler_) inventoryHandler_->openItemBySlot(backpackIndex); +} + +void GameHandler::openItemInBag(int bagIndex, int slotIndex) { + if (inventoryHandler_) inventoryHandler_->openItemInBag(bagIndex, slotIndex); +} + +void GameHandler::useItemById(uint32_t itemId) { + if (inventoryHandler_) inventoryHandler_->useItemById(itemId); +} + +uint32_t GameHandler::getItemIdForSpell(uint32_t spellId) const { + if (spellId == 0) return 0; + // Search backpack and bags for an item whose on-use spell matches + for (int i = 0; i < inventory.getBackpackSize(); i++) { + const auto& slot = inventory.getBackpackSlot(i); + if (slot.empty()) continue; + auto* info = getItemInfo(slot.item.itemId); + if (!info || !info->valid) continue; + for (const auto& sp : info->spells) { + if (sp.spellId == spellId && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) + return slot.item.itemId; + } + } + for (int bag = 0; bag < inventory.NUM_BAG_SLOTS; bag++) { + for (int s = 0; s < inventory.getBagSize(bag); s++) { + const auto& slot = inventory.getBagSlot(bag, s); + if (slot.empty()) continue; + auto* info = getItemInfo(slot.item.itemId); + if (!info || !info->valid) continue; + for (const auto& sp : info->spells) { + if (sp.spellId == spellId && (sp.spellTrigger == 0 || sp.spellTrigger == 5)) + return slot.item.itemId; + } + } + } + return 0; +} + +void GameHandler::unstuck() { + if (unstuckCallback_) { + unstuckCallback_(); + addSystemChatMessage("Unstuck: snapped upward. Use /unstuckgy for full teleport."); + } +} + +void GameHandler::unstuckGy() { + if (unstuckGyCallback_) { + unstuckGyCallback_(); + addSystemChatMessage("Unstuck: teleported to safe location."); + } +} + +void GameHandler::unstuckHearth() { + if (unstuckHearthCallback_) { + unstuckHearthCallback_(); + addSystemChatMessage("Unstuck: teleported to hearthstone location."); + } else { + addSystemChatMessage("No hearthstone bind point set."); + } +} + +// ============================================================ +// Trainer +// ============================================================ + +void GameHandler::trainSpell(uint32_t spellId) { + if (inventoryHandler_) inventoryHandler_->trainSpell(spellId); +} + +void GameHandler::closeTrainer() { + if (inventoryHandler_) inventoryHandler_->closeTrainer(); +} + +void GameHandler::preloadDBCCaches() const { + LOG_INFO("Pre-loading DBC caches during world entry..."); + auto t0 = std::chrono::steady_clock::now(); + + loadSpellNameCache(); // Spell.dbc — largest, ~170ms cold + loadTitleNameCache(); // CharTitles.dbc + loadFactionNameCache(); // Faction.dbc + loadAreaNameCache(); // WorldMapArea.dbc + loadMapNameCache(); // Map.dbc + loadLfgDungeonDbc(); // LFGDungeons.dbc + + // Validate animation constants against AnimationData.dbc + if (auto* am = services_.assetManager) { + auto animDbc = am->loadDBC("AnimationData.dbc"); + rendering::anim::validateAgainstDBC(animDbc); + } + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + LOG_INFO("DBC cache pre-load complete in ", elapsed, " ms"); +} + +void GameHandler::loadSpellNameCache() const { + if (spellHandler_) spellHandler_->loadSpellNameCache(); +} + +void GameHandler::loadSkillLineAbilityDbc() { + if (spellHandler_) spellHandler_->loadSkillLineAbilityDbc(); +} + +const std::vector& GameHandler::getSpellBookTabs() { + static const std::vector kEmpty; + if (spellHandler_) return spellHandler_->getSpellBookTabs(); + return kEmpty; +} + +void GameHandler::categorizeTrainerSpells() { + if (spellHandler_) spellHandler_->categorizeTrainerSpells(); +} + +void GameHandler::loadTalentDbc() { + if (spellHandler_) spellHandler_->loadTalentDbc(); +} + +static const std::string EMPTY_STRING; + +const int32_t* GameHandler::getSpellEffectBasePoints(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSpellEffectBasePoints(spellId); + return nullptr; +} + +float GameHandler::getSpellDuration(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSpellDuration(spellId); + return 0.0f; +} + +const std::string& GameHandler::getSpellName(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSpellName(spellId); + return EMPTY_STRING; +} + +const std::string& GameHandler::getSpellRank(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSpellRank(spellId); + return EMPTY_STRING; +} + +const std::string& GameHandler::getSpellDescription(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSpellDescription(spellId); + return EMPTY_STRING; +} + +std::string GameHandler::getEnchantName(uint32_t enchantId) const { + if (spellHandler_) return spellHandler_->getEnchantName(enchantId); + return {}; +} + +uint8_t GameHandler::getSpellDispelType(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSpellDispelType(spellId); + return 0; +} + +bool GameHandler::isSpellInterruptible(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->isSpellInterruptible(spellId); + return true; +} + +uint32_t GameHandler::getSpellSchoolMask(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSpellSchoolMask(spellId); + return 0; +} + +const std::string& GameHandler::getSkillLineName(uint32_t spellId) const { + if (spellHandler_) return spellHandler_->getSkillLineName(spellId); + return EMPTY_STRING; +} + + +} // namespace game +} // namespace wowee diff --git a/src/game/game_handler_packets.cpp b/src/game/game_handler_packets.cpp new file mode 100644 index 00000000..16132bfd --- /dev/null +++ b/src/game/game_handler_packets.cpp @@ -0,0 +1,2937 @@ +#include "game/game_handler.hpp" +#include "game/game_utils.hpp" +#include "game/chat_handler.hpp" +#include "game/movement_handler.hpp" +#include "game/combat_handler.hpp" +#include "game/spell_handler.hpp" +#include "game/inventory_handler.hpp" +#include "game/social_handler.hpp" +#include "game/quest_handler.hpp" +#include "game/warden_handler.hpp" +#include "game/packet_parsers.hpp" +#include "game/transport_manager.hpp" +#include "game/warden_crypto.hpp" +#include "game/warden_memory.hpp" +#include "game/warden_module.hpp" +#include "game/opcodes.hpp" +#include "game/update_field_table.hpp" +#include "game/expansion_profile.hpp" +#include "rendering/renderer.hpp" +#include "rendering/spell_visual_system.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/activity_sound_manager.hpp" +#include "audio/combat_sound_manager.hpp" +#include "audio/spell_sound_manager.hpp" +#include "audio/ui_sound_manager.hpp" +#include "pipeline/dbc_layout.hpp" +#include "network/world_socket.hpp" +#include "network/packet.hpp" +#include "auth/crypto.hpp" +#include "core/coordinates.hpp" +#include "core/application.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "core/logger.hpp" +#include "rendering/animation/animation_ids.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace wowee { +namespace game { + +namespace { + +const char* worldStateName(WorldState state) { + switch (state) { + case WorldState::DISCONNECTED: return "DISCONNECTED"; + case WorldState::CONNECTING: return "CONNECTING"; + case WorldState::CONNECTED: return "CONNECTED"; + case WorldState::CHALLENGE_RECEIVED: return "CHALLENGE_RECEIVED"; + case WorldState::AUTH_SENT: return "AUTH_SENT"; + case WorldState::AUTHENTICATED: return "AUTHENTICATED"; + case WorldState::READY: return "READY"; + case WorldState::CHAR_LIST_REQUESTED: return "CHAR_LIST_REQUESTED"; + case WorldState::CHAR_LIST_RECEIVED: return "CHAR_LIST_RECEIVED"; + case WorldState::ENTERING_WORLD: return "ENTERING_WORLD"; + case WorldState::IN_WORLD: return "IN_WORLD"; + case WorldState::FAILED: return "FAILED"; + } + return "UNKNOWN"; +} + +bool isAuthCharPipelineOpcode(LogicalOpcode op) { + switch (op) { + case Opcode::SMSG_AUTH_CHALLENGE: + case Opcode::SMSG_AUTH_RESPONSE: + case Opcode::SMSG_CLIENTCACHE_VERSION: + case Opcode::SMSG_TUTORIAL_FLAGS: + case Opcode::SMSG_WARDEN_DATA: + case Opcode::SMSG_CHAR_ENUM: + case Opcode::SMSG_CHAR_CREATE: + case Opcode::SMSG_CHAR_DELETE: + return true; + default: + return false; + } +} + +int parseEnvIntClamped(const char* key, int defaultValue, int minValue, int maxValue) { + const char* raw = std::getenv(key); + if (!raw || !*raw) return defaultValue; + char* end = nullptr; + long parsed = std::strtol(raw, &end, 10); + if (end == raw) return defaultValue; + return static_cast(std::clamp(parsed, minValue, maxValue)); +} + +int incomingPacketsBudgetPerUpdate(WorldState state) { + static const int inWorldBudget = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS", 24, 1, 512); + static const int loginBudget = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKETS_LOGIN", 96, 1, 512); + return state == WorldState::IN_WORLD ? inWorldBudget : loginBudget; +} + +float incomingPacketBudgetMs(WorldState state) { + static const int inWorldBudgetMs = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS", 2, 1, 50); + static const int loginBudgetMs = + parseEnvIntClamped("WOWEE_NET_MAX_GAMEHANDLER_PACKET_MS_LOGIN", 8, 1, 50); + return static_cast(state == WorldState::IN_WORLD ? inWorldBudgetMs : loginBudgetMs); +} + +float slowPacketLogThresholdMs() { + static const int thresholdMs = + parseEnvIntClamped("WOWEE_NET_SLOW_PACKET_LOG_MS", 10, 1, 60000); + return static_cast(thresholdMs); +} + +constexpr size_t kMaxQueuedInboundPackets = 4096; + +} // end anonymous namespace + +void GameHandler::registerOpcodeHandlers() { + // ----------------------------------------------------------------------- + // Auth / session / pre-world handshake + // ----------------------------------------------------------------------- + dispatchTable_[Opcode::SMSG_AUTH_CHALLENGE] = [this](network::Packet& packet) { + if (state == WorldState::CONNECTED) + handleAuthChallenge(packet); + else + LOG_WARNING("Unexpected SMSG_AUTH_CHALLENGE in state: ", worldStateName(state)); + }; + dispatchTable_[Opcode::SMSG_AUTH_RESPONSE] = [this](network::Packet& packet) { + if (state == WorldState::AUTH_SENT) + handleAuthResponse(packet); + else + LOG_WARNING("Unexpected SMSG_AUTH_RESPONSE in state: ", worldStateName(state)); + }; + dispatchTable_[Opcode::SMSG_CHAR_CREATE] = [this](network::Packet& packet) { + handleCharCreateResponse(packet); + }; + dispatchTable_[Opcode::SMSG_CHAR_DELETE] = [this](network::Packet& packet) { + uint8_t result = packet.readUInt8(); + lastCharDeleteResult_ = result; + bool success = (result == 0x00 || result == 0x47); + LOG_INFO("SMSG_CHAR_DELETE result: ", static_cast(result), success ? " (success)" : " (failed)"); + requestCharacterList(); + if (charDeleteCallback_) charDeleteCallback_(success); + }; + dispatchTable_[Opcode::SMSG_CHAR_ENUM] = [this](network::Packet& packet) { + if (state == WorldState::CHAR_LIST_REQUESTED) + handleCharEnum(packet); + else + LOG_WARNING("Unexpected SMSG_CHAR_ENUM in state: ", worldStateName(state)); + }; + registerHandler(Opcode::SMSG_CHARACTER_LOGIN_FAILED, &GameHandler::handleCharLoginFailed); + dispatchTable_[Opcode::SMSG_LOGIN_VERIFY_WORLD] = [this](network::Packet& packet) { + if (state == WorldState::ENTERING_WORLD || state == WorldState::IN_WORLD) + handleLoginVerifyWorld(packet); + else + LOG_WARNING("Unexpected SMSG_LOGIN_VERIFY_WORLD in state: ", worldStateName(state)); + }; + registerHandler(Opcode::SMSG_LOGIN_SETTIMESPEED, &GameHandler::handleLoginSetTimeSpeed); + registerHandler(Opcode::SMSG_CLIENTCACHE_VERSION, &GameHandler::handleClientCacheVersion); + registerHandler(Opcode::SMSG_TUTORIAL_FLAGS, &GameHandler::handleTutorialFlags); + registerHandler(Opcode::SMSG_ACCOUNT_DATA_TIMES, &GameHandler::handleAccountDataTimes); + registerHandler(Opcode::SMSG_MOTD, &GameHandler::handleMotd); + registerHandler(Opcode::SMSG_NOTIFICATION, &GameHandler::handleNotification); + registerHandler(Opcode::SMSG_PONG, &GameHandler::handlePong); + + // ----------------------------------------------------------------------- + // World object updates + entity queries (delegated to EntityController) + // ----------------------------------------------------------------------- + entityController_->registerOpcodes(dispatchTable_); + + // ----------------------------------------------------------------------- + // Item push / logout + // ----------------------------------------------------------------------- + registerSkipHandler(Opcode::SMSG_ADDON_INFO); + registerSkipHandler(Opcode::SMSG_EXPECTED_SPAM_RECORDS); + + // ----------------------------------------------------------------------- + // XP / exploration + // ----------------------------------------------------------------------- + registerHandler(Opcode::SMSG_LOG_XPGAIN, &GameHandler::handleXpGain); + dispatchTable_[Opcode::SMSG_EXPLORATION_EXPERIENCE] = [this](network::Packet& packet) { + if (packet.hasRemaining(8)) { + uint32_t areaId = packet.readUInt32(); + uint32_t xpGained = packet.readUInt32(); + if (xpGained > 0) { + 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); + addCombatText(CombatTextEntry::XP_GAIN, static_cast(xpGained), 0, true); + if (areaDiscoveryCallback_) areaDiscoveryCallback_(areaName, xpGained); + fireAddonEvent("CHAT_MSG_COMBAT_XP_GAIN", {msg, std::to_string(xpGained)}); + } + } + }; + + registerSkipHandler(Opcode::SMSG_PET_NAME_QUERY_RESPONSE); + + // ----------------------------------------------------------------------- + // Entity delta updates: health / power / world state / combo / timers / PvP + // (SMSG_HEALTH_UPDATE, SMSG_POWER_UPDATE, SMSG_UPDATE_COMBO_POINTS, + // SMSG_PVP_CREDIT, SMSG_PROCRESIST → moved to CombatHandler) + // ----------------------------------------------------------------------- + dispatchTable_[Opcode::SMSG_UPDATE_WORLD_STATE] = [this](network::Packet& packet) { + if (!packet.hasRemaining(8)) return; + uint32_t field = packet.readUInt32(); + uint32_t value = packet.readUInt32(); + worldStates_[field] = value; + LOG_DEBUG("SMSG_UPDATE_WORLD_STATE: field=", field, " value=", value); + fireAddonEvent("UPDATE_WORLD_STATES", {}); + }; + dispatchTable_[Opcode::SMSG_WORLD_STATE_UI_TIMER_UPDATE] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t serverTime = packet.readUInt32(); + LOG_DEBUG("SMSG_WORLD_STATE_UI_TIMER_UPDATE: serverTime=", serverTime); + } + }; + dispatchTable_[Opcode::SMSG_START_MIRROR_TIMER] = [this](network::Packet& packet) { + if (!packet.hasRemaining(21)) return; + uint32_t type = packet.readUInt32(); + int32_t value = static_cast(packet.readUInt32()); + int32_t maxV = static_cast(packet.readUInt32()); + int32_t scale = static_cast(packet.readUInt32()); + /*uint32_t tracker =*/ packet.readUInt32(); + uint8_t paused = packet.readUInt8(); + if (type < 3) { + mirrorTimers_[type].value = value; + mirrorTimers_[type].maxValue = maxV; + mirrorTimers_[type].scale = scale; + mirrorTimers_[type].paused = (paused != 0); + mirrorTimers_[type].active = true; + fireAddonEvent("MIRROR_TIMER_START", { + std::to_string(type), std::to_string(value), + std::to_string(maxV), std::to_string(scale), + paused ? "1" : "0"}); + } + }; + dispatchTable_[Opcode::SMSG_STOP_MIRROR_TIMER] = [this](network::Packet& packet) { + if (!packet.hasRemaining(4)) return; + uint32_t type = packet.readUInt32(); + if (type < 3) { + mirrorTimers_[type].active = false; + mirrorTimers_[type].value = 0; + fireAddonEvent("MIRROR_TIMER_STOP", {std::to_string(type)}); + } + }; + dispatchTable_[Opcode::SMSG_PAUSE_MIRROR_TIMER] = [this](network::Packet& packet) { + if (!packet.hasRemaining(5)) return; + uint32_t type = packet.readUInt32(); + uint8_t paused = packet.readUInt8(); + if (type < 3) { + mirrorTimers_[type].paused = (paused != 0); + fireAddonEvent("MIRROR_TIMER_PAUSE", {paused ? "1" : "0"}); + } + }; + + // ----------------------------------------------------------------------- + // Cast result / spell proc + // (SMSG_CAST_RESULT, SMSG_SPELL_FAILED_OTHER → moved to SpellHandler) + // (SMSG_PROCRESIST → moved to CombatHandler) + // ----------------------------------------------------------------------- + + // ----------------------------------------------------------------------- + // Pet stable + // ----------------------------------------------------------------------- + dispatchTable_[Opcode::MSG_LIST_STABLED_PETS] = [this](network::Packet& packet) { + if (state == WorldState::IN_WORLD) handleListStabledPets(packet); + }; + dispatchTable_[Opcode::SMSG_STABLE_RESULT] = [this](network::Packet& packet) { + if (!packet.hasRemaining(1)) return; + uint8_t result = packet.readUInt8(); + const char* msg = nullptr; + switch (result) { + case 0x01: msg = "Pet stored in stable."; break; + case 0x06: msg = "Pet retrieved from stable."; break; + case 0x07: msg = "Stable slot purchased."; break; + case 0x08: msg = "Stable list updated."; break; + case 0x09: msg = "Stable failed: not enough money or other error."; addUIError(msg); break; + default: break; + } + if (msg) addSystemChatMessage(msg); + LOG_INFO("SMSG_STABLE_RESULT: result=", static_cast(result)); + if (stableWindowOpen_ && stableMasterGuid_ != 0 && socket && result <= 0x08) { + auto refreshPkt = ListStabledPetsPacket::build(stableMasterGuid_); + socket->send(refreshPkt); + } + }; + + // ----------------------------------------------------------------------- + // Titles / achievements / character services + // ----------------------------------------------------------------------- + dispatchTable_[Opcode::SMSG_TITLE_EARNED] = [this](network::Packet& packet) { + if (!packet.hasRemaining(8)) return; + uint32_t titleBit = packet.readUInt32(); + uint32_t isLost = packet.readUInt32(); + loadTitleNameCache(); + std::string titleStr; + auto tit = titleNameCache_.find(titleBit); + if (tit != titleNameCache_.end() && !tit->second.empty()) { + const auto& ln = lookupName(playerGuid); + const std::string& pName = ln.empty() ? std::string("you") : ln; + const std::string& fmt = tit->second; + size_t pos = fmt.find("%s"); + if (pos != std::string::npos) + titleStr = fmt.substr(0, pos) + pName + fmt.substr(pos + 2); + else + titleStr = fmt; + } + std::string msg; + if (!titleStr.empty()) { + msg = isLost ? ("Title removed: " + titleStr + ".") : ("Title earned: " + titleStr + "!"); + } else { + char buf[64]; + std::snprintf(buf, sizeof(buf), isLost ? "Title removed (bit %u)." : "Title earned (bit %u)!", titleBit); + msg = buf; + } + if (isLost) knownTitleBits_.erase(titleBit); + else knownTitleBits_.insert(titleBit); + addSystemChatMessage(msg); + LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, " title='", titleStr, "'"); + }; + dispatchTable_[Opcode::SMSG_LEARNED_DANCE_MOVES] = [this](network::Packet& packet) { + LOG_DEBUG("SMSG_LEARNED_DANCE_MOVES: ignored (size=", packet.getSize(), ")"); + }; + dispatchTable_[Opcode::SMSG_CHAR_RENAME] = [this](network::Packet& packet) { + if (packet.hasRemaining(13)) { + uint32_t result = packet.readUInt32(); + /*uint64_t guid =*/ packet.readUInt64(); + std::string newName = packet.readString(); + if (result == 0) { + addSystemChatMessage("Character name changed to: " + newName); + } else { + static const char* kRenameErrors[] = { + nullptr, "Name already in use.", "Name too short.", "Name too long.", + "Name contains invalid characters.", "Name contains a profanity.", + "Name is reserved.", "Character name does not meet requirements.", + }; + const char* errMsg = (result < 8) ? kRenameErrors[result] : nullptr; + std::string renameErr = errMsg ? std::string("Rename failed: ") + errMsg : "Character rename failed."; + addUIError(renameErr); addSystemChatMessage(renameErr); + } + LOG_INFO("SMSG_CHAR_RENAME: result=", result, " newName=", newName); + } + }; + + // ----------------------------------------------------------------------- + // Bind / heartstone / phase / barber / corpse + // ----------------------------------------------------------------------- + dispatchTable_[Opcode::SMSG_PLAYERBOUND] = [this](network::Packet& packet) { + if (!packet.hasRemaining(16)) return; + /*uint64_t binderGuid =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t zoneId = packet.readUInt32(); + homeBindMapId_ = mapId; + homeBindZoneId_ = zoneId; + std::string pbMsg = "Your home location has been set"; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) pbMsg += " to " + zoneName; + pbMsg += '.'; + addSystemChatMessage(pbMsg); + }; + registerSkipHandler(Opcode::SMSG_BINDER_CONFIRM); + registerSkipHandler(Opcode::SMSG_SET_PHASE_SHIFT); + dispatchTable_[Opcode::SMSG_TOGGLE_XP_GAIN] = [this](network::Packet& packet) { + if (!packet.hasRemaining(1)) return; + uint8_t enabled = packet.readUInt8(); + addSystemChatMessage(enabled ? "XP gain enabled." : "XP gain disabled."); + }; + dispatchTable_[Opcode::SMSG_BINDZONEREPLY] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t result = packet.readUInt32(); + if (result == 0) addSystemChatMessage("Your home is now set to this location."); + else { addUIError("You are too far from the innkeeper."); addSystemChatMessage("You are too far from the innkeeper."); } + } + }; + dispatchTable_[Opcode::SMSG_CHANGEPLAYER_DIFFICULTY_RESULT] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t result = packet.readUInt32(); + if (result == 0) { + addSystemChatMessage("Difficulty changed."); + } else { + static const char* reasons[] = { + "", "Error", "Too many members", "Already in dungeon", + "You are in a battleground", "Raid not allowed in heroic", + "You must be in a raid group", "Player not in group" + }; + const char* msg = (result < 8) ? reasons[result] : "Difficulty change failed."; + addUIError(std::string("Cannot change difficulty: ") + msg); + addSystemChatMessage(std::string("Cannot change difficulty: ") + msg); + } + } + }; + dispatchTable_[Opcode::SMSG_CORPSE_NOT_IN_INSTANCE] = [this](network::Packet& /*packet*/) { + addUIError("Your corpse is outside this instance."); + addSystemChatMessage("Your corpse is outside this instance. Release spirit to retrieve it."); + }; + dispatchTable_[Opcode::SMSG_CROSSED_INEBRIATION_THRESHOLD] = [this](network::Packet& packet) { + if (packet.hasRemaining(12)) { + uint64_t guid = packet.readUInt64(); + uint32_t threshold = packet.readUInt32(); + if (guid == playerGuid && threshold > 0) addSystemChatMessage("You feel rather drunk."); + LOG_DEBUG("SMSG_CROSSED_INEBRIATION_THRESHOLD: guid=0x", std::hex, guid, std::dec, " threshold=", threshold); + } + }; + dispatchTable_[Opcode::SMSG_CLEAR_FAR_SIGHT_IMMEDIATE] = [this](network::Packet& /*packet*/) { + LOG_DEBUG("SMSG_CLEAR_FAR_SIGHT_IMMEDIATE"); + }; + registerSkipHandler(Opcode::SMSG_COMBAT_EVENT_FAILED); + dispatchTable_[Opcode::SMSG_FORCE_ANIM] = [this](network::Packet& packet) { + if (packet.hasRemaining(1)) { + uint64_t animGuid = packet.readPackedGuid(); + if (packet.hasRemaining(4)) { + uint32_t animId = packet.readUInt32(); + if (emoteAnimCallback_) emoteAnimCallback_(animGuid, animId); + } + } + }; + // Consume silently — opcodes we receive but don't need to act on + for (auto op : { + Opcode::SMSG_FLIGHT_SPLINE_SYNC, Opcode::SMSG_FORCE_DISPLAY_UPDATE, + Opcode::SMSG_FORCE_SEND_QUEUED_PACKETS, Opcode::SMSG_FORCE_SET_VEHICLE_REC_ID, + Opcode::SMSG_CORPSE_MAP_POSITION_QUERY_RESPONSE, Opcode::SMSG_DAMAGE_CALC_LOG, + Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT, Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE, + }) { registerSkipHandler(op); } + + // Game object despawn animation — reset state to closed before actual despawn + dispatchTable_[Opcode::SMSG_GAMEOBJECT_DESPAWN_ANIM] = [this](network::Packet& packet) { + if (!packet.hasRemaining(8)) return; + uint64_t guid = packet.readUInt64(); + // Trigger a CLOSE animation / freeze before the object is removed + if (gameObjectStateCallback_) gameObjectStateCallback_(guid, 0); + }; + // Game object reset state — return to READY(closed) state + dispatchTable_[Opcode::SMSG_GAMEOBJECT_RESET_STATE] = [this](network::Packet& packet) { + if (!packet.hasRemaining(8)) return; + uint64_t guid = packet.readUInt64(); + if (gameObjectStateCallback_) gameObjectStateCallback_(guid, 0); + }; + dispatchTable_[Opcode::SMSG_FORCED_DEATH_UPDATE] = [this](network::Packet& packet) { + playerDead_ = true; + if (ghostStateCallback_) ghostStateCallback_(false); + fireAddonEvent("PLAYER_DEAD", {}); + addSystemChatMessage("You have been killed."); + LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed"); + packet.skipAll(); + }; + // SMSG_DEFENSE_MESSAGE — moved to ChatHandler::registerOpcodes + dispatchTable_[Opcode::SMSG_CORPSE_RECLAIM_DELAY] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t delayMs = packet.readUInt32(); + auto nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + corpseReclaimAvailableMs_ = nowMs + delayMs; + LOG_INFO("SMSG_CORPSE_RECLAIM_DELAY: ", delayMs, "ms"); + } + }; + dispatchTable_[Opcode::SMSG_DEATH_RELEASE_LOC] = [this](network::Packet& packet) { + if (packet.hasRemaining(16)) { + uint32_t relMapId = packet.readUInt32(); + float relX = packet.readFloat(), relY = packet.readFloat(), relZ = packet.readFloat(); + LOG_INFO("SMSG_DEATH_RELEASE_LOC (graveyard spawn): map=", relMapId, " x=", relX, " y=", relY, " z=", relZ); + } + }; + dispatchTable_[Opcode::SMSG_ENABLE_BARBER_SHOP] = [this](network::Packet& /*packet*/) { + LOG_INFO("SMSG_ENABLE_BARBER_SHOP: barber shop available"); + barberShopOpen_ = true; + fireAddonEvent("BARBER_SHOP_OPEN", {}); + }; + + // ---- Batch 3: Corpse/gametime, combat clearing, mount, loot notify, + // movement/speed/flags, attack, spells, group ---- + + dispatchTable_[Opcode::MSG_CORPSE_QUERY] = [this](network::Packet& packet) { + if (!packet.hasRemaining(1)) return; + uint8_t found = packet.readUInt8(); + if (found && packet.hasRemaining(20)) { + /*uint32_t mapId =*/ packet.readUInt32(); + float cx = packet.readFloat(); + float cy = packet.readFloat(); + float cz = packet.readFloat(); + uint32_t corpseMapId = packet.readUInt32(); + corpseX_ = cx; + corpseY_ = cy; + corpseZ_ = cz; + corpseMapId_ = corpseMapId; + LOG_INFO("MSG_CORPSE_QUERY: corpse at (", cx, ",", cy, ",", cz, ") map=", corpseMapId); + } + }; + dispatchTable_[Opcode::SMSG_FEIGN_DEATH_RESISTED] = [this](network::Packet& /*packet*/) { + addUIError("Your Feign Death was resisted."); + addSystemChatMessage("Your Feign Death attempt was resisted."); + }; + dispatchTable_[Opcode::SMSG_CHANNEL_MEMBER_COUNT] = [this](network::Packet& packet) { + std::string chanName = packet.readString(); + if (packet.hasRemaining(5)) { + /*uint8_t flags =*/ packet.readUInt8(); + uint32_t count = packet.readUInt32(); + LOG_DEBUG("SMSG_CHANNEL_MEMBER_COUNT: channel=", chanName, " members=", count); + } + }; + for (auto op : { Opcode::SMSG_GAMETIME_SET, Opcode::SMSG_GAMETIME_UPDATE }) { + dispatchTable_[op] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t gameTimePacked = packet.readUInt32(); + gameTime_ = static_cast(gameTimePacked); + } + packet.skipAll(); + }; + } + dispatchTable_[Opcode::SMSG_GAMESPEED_SET] = [this](network::Packet& packet) { + if (packet.hasRemaining(8)) { + uint32_t gameTimePacked = packet.readUInt32(); + float timeSpeed = packet.readFloat(); + gameTime_ = static_cast(gameTimePacked); + timeSpeed_ = timeSpeed; + } + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_GAMETIMEBIAS_SET] = [this](network::Packet& packet) { + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_ACHIEVEMENT_DELETED] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t achId = packet.readUInt32(); + earnedAchievements_.erase(achId); + achievementDates_.erase(achId); + } + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_CRITERIA_DELETED] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t critId = packet.readUInt32(); + criteriaProgress_.erase(critId); + } + packet.skipAll(); + }; + + // Combat clearing + dispatchTable_[Opcode::SMSG_BREAK_TARGET] = [this](network::Packet& packet) { + if (packet.hasRemaining(8)) { + uint64_t bGuid = packet.readUInt64(); + if (bGuid == targetGuid) targetGuid = 0; + } + }; + dispatchTable_[Opcode::SMSG_CLEAR_TARGET] = [this](network::Packet& packet) { + if (packet.hasRemaining(8)) { + uint64_t cGuid = packet.readUInt64(); + if (cGuid == 0 || cGuid == targetGuid) targetGuid = 0; + } + }; + + // Mount/dismount + dispatchTable_[Opcode::SMSG_DISMOUNT] = [this](network::Packet& /*packet*/) { + currentMountDisplayId_ = 0; + if (mountCallback_) mountCallback_(0); + }; + dispatchTable_[Opcode::SMSG_MOUNTRESULT] = [this](network::Packet& packet) { + if (!packet.hasRemaining(4)) return; + uint32_t result = packet.readUInt32(); + if (result != 4) { + const char* msgs[] = { "Cannot mount here.", "Invalid mount spell.", + "Too far away to mount.", "Already mounted." }; + std::string mountErr = result < 4 ? msgs[result] : "Cannot mount."; + addUIError(mountErr); + addSystemChatMessage(mountErr); + } + }; + dispatchTable_[Opcode::SMSG_DISMOUNTRESULT] = [this](network::Packet& packet) { + if (!packet.hasRemaining(4)) return; + uint32_t result = packet.readUInt32(); + if (result != 0) { + addUIError("Cannot dismount here."); + addSystemChatMessage("Cannot dismount here."); + } + }; + + // Camera shake + dispatchTable_[Opcode::SMSG_CAMERA_SHAKE] = [this](network::Packet& packet) { + if (packet.hasRemaining(8)) { + uint32_t shakeId = packet.readUInt32(); + uint32_t shakeType = packet.readUInt32(); + (void)shakeType; + float magnitude = (shakeId < 50) ? 0.04f : 0.08f; + if (cameraShakeCallback_) + cameraShakeCallback_(magnitude, 18.0f, 0.5f); + } + }; + + // (SMSG_PLAY_SPELL_VISUAL, SMSG_CLEAR_COOLDOWN, SMSG_MODIFY_COOLDOWN → moved to SpellHandler) + + // ---- Batch 4: Ready check, duels, guild, loot/gossip/vendor, factions, spell mods ---- + + // Guild + registerHandler(Opcode::SMSG_PET_SPELLS, &GameHandler::handlePetSpells); + + // Loot/gossip/vendor delegates + registerHandler(Opcode::SMSG_SUMMON_REQUEST, &GameHandler::handleSummonRequest); + dispatchTable_[Opcode::SMSG_SUMMON_CANCEL] = [this](network::Packet& /*packet*/) { + pendingSummonRequest_ = false; + addSystemChatMessage("Summon cancelled."); + }; + + // Bind point + dispatchTable_[Opcode::SMSG_BINDPOINTUPDATE] = [this](network::Packet& packet) { + BindPointUpdateData data; + if (BindPointUpdateParser::parse(packet, data)) { + glm::vec3 canonical = core::coords::serverToCanonical( + glm::vec3(data.x, data.y, data.z)); + bool wasSet = hasHomeBind_; + hasHomeBind_ = true; + homeBindMapId_ = data.mapId; + homeBindZoneId_ = data.zoneId; + homeBindPos_ = canonical; + if (bindPointCallback_) + bindPointCallback_(data.mapId, canonical.x, canonical.y, canonical.z); + if (wasSet) { + std::string bindMsg = "Your home has been set"; + std::string zoneName = getAreaName(data.zoneId); + if (!zoneName.empty()) bindMsg += " to " + zoneName; + bindMsg += '.'; + addSystemChatMessage(bindMsg); + } + } + }; + + // Spirit healer / resurrect + dispatchTable_[Opcode::SMSG_SPIRIT_HEALER_CONFIRM] = [this](network::Packet& packet) { + if (!packet.hasRemaining(8)) return; + uint64_t npcGuid = packet.readUInt64(); + if (npcGuid) { + resurrectCasterGuid_ = npcGuid; + resurrectCasterName_ = ""; + resurrectIsSpiritHealer_ = true; + resurrectRequestPending_ = true; + } + }; + dispatchTable_[Opcode::SMSG_RESURRECT_REQUEST] = [this](network::Packet& packet) { + if (!packet.hasRemaining(8)) return; + uint64_t casterGuid = packet.readUInt64(); + std::string casterName; + if (packet.hasData()) + casterName = packet.readString(); + if (casterGuid) { + resurrectCasterGuid_ = casterGuid; + resurrectIsSpiritHealer_ = false; + if (!casterName.empty()) { + resurrectCasterName_ = casterName; + } else { + resurrectCasterName_ = lookupName(casterGuid); + } + resurrectRequestPending_ = true; + fireAddonEvent("RESURRECT_REQUEST", {resurrectCasterName_}); + } + }; + + // Time sync + dispatchTable_[Opcode::SMSG_TIME_SYNC_REQ] = [this](network::Packet& packet) { + if (!packet.hasRemaining(4)) return; + uint32_t counter = packet.readUInt32(); + if (socket) { + network::Packet resp(wireOpcode(Opcode::CMSG_TIME_SYNC_RESP)); + resp.writeUInt32(counter); + resp.writeUInt32(nextMovementTimestampMs()); + socket->send(resp); + } + }; + + // (SMSG_TRAINER_BUY_SUCCEEDED, SMSG_TRAINER_BUY_FAILED → moved to InventoryHandler) + + // Minimap ping + dispatchTable_[Opcode::MSG_MINIMAP_PING] = [this](network::Packet& packet) { + const bool mmTbcLike = isPreWotlk(); + if (!packet.hasRemaining(mmTbcLike ? 8u : 1u) ) return; + uint64_t senderGuid = mmTbcLike + ? packet.readUInt64() : packet.readPackedGuid(); + if (!packet.hasRemaining(8)) return; + float pingX = packet.readFloat(); + float pingY = packet.readFloat(); + MinimapPing ping; + ping.senderGuid = senderGuid; + ping.wowX = pingY; + ping.wowY = pingX; + ping.age = 0.0f; + minimapPings_.push_back(ping); + if (senderGuid != playerGuid) { + withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playMinimapPing(); }); + } + }; + dispatchTable_[Opcode::SMSG_ZONE_UNDER_ATTACK] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t areaId = packet.readUInt32(); + std::string areaName = getAreaName(areaId); + std::string msg = areaName.empty() + ? std::string("A zone is under attack!") + : (areaName + " is under attack!"); + addUIError(msg); + addSystemChatMessage(msg); + } + }; + + // Spirit healer time / durability + dispatchTable_[Opcode::SMSG_AREA_SPIRIT_HEALER_TIME] = [this](network::Packet& packet) { + if (packet.hasRemaining(12)) { + /*uint64_t guid =*/ packet.readUInt64(); + uint32_t timeMs = packet.readUInt32(); + uint32_t secs = timeMs / 1000; + char buf[128]; + std::snprintf(buf, sizeof(buf), "You will be able to resurrect in %u seconds.", secs); + addSystemChatMessage(buf); + } + }; + dispatchTable_[Opcode::SMSG_DURABILITY_DAMAGE_DEATH] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t pct = packet.readUInt32(); + char buf[80]; + std::snprintf(buf, sizeof(buf), + "You have lost %u%% of your gear's durability due to death.", pct); + addUIError(buf); + addSystemChatMessage(buf); + } + }; + + // (SMSG_INITIALIZE_FACTIONS, SMSG_SET_FACTION_STANDING, + // SMSG_SET_FACTION_ATWAR, SMSG_SET_FACTION_VISIBLE → moved to SocialHandler) + dispatchTable_[Opcode::SMSG_FEATURE_SYSTEM_STATUS] = [this](network::Packet& packet) { + packet.skipAll(); + }; + + // (SMSG_SET_FLAT_SPELL_MODIFIER, SMSG_SET_PCT_SPELL_MODIFIER, SMSG_SPELL_DELAYED → moved to SpellHandler) + + // Proficiency + dispatchTable_[Opcode::SMSG_SET_PROFICIENCY] = [this](network::Packet& packet) { + if (!packet.hasRemaining(5)) return; + uint8_t itemClass = packet.readUInt8(); + uint32_t mask = packet.readUInt32(); + if (itemClass == 2) weaponProficiency_ = mask; + else if (itemClass == 4) armorProficiency_ = mask; + }; + + // Loot money / misc consume + for (auto op : { Opcode::SMSG_LOOT_CLEAR_MONEY, Opcode::SMSG_NPC_TEXT_UPDATE }) { + dispatchTable_[op] = [](network::Packet& /*packet*/) {}; + } + + // Play sound + dispatchTable_[Opcode::SMSG_PLAY_SOUND] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t soundId = packet.readUInt32(); + if (playSoundCallback_) playSoundCallback_(soundId); + } + }; + + // SMSG_SERVER_MESSAGE — moved to ChatHandler::registerOpcodes + // SMSG_CHAT_SERVER_MESSAGE — moved to ChatHandler::registerOpcodes + // SMSG_AREA_TRIGGER_MESSAGE — moved to ChatHandler::registerOpcodes + dispatchTable_[Opcode::SMSG_TRIGGER_CINEMATIC] = [this](network::Packet& packet) { + packet.skipAll(); + network::Packet ack(wireOpcode(Opcode::CMSG_NEXT_CINEMATIC_CAMERA)); + socket->send(ack); + }; + + // ---- Batch 5: Teleport, taxi, BG, LFG, arena, movement relay, mail, bank, auction, quests ---- + + // Teleport + dispatchTable_[Opcode::SMSG_TRANSFER_PENDING] = [this](network::Packet& packet) { + uint32_t pendingMapId = packet.readUInt32(); + if (packet.hasRemaining(8)) { + packet.readUInt32(); // transportEntry + packet.readUInt32(); // transportMapId + } + (void)pendingMapId; + }; + dispatchTable_[Opcode::SMSG_TRANSFER_ABORTED] = [this](network::Packet& packet) { + uint32_t mapId = packet.readUInt32(); + uint8_t reason = (packet.hasData()) ? packet.readUInt8() : 0; + (void)mapId; + const char* abortMsg = nullptr; + switch (reason) { + case 0x01: abortMsg = "Transfer aborted: difficulty unavailable."; break; + case 0x02: abortMsg = "Transfer aborted: expansion required."; break; + case 0x03: abortMsg = "Transfer aborted: instance not found."; break; + case 0x04: abortMsg = "Transfer aborted: too many instances. Please wait before entering a new instance."; break; + case 0x06: abortMsg = "Transfer aborted: instance is full."; break; + case 0x07: abortMsg = "Transfer aborted: zone is in combat."; break; + case 0x08: abortMsg = "Transfer aborted: you are already in this instance."; break; + case 0x09: abortMsg = "Transfer aborted: not enough players."; break; + default: abortMsg = "Transfer aborted."; break; + } + addUIError(abortMsg); + addSystemChatMessage(abortMsg); + }; + + // Taxi + dispatchTable_[Opcode::SMSG_STANDSTATE_UPDATE] = [this](network::Packet& packet) { + if (packet.hasRemaining(1)) { + standState_ = packet.readUInt8(); + if (standStateCallback_) standStateCallback_(standState_); + } + }; + dispatchTable_[Opcode::SMSG_NEW_TAXI_PATH] = [this](network::Packet& /*packet*/) { + addSystemChatMessage("New flight path discovered!"); + }; + + // Arena + dispatchTable_[Opcode::MSG_TALENT_WIPE_CONFIRM] = [this](network::Packet& packet) { + if (!packet.hasRemaining(12)) { packet.skipAll(); return; } + talentWipeNpcGuid_ = packet.readUInt64(); + talentWipeCost_ = packet.readUInt32(); + talentWipePending_ = true; + fireAddonEvent("CONFIRM_TALENT_WIPE", {std::to_string(talentWipeCost_)}); + }; + + // (SMSG_CHANNEL_LIST → moved to ChatHandler) + // (SMSG_GROUP_SET_LEADER → moved to SocialHandler) + + // Gameobject / page text (entity queries moved to EntityController::registerOpcodes) + dispatchTable_[Opcode::SMSG_GAMEOBJECT_CUSTOM_ANIM] = [this](network::Packet& packet) { + if (packet.getSize() < 12) return; + uint64_t guid = packet.readUInt64(); + uint32_t animId = packet.readUInt32(); + if (gameObjectCustomAnimCallback_) + gameObjectCustomAnimCallback_(guid, animId); + if (animId == 0) { + auto goEnt = entityController_->getEntityManager().getEntity(guid); + if (goEnt && goEnt->getType() == ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(goEnt); + // Only show fishing message if the bobber belongs to us + // OBJECT_FIELD_CREATED_BY is a uint64 at field indices 6-7 + uint64_t createdBy = static_cast(go->getField(6)) + | (static_cast(go->getField(7)) << 32); + if (createdBy == playerGuid) { + auto* info = getCachedGameObjectInfo(go->getEntry()); + if (info && info->type == 17) { + addUIError("A fish is on your line!"); + addSystemChatMessage("A fish is on your line!"); + withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playQuestUpdate(); }); + } + } + } + } + }; + + // Item refund / socket gems / item time + dispatchTable_[Opcode::SMSG_ITEM_REFUND_RESULT] = [this](network::Packet& packet) { + if (packet.hasRemaining(12)) { + packet.readUInt64(); // itemGuid + uint32_t result = packet.readUInt32(); + addSystemChatMessage(result == 0 ? "Item returned. Refund processed." + : "Could not return item for refund."); + } + }; + dispatchTable_[Opcode::SMSG_SOCKET_GEMS_RESULT] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t result = packet.readUInt32(); + if (result == 0) addSystemChatMessage("Gems socketed successfully."); + else addSystemChatMessage("Failed to socket gems."); + } + }; + dispatchTable_[Opcode::SMSG_ITEM_TIME_UPDATE] = [this](network::Packet& packet) { + if (packet.hasRemaining(12)) { + packet.readUInt64(); // itemGuid + packet.readUInt32(); // durationMs + } + }; + + // ---- Batch 6: Spell miss / env damage / control / spell failure ---- + + + // ---- Achievement / fishing delegates ---- + dispatchTable_[Opcode::SMSG_ALL_ACHIEVEMENT_DATA] = [this](network::Packet& packet) { + handleAllAchievementData(packet); + }; + dispatchTable_[Opcode::SMSG_FISH_NOT_HOOKED] = [this](network::Packet& /*packet*/) { + addSystemChatMessage("Your fish got away."); + }; + dispatchTable_[Opcode::SMSG_FISH_ESCAPED] = [this](network::Packet& /*packet*/) { + addSystemChatMessage("Your fish escaped!"); + }; + + // ---- Auto-repeat / auras / dispel / totem ---- + dispatchTable_[Opcode::SMSG_CANCEL_AUTO_REPEAT] = [this](network::Packet& /*packet*/) { + // Server signals to stop a repeating spell (wand/shoot); no client action needed + }; + + + // ---- Batch 7: World states, action buttons, level-up, vendor, inventory ---- + + // ---- SMSG_INIT_WORLD_STATES ---- + dispatchTable_[Opcode::SMSG_INIT_WORLD_STATES] = [this](network::Packet& packet) { + // WotLK format: uint32 mapId, uint32 zoneId, uint32 areaId, uint16 count, N*(uint32 key, uint32 val) + // Classic/TBC format: uint32 mapId, uint32 zoneId, uint16 count, N*(uint32 key, uint32 val) + if (!packet.hasRemaining(10)) { + LOG_WARNING("SMSG_INIT_WORLD_STATES too short: ", packet.getSize(), " bytes"); + return; + } + worldStateMapId_ = packet.readUInt32(); + { + uint32_t newZoneId = packet.readUInt32(); + if (newZoneId != worldStateZoneId_ && newZoneId != 0) { + worldStateZoneId_ = newZoneId; + fireAddonEvent("ZONE_CHANGED_NEW_AREA", {}); + fireAddonEvent("ZONE_CHANGED", {}); + } else { + worldStateZoneId_ = newZoneId; + } + } + // WotLK adds areaId (uint32) before count; Classic/TBC/Turtle use the shorter format + size_t remaining = packet.getRemainingSize(); + bool isWotLKFormat = isActiveExpansion("wotlk"); + if (isWotLKFormat && remaining >= 6) { + packet.readUInt32(); // areaId (WotLK only) + } + uint16_t count = packet.readUInt16(); + size_t needed = static_cast(count) * 8; + size_t available = packet.getRemainingSize(); + if (available < needed) { + // Be tolerant across expansion/private-core variants: if packet shape + // still looks like N*(key,val) dwords, parse what is present. + if ((available % 8) == 0) { + uint16_t adjustedCount = static_cast(available / 8); + LOG_WARNING("SMSG_INIT_WORLD_STATES count mismatch: header=", count, + " adjusted=", adjustedCount, " (available=", available, ")"); + count = adjustedCount; + needed = available; + } else { + LOG_WARNING("SMSG_INIT_WORLD_STATES truncated: expected ", needed, + " bytes of state pairs, got ", available); + packet.skipAll(); + return; + } + } + worldStates_.clear(); + worldStates_.reserve(count); + for (uint16_t i = 0; i < count; ++i) { + uint32_t key = packet.readUInt32(); + uint32_t val = packet.readUInt32(); + worldStates_[key] = val; + } + }; + + // ---- SMSG_ACTION_BUTTONS ---- + dispatchTable_[Opcode::SMSG_ACTION_BUTTONS] = [this](network::Packet& packet) { + // Slot encoding differs by expansion: + // Classic/Turtle: uint16 actionId + uint8 type + uint8 misc + // type: 0=spell, 1=item, 64=macro + // TBC/WotLK: uint32 packed = actionId | (type << 24) + // type: 0x00=spell, 0x80=item, 0x40=macro + // Format differences: + // Classic 1.12: no mode byte, 120 slots (480 bytes) + // TBC 2.4.3: no mode byte, 132 slots (528 bytes) + // WotLK 3.3.5a: uint8 mode + 144 slots (577 bytes) + size_t rem = packet.getRemainingSize(); + const bool hasModeByteExp = isActiveExpansion("wotlk"); + int serverBarSlots; + if (isClassicLikeExpansion()) { + serverBarSlots = 120; + } else if (isActiveExpansion("tbc")) { + serverBarSlots = 132; + } else { + serverBarSlots = 144; + } + if (hasModeByteExp) { + if (rem < 1) return; + /*uint8_t mode =*/ packet.readUInt8(); + rem--; + } + for (int i = 0; i < serverBarSlots; ++i) { + if (rem < 4) return; + uint32_t packed = packet.readUInt32(); + rem -= 4; + 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. + continue; + } + uint8_t type = 0; + uint32_t id = 0; + if (isClassicLikeExpansion()) { + id = packed & 0x0000FFFFu; + type = static_cast((packed >> 16) & 0xFF); + } else { + type = static_cast((packed >> 24) & 0xFF); + id = packed & 0x00FFFFFFu; + } + if (id == 0) continue; + ActionBarSlot slot; + switch (type) { + case 0x00: slot.type = ActionBarSlot::SPELL; slot.id = id; break; + case 0x01: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // Classic item + case 0x80: slot.type = ActionBarSlot::ITEM; slot.id = id; break; // TBC/WotLK item + case 0x40: slot.type = ActionBarSlot::MACRO; slot.id = id; break; // macro (all expansions) + default: continue; // unknown — leave as-is + } + actionBar[i] = slot; + } + // Apply any pending cooldowns from spellHandler's cooldowns to newly populated slots. + // SMSG_SPELL_COOLDOWN often arrives before SMSG_ACTION_BUTTONS during login, + // so the per-slot cooldownRemaining would be 0 without this sync. + if (spellHandler_) { + for (auto& slot : actionBar) { + if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { + auto cdIt = spellHandler_->spellCooldowns_.find(slot.id); + if (cdIt != spellHandler_->spellCooldowns_.end() && cdIt->second > 0.0f) { + slot.cooldownRemaining = cdIt->second; + slot.cooldownTotal = cdIt->second; + } + } else if (slot.type == ActionBarSlot::ITEM && slot.id != 0) { + // Items (potions, trinkets): look up the item's on-use spell + // and check if that spell has a pending cooldown. + const auto* qi = getItemInfo(slot.id); + if (qi && qi->valid) { + for (const auto& sp : qi->spells) { + if (sp.spellId == 0) continue; + auto cdIt = spellHandler_->spellCooldowns_.find(sp.spellId); + if (cdIt != spellHandler_->spellCooldowns_.end() && cdIt->second > 0.0f) { + slot.cooldownRemaining = cdIt->second; + slot.cooldownTotal = cdIt->second; + break; + } + } + } + } + } + } + LOG_INFO("SMSG_ACTION_BUTTONS: populated action bar from server"); + fireAddonEvent("ACTIONBAR_SLOT_CHANGED", {}); + packet.skipAll(); + }; + + // ---- SMSG_LEVELUP_INFO / SMSG_LEVELUP_INFO_ALT (shared body) ---- + for (auto op : {Opcode::SMSG_LEVELUP_INFO, Opcode::SMSG_LEVELUP_INFO_ALT}) { + dispatchTable_[op] = [this](network::Packet& packet) { + // Server-authoritative level-up event. + // WotLK layout: uint32 newLevel + uint32 hpDelta + uint32 manaDelta + 5x uint32 statDeltas + if (packet.hasRemaining(4)) { + uint32_t newLevel = packet.readUInt32(); + if (newLevel > 0) { + // Parse stat deltas (WotLK layout has 7 more uint32s) + lastLevelUpDeltas_ = {}; + if (packet.hasRemaining(28)) { + lastLevelUpDeltas_.hp = packet.readUInt32(); + lastLevelUpDeltas_.mana = packet.readUInt32(); + lastLevelUpDeltas_.str = packet.readUInt32(); + lastLevelUpDeltas_.agi = packet.readUInt32(); + lastLevelUpDeltas_.sta = packet.readUInt32(); + lastLevelUpDeltas_.intel = packet.readUInt32(); + lastLevelUpDeltas_.spi = packet.readUInt32(); + } + uint32_t oldLevel = serverPlayerLevel_; + serverPlayerLevel_ = std::max(serverPlayerLevel_, newLevel); + // Update the character-list entry so the selection screen + // shows the correct level if the player logs out and back. + for (auto& ch : characters) { + if (ch.guid == playerGuid) { + ch.level = serverPlayerLevel_; + break; // was 'return' — must NOT exit here or level-up notification is skipped + } + } + if (newLevel > oldLevel) { + addSystemChatMessage("You have reached level " + std::to_string(newLevel) + "!"); + withSoundManager(&audio::AudioCoordinator::getUiSoundManager, [](auto* sfx) { sfx->playLevelUp(); }); + if (levelUpCallback_) levelUpCallback_(newLevel); + fireAddonEvent("PLAYER_LEVEL_UP", {std::to_string(newLevel)}); + } + } + } + packet.skipAll(); + }; + } + + // ---- MSG_RAID_TARGET_UPDATE ---- + dispatchTable_[Opcode::MSG_RAID_TARGET_UPDATE] = [this](network::Packet& packet) { + // uint8 type: 0 = full update (8 × (uint8 icon + uint64 guid)), + // 1 = single update (uint8 icon + uint64 guid) + size_t remRTU = packet.getRemainingSize(); + if (remRTU < 1) return; + uint8_t rtuType = packet.readUInt8(); + if (rtuType == 0) { + // Full update: always 8 entries + for (uint32_t i = 0; i < kRaidMarkCount; ++i) { + if (!packet.hasRemaining(9)) return; + uint8_t icon = packet.readUInt8(); + uint64_t guid = packet.readUInt64(); + if (socialHandler_) + socialHandler_->setRaidTargetGuid(icon, guid); + } + } else { + // Single update + if (packet.hasRemaining(9)) { + uint8_t icon = packet.readUInt8(); + uint64_t guid = packet.readUInt64(); + if (socialHandler_) + socialHandler_->setRaidTargetGuid(icon, guid); + } + } + LOG_DEBUG("MSG_RAID_TARGET_UPDATE: type=", static_cast(rtuType)); + fireAddonEvent("RAID_TARGET_UPDATE", {}); + }; + + // ---- SMSG_CRITERIA_UPDATE ---- + dispatchTable_[Opcode::SMSG_CRITERIA_UPDATE] = [this](network::Packet& packet) { + // uint32 criteriaId + uint64 progress + uint32 elapsedTime + uint32 creationTime + if (packet.hasRemaining(20)) { + uint32_t criteriaId = packet.readUInt32(); + uint64_t progress = packet.readUInt64(); + packet.readUInt32(); // elapsedTime + packet.readUInt32(); // creationTime + uint64_t oldProgress = 0; + auto cpit = criteriaProgress_.find(criteriaId); + if (cpit != criteriaProgress_.end()) oldProgress = cpit->second; + criteriaProgress_[criteriaId] = progress; + LOG_DEBUG("SMSG_CRITERIA_UPDATE: id=", criteriaId, " progress=", progress); + // Fire addon event for achievement tracking addons + if (progress != oldProgress) + fireAddonEvent("CRITERIA_UPDATE", {std::to_string(criteriaId), std::to_string(progress)}); + } + }; + + // ---- SMSG_BARBER_SHOP_RESULT ---- + dispatchTable_[Opcode::SMSG_BARBER_SHOP_RESULT] = [this](network::Packet& packet) { + // uint32 result (0 = success, 1 = no money, 2 = not barber, 3 = sitting) + if (packet.hasRemaining(4)) { + uint32_t result = packet.readUInt32(); + if (result == 0) { + addSystemChatMessage("Hairstyle changed."); + barberShopOpen_ = false; + fireAddonEvent("BARBER_SHOP_CLOSE", {}); + } else { + const char* msg = (result == 1) ? "Not enough money for new hairstyle." + : (result == 2) ? "You are not at a barber shop." + : (result == 3) ? "You must stand up to use the barber shop." + : "Barber shop unavailable."; + addUIError(msg); + addSystemChatMessage(msg); + } + LOG_DEBUG("SMSG_BARBER_SHOP_RESULT: result=", result); + } + }; + + // ----------------------------------------------------------------------- + // Batch 8-12: Remaining opcodes (inspects, quests, auctions, spells, + // calendars, battlefields, voice, misc consume-only) + // ----------------------------------------------------------------------- + // uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs + dispatchTable_[Opcode::SMSG_OVERRIDE_LIGHT] = [this](network::Packet& packet) { + // uint32 currentZoneLightId + uint32 overrideLightId + uint32 transitionMs + if (packet.hasRemaining(12)) { + uint32_t zoneLightId = packet.readUInt32(); + uint32_t overrideLightId = packet.readUInt32(); + uint32_t transitionMs = packet.readUInt32(); + overrideLightId_ = overrideLightId; + overrideLightTransMs_ = transitionMs; + LOG_DEBUG("SMSG_OVERRIDE_LIGHT: zone=", zoneLightId, + " override=", overrideLightId, " transition=", transitionMs, "ms"); + } + }; + // Classic 1.12: uint32 weatherType + float intensity (8 bytes, no isAbrupt) + // TBC 2.4.3 / WotLK 3.3.5a: uint32 weatherType + float intensity + uint8 isAbrupt (9 bytes) + dispatchTable_[Opcode::SMSG_WEATHER] = [this](network::Packet& packet) { + // Classic 1.12: uint32 weatherType + float intensity (8 bytes, no isAbrupt) + // TBC 2.4.3 / WotLK 3.3.5a: uint32 weatherType + float intensity + uint8 isAbrupt (9 bytes) + if (packet.hasRemaining(8)) { + uint32_t wType = packet.readUInt32(); + float wIntensity = packet.readFloat(); + if (packet.hasRemaining(1)) + /*uint8_t isAbrupt =*/ packet.readUInt8(); + uint32_t prevWeatherType = weatherType_; + weatherType_ = wType; + weatherIntensity_ = wIntensity; + const char* typeName = (wType == 1) ? "Rain" : (wType == 2) ? "Snow" : (wType == 3) ? "Storm" : "Clear"; + LOG_INFO("Weather changed: type=", wType, " (", typeName, "), intensity=", wIntensity); + // Announce weather changes (including initial zone weather) + if (wType != prevWeatherType) { + const char* weatherMsg = nullptr; + if (wIntensity < 0.05f || wType == 0) { + if (prevWeatherType != 0) + weatherMsg = "The weather clears."; + } else if (wType == 1) { + weatherMsg = "It begins to rain."; + } else if (wType == 2) { + weatherMsg = "It begins to snow."; + } else if (wType == 3) { + weatherMsg = "A storm rolls in."; + } + if (weatherMsg) addSystemChatMessage(weatherMsg); + } + // Notify addons of weather change + fireAddonEvent("WEATHER_CHANGED", {std::to_string(wType), std::to_string(wIntensity)}); + // Storm transition: trigger a low-frequency thunder rumble shake + if (wType == 3 && wIntensity > 0.3f && cameraShakeCallback_) { + float mag = 0.03f + wIntensity * 0.04f; // 0.03–0.07 units + cameraShakeCallback_(mag, 6.0f, 0.6f); + } + } + }; + // Server-script text message — display in system chat + dispatchTable_[Opcode::SMSG_SCRIPT_MESSAGE] = [this](network::Packet& packet) { + // Server-script text message — display in system chat + std::string msg = packet.readString(); + if (!msg.empty()) { + addSystemChatMessage(msg); + LOG_INFO("SMSG_SCRIPT_MESSAGE: ", msg); + } + }; + // uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType + dispatchTable_[Opcode::SMSG_ENCHANTMENTLOG] = [this](network::Packet& packet) { + // uint64 targetGuid + uint64 casterGuid + uint32 spellId + uint32 displayId + uint32 animType + if (packet.hasRemaining(28)) { + uint64_t enchTargetGuid = packet.readUInt64(); + uint64_t enchCasterGuid = packet.readUInt64(); + uint32_t enchSpellId = packet.readUInt32(); + /*uint32_t displayId =*/ packet.readUInt32(); + /*uint32_t animType =*/ packet.readUInt32(); + LOG_DEBUG("SMSG_ENCHANTMENTLOG: spellId=", enchSpellId); + // Show enchant message if the player is involved + if (enchTargetGuid == playerGuid || enchCasterGuid == playerGuid) { + const std::string& enchName = getSpellName(enchSpellId); + std::string casterName = lookupName(enchCasterGuid); + if (!enchName.empty()) { + std::string msg; + if (enchCasterGuid == playerGuid) + msg = "You enchant with " + enchName + "."; + else if (!casterName.empty()) + msg = casterName + " enchants your item with " + enchName + "."; + else + msg = "Your item has been enchanted with " + enchName + "."; + addSystemChatMessage(msg); + } + } + } + }; + // WotLK: uint64 playerGuid + uint8 teamCount + per-team fields + dispatchTable_[Opcode::MSG_INSPECT_ARENA_TEAMS] = [this](network::Packet& packet) { + // WotLK: uint64 playerGuid + uint8 teamCount + per-team fields + if (!packet.hasRemaining(9)) { + packet.skipAll(); + return; + } + uint64_t inspGuid = packet.readUInt64(); + uint8_t teamCount = packet.readUInt8(); + if (teamCount > 3) teamCount = 3; // 2v2, 3v3, 5v5 + if (socialHandler_) { + auto& ir = socialHandler_->mutableInspectResult(); + if (inspGuid == ir.guid || ir.guid == 0) { + ir.guid = inspGuid; + ir.arenaTeams.clear(); + for (uint8_t t = 0; t < teamCount; ++t) { + if (!packet.hasRemaining(21)) break; + SocialHandler::InspectArenaTeam team; + team.teamId = packet.readUInt32(); + team.type = packet.readUInt8(); + team.weekGames = packet.readUInt32(); + team.weekWins = packet.readUInt32(); + team.seasonGames = packet.readUInt32(); + team.seasonWins = packet.readUInt32(); + team.name = packet.readString(); + if (!packet.hasRemaining(4)) break; + team.personalRating = packet.readUInt32(); + ir.arenaTeams.push_back(std::move(team)); + } + } + } + LOG_DEBUG("MSG_INSPECT_ARENA_TEAMS: guid=0x", std::hex, inspGuid, std::dec, + " teams=", static_cast(teamCount)); + }; + // auctionId(u32) + action(u32) + error(u32) + itemEntry(u32) + randomPropertyId(u32) + ... + // action: 0=sold/won, 1=expired, 2=bid placed on your auction + // auctionHouseId(u32) + auctionId(u32) + bidderGuid(u64) + bidAmount(u32) + outbidAmount(u32) + itemEntry(u32) + randomPropertyId(u32) + // uint32 auctionId + uint32 itemEntry + uint32 itemRandom — auction expired/cancelled + // uint64 containerGuid — tells client to open this container + // The actual items come via update packets; we just log this. + // PackedGuid (player guid) + uint32 vehicleId + // vehicleId == 0 means the player left the vehicle + dispatchTable_[Opcode::SMSG_PLAYER_VEHICLE_DATA] = [this](network::Packet& packet) { + // PackedGuid (player guid) + uint32 vehicleId + // vehicleId == 0 means the player left the vehicle + if (packet.hasRemaining(1)) { + (void)packet.readPackedGuid(); // player guid (unused) + } + uint32_t newVehicleId = 0; + if (packet.hasRemaining(4)) { + newVehicleId = packet.readUInt32(); + } + bool wasInVehicle = vehicleId_ != 0; + bool nowInVehicle = newVehicleId != 0; + vehicleId_ = newVehicleId; + if (wasInVehicle != nowInVehicle && vehicleStateCallback_) { + vehicleStateCallback_(nowInVehicle, newVehicleId); + } + }; + // guid(8) + status(1): status 1 = NPC has available/new routes for this player + dispatchTable_[Opcode::SMSG_TAXINODE_STATUS] = [this](network::Packet& packet) { + // guid(8) + status(1): status 1 = NPC has available/new routes for this player + if (packet.hasRemaining(9)) { + uint64_t npcGuid = packet.readUInt64(); + uint8_t status = packet.readUInt8(); + taxiNpcHasRoutes_[npcGuid] = (status != 0); + } + }; + // SMSG_GUILD_DECLINE — moved to SocialHandler::registerOpcodes + // Clear cached talent data so the talent screen reflects the reset. + dispatchTable_[Opcode::SMSG_TALENTS_INVOLUNTARILY_RESET] = [this](network::Packet& packet) { + // Clear cached talent data so the talent screen reflects the reset. + if (spellHandler_) spellHandler_->resetTalentState(); + addUIError("Your talents have been reset by the server."); + addSystemChatMessage("Your talents have been reset by the server."); + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_SET_REST_START] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t restTrigger = packet.readUInt32(); + isResting_ = (restTrigger > 0); + addSystemChatMessage(isResting_ ? "You are now resting." + : "You are no longer resting."); + fireAddonEvent("PLAYER_UPDATE_RESTING", {}); + } + }; + dispatchTable_[Opcode::SMSG_UPDATE_AURA_DURATION] = [this](network::Packet& packet) { + if (packet.hasRemaining(5)) { + uint8_t slot = packet.readUInt8(); + uint32_t durationMs = packet.readUInt32(); + handleUpdateAuraDuration(slot, durationMs); + } + }; + dispatchTable_[Opcode::SMSG_ITEM_NAME_QUERY_RESPONSE] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t itemId = packet.readUInt32(); + std::string name = packet.readString(); + if (!itemInfoCache_.count(itemId) && !name.empty()) { + ItemQueryResponseData stub; + stub.entry = itemId; + stub.name = std::move(name); + stub.valid = true; + itemInfoCache_[itemId] = std::move(stub); + } + } + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_MOUNTSPECIAL_ANIM] = [this](network::Packet& packet) { (void)packet.readPackedGuid(); }; + dispatchTable_[Opcode::SMSG_CHAR_CUSTOMIZE] = [this](network::Packet& packet) { + if (packet.hasRemaining(1)) { + uint8_t result = packet.readUInt8(); + addSystemChatMessage(result == 0 ? "Character customization complete." + : "Character customization failed."); + } + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_CHAR_FACTION_CHANGE] = [this](network::Packet& packet) { + if (packet.hasRemaining(1)) { + uint8_t result = packet.readUInt8(); + addSystemChatMessage(result == 0 ? "Faction change complete." + : "Faction change failed."); + } + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_INVALIDATE_PLAYER] = [this](network::Packet& packet) { + if (packet.hasRemaining(8)) { + uint64_t guid = packet.readUInt64(); + entityController_->invalidatePlayerName(guid); + } + }; + // uint32 movieId — we don't play movies; acknowledge immediately. + dispatchTable_[Opcode::SMSG_TRIGGER_MOVIE] = [this](network::Packet& packet) { + // uint32 movieId — we don't play movies; acknowledge immediately. + packet.skipAll(); + // WotLK servers expect CMSG_COMPLETE_MOVIE after the movie finishes; + // without it, the server may hang or disconnect the client. + uint16_t wire = wireOpcode(Opcode::CMSG_COMPLETE_MOVIE); + if (wire != 0xFFFF) { + network::Packet ack(wire); + socket->send(ack); + LOG_DEBUG("SMSG_TRIGGER_MOVIE: skipped, sent CMSG_COMPLETE_MOVIE"); + } + }; + // Server-side LFG invite timed out (no response within time limit) + dispatchTable_[Opcode::SMSG_LFG_TIMEDOUT] = [this](network::Packet& packet) { + // Server-side LFG invite timed out (no response within time limit) + addSystemChatMessage("Dungeon Finder: Invite timed out."); + if (openLfgCallback_) openLfgCallback_(); + packet.skipAll(); + }; + // Another party member failed to respond to a LFG role-check in time + dispatchTable_[Opcode::SMSG_LFG_OTHER_TIMEDOUT] = [this](network::Packet& packet) { + // Another party member failed to respond to a LFG role-check in time + addSystemChatMessage("Dungeon Finder: Another player's invite timed out."); + if (openLfgCallback_) openLfgCallback_(); + packet.skipAll(); + }; + // uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time) + dispatchTable_[Opcode::SMSG_LFG_AUTOJOIN_FAILED] = [this](network::Packet& packet) { + // uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time) + if (packet.hasRemaining(4)) { + uint32_t result = packet.readUInt32(); + (void)result; + } + addUIError("Dungeon Finder: Auto-join failed."); + addSystemChatMessage("Dungeon Finder: Auto-join failed."); + packet.skipAll(); + }; + // No eligible players found for auto-join + dispatchTable_[Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER] = [this](network::Packet& packet) { + // No eligible players found for auto-join + addUIError("Dungeon Finder: No players available for auto-join."); + addSystemChatMessage("Dungeon Finder: No players available for auto-join."); + packet.skipAll(); + }; + // Party leader is currently set to Looking for More (LFM) mode + dispatchTable_[Opcode::SMSG_LFG_LEADER_IS_LFM] = [this](network::Packet& packet) { + // Party leader is currently set to Looking for More (LFM) mode + addSystemChatMessage("Your party leader is currently Looking for More."); + packet.skipAll(); + }; + // uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone + dispatchTable_[Opcode::SMSG_MEETINGSTONE_SETQUEUE] = [this](network::Packet& packet) { + // uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone + if (packet.hasRemaining(6)) { + uint32_t zoneId = packet.readUInt32(); + uint8_t levelMin = packet.readUInt8(); + uint8_t levelMax = packet.readUInt8(); + char buf[128]; + std::string zoneName = getAreaName(zoneId); + if (!zoneName.empty()) + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for %s (levels %u-%u).", + zoneName.c_str(), levelMin, levelMax); + else + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", + zoneId, levelMin, levelMax); + addSystemChatMessage(buf); + LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId, + " levels=", static_cast(levelMin), "-", static_cast(levelMax)); + } + packet.skipAll(); + }; + // Server confirms group found and teleport summon is ready + dispatchTable_[Opcode::SMSG_MEETINGSTONE_COMPLETE] = [this](network::Packet& packet) { + // Server confirms group found and teleport summon is ready + addSystemChatMessage("Meeting Stone: Your group is ready! Use the Meeting Stone to summon."); + LOG_INFO("SMSG_MEETINGSTONE_COMPLETE"); + packet.skipAll(); + }; + // Meeting stone search is still ongoing + dispatchTable_[Opcode::SMSG_MEETINGSTONE_IN_PROGRESS] = [this](network::Packet& packet) { + // Meeting stone search is still ongoing + addSystemChatMessage("Meeting Stone: Searching for group members..."); + LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS"); + packet.skipAll(); + }; + // uint64 memberGuid — a player was added to your group via meeting stone + dispatchTable_[Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED] = [this](network::Packet& packet) { + // uint64 memberGuid — a player was added to your group via meeting stone + if (packet.hasRemaining(8)) { + uint64_t memberGuid = packet.readUInt64(); + const auto& memberName = lookupName(memberGuid); + if (!memberName.empty()) { + addSystemChatMessage("Meeting Stone: " + memberName + + " has been added to your group."); + } else { + addSystemChatMessage("Meeting Stone: A new player has been added to your group."); + } + LOG_INFO("SMSG_MEETINGSTONE_MEMBER_ADDED: guid=0x", std::hex, memberGuid, std::dec); + } + }; + // uint8 reason — failed to join group via meeting stone + // 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available + dispatchTable_[Opcode::SMSG_MEETINGSTONE_JOINFAILED] = [this](network::Packet& packet) { + // uint8 reason — failed to join group via meeting stone + // 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available + static const char* kMeetingstoneErrors[] = { + "Target player is not using the Meeting Stone.", + "Target player is already in a group.", + "You are not in a valid zone for that Meeting Stone.", + "Target player is not available.", + }; + if (packet.hasRemaining(1)) { + uint8_t reason = packet.readUInt8(); + const char* msg = (reason < 4) ? kMeetingstoneErrors[reason] + : "Meeting Stone: Could not join group."; + addSystemChatMessage(msg); + LOG_INFO("SMSG_MEETINGSTONE_JOINFAILED: reason=", static_cast(reason)); + } + }; + // Player was removed from the meeting stone queue (left, or group disbanded) + dispatchTable_[Opcode::SMSG_MEETINGSTONE_LEAVE] = [this](network::Packet& packet) { + // Player was removed from the meeting stone queue (left, or group disbanded) + addSystemChatMessage("You have left the Meeting Stone queue."); + LOG_DEBUG("SMSG_MEETINGSTONE_LEAVE"); + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_GMTICKET_CREATE] = [this](network::Packet& packet) { + if (packet.hasRemaining(1)) { + uint8_t res = packet.readUInt8(); + addSystemChatMessage(res == 1 ? "GM ticket submitted." + : "Failed to submit GM ticket."); + } + }; + dispatchTable_[Opcode::SMSG_GMTICKET_UPDATETEXT] = [this](network::Packet& packet) { + if (packet.hasRemaining(1)) { + uint8_t res = packet.readUInt8(); + addSystemChatMessage(res == 1 ? "GM ticket updated." + : "Failed to update GM ticket."); + } + }; + dispatchTable_[Opcode::SMSG_GMTICKET_DELETETICKET] = [this](network::Packet& packet) { + if (packet.hasRemaining(1)) { + uint8_t res = packet.readUInt8(); + addSystemChatMessage(res == 9 ? "GM ticket deleted." + : "No ticket to delete."); + } + }; + // WotLK 3.3.5a format: + // uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended + // If status == 6 (GMTICKET_STATUS_HASTEXT): + // cstring ticketText + // uint32 ticketAge (seconds old) + // uint32 daysUntilOld (days remaining before escalation) + // float waitTimeHours (estimated GM wait time) + dispatchTable_[Opcode::SMSG_GMTICKET_GETTICKET] = [this](network::Packet& packet) { + // WotLK 3.3.5a format: + // uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended + // If status == 6 (GMTICKET_STATUS_HASTEXT): + // cstring ticketText + // uint32 ticketAge (seconds old) + // uint32 daysUntilOld (days remaining before escalation) + // float waitTimeHours (estimated GM wait time) + if (!packet.hasRemaining(1)) { packet.skipAll(); return; } + uint8_t gmStatus = packet.readUInt8(); + // Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text + if (gmStatus == 6 && packet.hasRemaining(1)) { + gmTicketText_ = packet.readString(); + uint32_t ageSec = (packet.hasRemaining(4)) ? packet.readUInt32() : 0; + /*uint32_t daysLeft =*/ (packet.hasRemaining(4)) ? packet.readUInt32() : 0; + gmTicketWaitHours_ = (packet.hasRemaining(4)) + ? packet.readFloat() : 0.0f; + gmTicketActive_ = true; + char buf[256]; + if (ageSec < 60) { + std::snprintf(buf, sizeof(buf), + "You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.", + ageSec, gmTicketWaitHours_); + } else { + uint32_t ageMin = ageSec / 60; + std::snprintf(buf, sizeof(buf), + "You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.", + ageMin, gmTicketWaitHours_); + } + addSystemChatMessage(buf); + LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec, + "s wait=", gmTicketWaitHours_, "h"); + } else if (gmStatus == 3) { + gmTicketActive_ = false; + gmTicketText_.clear(); + addSystemChatMessage("Your GM ticket has been closed."); + LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed"); + } else if (gmStatus == 10) { + gmTicketActive_ = false; + gmTicketText_.clear(); + addSystemChatMessage("Your GM ticket has been suspended."); + LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended"); + } else { + // Status 1 = no open ticket (default/no ticket) + gmTicketActive_ = false; + gmTicketText_.clear(); + LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", static_cast(gmStatus), ")"); + } + packet.skipAll(); + }; + // uint32 status: 1 = GM support available, 0 = offline/unavailable + dispatchTable_[Opcode::SMSG_GMTICKET_SYSTEMSTATUS] = [this](network::Packet& packet) { + // uint32 status: 1 = GM support available, 0 = offline/unavailable + if (packet.hasRemaining(4)) { + uint32_t sysStatus = packet.readUInt32(); + gmSupportAvailable_ = (sysStatus != 0); + addSystemChatMessage(gmSupportAvailable_ + ? "GM support is currently available." + : "GM support is currently unavailable."); + LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_); + } + packet.skipAll(); + }; + // uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death) + dispatchTable_[Opcode::SMSG_CONVERT_RUNE] = [this](network::Packet& packet) { + // uint8 runeIndex + uint8 newRuneType (0=Blood,1=Unholy,2=Frost,3=Death) + if (!packet.hasRemaining(2)) { + packet.skipAll(); + return; + } + uint8_t idx = packet.readUInt8(); + uint8_t type = packet.readUInt8(); + if (idx < 6) playerRunes_[idx].type = static_cast(type & 0x3); + }; + // uint8 runeReadyMask (bit i=1 → rune i is ready) + // uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255) + dispatchTable_[Opcode::SMSG_RESYNC_RUNES] = [this](network::Packet& packet) { + // uint8 runeReadyMask (bit i=1 → rune i is ready) + // uint8[6] cooldowns (0=ready, 255=just used → readyFraction = 1 - val/255) + if (!packet.hasRemaining(7)) { + packet.skipAll(); + return; + } + uint8_t readyMask = packet.readUInt8(); + for (int i = 0; i < 6; i++) { + uint8_t cd = packet.readUInt8(); + playerRunes_[i].ready = (readyMask & (1u << i)) != 0; + playerRunes_[i].readyFraction = 1.0f - cd / 255.0f; + if (playerRunes_[i].ready) playerRunes_[i].readyFraction = 1.0f; + } + }; + // uint32 runeMask (bit i=1 → rune i just became ready) + dispatchTable_[Opcode::SMSG_ADD_RUNE_POWER] = [this](network::Packet& packet) { + // uint32 runeMask (bit i=1 → rune i just became ready) + if (!packet.hasRemaining(4)) { + packet.skipAll(); + return; + } + uint32_t runeMask = packet.readUInt32(); + for (int i = 0; i < 6; i++) { + if (runeMask & (1u << i)) { + playerRunes_[i].ready = true; + playerRunes_[i].readyFraction = 1.0f; + } + } + }; + + // uint8 result: 0=success, 1=failed, 2=disabled + dispatchTable_[Opcode::SMSG_COMPLAIN_RESULT] = [this](network::Packet& packet) { + // uint8 result: 0=success, 1=failed, 2=disabled + if (packet.hasRemaining(1)) { + uint8_t result = packet.readUInt8(); + if (result == 0) + addSystemChatMessage("Your complaint has been submitted."); + else if (result == 2) + addUIError("Report a Player is currently disabled."); + } + packet.skipAll(); + }; + // uint32 slot + packed_guid unit (0 packed = clear slot) + dispatchTable_[Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT] = [this](network::Packet& packet) { + // uint32 slot + packed_guid unit (0 packed = clear slot) + if (!packet.hasRemaining(5)) { + packet.skipAll(); + return; + } + uint32_t slot = packet.readUInt32(); + uint64_t unit = packet.readPackedGuid(); + if (socialHandler_) { + socialHandler_->setEncounterUnitGuid(slot, unit); + LOG_DEBUG("SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: slot=", slot, + " guid=0x", std::hex, unit, std::dec); + } + }; + // charName (cstring) + guid (uint64) + achievementId (uint32) + ... + dispatchTable_[Opcode::SMSG_SERVER_FIRST_ACHIEVEMENT] = [this](network::Packet& packet) { + // charName (cstring) + guid (uint64) + achievementId (uint32) + ... + if (packet.hasData()) { + std::string charName = packet.readString(); + if (packet.hasRemaining(12)) { + /*uint64_t guid =*/ packet.readUInt64(); + uint32_t achievementId = packet.readUInt32(); + loadAchievementNameCache(); + auto nit = achievementNameCache_.find(achievementId); + char buf[256]; + if (nit != achievementNameCache_.end() && !nit->second.empty()) { + std::snprintf(buf, sizeof(buf), + "%s is the first on the realm to earn: %s!", + charName.c_str(), nit->second.c_str()); + } else { + std::snprintf(buf, sizeof(buf), + "%s is the first on the realm to earn achievement #%u!", + charName.c_str(), achievementId); + } + addSystemChatMessage(buf); + } + } + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_SUSPEND_COMMS] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t seqIdx = packet.readUInt32(); + if (socket) { + network::Packet ack(wireOpcode(Opcode::CMSG_SUSPEND_COMMS_ACK)); + ack.writeUInt32(seqIdx); + socket->send(ack); + } + } + }; + // SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect. + // Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock), + // or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept. + dispatchTable_[Opcode::SMSG_PRE_RESURRECT] = [this](network::Packet& packet) { + // SMSG_PRE_RESURRECT: packed GUID of the player who can self-resurrect. + // Sent when the dead player has Reincarnation (Shaman), Twisting Nether (Warlock), + // or Deathpact (Death Knight passive). The client must send CMSG_SELF_RES to accept. + uint64_t targetGuid = packet.readPackedGuid(); + if (targetGuid == playerGuid || targetGuid == 0) { + selfResAvailable_ = true; + LOG_INFO("SMSG_PRE_RESURRECT: self-resurrection available (guid=0x", + std::hex, targetGuid, std::dec, ")"); + } + }; + dispatchTable_[Opcode::SMSG_PLAYERBINDERROR] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t error = packet.readUInt32(); + if (error == 0) { + addUIError("Your hearthstone is not bound."); + addSystemChatMessage("Your hearthstone is not bound."); + } else { + addUIError("Hearthstone bind failed."); + addSystemChatMessage("Hearthstone bind failed."); + } + } + }; + dispatchTable_[Opcode::SMSG_RAID_GROUP_ONLY] = [this](network::Packet& packet) { + addUIError("You must be in a raid group to enter this instance."); + addSystemChatMessage("You must be in a raid group to enter this instance."); + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_RAID_READY_CHECK_ERROR] = [this](network::Packet& packet) { + if (packet.hasRemaining(1)) { + uint8_t err = packet.readUInt8(); + if (err == 0) { addUIError("Ready check failed: not in a group."); addSystemChatMessage("Ready check failed: not in a group."); } + else if (err == 1) { addUIError("Ready check failed: in instance."); addSystemChatMessage("Ready check failed: in instance."); } + else { addUIError("Ready check failed."); addSystemChatMessage("Ready check failed."); } + } + }; + dispatchTable_[Opcode::SMSG_RESET_FAILED_NOTIFY] = [this](network::Packet& packet) { + addUIError("Cannot reset instance: another player is still inside."); + addSystemChatMessage("Cannot reset instance: another player is still inside."); + packet.skipAll(); + }; + // uint32 splitType + uint32 deferTime + string realmName + // Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers. + dispatchTable_[Opcode::SMSG_REALM_SPLIT] = [this](network::Packet& packet) { + // uint32 splitType + uint32 deferTime + string realmName + // Client must respond with CMSG_REALM_SPLIT to avoid session timeout on some servers. + uint32_t splitType = 0; + if (packet.hasRemaining(4)) + splitType = packet.readUInt32(); + packet.skipAll(); + if (socket) { + network::Packet resp(wireOpcode(Opcode::CMSG_REALM_SPLIT)); + resp.writeUInt32(splitType); + resp.writeString("3.3.5"); + socket->send(resp); + LOG_DEBUG("SMSG_REALM_SPLIT splitType=", splitType, " — sent CMSG_REALM_SPLIT ack"); + } + }; + dispatchTable_[Opcode::SMSG_REAL_GROUP_UPDATE] = [this](network::Packet& packet) { + auto rem = [&]() { return packet.getRemainingSize(); }; + if (rem() < 1) return; + uint8_t newGroupType = packet.readUInt8(); + if (rem() < 4) return; + uint32_t newMemberFlags = packet.readUInt32(); + if (rem() < 8) return; + uint64_t newLeaderGuid = packet.readUInt64(); + + if (socialHandler_) { + auto& pd = socialHandler_->mutablePartyData(); + pd.groupType = newGroupType; + pd.leaderGuid = newLeaderGuid; + + // Update local player's flags in the member list + uint64_t localGuid = playerGuid; + for (auto& m : pd.members) { + if (m.guid == localGuid) { + m.flags = static_cast(newMemberFlags & 0xFF); + break; + } + } + } + LOG_DEBUG("SMSG_REAL_GROUP_UPDATE groupType=", static_cast(newGroupType), + " memberFlags=0x", std::hex, newMemberFlags, std::dec, + " leaderGuid=", newLeaderGuid); + fireAddonEvent("PARTY_LEADER_CHANGED", {}); + fireAddonEvent("GROUP_ROSTER_UPDATE", {}); + }; + dispatchTable_[Opcode::SMSG_PLAY_MUSIC] = [this](network::Packet& packet) { + if (packet.hasRemaining(4)) { + uint32_t soundId = packet.readUInt32(); + if (playMusicCallback_) playMusicCallback_(soundId); + } + }; + dispatchTable_[Opcode::SMSG_PLAY_OBJECT_SOUND] = [this](network::Packet& packet) { + if (packet.hasRemaining(12)) { + // uint32 soundId + uint64 sourceGuid + uint32_t soundId = packet.readUInt32(); + uint64_t srcGuid = packet.readUInt64(); + LOG_DEBUG("SMSG_PLAY_OBJECT_SOUND: id=", soundId, " src=0x", std::hex, srcGuid, std::dec); + if (playPositionalSoundCallback_) playPositionalSoundCallback_(soundId, srcGuid); + else if (playSoundCallback_) playSoundCallback_(soundId); + } else if (packet.hasRemaining(4)) { + uint32_t soundId = packet.readUInt32(); + if (playSoundCallback_) playSoundCallback_(soundId); + } + }; + // uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL) + dispatchTable_[Opcode::SMSG_PLAY_SPELL_IMPACT] = [this](network::Packet& packet) { + // uint64 targetGuid + uint32 visualId (same structure as SMSG_PLAY_SPELL_VISUAL) + if (!packet.hasRemaining(12)) { + packet.skipAll(); return; + } + uint64_t impTargetGuid = packet.readUInt64(); + uint32_t impVisualId = packet.readUInt32(); + if (impVisualId == 0) return; + auto* renderer = services_.renderer; + if (!renderer) return; + glm::vec3 spawnPos; + if (impTargetGuid == playerGuid) { + spawnPos = renderer->getCharacterPosition(); + } else { + auto entity = entityController_->getEntityManager().getEntity(impTargetGuid); + if (!entity) return; + glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); + spawnPos = core::coords::canonicalToRender(canonical); + } + if (auto* sv = renderer->getSpellVisualSystem()) sv->playSpellVisual(impVisualId, spawnPos, /*useImpactKit=*/true); + }; + // SMSG_READ_ITEM_OK — moved to InventoryHandler::registerOpcodes + // SMSG_READ_ITEM_FAILED — moved to InventoryHandler::registerOpcodes + // SMSG_QUERY_QUESTS_COMPLETED_RESPONSE — moved to QuestHandler::registerOpcodes + dispatchTable_[Opcode::SMSG_NPC_WONT_TALK] = [this](network::Packet& packet) { + addUIError("That creature can't talk to you right now."); + addSystemChatMessage("That creature can't talk to you right now."); + packet.skipAll(); + }; + + // SMSG_PET_UNLEARN_CONFIRM: uint64 petGuid + uint32 cost (copper). + // The other pet opcodes have different formats and must NOT set unlearn state. + dispatchTable_[Opcode::SMSG_PET_UNLEARN_CONFIRM] = [this](network::Packet& packet) { + if (packet.hasRemaining(12)) { + petUnlearnGuid_ = packet.readUInt64(); + petUnlearnCost_ = packet.readUInt32(); + petUnlearnPending_ = true; + } + packet.skipAll(); + }; + // These pet opcodes have incompatible formats — just consume the packet. + // Previously they shared the unlearn handler, which misinterpreted sound IDs + // or GUID lists as unlearn costs and could trigger a bogus unlearn dialog. + for (auto op : { Opcode::SMSG_PET_GUIDS, Opcode::SMSG_PET_DISMISS_SOUND, + Opcode::SMSG_PET_ACTION_SOUND }) { + dispatchTable_[op] = [](network::Packet& packet) { packet.skipAll(); }; + } + // Server signals that the pet can now be named (first tame) + dispatchTable_[Opcode::SMSG_PET_RENAMEABLE] = [this](network::Packet& packet) { + // Server signals that the pet can now be named (first tame) + petRenameablePending_ = true; + packet.skipAll(); + }; + dispatchTable_[Opcode::SMSG_PET_NAME_INVALID] = [this](network::Packet& packet) { + addUIError("That pet name is invalid. Please choose a different name."); + addSystemChatMessage("That pet name is invalid. Please choose a different name."); + packet.skipAll(); + }; + // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) + // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to + // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. + dispatchTable_[Opcode::SMSG_INSPECT] = [this](network::Packet& packet) { + // Classic 1.12: PackedGUID + 19×uint32 itemEntries (EQUIPMENT_SLOT_END=19) + // This opcode is only reachable on Classic servers; TBC/WotLK wire 0x115 maps to + // SMSG_INSPECT_RESULTS_UPDATE which is handled separately. + if (!packet.hasRemaining(2)) { + packet.skipAll(); return; + } + uint64_t guid = packet.readPackedGuid(); + if (guid == 0) { packet.skipAll(); return; } + + constexpr int kGearSlots = 19; + size_t needed = kGearSlots * sizeof(uint32_t); + if (!packet.hasRemaining(needed)) { + packet.skipAll(); return; + } + + std::array items{}; + for (int s = 0; s < kGearSlots; ++s) + items[s] = packet.readUInt32(); + + // Resolve player name + auto ent = entityController_->getEntityManager().getEntity(guid); + std::string playerName = "Target"; + if (ent) { + auto pl = std::dynamic_pointer_cast(ent); + if (pl && !pl->getName().empty()) playerName = pl->getName(); + } + + // Populate inspect result immediately (no talent data in Classic SMSG_INSPECT) + if (socialHandler_) { + auto& ir = socialHandler_->mutableInspectResult(); + ir.guid = guid; + ir.playerName = playerName; + ir.totalTalents = 0; + ir.unspentTalents = 0; + ir.talentGroups = 0; + ir.activeTalentGroup = 0; + ir.itemEntries = items; + ir.enchantIds = {}; + } + + // Also cache for future talent-inspect cross-reference + inspectedPlayerItemEntries_[guid] = items; + + // Trigger item queries for non-empty slots + for (int s = 0; s < kGearSlots; ++s) { + if (items[s] != 0) queryItemInfo(items[s], 0); + } + + LOG_INFO("SMSG_INSPECT (Classic): ", playerName, " has gear in ", + std::count_if(items.begin(), items.end(), + [](uint32_t e) { return e != 0; }), "/19 slots"); + if (addonEventCallback_) { + char guidBuf[32]; + snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); + fireAddonEvent("INSPECT_READY", {guidBuf}); + } + }; + // Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[] + dispatchTable_[Opcode::SMSG_MULTIPLE_MOVES] = [this](network::Packet& packet) { + // Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[] + if (movementHandler_) movementHandler_->handleCompressedMoves(packet); + }; + // Each sub-packet uses the standard WotLK server wire format: + // uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2) + // uint16_le subOpcode + // payload (subSize - 2 bytes) + dispatchTable_[Opcode::SMSG_MULTIPLE_PACKETS] = [this](network::Packet& packet) { + // Each sub-packet uses the standard WotLK server wire format: + // uint16_be subSize (includes the 2-byte opcode; payload = subSize - 2) + // uint16_le subOpcode + // payload (subSize - 2 bytes) + const auto& pdata = packet.getData(); + size_t dataLen = pdata.size(); + size_t pos = packet.getReadPos(); + static uint32_t multiPktWarnCount = 0; + std::vector subPackets; + while (pos + 4 <= dataLen) { + uint16_t subSize = static_cast( + (static_cast(pdata[pos]) << 8) | pdata[pos + 1]); + if (subSize < 2) break; + size_t payloadLen = subSize - 2; + if (pos + 4 + payloadLen > dataLen) { + if (++multiPktWarnCount <= 10) { + LOG_WARNING("SMSG_MULTIPLE_PACKETS: sub-packet overruns buffer at pos=", + pos, " subSize=", subSize, " dataLen=", dataLen); + } + break; + } + uint16_t subOpcode = static_cast(pdata[pos + 2]) | + (static_cast(pdata[pos + 3]) << 8); + std::vector subPayload(pdata.begin() + pos + 4, + pdata.begin() + pos + 4 + payloadLen); + subPackets.emplace_back(subOpcode, std::move(subPayload)); + pos += 4 + payloadLen; + } + for (auto it = subPackets.rbegin(); it != subPackets.rend(); ++it) { + enqueueIncomingPacketFront(std::move(*it)); + } + packet.skipAll(); + }; + // Recruit-A-Friend: a mentor is offering to grant you a level + dispatchTable_[Opcode::SMSG_PROPOSE_LEVEL_GRANT] = [this](network::Packet& packet) { + // Recruit-A-Friend: a mentor is offering to grant you a level + if (packet.hasRemaining(8)) { + uint64_t mentorGuid = packet.readUInt64(); + std::string mentorName; + auto ent = entityController_->getEntityManager().getEntity(mentorGuid); + if (auto* unit = dynamic_cast(ent.get())) mentorName = unit->getName(); + if (mentorName.empty()) mentorName = lookupName(mentorGuid); + addSystemChatMessage(mentorName.empty() + ? "A player is offering to grant you a level." + : (mentorName + " is offering to grant you a level.")); + } + packet.skipAll(); + }; + // SMSG_REFER_A_FRIEND_EXPIRED — moved to SocialHandler::registerOpcodes + // SMSG_REFER_A_FRIEND_FAILURE — moved to SocialHandler::registerOpcodes + // SMSG_REPORT_PVP_AFK_RESULT — moved to SocialHandler::registerOpcodes + dispatchTable_[Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS] = [this](network::Packet& packet) { + loadAchievementNameCache(); + if (!packet.hasRemaining(1)) return; + uint64_t inspectedGuid = packet.readPackedGuid(); + if (inspectedGuid == 0) { packet.skipAll(); return; } + std::unordered_set achievements; + while (packet.hasRemaining(4)) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (!packet.hasRemaining(4)) break; + /*date*/ packet.readUInt32(); + achievements.insert(id); + } + while (packet.hasRemaining(4)) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (!packet.hasRemaining(16)) break; + packet.readUInt64(); packet.readUInt32(); packet.readUInt32(); + } + inspectedPlayerAchievements_[inspectedGuid] = std::move(achievements); + LOG_INFO("SMSG_RESPOND_INSPECT_ACHIEVEMENTS: guid=0x", std::hex, inspectedGuid, std::dec, + " achievements=", inspectedPlayerAchievements_[inspectedGuid].size()); + }; + dispatchTable_[Opcode::SMSG_ON_CANCEL_EXPECTED_RIDE_VEHICLE_AURA] = [this](network::Packet& packet) { + vehicleId_ = 0; // Vehicle ride cancelled; clear UI + if (vehicleStateCallback_) { + vehicleStateCallback_(false, 0); + } + packet.skipAll(); + }; + // uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played + dispatchTable_[Opcode::SMSG_PLAY_TIME_WARNING] = [this](network::Packet& packet) { + // uint32 type (0=normal, 1=heavy, 2=tired/restricted) + uint32 minutes played + if (packet.hasRemaining(4)) { + uint32_t warnType = packet.readUInt32(); + uint32_t minutesPlayed = (packet.hasRemaining(4)) + ? packet.readUInt32() : 0; + const char* severity = (warnType >= 2) ? "[Tired] " : "[Play Time] "; + char buf[128]; + if (minutesPlayed > 0) { + uint32_t h = minutesPlayed / 60; + uint32_t m = minutesPlayed % 60; + if (h > 0) + std::snprintf(buf, sizeof(buf), "%sYou have been playing for %uh %um.", severity, h, m); + else + std::snprintf(buf, sizeof(buf), "%sYou have been playing for %um.", severity, m); + } else { + std::snprintf(buf, sizeof(buf), "%sYou have been playing for a long time.", severity); + } + addSystemChatMessage(buf); + addUIError(buf); + } + }; + // WotLK 3.3.5a format: + // uint64 mirrorGuid — GUID of the mirror image unit + // uint32 displayId — display ID to render the image with + // uint8 raceId — race of caster + // uint8 genderFlag — gender of caster + // uint8 classId — class of caster + // uint64 casterGuid — GUID of the player who cast the spell + // Followed by equipped item display IDs (11 × uint32) if casterGuid != 0 + // Purpose: tells client how to render the image (same appearance as caster). + // We parse the GUIDs so units render correctly via their existing display IDs. + dispatchTable_[Opcode::SMSG_MIRRORIMAGE_DATA] = [this](network::Packet& packet) { + // WotLK 3.3.5a format: + // uint64 mirrorGuid — GUID of the mirror image unit + // uint32 displayId — display ID to render the image with + // uint8 raceId — race of caster + // uint8 genderFlag — gender of caster + // uint8 classId — class of caster + // uint64 casterGuid — GUID of the player who cast the spell + // Followed by equipped item display IDs (11 × uint32) if casterGuid != 0 + // Purpose: tells client how to render the image (same appearance as caster). + // We parse the GUIDs so units render correctly via their existing display IDs. + if (!packet.hasRemaining(8)) return; + uint64_t mirrorGuid = packet.readUInt64(); + if (!packet.hasRemaining(4)) return; + uint32_t displayId = packet.readUInt32(); + if (!packet.hasRemaining(3)) return; + /*uint8_t raceId =*/ packet.readUInt8(); + /*uint8_t gender =*/ packet.readUInt8(); + /*uint8_t classId =*/ packet.readUInt8(); + // Apply display ID to the mirror image unit so it renders correctly + if (mirrorGuid != 0 && displayId != 0) { + auto entity = entityController_->getEntityManager().getEntity(mirrorGuid); + if (entity) { + auto unit = std::dynamic_pointer_cast(entity); + if (unit && unit->getDisplayId() == 0) + unit->setDisplayId(displayId); + } + } + LOG_DEBUG("SMSG_MIRRORIMAGE_DATA: mirrorGuid=0x", std::hex, mirrorGuid, + " displayId=", std::dec, displayId); + packet.skipAll(); + }; + // uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds) + dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE] = [this](network::Packet& packet) { + // uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds) + if (!packet.hasRemaining(20)) { + packet.skipAll(); return; + } + uint64_t bfGuid = packet.readUInt64(); + uint32_t bfZoneId = packet.readUInt32(); + uint64_t expireTime = packet.readUInt64(); + (void)bfGuid; (void)expireTime; + // Store the invitation so the UI can show a prompt + bfMgrInvitePending_ = true; + bfMgrZoneId_ = bfZoneId; + char buf[128]; + std::string bfZoneName = getAreaName(bfZoneId); + if (!bfZoneName.empty()) + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in %s. Click to enter.", + bfZoneName.c_str()); + else + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in zone %u. Click to enter.", + bfZoneId); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId); + }; + // uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue + dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_ENTERED] = [this](network::Packet& packet) { + // uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue + if (packet.hasRemaining(8)) { + uint64_t bfGuid2 = packet.readUInt64(); + (void)bfGuid2; + uint8_t isSafe = (packet.hasRemaining(1)) ? packet.readUInt8() : 0; + uint8_t onQueue = (packet.hasRemaining(1)) ? packet.readUInt8() : 0; + bfMgrInvitePending_ = false; + bfMgrActive_ = true; + addSystemChatMessage(isSafe ? "You are in the battlefield zone (safe area)." + : "You have entered the battlefield!"); + if (onQueue) addSystemChatMessage("You are in the battlefield queue."); + LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTERED: isSafe=", static_cast(isSafe), " onQueue=", static_cast(onQueue)); + } + packet.skipAll(); + }; + // uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime + dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE] = [this](network::Packet& packet) { + // uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime + if (!packet.hasRemaining(20)) { + packet.skipAll(); return; + } + uint64_t bfGuid3 = packet.readUInt64(); + uint32_t bfId = packet.readUInt32(); + uint64_t expTime = packet.readUInt64(); + (void)bfGuid3; (void)expTime; + bfMgrInvitePending_ = true; + bfMgrZoneId_ = bfId; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "A spot has opened in the battlefield queue (battlefield %u).", bfId); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: bfId=", bfId); + }; + // uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result + // result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level, + // 4=in_cooldown, 5=queued_other_bf, 6=bf_full + dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE] = [this](network::Packet& packet) { + // uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result + // result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level, + // 4=in_cooldown, 5=queued_other_bf, 6=bf_full + if (!packet.hasRemaining(11)) { + packet.skipAll(); return; + } + uint32_t bfId2 = packet.readUInt32(); + /*uint32_t teamId =*/ packet.readUInt32(); + uint8_t accepted = packet.readUInt8(); + /*uint8_t logging =*/ packet.readUInt8(); + uint8_t result = packet.readUInt8(); + (void)bfId2; + if (accepted) { + addSystemChatMessage("You have joined the battlefield queue."); + } else { + static const char* kBfQueueErrors[] = { + "Queued for battlefield.", "Not in a group.", "Level too high.", + "Level too low.", "Battlefield in cooldown.", "Already queued for another battlefield.", + "Battlefield is full." + }; + const char* msg = (result < 7) ? kBfQueueErrors[result] + : "Battlefield queue request failed."; + addSystemChatMessage(std::string("Battlefield: ") + msg); + } + LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: accepted=", static_cast(accepted), + " result=", static_cast(result)); + packet.skipAll(); + }; + // uint64 battlefieldGuid + uint8 remove + dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING] = [this](network::Packet& packet) { + // uint64 battlefieldGuid + uint8 remove + if (packet.hasRemaining(9)) { + uint64_t bfGuid4 = packet.readUInt64(); + uint8_t remove = packet.readUInt8(); + (void)bfGuid4; + if (remove) { + addSystemChatMessage("You will be removed from the battlefield shortly."); + } + LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECT_PENDING: remove=", static_cast(remove)); + } + packet.skipAll(); + }; + // uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated + dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_EJECTED] = [this](network::Packet& packet) { + // uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated + if (packet.hasRemaining(17)) { + uint64_t bfGuid5 = packet.readUInt64(); + uint32_t reason = packet.readUInt32(); + /*uint32_t status =*/ packet.readUInt32(); + uint8_t relocated = packet.readUInt8(); + (void)bfGuid5; + static const char* kEjectReasons[] = { + "Removed from battlefield.", "Transported from battlefield.", + "Left battlefield voluntarily.", "Offline.", + }; + const char* msg = (reason < 4) ? kEjectReasons[reason] + : "You have been ejected from the battlefield."; + addSystemChatMessage(msg); + if (relocated) addSystemChatMessage("You have been relocated outside the battlefield."); + LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECTED: reason=", reason, " relocated=", static_cast(relocated)); + } + bfMgrActive_ = false; + bfMgrInvitePending_ = false; + packet.skipAll(); + }; + // uint32 oldState + uint32 newState + // States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown + dispatchTable_[Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE] = [this](network::Packet& packet) { + // uint32 oldState + uint32 newState + // States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown + if (packet.hasRemaining(8)) { + /*uint32_t oldState =*/ packet.readUInt32(); + uint32_t newState = packet.readUInt32(); + static const char* kBfStates[] = { + "waiting", "starting", "in progress", "ending", "in cooldown" + }; + const char* stateStr = (newState < 5) ? kBfStates[newState] : "unknown state"; + char buf[128]; + std::snprintf(buf, sizeof(buf), "Battlefield is now %s.", stateStr); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_STATE_CHANGE: newState=", newState); + } + packet.skipAll(); + }; + // uint32 numPending — number of unacknowledged calendar invites + dispatchTable_[Opcode::SMSG_CALENDAR_SEND_NUM_PENDING] = [this](network::Packet& packet) { + // uint32 numPending — number of unacknowledged calendar invites + if (packet.hasRemaining(4)) { + uint32_t numPending = packet.readUInt32(); + calendarPendingInvites_ = numPending; + if (numPending > 0) { + char buf[64]; + std::snprintf(buf, sizeof(buf), + "You have %u pending calendar invite%s.", + numPending, numPending == 1 ? "" : "s"); + addSystemChatMessage(buf); + } + LOG_DEBUG("SMSG_CALENDAR_SEND_NUM_PENDING: ", numPending, " pending invites"); + } + }; + // uint32 command + uint8 result + cstring info + // result 0 = success; non-zero = error code + // command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove, + // 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status + dispatchTable_[Opcode::SMSG_CALENDAR_COMMAND_RESULT] = [this](network::Packet& packet) { + // uint32 command + uint8 result + cstring info + // result 0 = success; non-zero = error code + // command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove, + // 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status + if (!packet.hasRemaining(5)) { + packet.skipAll(); return; + } + /*uint32_t command =*/ packet.readUInt32(); + uint8_t result = packet.readUInt8(); + std::string info = (packet.hasData()) ? packet.readString() : ""; + if (result != 0) { + // Map common calendar error codes to friendly strings + static const char* kCalendarErrors[] = { + "", + "Calendar: Internal error.", // 1 = CALENDAR_ERROR_INTERNAL + "Calendar: Guild event limit reached.",// 2 + "Calendar: Event limit reached.", // 3 + "Calendar: You cannot invite that player.", // 4 + "Calendar: No invites remaining.", // 5 + "Calendar: Invalid date.", // 6 + "Calendar: Cannot invite yourself.", // 7 + "Calendar: Cannot modify this event.", // 8 + "Calendar: Not invited.", // 9 + "Calendar: Already invited.", // 10 + "Calendar: Player not found.", // 11 + "Calendar: Not enough focus.", // 12 + "Calendar: Event locked.", // 13 + "Calendar: Event deleted.", // 14 + "Calendar: Not a moderator.", // 15 + }; + const char* errMsg = (result < 16) ? kCalendarErrors[result] + : "Calendar: Command failed."; + if (errMsg && errMsg[0] != '\0') addSystemChatMessage(errMsg); + else if (!info.empty()) addSystemChatMessage("Calendar: " + info); + } + packet.skipAll(); + }; + // Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) + + // eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) + + // isGuildEvent(1) + inviterGuid(8) + dispatchTable_[Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT] = [this](network::Packet& packet) { + // Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) + + // eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) + + // isGuildEvent(1) + inviterGuid(8) + if (!packet.hasRemaining(9)) { + packet.skipAll(); return; + } + /*uint64_t eventId =*/ packet.readUInt64(); + std::string title = (packet.hasData()) ? packet.readString() : ""; + packet.skipAll(); // consume remaining fields + if (!title.empty()) { + addSystemChatMessage("Calendar invite: " + title); + } else { + addSystemChatMessage("You have a new calendar invite."); + } + if (calendarPendingInvites_ < 255) ++calendarPendingInvites_; + LOG_INFO("SMSG_CALENDAR_EVENT_INVITE_ALERT: title='", title, "'"); + }; + // Sent when an event invite's RSVP status changes for the local player + // Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) + + // inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring) + dispatchTable_[Opcode::SMSG_CALENDAR_EVENT_STATUS] = [this](network::Packet& packet) { + // Sent when an event invite's RSVP status changes for the local player + // Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) + + // inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring) + if (!packet.hasRemaining(31)) { + packet.skipAll(); return; + } + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + /*uint8_t evType =*/ packet.readUInt8(); + /*uint32_t flags =*/ packet.readUInt32(); + /*uint64_t invTime =*/ packet.readUInt64(); + uint8_t status = packet.readUInt8(); + /*uint8_t rank =*/ packet.readUInt8(); + /*uint8_t isGuild =*/ packet.readUInt8(); + std::string evTitle = (packet.hasData()) ? packet.readString() : ""; + // status: 0=Invited,1=Accepted,2=Declined,3=Confirmed,4=Out,5=Standby,6=SignedUp,7=Not Signed Up,8=Tentative + static const char* kRsvpStatus[] = { + "invited", "accepted", "declined", "confirmed", + "out", "on standby", "signed up", "not signed up", "tentative" + }; + const char* statusStr = (status < 9) ? kRsvpStatus[status] : "unknown"; + if (!evTitle.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "Calendar event '%s': your RSVP is %s.", + evTitle.c_str(), statusStr); + addSystemChatMessage(buf); + } + packet.skipAll(); + }; + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime + dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED] = [this](network::Packet& packet) { + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime + if (packet.hasRemaining(28)) { + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t difficulty = packet.readUInt32(); + /*uint64_t resetTime =*/ packet.readUInt64(); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; + std::string msg = "Calendar: Raid lockout added for " + mapLabel; + if (diffStr) msg += std::string(" (") + diffStr + ")"; + msg += '.'; + addSystemChatMessage(msg); + LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty); + } + packet.skipAll(); + }; + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + dispatchTable_[Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED] = [this](network::Packet& packet) { + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + if (packet.hasRemaining(20)) { + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t difficulty = packet.readUInt32(); + std::string mapLabel = getMapName(mapId); + if (mapLabel.empty()) mapLabel = "map #" + std::to_string(mapId); + static const char* kDiff[] = {"Normal","Heroic","25-Man","25-Man Heroic"}; + const char* diffStr = (difficulty < 4) ? kDiff[difficulty] : nullptr; + std::string msg = "Calendar: Raid lockout removed for " + mapLabel; + if (diffStr) msg += std::string(" (") + diffStr + ")"; + msg += '.'; + addSystemChatMessage(msg); + LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId, + " difficulty=", difficulty); + } + packet.skipAll(); + }; + // uint32 unixTime — server's current unix timestamp; use to sync gameTime_ + dispatchTable_[Opcode::SMSG_SERVERTIME] = [this](network::Packet& packet) { + // uint32 unixTime — server's current unix timestamp; use to sync gameTime_ + if (packet.hasRemaining(4)) { + uint32_t srvTime = packet.readUInt32(); + if (srvTime > 0) { + gameTime_ = static_cast(srvTime); + LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime); + } + } + }; + // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string + // kickReasonType: 0=other, 1=afk, 2=vote kick + dispatchTable_[Opcode::SMSG_KICK_REASON] = [this](network::Packet& packet) { + // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string + // kickReasonType: 0=other, 1=afk, 2=vote kick + if (!packet.hasRemaining(12)) { + packet.skipAll(); + return; + } + uint64_t kickerGuid = packet.readUInt64(); + uint32_t reasonType = packet.readUInt32(); + std::string reason; + if (packet.hasData()) + reason = packet.readString(); + (void)kickerGuid; // not displayed; reasonType IS used below + std::string msg = "You have been removed from the group."; + if (!reason.empty()) + msg = "You have been removed from the group: " + reason; + else if (reasonType == 1) + msg = "You have been removed from the group for being AFK."; + else if (reasonType == 2) + msg = "You have been removed from the group by vote."; + addSystemChatMessage(msg); + addUIError(msg); + LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType, + " reason='", reason, "'"); + }; + // uint32 throttleMs — rate-limited group action; notify the player + dispatchTable_[Opcode::SMSG_GROUPACTION_THROTTLED] = [this](network::Packet& packet) { + // uint32 throttleMs — rate-limited group action; notify the player + if (packet.hasRemaining(4)) { + uint32_t throttleMs = packet.readUInt32(); + char buf[128]; + if (throttleMs > 0) { + std::snprintf(buf, sizeof(buf), + "Group action throttled. Please wait %.1f seconds.", + throttleMs / 1000.0f); + } else { + std::snprintf(buf, sizeof(buf), "Group action throttled."); + } + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_GROUPACTION_THROTTLED: throttleMs=", throttleMs); + } + }; + // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count + // per count: string responseText + dispatchTable_[Opcode::SMSG_GMRESPONSE_RECEIVED] = [this](network::Packet& packet) { + // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count + // per count: string responseText + if (!packet.hasRemaining(4)) { + packet.skipAll(); + return; + } + uint32_t ticketId = packet.readUInt32(); + std::string subject; + std::string body; + if (packet.hasData()) subject = packet.readString(); + if (packet.hasData()) body = packet.readString(); + uint32_t responseCount = 0; + if (packet.hasRemaining(4)) + responseCount = packet.readUInt32(); + std::string responseText; + for (uint32_t i = 0; i < responseCount && i < 10; ++i) { + if (packet.hasData()) { + std::string t = packet.readString(); + if (i == 0) responseText = t; + } + } + (void)ticketId; + std::string msg; + if (!responseText.empty()) + msg = "[GM Response] " + responseText; + else if (!body.empty()) + msg = "[GM Response] " + body; + else if (!subject.empty()) + msg = "[GM Response] " + subject; + else + msg = "[GM Response] Your ticket has been answered."; + addSystemChatMessage(msg); + addUIError(msg); + LOG_INFO("SMSG_GMRESPONSE_RECEIVED: ticketId=", ticketId, + " subject='", subject, "'"); + }; + // uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help) + dispatchTable_[Opcode::SMSG_GMRESPONSE_STATUS_UPDATE] = [this](network::Packet& packet) { + // uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help) + if (packet.hasRemaining(5)) { + uint32_t ticketId = packet.readUInt32(); + uint8_t status = packet.readUInt8(); + const char* statusStr = (status == 1) ? "open" + : (status == 2) ? "answered" + : (status == 3) ? "needs more info" + : "updated"; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "[GM Ticket #%u] Status: %s.", ticketId, statusStr); + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_GMRESPONSE_STATUS_UPDATE: ticketId=", ticketId, + " status=", static_cast(status)); + } + }; + // GM ticket status (new/updated); no ticket UI yet + registerSkipHandler(Opcode::SMSG_GM_TICKET_STATUS_UPDATE); + // Client uses this outbound; treat inbound variant as no-op for robustness. + registerSkipHandler(Opcode::MSG_MOVE_WORLDPORT_ACK); + // Observed custom server packet (8 bytes). Safe-consume for now. + registerSkipHandler(Opcode::MSG_MOVE_TIME_SKIPPED); + // loggingOut_ already cleared by cancelLogout(); this is server's confirmation + registerSkipHandler(Opcode::SMSG_LOGOUT_CANCEL_ACK); + // These packets are not damage-shield events. Consume them without + // synthesizing reflected damage entries or misattributing GUIDs. + registerSkipHandler(Opcode::SMSG_AURACASTLOG); + // These packets are not damage-shield events. Consume them without + // synthesizing reflected damage entries or misattributing GUIDs. + registerSkipHandler(Opcode::SMSG_SPELLBREAKLOG); + // Consume silently — informational, no UI action needed + registerSkipHandler(Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE); + // Consume silently — informational, no UI action needed + registerSkipHandler(Opcode::SMSG_LOOT_LIST); + // Same format as LOCKOUT_ADDED; consume + registerSkipHandler(Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED); + // Consume — remaining server notifications not yet parsed + for (auto op : { + Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE, + Opcode::SMSG_AUCTION_LIST_PENDING_SALES, + Opcode::SMSG_AVAILABLE_VOICE_CHANNEL, + Opcode::SMSG_CALENDAR_ARENA_TEAM, + Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION, + Opcode::SMSG_CALENDAR_EVENT_INVITE, + Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES, + Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT, + Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED, + Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT, + Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT, + Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT, + Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT, + Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT, + Opcode::SMSG_CALENDAR_FILTER_GUILD, + Opcode::SMSG_CALENDAR_SEND_CALENDAR, + Opcode::SMSG_CALENDAR_SEND_EVENT, + Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE, + Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE, + Opcode::SMSG_CHEAT_PLAYER_LOOKUP, + Opcode::SMSG_CHECK_FOR_BOTS, + Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO, + Opcode::SMSG_COMMENTATOR_MAP_INFO, + Opcode::SMSG_COMMENTATOR_PLAYER_INFO, + Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1, + Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2, + Opcode::SMSG_COMMENTATOR_STATE_CHANGED, + Opcode::SMSG_COOLDOWN_CHEAT, + Opcode::SMSG_DANCE_QUERY_RESPONSE, + Opcode::SMSG_DBLOOKUP, + Opcode::SMSG_DEBUGAURAPROC, + Opcode::SMSG_DEBUG_AISTATE, + Opcode::SMSG_DEBUG_LIST_TARGETS, + Opcode::SMSG_DEBUG_SERVER_GEO, + Opcode::SMSG_DUMP_OBJECTS_DATA, + Opcode::SMSG_FORCEACTIONSHOW, + Opcode::SMSG_GM_PLAYER_INFO, + Opcode::SMSG_GODMODE, + Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT, + Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT, + Opcode::SMSG_INVALIDATE_DANCE, + Opcode::SMSG_LFG_PENDING_INVITE, + Opcode::SMSG_LFG_PENDING_MATCH, + Opcode::SMSG_LFG_PENDING_MATCH_DONE, + Opcode::SMSG_LFG_UPDATE, + Opcode::SMSG_LFG_UPDATE_LFG, + Opcode::SMSG_LFG_UPDATE_LFM, + Opcode::SMSG_LFG_UPDATE_QUEUED, + Opcode::SMSG_MOVE_CHARACTER_CHEAT, + Opcode::SMSG_NOTIFY_DANCE, + Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST, + Opcode::SMSG_PETGODMODE, + Opcode::SMSG_PET_UPDATE_COMBO_POINTS, + Opcode::SMSG_PLAYER_SKINNED, + Opcode::SMSG_PLAY_DANCE, + Opcode::SMSG_PROFILEDATA_RESPONSE, + Opcode::SMSG_PVP_QUEUE_STATS, + Opcode::SMSG_QUERY_OBJECT_POSITION, + Opcode::SMSG_QUERY_OBJECT_ROTATION, + Opcode::SMSG_REDIRECT_CLIENT, + Opcode::SMSG_RESET_RANGED_COMBAT_TIMER, + Opcode::SMSG_SEND_ALL_COMBAT_LOG, + Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE, + Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT, + Opcode::SMSG_SET_PROJECTILE_POSITION, + Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK, + Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS, + Opcode::SMSG_STOP_DANCE, + Opcode::SMSG_TEST_DROP_RATE_RESULT, + Opcode::SMSG_UPDATE_ACCOUNT_DATA, + Opcode::SMSG_UPDATE_ACCOUNT_DATA_COMPLETE, + Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP, + Opcode::SMSG_UPDATE_LAST_INSTANCE, + Opcode::SMSG_VOICESESSION_FULL, + Opcode::SMSG_VOICE_CHAT_STATUS, + Opcode::SMSG_VOICE_PARENTAL_CONTROLS, + Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY, + Opcode::SMSG_VOICE_SESSION_ENABLE, + Opcode::SMSG_VOICE_SESSION_LEAVE, + Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE, + Opcode::SMSG_VOICE_SET_TALKER_MUTED + }) { registerSkipHandler(op); } + + // ----------------------------------------------------------------------- + // Domain handler registrations (override duplicate entries above) + // ----------------------------------------------------------------------- + chatHandler_->registerOpcodes(dispatchTable_); + movementHandler_->registerOpcodes(dispatchTable_); + combatHandler_->registerOpcodes(dispatchTable_); + spellHandler_->registerOpcodes(dispatchTable_); + inventoryHandler_->registerOpcodes(dispatchTable_); + socialHandler_->registerOpcodes(dispatchTable_); + questHandler_->registerOpcodes(dispatchTable_); + wardenHandler_->registerOpcodes(dispatchTable_); +} + +void GameHandler::handlePacket(network::Packet& packet) { + if (packet.getSize() < 1) { + LOG_DEBUG("Received empty world packet (ignored)"); + return; + } + + uint16_t opcode = packet.getOpcode(); + + try { + + const bool allowVanillaAliases = isPreWotlk(); + + // Vanilla compatibility aliases: + // - 0x006B: can be SMSG_COMPRESSED_MOVES on some vanilla-family servers + // and SMSG_WEATHER on others + // - 0x0103: SMSG_PLAY_MUSIC (some vanilla-family servers) + // + // We gate these by payload shape so expansion-native mappings remain intact. + if (allowVanillaAliases && opcode == 0x006B) { + // Try compressed movement batch first: + // [u8 subSize][u16 subOpcode][subPayload...] ... + // where subOpcode is typically SMSG_MONSTER_MOVE / SMSG_MONSTER_MOVE_TRANSPORT. + const auto& data = packet.getData(); + if (packet.getReadPos() + 3 <= data.size()) { + size_t pos = packet.getReadPos(); + uint8_t subSize = data[pos]; + if (subSize >= 2 && pos + 1 + subSize <= data.size()) { + uint16_t subOpcode = static_cast(data[pos + 1]) | + (static_cast(data[pos + 2]) << 8); + uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE); + uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT); + if ((monsterMoveWire != 0xFFFF && subOpcode == monsterMoveWire) || + (monsterMoveTransportWire != 0xFFFF && subOpcode == monsterMoveTransportWire)) { + LOG_INFO("Opcode 0x006B interpreted as SMSG_COMPRESSED_MOVES (subOpcode=0x", + std::hex, subOpcode, std::dec, ")"); + if (movementHandler_) movementHandler_->handleCompressedMoves(packet); + return; + } + } + } + + // Expected weather payload: uint32 weatherType, float intensity, uint8 abrupt + if (packet.hasRemaining(9)) { + uint32_t wType = packet.readUInt32(); + float wIntensity = packet.readFloat(); + uint8_t abrupt = packet.readUInt8(); + bool plausibleWeather = + (wType <= 3) && + std::isfinite(wIntensity) && + (wIntensity >= 0.0f && wIntensity <= 1.5f) && + (abrupt <= 1); + if (plausibleWeather) { + weatherType_ = wType; + weatherIntensity_ = wIntensity; + const char* typeName = + (wType == 1) ? "Rain" : + (wType == 2) ? "Snow" : + (wType == 3) ? "Storm" : "Clear"; + LOG_INFO("Weather changed (0x006B alias): type=", wType, + " (", typeName, "), intensity=", wIntensity, + ", abrupt=", static_cast(abrupt)); + return; + } + // Not weather-shaped: rewind and fall through to normal opcode table handling. + packet.setReadPos(0); + } + } else if (allowVanillaAliases && opcode == 0x0103) { + // Expected play-music payload: uint32 sound/music id + if (packet.getRemainingSize() == 4) { + uint32_t soundId = packet.readUInt32(); + LOG_INFO("SMSG_PLAY_MUSIC (0x0103 alias): soundId=", soundId); + if (playMusicCallback_) playMusicCallback_(soundId); + return; + } + } else if (opcode == 0x0480) { + // Observed on this WotLK profile immediately after CMSG_BUYBACK_ITEM. + // Treat as vendor/buyback transaction result (7-byte payload on this core). + if (packet.hasRemaining(7)) { + uint8_t opType = packet.readUInt8(); + uint8_t resultCode = packet.readUInt8(); + uint8_t slotOrCount = packet.readUInt8(); + uint32_t itemId = packet.readUInt32(); + LOG_INFO("Vendor txn result (0x480): opType=", static_cast(opType), + " result=", static_cast(resultCode), + " slot/count=", static_cast(slotOrCount), + " itemId=", itemId, + " pendingBuybackSlot=", pendingBuybackSlot_, + " pendingBuyItemId=", pendingBuyItemId_, + " pendingBuyItemSlot=", pendingBuyItemSlot_); + + if (pendingBuybackSlot_ >= 0) { + if (resultCode == 0) { + // Success: remove the bought-back slot from our local UI cache. + if (pendingBuybackSlot_ < static_cast(buybackItems_.size())) { + buybackItems_.erase(buybackItems_.begin() + pendingBuybackSlot_); + } + } else { + const char* msg = "Buyback failed."; + // Best-effort mapping; keep raw code visible for unknowns. + switch (resultCode) { + case 2: msg = "Buyback failed: not enough money."; break; + case 4: msg = "Buyback failed: vendor too far away."; break; + case 5: msg = "Buyback failed: item unavailable."; break; + case 6: msg = "Buyback failed: inventory full."; break; + case 8: msg = "Buyback failed: requirements not met."; break; + default: break; + } + addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")"); + } + pendingBuybackSlot_ = -1; + pendingBuybackWireSlot_ = 0; + + // Refresh vendor list so UI state stays in sync after buyback result. + if (getVendorItems().vendorGuid != 0 && socket && state == WorldState::IN_WORLD) { + auto pkt = ListInventoryPacket::build(getVendorItems().vendorGuid); + socket->send(pkt); + } + } else if (pendingBuyItemId_ != 0) { + if (resultCode != 0) { + const char* msg = "Purchase failed."; + switch (resultCode) { + case 2: msg = "Purchase failed: not enough money."; break; + case 4: msg = "Purchase failed: vendor too far away."; break; + case 5: msg = "Purchase failed: item sold out."; break; + case 6: msg = "Purchase failed: inventory full."; break; + case 8: msg = "Purchase failed: requirements not met."; break; + default: break; + } + addSystemChatMessage(std::string(msg) + " (code " + std::to_string(resultCode) + ")"); + } + pendingBuyItemId_ = 0; + pendingBuyItemSlot_ = 0; + } + return; + } + } else if (opcode == 0x046A) { + // Server-specific vendor/buyback state packet (observed 25-byte records). + // Consume to keep stream aligned; currently not used for gameplay logic. + if (packet.hasRemaining(25)) { + packet.setReadPos(packet.getReadPos() + 25); + return; + } + } + + auto preLogicalOp = opcodeTable_.fromWire(opcode); + if (wardenGateSeen_ && (!preLogicalOp || *preLogicalOp != Opcode::SMSG_WARDEN_DATA)) { + ++wardenPacketsAfterGate_; + } + if (preLogicalOp && isAuthCharPipelineOpcode(*preLogicalOp)) { + LOG_WARNING("AUTH/CHAR RX opcode=0x", std::hex, opcode, std::dec, + " logical=", static_cast(*preLogicalOp), + " state=", worldStateName(state), + " size=", packet.getSize()); + } + + LOG_DEBUG("Received world packet: opcode=0x", std::hex, opcode, std::dec, + " size=", packet.getSize(), " bytes"); + + // Translate wire opcode to logical opcode via expansion table + auto logicalOp = opcodeTable_.fromWire(opcode); + + if (!logicalOp) { + static std::unordered_set loggedUnknownWireOpcodes; + if (loggedUnknownWireOpcodes.insert(opcode).second) { + LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec, + " state=", static_cast(state), + " size=", packet.getSize()); + } + return; + } + + // Dispatch via the opcode handler table + auto it = dispatchTable_.find(*logicalOp); + if (it != dispatchTable_.end()) { + it->second(packet); + } else { + // In pre-world states we need full visibility (char create/login handshakes). + // In-world we keep de-duplication to avoid heavy log I/O in busy areas. + if (state != WorldState::IN_WORLD) { + static std::unordered_set loggedUnhandledByState; + const uint32_t key = (static_cast(static_cast(state)) << 16) | + static_cast(opcode); + if (loggedUnhandledByState.insert(key).second) { + LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec, + " state=", static_cast(state), + " size=", packet.getSize()); + const auto& data = packet.getData(); + std::string hex; + size_t limit = std::min(data.size(), 48); + hex.reserve(limit * 3); + for (size_t i = 0; i < limit; ++i) { + char b[4]; + snprintf(b, sizeof(b), "%02x ", data[i]); + hex += b; + } + LOG_INFO("Unhandled opcode payload hex (first ", limit, " bytes): ", hex); + } + } else { + static std::unordered_set loggedUnhandledOpcodes; + if (loggedUnhandledOpcodes.insert(static_cast(opcode)).second) { + LOG_WARNING("Unhandled world opcode: 0x", std::hex, opcode, std::dec); + } + } + } + } catch (const std::bad_alloc& e) { + LOG_ERROR("OOM while handling world opcode=0x", std::hex, opcode, std::dec, + " state=", worldStateName(state), + " size=", packet.getSize(), + " readPos=", packet.getReadPos(), + " what=", e.what()); + if (socket && state == WorldState::IN_WORLD) { + disconnect(); + fail("Out of memory while parsing world packet"); + } + } catch (const std::exception& e) { + LOG_ERROR("Exception while handling world opcode=0x", std::hex, opcode, std::dec, + " state=", worldStateName(state), + " size=", packet.getSize(), + " readPos=", packet.getReadPos(), + " what=", e.what()); + } +} + +void GameHandler::enqueueIncomingPacket(const network::Packet& packet) { + if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { + LOG_ERROR("Inbound packet queue overflow (", pendingIncomingPackets_.size(), + " packets); dropping oldest packet to preserve responsiveness"); + pendingIncomingPackets_.pop_front(); + } + pendingIncomingPackets_.push_back(packet); + lastRxTime_ = std::chrono::steady_clock::now(); + rxSilenceLogged_ = false; + rxSilence15sLogged_ = false; +} + +void GameHandler::enqueueIncomingPacketFront(network::Packet&& packet) { + if (pendingIncomingPackets_.size() >= kMaxQueuedInboundPackets) { + LOG_ERROR("Inbound packet queue overflow while prepending (", pendingIncomingPackets_.size(), + " packets); dropping newest queued packet to preserve ordering"); + pendingIncomingPackets_.pop_back(); + } + pendingIncomingPackets_.emplace_front(std::move(packet)); +} + +// enqueueUpdateObjectWork and processPendingUpdateObjectWork moved to EntityController + +void GameHandler::processQueuedIncomingPackets() { + if (pendingIncomingPackets_.empty() && !entityController_->hasPendingUpdateObjectWork()) { + return; + } + + const int maxPacketsThisUpdate = incomingPacketsBudgetPerUpdate(state); + const float budgetMs = incomingPacketBudgetMs(state); + const auto start = std::chrono::steady_clock::now(); + int processed = 0; + + while (processed < maxPacketsThisUpdate) { + float elapsedMs = std::chrono::duration( + std::chrono::steady_clock::now() - start).count(); + if (elapsedMs >= budgetMs) { + break; + } + + if (entityController_->hasPendingUpdateObjectWork()) { + entityController_->processPendingUpdateObjectWork(start, budgetMs); + if (entityController_->hasPendingUpdateObjectWork()) { + break; + } + continue; + } + + if (pendingIncomingPackets_.empty()) { + break; + } + + network::Packet packet = std::move(pendingIncomingPackets_.front()); + pendingIncomingPackets_.pop_front(); + const uint16_t wireOp = packet.getOpcode(); + const auto logicalOp = opcodeTable_.fromWire(wireOp); + auto packetHandleStart = std::chrono::steady_clock::now(); + handlePacket(packet); + float packetMs = std::chrono::duration( + std::chrono::steady_clock::now() - packetHandleStart).count(); + if (packetMs > slowPacketLogThresholdMs()) { + const char* logicalName = logicalOp + ? OpcodeTable::logicalToName(*logicalOp) + : "UNKNOWN"; + LOG_WARNING("SLOW packet handler: ", packetMs, + "ms wire=0x", std::hex, wireOp, std::dec, + " logical=", logicalName, + " size=", packet.getSize(), + " state=", worldStateName(state)); + } + ++processed; + } + + if (entityController_->hasPendingUpdateObjectWork()) { + return; + } + + if (!pendingIncomingPackets_.empty()) { + LOG_DEBUG("GameHandler packet budget reached (processed=", processed, + ", remaining=", pendingIncomingPackets_.size(), + ", state=", worldStateName(state), ")"); + } +} + + +} // namespace game +} // namespace wowee diff --git a/src/game/inventory_handler.cpp b/src/game/inventory_handler.cpp index 4abb9f96..53fb6a51 100644 --- a/src/game/inventory_handler.cpp +++ b/src/game/inventory_handler.cpp @@ -58,9 +58,9 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { uint32_t amount = packet.readUInt32(); if (packet.hasRemaining(1)) /*uint8_t soleLooter =*/ packet.readUInt8(); - owner_.playerMoneyCopper_ += amount; - owner_.pendingMoneyDelta_ = amount; - owner_.pendingMoneyDeltaTimer_ = 2.0f; + owner_.playerMoneyCopperRef() += amount; + owner_.pendingMoneyDeltaRef() = amount; + owner_.pendingMoneyDeltaTimerRef() = 2.0f; uint64_t notifyGuid = pendingLootMoneyGuid_ != 0 ? pendingLootMoneyGuid_ : currentLoot_.lootGuid; pendingLootMoneyGuid_ = 0; pendingLootMoneyAmount_ = 0; @@ -83,13 +83,13 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { if (notifyGuid != 0) recentLootMoneyAnnounceCooldowns_[notifyGuid] = 1.5f; } - if (owner_.addonEventCallback_) owner_.addonEventCallback_("PLAYER_MONEY", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("PLAYER_MONEY", {}); }; table[Opcode::SMSG_LOOT_CLEAR_MONEY] = [](network::Packet& /*packet*/) {}; // ---- Read item (books) (moved from GameHandler) ---- table[Opcode::SMSG_READ_ITEM_OK] = [this](network::Packet& packet) { - owner_.bookPages_.clear(); // fresh book for this item read + owner_.bookPagesRef().clear(); // fresh book for this item read packet.skipAll(); }; table[Opcode::SMSG_READ_ITEM_FAILED] = [this](network::Packet& packet) { @@ -133,7 +133,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { pendingLootRoll_.playerRolls.clear(); std::string link = buildItemLink(itemId, quality, itemName); owner_.addSystemChatMessage("Loot roll started for " + link + "."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("START_LOOT_ROLL", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("START_LOOT_ROLL", {}); }; table[Opcode::SMSG_LOOT_ALL_PASSED] = [this](network::Packet& packet) { @@ -197,7 +197,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { // +slot(4)+itemId(4)+suffixFactor(4)+randomPropertyId(4)+count(4)+countInInventory(4) if (!packet.hasRemaining(45)) return; uint64_t guid = packet.readUInt64(); - if (guid != owner_.playerGuid) { packet.skipAll(); return; } + if (guid != owner_.getPlayerGuid()) { packet.skipAll(); return; } /*uint32_t received =*/ packet.readUInt32(); /*uint32_t created =*/ packet.readUInt32(); /*uint32_t displayInChat =*/ packet.readUInt32(); @@ -211,7 +211,7 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { auto* info = owner_.getItemInfo(itemId); if (!info || info->name.empty()) { // Item info not yet cached — defer notification - owner_.pendingItemPushNotifs_.push_back({itemId, count}); + owner_.pendingItemPushNotifsRef().push_back({itemId, count}); owner_.ensureItemInfo(itemId); return; } @@ -229,12 +229,12 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { if (auto* sfx = ac->getUiSoundManager()) sfx->playLootItem(); } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("BAG_UPDATE", {}); - owner_.addonEventCallback_("ITEM_PUSH", {std::to_string(itemId), std::to_string(count)}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("BAG_UPDATE", {}); + owner_.addonEventCallbackRef()("ITEM_PUSH", {std::to_string(itemId), std::to_string(count)}); } - if (owner_.itemLootCallback_) - owner_.itemLootCallback_(itemId, count, quality, itemName); + if (owner_.itemLootCallbackRef()) + owner_.itemLootCallbackRef()(itemId, count, quality, itemName); }; // ---- Open container ---- @@ -260,9 +260,9 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { if (auto* sfx = ac->getUiSoundManager()) sfx->playDropOnGround(); } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("BAG_UPDATE", {}); - owner_.addonEventCallback_("PLAYER_MONEY", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("BAG_UPDATE", {}); + owner_.addonEventCallbackRef()("PLAYER_MONEY", {}); } } else { bool removedPending = false; @@ -419,12 +419,12 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { if (errCode == 0) { constexpr uint32_t kBuybackSlotEnd = 85; if (pendingBuybackWireSlot_ >= 74 && pendingBuybackWireSlot_ < kBuybackSlotEnd && - owner_.socket && owner_.state == WorldState::IN_WORLD && currentVendorItems_.vendorGuid != 0) { + owner_.getSocket() && owner_.getState() == WorldState::IN_WORLD && currentVendorItems_.vendorGuid != 0) { ++pendingBuybackWireSlot_; LOG_INFO("Buyback retry: vendorGuid=0x", std::hex, currentVendorItems_.vendorGuid, std::dec, " uiSlot=", pendingBuybackSlot_, " wireSlot=", pendingBuybackWireSlot_); - owner_.socket->send(BuybackItemPacket::build( + owner_.getSocket()->send(BuybackItemPacket::build( currentVendorItems_.vendorGuid, pendingBuybackWireSlot_)); return; } @@ -433,9 +433,9 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { } pendingBuybackSlot_ = -1; pendingBuybackWireSlot_ = 0; - if (currentVendorItems_.vendorGuid != 0 && owner_.socket && owner_.state == WorldState::IN_WORLD) { + if (currentVendorItems_.vendorGuid != 0 && owner_.getSocket() && owner_.getState() == WorldState::IN_WORLD) { auto pkt = ListInventoryPacket::build(currentVendorItems_.vendorGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } return; } @@ -484,9 +484,9 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { } pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("MERCHANT_UPDATE", {}); - owner_.addonEventCallback_("BAG_UPDATE", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("MERCHANT_UPDATE", {}); + owner_.addonEventCallbackRef()("BAG_UPDATE", {}); } } }; @@ -661,38 +661,38 @@ void InventoryHandler::registerOpcodes(DispatchTable& table) { // ============================================================ void InventoryHandler::lootTarget(uint64_t targetGuid) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; currentLoot_.items.clear(); LOG_INFO("Looting target 0x", std::hex, targetGuid, std::dec); auto packet = LootPacket::build(targetGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::lootItem(uint8_t slotIndex) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = AutostoreLootItemPacket::build(slotIndex); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::closeLoot() { if (!lootWindowOpen_) return; - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { auto packet = LootReleasePacket::build(currentLoot_.lootGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } lootWindowOpen_ = false; - if (owner_.lootWindowCallback_) owner_.lootWindowCallback_(false); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("LOOT_CLOSED", {}); + if (owner_.lootWindowCallbackRef()) owner_.lootWindowCallbackRef()(false); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("LOOT_CLOSED", {}); currentLoot_ = LootResponseData{}; } void InventoryHandler::lootMasterGive(uint8_t lootSlot, uint64_t targetGuid) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_MASTER_GIVE)); pkt.writeUInt64(currentLoot_.lootGuid); pkt.writeUInt8(lootSlot); pkt.writeUInt64(targetGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void InventoryHandler::handleLootResponse(network::Packet& packet) { @@ -706,10 +706,10 @@ void InventoryHandler::handleLootResponse(network::Packet& packet) { return; } lootWindowOpen_ = true; - if (owner_.lootWindowCallback_) owner_.lootWindowCallback_(true); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("LOOT_OPENED", {}); - owner_.addonEventCallback_("LOOT_READY", {}); + if (owner_.lootWindowCallbackRef()) owner_.lootWindowCallbackRef()(true); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("LOOT_OPENED", {}); + owner_.addonEventCallbackRef()("LOOT_READY", {}); } lastInteractedGoGuid_ = 0; pendingGameObjectLootOpens_.erase( @@ -724,7 +724,7 @@ void InventoryHandler::handleLootResponse(network::Packet& packet) { } if (currentLoot_.gold > 0) { - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { bool suppressFallback = false; auto cooldownIt = recentLootMoneyAnnounceCooldowns_.find(currentLoot_.lootGuid); if (cooldownIt != recentLootMoneyAnnounceCooldowns_.end() && cooldownIt->second > 0.0f) { @@ -734,15 +734,15 @@ void InventoryHandler::handleLootResponse(network::Packet& packet) { pendingLootMoneyAmount_ = suppressFallback ? 0 : currentLoot_.gold; pendingLootMoneyNotifyTimer_ = suppressFallback ? 0.0f : 0.4f; auto pkt = LootMoneyPacket::build(); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); currentLoot_.gold = 0; } } - if (autoLoot_ && owner_.state == WorldState::IN_WORLD && owner_.socket && !localLoot.itemAutoLootSent) { + if (autoLoot_ && owner_.getState() == WorldState::IN_WORLD && owner_.getSocket() && !localLoot.itemAutoLootSent) { for (const auto& item : currentLoot_.items) { auto pkt = AutostoreLootItemPacket::build(item.slotIndex); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } localLoot.itemAutoLootSent = true; } @@ -752,8 +752,8 @@ void InventoryHandler::handleLootReleaseResponse(network::Packet& packet) { (void)packet; localLootState_.erase(currentLoot_.lootGuid); lootWindowOpen_ = false; - if (owner_.lootWindowCallback_) owner_.lootWindowCallback_(false); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("LOOT_CLOSED", {}); + if (owner_.lootWindowCallbackRef()) owner_.lootWindowCallbackRef()(false); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("LOOT_CLOSED", {}); currentLoot_ = LootResponseData{}; } @@ -776,8 +776,8 @@ void InventoryHandler::handleLootRemoved(network::Packet& packet) { sfx->playLootItem(); } currentLoot_.items.erase(it); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("LOOT_SLOT_CLEARED", {std::to_string(slotIndex + 1)}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("LOOT_SLOT_CLEARED", {std::to_string(slotIndex + 1)}); break; } } @@ -788,12 +788,12 @@ void InventoryHandler::handleLootRemoved(network::Packet& packet) { // ============================================================ void InventoryHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollType) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_LOOT_ROLL)); pkt.writeUInt64(objectGuid); pkt.writeUInt32(slot); pkt.writeUInt8(rollType); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); if (rollType == 128) { // pass pendingLootRollActive_ = false; } @@ -880,10 +880,10 @@ void InventoryHandler::handleLootRollWon(network::Packet& packet) { // ============================================================ void InventoryHandler::openVendor(uint64_t npcGuid) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; buybackItems_.clear(); auto packet = ListInventoryPacket::build(npcGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::closeVendor() { @@ -896,11 +896,11 @@ void InventoryHandler::closeVendor() { pendingBuybackWireSlot_ = 0; pendingBuyItemId_ = 0; pendingBuyItemSlot_ = 0; - if (wasOpen && owner_.addonEventCallback_) owner_.addonEventCallback_("MERCHANT_CLOSED", {}); + if (wasOpen && owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("MERCHANT_CLOSED", {}); } void InventoryHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; LOG_INFO("Buy request: vendorGuid=0x", std::hex, vendorGuid, std::dec, " itemId=", itemId, " slot=", slot, " count=", count, " wire=0x", std::hex, wireOpcode(Opcode::CMSG_BUY_ITEM), std::dec); @@ -915,21 +915,21 @@ void InventoryHandler::buyItem(uint64_t vendorGuid, uint32_t itemId, uint32_t sl if (isWotLk) { packet.writeUInt8(0); } - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::sellItem(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; LOG_INFO("Sell request: vendorGuid=0x", std::hex, vendorGuid, " itemGuid=0x", itemGuid, std::dec, " count=", count, " wire=0x", std::hex, wireOpcode(Opcode::CMSG_SELL_ITEM), std::dec); auto packet = SellItemPacket::build(vendorGuid, itemGuid, count); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::sellItemBySlot(int backpackIndex) { - if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return; - const auto& slot = owner_.inventory.getBackpackSlot(backpackIndex); + if (backpackIndex < 0 || backpackIndex >= owner_.inventoryRef().getBackpackSize()) return; + const auto& slot = owner_.inventoryRef().getBackpackSlot(backpackIndex); if (slot.empty()) return; uint32_t sellPrice = slot.item.sellPrice; @@ -943,7 +943,7 @@ void InventoryHandler::sellItemBySlot(int backpackIndex) { return; } - uint64_t itemGuid = owner_.backpackSlotGuids_[backpackIndex]; + uint64_t itemGuid = owner_.backpackSlotGuidsRef()[backpackIndex]; if (itemGuid == 0) { itemGuid = owner_.resolveOnlineItemGuid(slot.item.itemId); } @@ -969,9 +969,9 @@ void InventoryHandler::sellItemBySlot(int backpackIndex) { } void InventoryHandler::sellItemInBag(int bagIndex, int slotIndex) { - if (bagIndex < 0 || bagIndex >= owner_.inventory.NUM_BAG_SLOTS) return; - if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return; - const auto& slot = owner_.inventory.getBagSlot(bagIndex, slotIndex); + if (bagIndex < 0 || bagIndex >= owner_.inventoryRef().NUM_BAG_SLOTS) return; + if (slotIndex < 0 || slotIndex >= owner_.inventoryRef().getBagSize(bagIndex)) return; + const auto& slot = owner_.inventoryRef().getBagSlot(bagIndex, slotIndex); if (slot.empty()) return; uint32_t sellPrice = slot.item.sellPrice; @@ -986,10 +986,10 @@ void InventoryHandler::sellItemInBag(int bagIndex, int slotIndex) { } uint64_t itemGuid = 0; - uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; + uint64_t bagGuid = owner_.equipSlotGuidsRef()[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; if (bagGuid != 0) { - auto it = owner_.containerContents_.find(bagGuid); - if (it != owner_.containerContents_.end() && slotIndex < static_cast(it->second.numSlots)) { + auto it = owner_.containerContentsRef().find(bagGuid); + if (it != owner_.containerContentsRef().end() && slotIndex < static_cast(it->second.numSlots)) { itemGuid = it->second.slotGuids[slotIndex]; } } @@ -1014,7 +1014,7 @@ void InventoryHandler::sellItemInBag(int bagIndex, int slotIndex) { } void InventoryHandler::buyBackItem(uint32_t buybackSlot) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || currentVendorItems_.vendorGuid == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || currentVendorItems_.vendorGuid == 0) return; constexpr uint32_t kBuybackSlotStart = 74; uint32_t wireSlot = kBuybackSlotStart + buybackSlot; pendingBuyItemId_ = 0; @@ -1025,11 +1025,11 @@ void InventoryHandler::buyBackItem(uint32_t buybackSlot) { pendingBuybackWireSlot_ = wireSlot; // Use the expansion-agnostic packet builder so the opcode resolves from // the active expansion's JSON mapping rather than a hardcoded WotLK value. - owner_.socket->send(BuybackItemPacket::build(currentVendorItems_.vendorGuid, wireSlot)); + owner_.getSocket()->send(BuybackItemPacket::build(currentVendorItems_.vendorGuid, wireSlot)); } void InventoryHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; uint32_t cost = estimateItemRepairCost(itemGuid); if (cost > 0 && owner_.getMoneyCopper() < cost) { @@ -1041,13 +1041,13 @@ void InventoryHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { packet.writeUInt64(vendorGuid); packet.writeUInt64(itemGuid); if (!isClassicLikeExpansion()) packet.writeUInt8(0); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); // Only do optimistic update if we verified the player can afford it if (cost > 0) { - owner_.playerMoneyCopper_ -= cost; - auto it = owner_.onlineItems_.find(itemGuid); - if (it != owner_.onlineItems_.end()) { + owner_.playerMoneyCopperRef() -= cost; + auto it = owner_.onlineItemsRef().find(itemGuid); + if (it != owner_.onlineItemsRef().end()) { it->second.curDurability = it->second.maxDurability; rebuildOnlineInventory(); } @@ -1055,7 +1055,7 @@ void InventoryHandler::repairItem(uint64_t vendorGuid, uint64_t itemGuid) { } void InventoryHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; uint32_t totalCost = estimateRepairAllCost(); @@ -1068,14 +1068,14 @@ void InventoryHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { packet.writeUInt64(vendorGuid); packet.writeUInt64(0); if (!isClassicLikeExpansion()) packet.writeUInt8(useGuildBank ? 1 : 0); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); // Only do optimistic update if we verified the player can afford it if (totalCost > 0) { if (!useGuildBank) { - owner_.playerMoneyCopper_ -= totalCost; + owner_.playerMoneyCopperRef() -= totalCost; } - for (auto& [guid, info] : owner_.onlineItems_) { + for (auto& [guid, info] : owner_.onlineItemsRef()) { if (info.maxDurability > 0 && info.curDurability < info.maxDurability) { info.curDurability = info.maxDurability; } @@ -1085,38 +1085,38 @@ void InventoryHandler::repairAll(uint64_t vendorGuid, bool useGuildBank) { } void InventoryHandler::autoEquipItemBySlot(int backpackIndex) { - if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return; - const auto& slot = owner_.inventory.getBackpackSlot(backpackIndex); + if (backpackIndex < 0 || backpackIndex >= owner_.inventoryRef().getBackpackSize()) return; + const auto& slot = owner_.inventoryRef().getBackpackSlot(backpackIndex); if (slot.empty()) return; - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { auto packet = AutoEquipItemPacket::build(0xFF, static_cast(Inventory::NUM_EQUIP_SLOTS + backpackIndex)); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } } void InventoryHandler::autoEquipItemInBag(int bagIndex, int slotIndex) { - if (bagIndex < 0 || bagIndex >= owner_.inventory.NUM_BAG_SLOTS) return; - if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return; + if (bagIndex < 0 || bagIndex >= owner_.inventoryRef().NUM_BAG_SLOTS) return; + if (slotIndex < 0 || slotIndex >= owner_.inventoryRef().getBagSize(bagIndex)) return; - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { auto packet = AutoEquipItemPacket::build( static_cast(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex), static_cast(slotIndex)); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } } void InventoryHandler::useItemBySlot(int backpackIndex) { - if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return; - const auto& slot = owner_.inventory.getBackpackSlot(backpackIndex); + if (backpackIndex < 0 || backpackIndex >= owner_.inventoryRef().getBackpackSize()) return; + const auto& slot = owner_.inventoryRef().getBackpackSlot(backpackIndex); if (slot.empty()) return; - uint64_t itemGuid = owner_.backpackSlotGuids_[backpackIndex]; + uint64_t itemGuid = owner_.backpackSlotGuidsRef()[backpackIndex]; if (itemGuid == 0) { itemGuid = owner_.resolveOnlineItemGuid(slot.item.itemId); } - if (itemGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (itemGuid != 0 && owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { uint32_t useSpellId = 0; if (auto* info = owner_.getItemInfo(slot.item.itemId)) { for (const auto& sp : info->spells) { @@ -1128,10 +1128,10 @@ void InventoryHandler::useItemBySlot(int backpackIndex) { LOG_DEBUG("useItemBySlot: entry=", slot.item.itemId, " spellId=", useSpellId); } - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildUseItem(0xFF, static_cast(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId) + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildUseItem(0xFF, static_cast(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId) : UseItemPacket::build(0xFF, static_cast(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } else if (itemGuid == 0) { LOG_WARNING("useItemBySlot: itemGuid=0 for item='", slot.item.name, "' entry=", slot.item.itemId, " — cannot use"); @@ -1140,16 +1140,16 @@ void InventoryHandler::useItemBySlot(int backpackIndex) { } void InventoryHandler::useItemInBag(int bagIndex, int slotIndex) { - if (bagIndex < 0 || bagIndex >= owner_.inventory.NUM_BAG_SLOTS) return; - if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return; - const auto& slot = owner_.inventory.getBagSlot(bagIndex, slotIndex); + if (bagIndex < 0 || bagIndex >= owner_.inventoryRef().NUM_BAG_SLOTS) return; + if (slotIndex < 0 || slotIndex >= owner_.inventoryRef().getBagSize(bagIndex)) return; + const auto& slot = owner_.inventoryRef().getBagSlot(bagIndex, slotIndex); if (slot.empty()) return; uint64_t itemGuid = 0; - uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; + uint64_t bagGuid = owner_.equipSlotGuidsRef()[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; if (bagGuid != 0) { - auto it = owner_.containerContents_.find(bagGuid); - if (it != owner_.containerContents_.end() && slotIndex < static_cast(it->second.numSlots)) { + auto it = owner_.containerContentsRef().find(bagGuid); + if (it != owner_.containerContentsRef().end() && slotIndex < static_cast(it->second.numSlots)) { itemGuid = it->second.slotGuids[slotIndex]; } } @@ -1160,7 +1160,7 @@ void InventoryHandler::useItemInBag(int bagIndex, int slotIndex) { LOG_INFO("useItemInBag: bag=", bagIndex, " slot=", slotIndex, " itemId=", slot.item.itemId, " itemGuid=0x", std::hex, itemGuid, std::dec); - if (itemGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (itemGuid != 0 && owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { uint32_t useSpellId = 0; if (auto* info = owner_.getItemInfo(slot.item.itemId)) { for (const auto& sp : info->spells) { @@ -1171,12 +1171,12 @@ void InventoryHandler::useItemInBag(int bagIndex, int slotIndex) { } } uint8_t wowBag = static_cast(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex); - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildUseItem(wowBag, static_cast(slotIndex), itemGuid, useSpellId) + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildUseItem(wowBag, static_cast(slotIndex), itemGuid, useSpellId) : UseItemPacket::build(wowBag, static_cast(slotIndex), itemGuid, useSpellId); LOG_INFO("useItemInBag: sending CMSG_USE_ITEM, bag=", (int)wowBag, " slot=", slotIndex, " packetSize=", packet.getSize()); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } else if (itemGuid == 0) { LOG_WARNING("Use item in bag failed: missing item GUID for bag ", bagIndex, " slot ", slotIndex); owner_.addSystemChatMessage("Cannot use that item right now."); @@ -1184,27 +1184,27 @@ void InventoryHandler::useItemInBag(int bagIndex, int slotIndex) { } void InventoryHandler::openItemBySlot(int backpackIndex) { - if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return; - if (owner_.inventory.getBackpackSlot(backpackIndex).empty()) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (backpackIndex < 0 || backpackIndex >= owner_.inventoryRef().getBackpackSize()) return; + if (owner_.inventoryRef().getBackpackSlot(backpackIndex).empty()) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = OpenItemPacket::build(0xFF, static_cast(Inventory::NUM_EQUIP_SLOTS + backpackIndex)); LOG_INFO("openItemBySlot: CMSG_OPEN_ITEM bag=0xFF slot=", (Inventory::NUM_EQUIP_SLOTS + backpackIndex)); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::openItemInBag(int bagIndex, int slotIndex) { - if (bagIndex < 0 || bagIndex >= owner_.inventory.NUM_BAG_SLOTS) return; - if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return; - if (owner_.inventory.getBagSlot(bagIndex, slotIndex).empty()) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (bagIndex < 0 || bagIndex >= owner_.inventoryRef().NUM_BAG_SLOTS) return; + if (slotIndex < 0 || slotIndex >= owner_.inventoryRef().getBagSize(bagIndex)) return; + if (owner_.inventoryRef().getBagSlot(bagIndex, slotIndex).empty()) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; uint8_t wowBag = static_cast(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex); auto packet = OpenItemPacket::build(wowBag, static_cast(slotIndex)); LOG_INFO("openItemInBag: CMSG_OPEN_ITEM bag=", (int)wowBag, " slot=", slotIndex); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (count == 0) count = 1; constexpr uint16_t kCmsgDestroyItem = 0x111; network::Packet packet(kCmsgDestroyItem); @@ -1213,34 +1213,34 @@ void InventoryHandler::destroyItem(uint8_t bag, uint8_t slot, uint8_t count) { packet.writeUInt32(static_cast(count)); LOG_DEBUG("Destroy item request: bag=", (int)bag, " slot=", (int)slot, " count=", (int)count, " wire=0x", std::hex, kCmsgDestroyItem, std::dec); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (count == 0) return; - int freeBp = owner_.inventory.findFreeBackpackSlot(); + int freeBp = owner_.inventoryRef().findFreeBackpackSlot(); if (freeBp >= 0) { uint8_t dstBag = 0xFF; uint8_t dstSlot = static_cast(Inventory::NUM_EQUIP_SLOTS + freeBp); LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot, ") count=", (int)count, " -> dst(bag=0xFF slot=", (int)dstSlot, ")"); auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); return; } - for (int b = 0; b < owner_.inventory.NUM_BAG_SLOTS; b++) { - int bagSize = owner_.inventory.getBagSize(b); + for (int b = 0; b < owner_.inventoryRef().NUM_BAG_SLOTS; b++) { + int bagSize = owner_.inventoryRef().getBagSize(b); for (int s = 0; s < bagSize; s++) { - if (owner_.inventory.getBagSlot(b, s).empty()) { + if (owner_.inventoryRef().getBagSlot(b, s).empty()) { uint8_t dstBag = static_cast(Inventory::FIRST_BAG_EQUIP_SLOT + b); uint8_t dstSlot = static_cast(s); LOG_INFO("splitItem: src(bag=", (int)srcBag, " slot=", (int)srcSlot, ") count=", (int)count, " -> dst(bag=", (int)dstBag, " slot=", (int)dstSlot, ")"); auto packet = SplitItemPacket::build(srcBag, srcSlot, dstBag, dstSlot, count); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); return; } } @@ -1249,11 +1249,11 @@ void InventoryHandler::splitItem(uint8_t srcBag, uint8_t srcSlot, uint8_t count) } void InventoryHandler::swapContainerItems(uint8_t srcBag, uint8_t srcSlot, uint8_t dstBag, uint8_t dstSlot) { - if (!owner_.socket || !owner_.socket->isConnected()) return; + if (!owner_.getSocket() || !owner_.getSocket()->isConnected()) return; LOG_INFO("swapContainerItems: src(bag=", (int)srcBag, " slot=", (int)srcSlot, ") -> dst(bag=", (int)dstBag, " slot=", (int)dstSlot, ")"); auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::swapBagSlots(int srcBagIndex, int dstBagIndex) { @@ -1262,26 +1262,26 @@ void InventoryHandler::swapBagSlots(int srcBagIndex, int dstBagIndex) { auto srcEquip = static_cast(static_cast(game::EquipSlot::BAG1) + srcBagIndex); auto dstEquip = static_cast(static_cast(game::EquipSlot::BAG1) + dstBagIndex); - auto srcItem = owner_.inventory.getEquipSlot(srcEquip).item; - auto dstItem = owner_.inventory.getEquipSlot(dstEquip).item; - owner_.inventory.setEquipSlot(srcEquip, dstItem); - owner_.inventory.setEquipSlot(dstEquip, srcItem); - owner_.inventory.swapBagContents(srcBagIndex, dstBagIndex); + auto srcItem = owner_.inventoryRef().getEquipSlot(srcEquip).item; + auto dstItem = owner_.inventoryRef().getEquipSlot(dstEquip).item; + owner_.inventoryRef().setEquipSlot(srcEquip, dstItem); + owner_.inventoryRef().setEquipSlot(dstEquip, srcItem); + owner_.inventoryRef().swapBagContents(srcBagIndex, dstBagIndex); - if (owner_.socket && owner_.socket->isConnected()) { + if (owner_.getSocket() && owner_.getSocket()->isConnected()) { uint8_t srcSlot = static_cast(Inventory::FIRST_BAG_EQUIP_SLOT + srcBagIndex); uint8_t dstSlot = static_cast(Inventory::FIRST_BAG_EQUIP_SLOT + dstBagIndex); LOG_INFO("swapBagSlots: bag ", srcBagIndex, " (slot ", (int)srcSlot, ") <-> bag ", dstBagIndex, " (slot ", (int)dstSlot, ")"); auto packet = SwapItemPacket::build(255, dstSlot, 255, srcSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } } void InventoryHandler::unequipToBackpack(EquipSlot equipSlot) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; - int freeSlot = owner_.inventory.findFreeBackpackSlot(); + int freeSlot = owner_.inventoryRef().findFreeBackpackSlot(); if (freeSlot < 0) { owner_.addSystemChatMessage("Cannot unequip: no free backpack slots."); return; @@ -1296,24 +1296,24 @@ void InventoryHandler::unequipToBackpack(EquipSlot equipSlot) { " -> backpackIndex=", freeSlot, " (dstSlot=", (int)dstSlot, ")"); auto packet = SwapItemPacket::build(dstBag, dstSlot, srcBag, srcSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::useItemById(uint32_t itemId) { if (itemId == 0) return; LOG_DEBUG("useItemById: searching for itemId=", itemId); - for (int i = 0; i < owner_.inventory.getBackpackSize(); i++) { - const auto& slot = owner_.inventory.getBackpackSlot(i); + for (int i = 0; i < owner_.inventoryRef().getBackpackSize(); i++) { + const auto& slot = owner_.inventoryRef().getBackpackSlot(i); if (!slot.empty() && slot.item.itemId == itemId) { LOG_DEBUG("useItemById: found itemId=", itemId, " at backpack slot ", i); useItemBySlot(i); return; } } - for (int bag = 0; bag < owner_.inventory.NUM_BAG_SLOTS; bag++) { - int bagSize = owner_.inventory.getBagSize(bag); + for (int bag = 0; bag < owner_.inventoryRef().NUM_BAG_SLOTS; bag++) { + int bagSize = owner_.inventoryRef().getBagSize(bag); for (int slot = 0; slot < bagSize; slot++) { - const auto& bagSlot = owner_.inventory.getBagSlot(bag, slot); + const auto& bagSlot = owner_.inventoryRef().getBagSlot(bag, slot); if (!bagSlot.empty() && bagSlot.item.itemId == itemId) { LOG_DEBUG("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot); useItemInBag(bag, slot); @@ -1338,14 +1338,14 @@ void InventoryHandler::handleListInventory(network::Packet& packet) { } vendorWindowOpen_ = true; owner_.closeGossip(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("MERCHANT_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("MERCHANT_SHOW", {}); // Auto-sell grey items - if (autoSellGrey_ && currentVendorItems_.vendorGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (autoSellGrey_ && currentVendorItems_.vendorGuid != 0 && owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { int itemsSold = 0; uint32_t totalSellPrice = 0; - for (int i = 0; i < owner_.inventory.getBackpackSize(); ++i) { - const auto& slot = owner_.inventory.getBackpackSlot(i); + for (int i = 0; i < owner_.inventoryRef().getBackpackSize(); ++i) { + const auto& slot = owner_.inventoryRef().getBackpackSlot(i); if (slot.empty()) continue; uint32_t quality = 0; uint32_t sellPrice = slot.item.sellPrice; @@ -1354,7 +1354,7 @@ void InventoryHandler::handleListInventory(network::Packet& packet) { if (sellPrice == 0) sellPrice = info->sellPrice; } if (quality == 0 && sellPrice > 0) { - uint64_t itemGuid = owner_.backpackSlotGuids_[i]; + uint64_t itemGuid = owner_.backpackSlotGuidsRef()[i]; if (itemGuid == 0) itemGuid = owner_.resolveOnlineItemGuid(slot.item.itemId); if (itemGuid != 0) { sellItem(currentVendorItems_.vendorGuid, itemGuid, 1); @@ -1363,10 +1363,10 @@ void InventoryHandler::handleListInventory(network::Packet& packet) { } } } - for (int b = 0; b < owner_.inventory.NUM_BAG_SLOTS; ++b) { - int bagSize = owner_.inventory.getBagSize(b); + for (int b = 0; b < owner_.inventoryRef().NUM_BAG_SLOTS; ++b) { + int bagSize = owner_.inventoryRef().getBagSize(b); for (int s = 0; s < bagSize; ++s) { - const auto& slot = owner_.inventory.getBagSlot(b, s); + const auto& slot = owner_.inventoryRef().getBagSlot(b, s); if (slot.empty()) continue; uint32_t quality = 0; uint32_t sellPrice = slot.item.sellPrice; @@ -1376,10 +1376,10 @@ void InventoryHandler::handleListInventory(network::Packet& packet) { } if (quality == 0 && sellPrice > 0) { uint64_t itemGuid = 0; - uint64_t bagGuid = owner_.equipSlotGuids_[19 + b]; + uint64_t bagGuid = owner_.equipSlotGuidsRef()[19 + b]; if (bagGuid != 0) { - auto cit = owner_.containerContents_.find(bagGuid); - if (cit != owner_.containerContents_.end() && s < static_cast(cit->second.numSlots)) + auto cit = owner_.containerContentsRef().find(bagGuid); + if (cit != owner_.containerContentsRef().end() && s < static_cast(cit->second.numSlots)) itemGuid = cit->second.slotGuids[s]; } if (itemGuid == 0) itemGuid = owner_.resolveOnlineItemGuid(slot.item.itemId); @@ -1407,7 +1407,7 @@ void InventoryHandler::handleListInventory(network::Packet& packet) { if (autoRepair_ && currentVendorItems_.canRepair && currentVendorItems_.vendorGuid != 0) { bool anyDamaged = false; for (int i = 0; i < Inventory::NUM_EQUIP_SLOTS; ++i) { - const auto& slot = owner_.inventory.getEquipSlot(static_cast(i)); + const auto& slot = owner_.inventoryRef().getEquipSlot(static_cast(i)); if (!slot.empty() && slot.item.maxDurability > 0 && slot.item.curDurability < slot.item.maxDurability) { anyDamaged = true; @@ -1421,11 +1421,11 @@ void InventoryHandler::handleListInventory(network::Packet& packet) { } // Play vendor sound - if (owner_.npcVendorCallback_ && currentVendorItems_.vendorGuid != 0) { + if (owner_.npcVendorCallbackRef() && currentVendorItems_.vendorGuid != 0) { auto entity = owner_.getEntityManager().getEntity(currentVendorItems_.vendorGuid); if (entity && entity->getType() == ObjectType::UNIT) { glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ()); - owner_.npcVendorCallback_(currentVendorItems_.vendorGuid, pos); + owner_.npcVendorCallbackRef()(currentVendorItems_.vendorGuid, pos); } } @@ -1443,7 +1443,7 @@ void InventoryHandler::handleTrainerList(network::Packet& packet) { if (!TrainerListParser::parse(packet, currentTrainerList_, isClassic)) return; trainerWindowOpen_ = true; owner_.closeGossip(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRAINER_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRAINER_SHOW", {}); LOG_INFO("Trainer list: ", currentTrainerList_.spells.size(), " spells"); @@ -1454,8 +1454,8 @@ void InventoryHandler::handleTrainerList(network::Packet& packet) { } void InventoryHandler::trainSpell(uint32_t spellId) { - LOG_INFO("trainSpell called: spellId=", spellId, " state=", (int)owner_.state, " socket=", (owner_.socket ? "yes" : "no")); - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) { + LOG_INFO("trainSpell called: spellId=", spellId, " state=", (int)owner_.getState(), " socket=", (owner_.getSocket() ? "yes" : "no")); + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) { LOG_WARNING("trainSpell: Not in world or no socket connection"); return; } @@ -1467,20 +1467,20 @@ void InventoryHandler::trainSpell(uint32_t spellId) { break; } } - LOG_INFO("Player money: ", owner_.playerMoneyCopper_, " copper, spell cost: ", spellCost, " copper"); + LOG_INFO("Player money: ", owner_.playerMoneyCopperRef(), " copper, spell cost: ", spellCost, " copper"); LOG_INFO("Sending CMSG_TRAINER_BUY_SPELL: guid=", currentTrainerList_.trainerGuid, " spellId=", spellId); auto packet = TrainerBuySpellPacket::build( currentTrainerList_.trainerGuid, spellId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("CMSG_TRAINER_BUY_SPELL sent"); } void InventoryHandler::closeTrainer() { trainerWindowOpen_ = false; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRAINER_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRAINER_CLOSED", {}); currentTrainerList_ = TrainerListData{}; trainerTabs_.clear(); } @@ -1494,11 +1494,11 @@ void InventoryHandler::categorizeTrainerSpells() { std::vector generalSpells; for (const auto& spell : currentTrainerList_.spells) { - auto slIt = owner_.spellToSkillLine_.find(spell.spellId); - if (slIt != owner_.spellToSkillLine_.end()) { + auto slIt = owner_.spellToSkillLineRef().find(spell.spellId); + if (slIt != owner_.spellToSkillLineRef().end()) { uint32_t skillLineId = slIt->second; - auto catIt = owner_.skillLineCategories_.find(skillLineId); - if (catIt != owner_.skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { + auto catIt = owner_.skillLineCategoriesRef().find(skillLineId); + if (catIt != owner_.skillLineCategoriesRef().end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { specialtySpells[skillLineId].push_back(&spell); continue; } @@ -1512,8 +1512,8 @@ void InventoryHandler::categorizeTrainerSpells() { std::vector>> named; for (auto& [skillLineId, spells] : specialtySpells) { - auto nameIt = owner_.skillLineNames_.find(skillLineId); - std::string tabName = (nameIt != owner_.skillLineNames_.end()) ? nameIt->second : "Specialty"; + auto nameIt = owner_.skillLineNamesRef().find(skillLineId); + std::string tabName = (nameIt != owner_.skillLineNamesRef().end()) ? nameIt->second : "Specialty"; std::sort(spells.begin(), spells.end(), byName); named.push_back({std::move(tabName), std::move(spells)}); } @@ -1543,7 +1543,7 @@ void InventoryHandler::openMailbox(uint64_t guid) { selectedMailIndex_ = -1; showMailCompose_ = false; clearMailAttachments(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("MAIL_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("MAIL_SHOW", {}); refreshMailList(); } @@ -1552,23 +1552,23 @@ void InventoryHandler::closeMailbox() { mailboxGuid_ = 0; showMailCompose_ = false; clearMailAttachments(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("MAIL_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("MAIL_CLOSED", {}); } void InventoryHandler::refreshMailList() { if (!mailboxOpen_ || mailboxGuid_ == 0) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GetMailListPacket::build(mailboxGuid_); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::sendMail(const std::string& recipient, const std::string& subject, const std::string& body, uint64_t money, uint64_t cod) { - if (owner_.state != WorldState::IN_WORLD) { + if (owner_.getState() != WorldState::IN_WORLD) { LOG_WARNING("sendMail: not in world"); return; } - if (!owner_.socket) { + if (!owner_.getSocket()) { LOG_WARNING("sendMail: no socket"); return; } @@ -1583,18 +1583,18 @@ void InventoryHandler::sendMail(const std::string& recipient, const std::string& itemGuids.push_back(att.itemGuid); } } - auto packet = owner_.packetParsers_->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod, itemGuids); + auto packet = owner_.getPacketParsers()->buildSendMail(mailboxGuid_, recipient, subject, body, money, cod, itemGuids); LOG_INFO("sendMail: to='", recipient, "' subject='", subject, "' money=", money, " attachments=", itemGuids.size(), " mailboxGuid=", mailboxGuid_); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); clearMailAttachments(); } bool InventoryHandler::attachItemFromBackpack(int backpackIndex) { - if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return false; - const auto& slot = owner_.inventory.getBackpackSlot(backpackIndex); + if (backpackIndex < 0 || backpackIndex >= owner_.inventoryRef().getBackpackSize()) return false; + const auto& slot = owner_.inventoryRef().getBackpackSlot(backpackIndex); if (slot.empty()) return false; - uint64_t itemGuid = owner_.backpackSlotGuids_[backpackIndex]; + uint64_t itemGuid = owner_.backpackSlotGuidsRef()[backpackIndex]; if (itemGuid == 0) return false; for (int i = 0; i < MAIL_MAX_ATTACHMENTS; ++i) { if (!mailAttachments_[i].occupied()) { @@ -1609,14 +1609,14 @@ bool InventoryHandler::attachItemFromBackpack(int backpackIndex) { } bool InventoryHandler::attachItemFromBag(int bagIndex, int slotIndex) { - if (bagIndex < 0 || bagIndex >= owner_.inventory.NUM_BAG_SLOTS) return false; - if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return false; - const auto& slot = owner_.inventory.getBagSlot(bagIndex, slotIndex); + if (bagIndex < 0 || bagIndex >= owner_.inventoryRef().NUM_BAG_SLOTS) return false; + if (slotIndex < 0 || slotIndex >= owner_.inventoryRef().getBagSize(bagIndex)) return false; + const auto& slot = owner_.inventoryRef().getBagSlot(bagIndex, slotIndex); if (slot.empty()) return false; - uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; + uint64_t bagGuid = owner_.equipSlotGuidsRef()[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; if (bagGuid == 0) return false; - auto it = owner_.containerContents_.find(bagGuid); - if (it == owner_.containerContents_.end()) return false; + auto it = owner_.containerContentsRef().find(bagGuid); + if (it == owner_.containerContentsRef().end()) return false; if (slotIndex >= static_cast(it->second.numSlots)) return false; uint64_t itemGuid = it->second.slotGuids[slotIndex]; if (itemGuid == 0) return false; @@ -1650,27 +1650,27 @@ int InventoryHandler::getMailAttachmentCount() const { } void InventoryHandler::mailTakeMoney(uint32_t mailId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || mailboxGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || mailboxGuid_ == 0) return; auto packet = MailTakeMoneyPacket::build(mailboxGuid_, mailId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::mailTakeItem(uint32_t mailId, uint32_t itemGuidLow) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || mailboxGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || mailboxGuid_ == 0) return; auto packet = MailTakeItemPacket::build(mailboxGuid_, mailId, itemGuidLow); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::mailDelete(uint32_t mailId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || mailboxGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || mailboxGuid_ == 0) return; auto packet = MailDeletePacket::build(mailboxGuid_, mailId, 0); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::mailMarkAsRead(uint32_t mailId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || mailboxGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || mailboxGuid_ == 0) return; auto packet = MailMarkAsReadPacket::build(mailboxGuid_, mailId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::handleShowMailbox(network::Packet& packet) { @@ -1678,13 +1678,13 @@ void InventoryHandler::handleShowMailbox(network::Packet& packet) { mailboxGuid_ = packet.readUInt64(); mailboxOpen_ = true; selectedMailIndex_ = -1; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("MAIL_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("MAIL_SHOW", {}); refreshMailList(); } void InventoryHandler::handleMailListResult(network::Packet& packet) { - if (!owner_.packetParsers_->parseMailList(packet, mailInbox_)) return; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("MAIL_INBOX_UPDATE", {}); + if (!owner_.getPacketParsers()->parseMailList(packet, mailInbox_)) return; + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("MAIL_INBOX_UPDATE", {}); for (const auto& mail : mailInbox_) { for (const auto& att : mail.attachments) { if (att.itemId != 0) owner_.ensureItemInfo(att.itemId); @@ -1709,14 +1709,14 @@ void InventoryHandler::handleSendMailResult(network::Packet& packet) { } else if (action == 4) { // TAKE_ITEM if (error == 0) { owner_.addSystemChatMessage("Item taken from mail."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("BAG_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("BAG_UPDATE", {}); } else { owner_.addSystemChatMessage("Failed to take item (error " + std::to_string(error) + ")."); } } else if (action == 5) { // TAKE_MONEY if (error == 0) { owner_.addSystemChatMessage("Money taken from mail."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("PLAYER_MONEY", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("PLAYER_MONEY", {}); } } else if (action == 2) { // DELETE if (error == 0) { @@ -1729,7 +1729,7 @@ void InventoryHandler::handleSendMailResult(network::Packet& packet) { void InventoryHandler::handleReceivedMail(network::Packet& packet) { (void)packet; hasNewMail_ = true; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("UPDATE_PENDING_MAIL", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("UPDATE_PENDING_MAIL", {}); } void InventoryHandler::handleQueryNextMailTime(network::Packet& packet) { @@ -1756,23 +1756,23 @@ void InventoryHandler::openBank(uint64_t guid) { effectiveBankSlots_ = 28; effectiveBankBagSlots_ = 7; } - if (owner_.addonEventCallback_) owner_.addonEventCallback_("BANKFRAME_OPENED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("BANKFRAME_OPENED", {}); } void InventoryHandler::closeBank() { bankOpen_ = false; bankerGuid_ = 0; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("BANKFRAME_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("BANKFRAME_CLOSED", {}); } void InventoryHandler::buyBankSlot() { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || bankerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || bankerGuid_ == 0) return; auto packet = BuyBankSlotPacket::build(bankerGuid_); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::depositItem(uint8_t srcBag, uint8_t srcSlot) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; int freeBankSlot = -1; for (int i = 0; i < effectiveBankSlots_; ++i) { if (bankSlotGuids_[i] == 0) { freeBankSlot = i; break; } @@ -1783,19 +1783,19 @@ void InventoryHandler::depositItem(uint8_t srcBag, uint8_t srcSlot) { } uint8_t dstSlot = static_cast(39 + freeBankSlot); auto packet = SwapItemPacket::build(0xFF, dstSlot, srcBag, srcSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::withdrawItem(uint8_t srcBag, uint8_t srcSlot) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; - int freeSlot = owner_.inventory.findFreeBackpackSlot(); + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; + int freeSlot = owner_.inventoryRef().findFreeBackpackSlot(); if (freeSlot < 0) { owner_.addSystemChatMessage("Inventory is full."); return; } uint8_t dstSlot = static_cast(Inventory::NUM_EQUIP_SLOTS + freeSlot); auto packet = SwapItemPacket::build(0xFF, dstSlot, srcBag, srcSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::handleShowBank(network::Packet& packet) { @@ -1809,7 +1809,7 @@ void InventoryHandler::handleBuyBankSlotResult(network::Packet& packet) { uint32_t result = packet.readUInt32(); if (result == 0) { owner_.addSystemChatMessage("Bank slot purchased."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("PLAYERBANKBAGSLOTS_CHANGED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("PLAYERBANKBAGSLOTS_CHANGED", {}); } else { owner_.addSystemChatMessage("Failed to purchase bank slot."); } @@ -1823,56 +1823,56 @@ void InventoryHandler::openGuildBank(uint64_t guid) { guildBankerGuid_ = guid; guildBankOpen_ = true; guildBankActiveTab_ = 0; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("GUILDBANKFRAME_OPENED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("GUILDBANKFRAME_OPENED", {}); queryGuildBankTab(0); } void InventoryHandler::closeGuildBank() { guildBankOpen_ = false; guildBankerGuid_ = 0; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("GUILDBANKFRAME_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("GUILDBANKFRAME_CLOSED", {}); } void InventoryHandler::queryGuildBankTab(uint8_t tabId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || guildBankerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || guildBankerGuid_ == 0) return; auto packet = GuildBankQueryTabPacket::build(guildBankerGuid_, tabId, false); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::buyGuildBankTab() { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || guildBankerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || guildBankerGuid_ == 0) return; uint8_t nextTab = static_cast(guildBankData_.tabs.size()); auto packet = GuildBankBuyTabPacket::build(guildBankerGuid_, nextTab); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::depositGuildBankMoney(uint32_t amount) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || guildBankerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || guildBankerGuid_ == 0) return; auto packet = GuildBankDepositMoneyPacket::build(guildBankerGuid_, amount); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::withdrawGuildBankMoney(uint32_t amount) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || guildBankerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || guildBankerGuid_ == 0) return; auto packet = GuildBankWithdrawMoneyPacket::build(guildBankerGuid_, amount); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::guildBankWithdrawItem(uint8_t tabId, uint8_t bankSlot, uint8_t destBag, uint8_t destSlot) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || guildBankerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || guildBankerGuid_ == 0) return; auto packet = GuildBankSwapItemsPacket::buildBankToInventory(guildBankerGuid_, tabId, bankSlot, destBag, destSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::guildBankDepositItem(uint8_t tabId, uint8_t bankSlot, uint8_t srcBag, uint8_t srcSlot) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || guildBankerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || guildBankerGuid_ == 0) return; auto packet = GuildBankSwapItemsPacket::buildInventoryToBank(guildBankerGuid_, tabId, bankSlot, srcBag, srcSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::handleGuildBankList(network::Packet& packet) { if (!GuildBankListParser::parse(packet, guildBankData_)) return; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("GUILDBANKBAGSLOTS_CHANGED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("GUILDBANKBAGSLOTS_CHANGED", {}); for (const auto& tab : guildBankData_.tabs) { for (const auto& item : tab.items) { if (item.itemEntry != 0) owner_.ensureItemInfo(item.itemEntry); @@ -1888,37 +1888,37 @@ void InventoryHandler::openAuctionHouse(uint64_t guid) { auctioneerGuid_ = guid; auctionOpen_ = true; auctionActiveTab_ = 0; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("AUCTION_HOUSE_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("AUCTION_HOUSE_SHOW", {}); } void InventoryHandler::closeAuctionHouse() { auctionOpen_ = false; auctioneerGuid_ = 0; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("AUCTION_HOUSE_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("AUCTION_HOUSE_CLOSED", {}); } void InventoryHandler::auctionSearch(const std::string& name, uint8_t levelMin, uint8_t levelMax, uint32_t quality, uint32_t itemClass, uint32_t itemSubClass, uint32_t invTypeMask, uint8_t usableOnly, uint32_t offset) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || auctioneerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || auctioneerGuid_ == 0) return; lastAuctionSearch_ = {name, levelMin, levelMax, quality, itemClass, itemSubClass, invTypeMask, usableOnly, offset}; hasAuctionSearch_ = true; pendingAuctionTarget_ = AuctionResultTarget::BROWSE; auto packet = AuctionListItemsPacket::build(auctioneerGuid_, offset, name, levelMin, levelMax, invTypeMask, itemClass, itemSubClass, quality, usableOnly, 0); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); auctionSearchDelayTimer_ = 5.0f; } void InventoryHandler::auctionSellItem(int backpackIndex, uint32_t bid, uint32_t buyout, uint32_t duration) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || auctioneerGuid_ == 0) return; - if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return; - const auto& slot = owner_.inventory.getBackpackSlot(backpackIndex); + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || auctioneerGuid_ == 0) return; + if (backpackIndex < 0 || backpackIndex >= owner_.inventoryRef().getBackpackSize()) return; + const auto& slot = owner_.inventoryRef().getBackpackSlot(backpackIndex); if (slot.empty()) return; - uint64_t itemGuid = owner_.backpackSlotGuids_[backpackIndex]; + uint64_t itemGuid = owner_.backpackSlotGuidsRef()[backpackIndex]; if (itemGuid == 0) { itemGuid = owner_.resolveOnlineItemGuid(slot.item.itemId); } @@ -1930,39 +1930,39 @@ void InventoryHandler::auctionSellItem(int backpackIndex, uint32_t bid, uint32_t stackCount = slot.item.stackCount; auto packet = AuctionSellItemPacket::build(auctioneerGuid_, itemGuid, stackCount, bid, buyout, duration, isPreWotlk()); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::auctionPlaceBid(uint32_t auctionId, uint32_t amount) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || auctioneerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || auctioneerGuid_ == 0) return; auto packet = AuctionPlaceBidPacket::build(auctioneerGuid_, auctionId, amount); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::auctionBuyout(uint32_t auctionId, uint32_t buyoutPrice) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || auctioneerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || auctioneerGuid_ == 0) return; auto packet = AuctionPlaceBidPacket::build(auctioneerGuid_, auctionId, buyoutPrice); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::auctionCancelItem(uint32_t auctionId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || auctioneerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || auctioneerGuid_ == 0) return; auto packet = AuctionRemoveItemPacket::build(auctioneerGuid_, auctionId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::auctionListOwnerItems(uint32_t offset) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || auctioneerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || auctioneerGuid_ == 0) return; pendingAuctionTarget_ = AuctionResultTarget::OWNER; auto packet = AuctionListOwnerItemsPacket::build(auctioneerGuid_, offset); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::auctionListBidderItems(uint32_t offset) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || auctioneerGuid_ == 0) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || auctioneerGuid_ == 0) return; pendingAuctionTarget_ = AuctionResultTarget::BIDDER; auto packet = AuctionListBidderItemsPacket::build(auctioneerGuid_, offset); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::handleAuctionHello(network::Packet& packet) { @@ -1974,7 +1974,7 @@ void InventoryHandler::handleAuctionHello(network::Packet& packet) { auctionOpen_ = true; auctionActiveTab_ = 0; owner_.closeGossip(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("AUCTION_HOUSE_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("AUCTION_HOUSE_SHOW", {}); } void InventoryHandler::handleAuctionListResult(network::Packet& packet) { @@ -1983,13 +1983,13 @@ void InventoryHandler::handleAuctionListResult(network::Packet& packet) { if (pendingAuctionTarget_ == AuctionResultTarget::OWNER) { auctionOwnerResults_ = std::move(result); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("AUCTION_OWNED_LIST_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("AUCTION_OWNED_LIST_UPDATE", {}); } else if (pendingAuctionTarget_ == AuctionResultTarget::BIDDER) { auctionBidderResults_ = std::move(result); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("AUCTION_BIDDER_LIST_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("AUCTION_BIDDER_LIST_UPDATE", {}); } else { auctionBrowseResults_ = std::move(result); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("AUCTION_ITEM_LIST_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("AUCTION_ITEM_LIST_UPDATE", {}); } // Ensure item info for all entries @@ -2026,9 +2026,9 @@ void InventoryHandler::handleAuctionCommandResult(network::Packet& packet) { if (error == 0) { std::string msg = std::string("Auction ") + actionStr + " successful."; owner_.addSystemChatMessage(msg); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("PLAYER_MONEY", {}); - owner_.addonEventCallback_("BAG_UPDATE", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("PLAYER_MONEY", {}); + owner_.addonEventCallbackRef()("BAG_UPDATE", {}); } // Re-query after successful buy/bid so the list reflects the change. // Previously gated on name.length()>0 which skipped browse-all (empty name). @@ -2058,10 +2058,10 @@ void InventoryHandler::handleAuctionCommandResult(network::Packet& packet) { // ============================================================ void InventoryHandler::queryItemText(uint64_t itemGuid) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_ITEM_TEXT_QUERY)); pkt.writeUInt64(itemGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void InventoryHandler::handleItemTextQueryResponse(network::Packet& packet) { @@ -2079,54 +2079,54 @@ void InventoryHandler::handleItemTextQueryResponse(network::Packet& packet) { void InventoryHandler::acceptTradeRequest() { if (tradeStatus_ != TradeStatus::PendingIncoming) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = BeginTradePacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::declineTradeRequest() { if (tradeStatus_ != TradeStatus::PendingIncoming) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = CancelTradePacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); resetTradeState(); } void InventoryHandler::acceptTrade() { if (tradeStatus_ != TradeStatus::Open) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = AcceptTradePacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::cancelTrade() { if (tradeStatus_ == TradeStatus::None) return; - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { auto packet = CancelTradePacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } resetTradeState(); } void InventoryHandler::setTradeItem(uint8_t tradeSlot, uint8_t srcBag, uint8_t srcSlot) { if (tradeStatus_ != TradeStatus::Open) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = SetTradeItemPacket::build(tradeSlot, srcBag, srcSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::clearTradeItem(uint8_t tradeSlot) { if (tradeStatus_ != TradeStatus::Open) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = ClearTradeItemPacket::build(tradeSlot); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::setTradeGold(uint64_t amount) { if (tradeStatus_ != TradeStatus::Open) return; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = SetTradeGoldPacket::build(static_cast(amount)); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void InventoryHandler::resetTradeState() { @@ -2157,23 +2157,23 @@ void InventoryHandler::handleTradeStatus(network::Packet& packet) { if (nit != owner_.getPlayerNameCache().end()) tradePeerName_ = nit->second; else tradePeerName_ = "Unknown"; owner_.addSystemChatMessage(tradePeerName_ + " wants to trade with you."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRADE_REQUEST", {tradePeerName_}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRADE_REQUEST", {tradePeerName_}); break; } case 2: // TRADE_STATUS_INITIATED tradeStatus_ = TradeStatus::Open; owner_.addSystemChatMessage("Trade opened."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRADE_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRADE_SHOW", {}); break; case 3: // TRADE_STATUS_CANCELLED resetTradeState(); owner_.addSystemChatMessage("Trade cancelled."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRADE_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRADE_CLOSED", {}); break; case 4: // TRADE_STATUS_ACCEPTED tradeStatus_ = TradeStatus::Accepted; owner_.addSystemChatMessage("Trade partner accepted."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRADE_ACCEPT_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRADE_ACCEPT_UPDATE", {}); break; case 5: // TRADE_STATUS_ALREADY_TRADING owner_.addSystemChatMessage("You are already trading."); @@ -2183,10 +2183,10 @@ void InventoryHandler::handleTradeStatus(network::Packet& packet) { // packet batch and needs the trade state to store final item/gold data. tradeStatus_ = TradeStatus::None; owner_.addSystemChatMessage("Trade complete."); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("TRADE_CLOSED", {}); - owner_.addonEventCallback_("BAG_UPDATE", {}); - owner_.addonEventCallback_("PLAYER_MONEY", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("TRADE_CLOSED", {}); + owner_.addonEventCallbackRef()("BAG_UPDATE", {}); + owner_.addonEventCallbackRef()("PLAYER_MONEY", {}); } break; case 9: // TRADE_STATUS_TARGET_TO_FAR @@ -2196,11 +2196,11 @@ void InventoryHandler::handleTradeStatus(network::Packet& packet) { case 13: // TRADE_STATUS_FAILED resetTradeState(); owner_.addSystemChatMessage("Trade failed."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRADE_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRADE_CLOSED", {}); break; case 8: // TRADE_STATUS_UNACCEPT tradeStatus_ = TradeStatus::Open; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRADE_ACCEPT_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRADE_ACCEPT_UPDATE", {}); break; case 17: // TRADE_STATUS_PETITION owner_.addSystemChatMessage("You cannot trade while petition is active."); @@ -2262,7 +2262,7 @@ void InventoryHandler::handleTradeStatusExtended(network::Packet& packet) { else peerTradeGold_ = gold; } - if (owner_.addonEventCallback_) owner_.addonEventCallback_("TRADE_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("TRADE_UPDATE", {}); } // ============================================================ @@ -2274,7 +2274,7 @@ bool InventoryHandler::supportsEquipmentSets() const { } void InventoryHandler::useEquipmentSet(uint32_t setId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_USE); if (wire == 0xFFFF) { owner_.addUIError("Equipment sets not supported."); return; } const EquipmentSet* es = nullptr; @@ -2308,7 +2308,7 @@ void InventoryHandler::useEquipmentSet(uint32_t setId) { } } for (int bag = 0; bag < 4 && !found; ++bag) { - int bagSize = owner_.inventory.getBagSize(bag); + int bagSize = owner_.inventoryRef().getBagSize(bag); for (int s = 0; s < bagSize && !found; ++s) { if (owner_.getBagItemGuid(bag, s) == itemGuid) { srcBag = static_cast(Inventory::FIRST_BAG_EQUIP_SLOT + bag); @@ -2321,12 +2321,12 @@ void InventoryHandler::useEquipmentSet(uint32_t setId) { pkt.writeUInt8(srcBag); pkt.writeUInt8(srcSlot); } - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void InventoryHandler::saveEquipmentSet(const std::string& name, const std::string& iconName, uint64_t existingGuid, uint32_t setIndex) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; uint16_t wire = wireOpcode(Opcode::CMSG_EQUIPMENT_SET_SAVE); if (wire == 0xFFFF) { owner_.addUIError("Equipment sets not supported."); return; } pendingSaveSetName_ = name; @@ -2345,16 +2345,16 @@ void InventoryHandler::saveEquipmentSet(const std::string& name, const std::stri for (int i = 0; i < 19; ++i) { pkt.writePackedGuid(owner_.getEquipSlotGuid(i)); } - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void InventoryHandler::deleteEquipmentSet(uint64_t setGuid) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; uint16_t wire = wireOpcode(Opcode::CMSG_DELETEEQUIPMENT_SET); if (wire == 0xFFFF) { owner_.addUIError("Equipment sets not supported."); return; } network::Packet pkt(wire); pkt.writeUInt64(setGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); equipmentSets_.erase( std::remove_if(equipmentSets_.begin(), equipmentSets_.end(), [setGuid](const EquipmentSet& es) { return es.setGuid == setGuid; }), @@ -2407,26 +2407,26 @@ void InventoryHandler::handleEquipmentSetList(network::Packet& packet) { // ============================================================ void InventoryHandler::queryItemInfo(uint32_t entry, uint64_t guid) { - if (owner_.itemInfoCache_.count(entry) || owner_.pendingItemQueries_.count(entry)) return; + if (owner_.itemInfoCacheRef().count(entry) || owner_.pendingItemQueriesRef().count(entry)) return; if (!owner_.isInWorld()) return; - owner_.pendingItemQueries_.insert(entry); + owner_.pendingItemQueriesRef().insert(entry); // Some cores reject CMSG_ITEM_QUERY_SINGLE when the GUID is 0. // If we don't have the item object's GUID (e.g. visible equipment decoding), // fall back to the player's GUID to keep the request non-zero. - uint64_t queryGuid = (guid != 0) ? guid : owner_.playerGuid; - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildItemQuery(entry, queryGuid) + uint64_t queryGuid = (guid != 0) ? guid : owner_.getPlayerGuid(); + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildItemQuery(entry, queryGuid) : ItemQueryPacket::build(entry, queryGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_DEBUG("queryItemInfo: entry=", entry, " guid=0x", std::hex, queryGuid, std::dec, - " pending=", owner_.pendingItemQueries_.size()); + " pending=", owner_.pendingItemQueriesRef().size()); } void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { ItemQueryResponseData data; - bool parsed = owner_.packetParsers_ - ? owner_.packetParsers_->parseItemQueryResponse(packet, data) + bool parsed = owner_.getPacketParsers() + ? owner_.getPacketParsers()->parseItemQueryResponse(packet, data) : ItemQueryResponseParser::parse(packet, data); if (!parsed) { // Extract entry from raw packet so we can clear the pending query even on parse failure. @@ -2436,24 +2436,24 @@ void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { // High bit indicates a negative (invalid/missing) item entry response; // mask it off so we can still clear the pending query by entry ID. uint32_t rawEntry = packet.readUInt32() & ~0x80000000u; - owner_.pendingItemQueries_.erase(rawEntry); + owner_.pendingItemQueriesRef().erase(rawEntry); } LOG_WARNING("handleItemQueryResponse: parse failed, size=", packet.getSize()); return; } - owner_.pendingItemQueries_.erase(data.entry); + owner_.pendingItemQueriesRef().erase(data.entry); LOG_DEBUG("handleItemQueryResponse: entry=", data.entry, " name='", data.name, "' class=", data.itemClass, " subClass=", data.subClass, " invType=", data.inventoryType, " valid=", data.valid); if (data.valid) { - owner_.itemInfoCache_[data.entry] = data; + owner_.itemInfoCacheRef()[data.entry] = data; rebuildOnlineInventory(); maybeDetectVisibleItemLayout(); // Flush any deferred loot notifications waiting on this item's name/quality. - for (auto it = owner_.pendingItemPushNotifs_.begin(); it != owner_.pendingItemPushNotifs_.end(); ) { + for (auto it = owner_.pendingItemPushNotifsRef().begin(); it != owner_.pendingItemPushNotifsRef().end(); ) { if (it->itemId == data.entry) { std::string itemName = data.name.empty() ? ("item #" + std::to_string(data.entry)) : data.name; std::string link = buildItemLink(data.entry, data.quality, itemName); @@ -2463,8 +2463,8 @@ void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { if (auto* ac = owner_.services().audioCoordinator) { if (auto* sfx = ac->getUiSoundManager()) sfx->playLootItem(); } - if (owner_.itemLootCallback_) owner_.itemLootCallback_(data.entry, it->count, data.quality, itemName); - it = owner_.pendingItemPushNotifs_.erase(it); + if (owner_.itemLootCallbackRef()) owner_.itemLootCallbackRef()(data.entry, it->count, data.quality, itemName); + it = owner_.pendingItemPushNotifsRef().erase(it); } else { ++it; } @@ -2473,7 +2473,7 @@ void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { // Selectively re-emit only players whose equipment references this item entry const uint32_t resolvedEntry = data.entry; int reemitCount = 0; - for (const auto& [guid, entries] : owner_.otherPlayerVisibleItemEntries_) { + for (const auto& [guid, entries] : owner_.otherPlayerVisibleItemEntriesRef()) { for (uint32_t e : entries) { if (e == resolvedEntry) { emitOtherPlayerEquipment(guid); @@ -2486,8 +2486,8 @@ void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { LOG_WARNING("Re-emitted equipment for ", reemitCount, " players after resolving entry=", resolvedEntry); } // Same for inspect-based entries - if (owner_.playerEquipmentCallback_) { - for (const auto& [guid, entries] : owner_.inspectedPlayerItemEntries_) { + if (owner_.playerEquipmentCallbackRef()) { + for (const auto& [guid, entries] : owner_.inspectedPlayerItemEntriesRef()) { bool relevant = false; for (uint32_t e : entries) { if (e == resolvedEntry) { relevant = true; break; } @@ -2498,12 +2498,12 @@ void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { for (int s = 0; s < 19; s++) { uint32_t entry = entries[s]; if (entry == 0) continue; - auto infoIt = owner_.itemInfoCache_.find(entry); - if (infoIt == owner_.itemInfoCache_.end()) continue; + auto infoIt = owner_.itemInfoCacheRef().find(entry); + if (infoIt == owner_.itemInfoCacheRef().end()) continue; displayIds[s] = infoIt->second.displayInfoId; invTypes[s] = static_cast(infoIt->second.inventoryType); } - owner_.playerEquipmentCallback_(guid, displayIds, invTypes); + owner_.playerEquipmentCallbackRef()(guid, displayIds, invTypes); } } } @@ -2511,14 +2511,14 @@ void InventoryHandler::handleItemQueryResponse(network::Packet& packet) { uint64_t InventoryHandler::resolveOnlineItemGuid(uint32_t itemId) const { if (itemId == 0) return 0; - for (const auto& [guid, info] : owner_.onlineItems_) { + for (const auto& [guid, info] : owner_.onlineItemsRef()) { if (info.entry == itemId) return guid; } return 0; } void InventoryHandler::detectInventorySlotBases(const std::map& fields) { - if (owner_.invSlotBase_ >= 0 && owner_.packSlotBase_ >= 0) return; + if (owner_.invSlotBaseRef() >= 0 && owner_.packSlotBaseRef() >= 0) return; if (fields.empty()) return; std::vector matchingPairs; @@ -2531,7 +2531,7 @@ void InventoryHandler::detectInventorySlotBases(const std::mapsecond) << 32) | low; if (guid == 0) continue; // Primary signal: GUID pairs that match spawned ITEM objects. - if (!owner_.onlineItems_.empty() && owner_.onlineItems_.count(guid)) { + if (!owner_.onlineItemsRef().empty() && owner_.onlineItemsRef().count(guid)) { matchingPairs.push_back(idx); } } @@ -2554,7 +2554,7 @@ void InventoryHandler::detectInventorySlotBases(const std::map= 2) { @@ -2587,25 +2587,25 @@ void InventoryHandler::detectInventorySlotBases(const std::mapsecond; for (int s = 0; s < numSlots && s < 36; s++) { uint64_t itemGuid = container.slotGuids[s]; if (itemGuid == 0) continue; - auto itemIt = owner_.onlineItems_.find(itemGuid); - if (itemIt == owner_.onlineItems_.end()) continue; + auto itemIt = owner_.onlineItemsRef().find(itemGuid); + if (itemIt == owner_.onlineItemsRef().end()) continue; ItemDef def; def.itemId = itemIt->second.entry; @@ -2945,8 +2945,8 @@ void InventoryHandler::rebuildOnlineInventory() { def.maxDurability = itemIt->second.maxDurability; def.maxStack = 1; - auto infoIt = owner_.itemInfoCache_.find(itemIt->second.entry); - if (infoIt != owner_.itemInfoCache_.end()) { + auto infoIt = owner_.itemInfoCacheRef().find(itemIt->second.entry); + if (infoIt != owner_.itemInfoCacheRef().end()) { def.name = infoIt->second.name; def.quality = static_cast(infoIt->second.quality); def.inventoryType = infoIt->second.inventoryType; @@ -2977,55 +2977,55 @@ void InventoryHandler::rebuildOnlineInventory() { queryItemInfo(def.itemId, itemGuid); } - owner_.inventory.setBankBagSlot(bagIdx, s, def); + owner_.inventoryRef().setBankBagSlot(bagIdx, s, def); } } // Only mark equipment dirty if equipped item displayInfoIds actually changed std::array currentEquipDisplayIds{}; for (int i = 0; i < 19; i++) { - const auto& slot = owner_.inventory.getEquipSlot(static_cast(i)); + const auto& slot = owner_.inventoryRef().getEquipSlot(static_cast(i)); if (!slot.empty()) currentEquipDisplayIds[i] = slot.item.displayInfoId; } - if (currentEquipDisplayIds != owner_.lastEquipDisplayIds_) { - owner_.lastEquipDisplayIds_ = currentEquipDisplayIds; - owner_.onlineEquipDirty_ = true; + if (currentEquipDisplayIds != owner_.lastEquipDisplayIdsRef()) { + owner_.lastEquipDisplayIdsRef() = currentEquipDisplayIds; + owner_.onlineEquipDirtyRef() = true; } LOG_DEBUG("Rebuilt online inventory: equip=", [&](){ - int c = 0; for (auto g : owner_.equipSlotGuids_) if (g) c++; return c; + int c = 0; for (auto g : owner_.equipSlotGuidsRef()) if (g) c++; return c; }(), " backpack=", [&](){ - int c = 0; for (auto g : owner_.backpackSlotGuids_) if (g) c++; return c; + int c = 0; for (auto g : owner_.backpackSlotGuidsRef()) if (g) c++; return c; }(), " keyring=", [&](){ - int c = 0; for (auto g : owner_.keyringSlotGuids_) if (g) c++; return c; + int c = 0; for (auto g : owner_.keyringSlotGuidsRef()) if (g) c++; return c; }()); } void InventoryHandler::maybeDetectVisibleItemLayout() { - if (owner_.visibleItemLayoutVerified_) return; - if (owner_.lastPlayerFields_.empty()) return; + if (owner_.visibleItemLayoutVerifiedRef()) return; + if (owner_.lastPlayerFieldsRef().empty()) return; std::array equipEntries{}; int nonZero = 0; // Prefer authoritative equipped item entry IDs derived from item objects (onlineItems_), // because Inventory::ItemDef may not be populated yet if templates haven't been queried. for (int i = 0; i < 19; i++) { - uint64_t itemGuid = owner_.equipSlotGuids_[i]; + uint64_t itemGuid = owner_.equipSlotGuidsRef()[i]; if (itemGuid != 0) { - auto it = owner_.onlineItems_.find(itemGuid); - if (it != owner_.onlineItems_.end() && it->second.entry != 0) { + auto it = owner_.onlineItemsRef().find(itemGuid); + if (it != owner_.onlineItemsRef().end() && it->second.entry != 0) { equipEntries[i] = it->second.entry; } } if (equipEntries[i] == 0) { - const auto& slot = owner_.inventory.getEquipSlot(static_cast(i)); + const auto& slot = owner_.inventoryRef().getEquipSlot(static_cast(i)); equipEntries[i] = slot.empty() ? 0u : slot.item.itemId; } if (equipEntries[i] != 0) nonZero++; } if (nonZero < 2) return; - const uint16_t maxKey = owner_.lastPlayerFields_.rbegin()->first; + const uint16_t maxKey = owner_.lastPlayerFieldsRef().rbegin()->first; int bestBase = -1; int bestStride = 0; int bestMatches = 0; @@ -3034,7 +3034,7 @@ void InventoryHandler::maybeDetectVisibleItemLayout() { const int strides[] = {2, 3, 4, 1}; for (int stride : strides) { - for (const auto& [baseIdxU16, _v] : owner_.lastPlayerFields_) { + for (const auto& [baseIdxU16, _v] : owner_.lastPlayerFieldsRef()) { const int base = static_cast(baseIdxU16); if (base + 18 * stride > static_cast(maxKey)) continue; @@ -3044,8 +3044,8 @@ void InventoryHandler::maybeDetectVisibleItemLayout() { uint32_t want = equipEntries[s]; if (want == 0) continue; const uint16_t idx = static_cast(base + s * stride); - auto it = owner_.lastPlayerFields_.find(idx); - if (it == owner_.lastPlayerFields_.end()) continue; + auto it = owner_.lastPlayerFieldsRef().find(idx); + if (it == owner_.lastPlayerFieldsRef().end()) continue; if (it->second == want) { matches++; } else if (it->second != 0) { @@ -3068,17 +3068,17 @@ void InventoryHandler::maybeDetectVisibleItemLayout() { } if (bestMatches >= 2 && bestBase >= 0 && bestStride > 0 && bestMismatches <= 1) { - owner_.visibleItemEntryBase_ = bestBase; - owner_.visibleItemStride_ = bestStride; - owner_.visibleItemLayoutVerified_ = true; - LOG_INFO("Detected PLAYER_VISIBLE_ITEM entry layout: base=", owner_.visibleItemEntryBase_, - " stride=", owner_.visibleItemStride_, " (matches=", bestMatches, + owner_.visibleItemEntryBaseRef() = bestBase; + owner_.visibleItemStrideRef() = bestStride; + owner_.visibleItemLayoutVerifiedRef() = true; + LOG_INFO("Detected PLAYER_VISIBLE_ITEM entry layout: base=", owner_.visibleItemEntryBaseRef(), + " stride=", owner_.visibleItemStrideRef(), " (matches=", bestMatches, " mismatches=", bestMismatches, " score=", bestScore, ")"); // Backfill existing player entities already in view. for (const auto& [guid, ent] : owner_.getEntityManager().getEntities()) { if (!ent || ent->getType() != ObjectType::PLAYER) continue; - if (guid == owner_.playerGuid) continue; + if (guid == owner_.getPlayerGuid()) continue; updateOtherPlayerVisibleItems(guid, ent->getFields()); } } @@ -3086,13 +3086,13 @@ void InventoryHandler::maybeDetectVisibleItemLayout() { } void InventoryHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::map& fields) { - if (guid == 0 || guid == owner_.playerGuid) return; + if (guid == 0 || guid == owner_.getPlayerGuid()) return; // Use the current base/stride (defaults are correct for WotLK 3.3.5a: base=284, stride=2). // The heuristic may refine these later, but we proceed immediately with whatever values // are set rather than waiting for verification. - const int base = owner_.visibleItemEntryBase_; - const int stride = owner_.visibleItemStride_; + const int base = owner_.visibleItemEntryBaseRef(); + const int stride = owner_.visibleItemStrideRef(); if (base < 0 || stride <= 0) return; // Defensive: should never happen with defaults. std::array newEntries{}; @@ -3129,7 +3129,7 @@ void InventoryHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::m } bool changed = false; - auto& old = owner_.otherPlayerVisibleItemEntries_[guid]; + auto& old = owner_.otherPlayerVisibleItemEntriesRef()[guid]; if (old != newEntries) { old = newEntries; changed = true; @@ -3138,7 +3138,7 @@ void InventoryHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::m // Request item templates for any new visible entries. for (uint32_t entry : newEntries) { if (entry == 0) continue; - if (!owner_.itemInfoCache_.count(entry) && !owner_.pendingItemQueries_.count(entry)) { + if (!owner_.itemInfoCacheRef().count(entry) && !owner_.pendingItemQueriesRef().count(entry)) { queryItemInfo(entry, 0); } } @@ -3150,21 +3150,21 @@ void InventoryHandler::updateOtherPlayerVisibleItems(uint64_t guid, const std::m LOG_DEBUG("updateOtherPlayerVisibleItems: guid=0x", std::hex, guid, std::dec, " all entries zero (base=", base, " stride=", stride, " fieldCount=", fields.size(), ") — queuing auto-inspect"); - if (owner_.socket && owner_.state == WorldState::IN_WORLD) { - owner_.pendingAutoInspect_.insert(guid); + if (owner_.getSocket() && owner_.getState() == WorldState::IN_WORLD) { + owner_.pendingAutoInspectRef().insert(guid); } } if (changed) { - owner_.otherPlayerVisibleDirty_.insert(guid); + owner_.otherPlayerVisibleDirtyRef().insert(guid); emitOtherPlayerEquipment(guid); } } void InventoryHandler::emitOtherPlayerEquipment(uint64_t guid) { - if (!owner_.playerEquipmentCallback_) return; - auto it = owner_.otherPlayerVisibleItemEntries_.find(guid); - if (it == owner_.otherPlayerVisibleItemEntries_.end()) return; + if (!owner_.playerEquipmentCallbackRef()) return; + auto it = owner_.otherPlayerVisibleItemEntriesRef().find(guid); + if (it == owner_.otherPlayerVisibleItemEntriesRef().end()) return; std::array displayIds{}; std::array invTypes{}; @@ -3175,8 +3175,8 @@ void InventoryHandler::emitOtherPlayerEquipment(uint64_t guid) { uint32_t entry = it->second[s]; if (entry == 0) continue; anyEntry = true; - auto infoIt = owner_.itemInfoCache_.find(entry); - if (infoIt == owner_.itemInfoCache_.end()) { unresolved++; continue; } + auto infoIt = owner_.itemInfoCacheRef().find(entry); + if (infoIt == owner_.itemInfoCacheRef().end()) { unresolved++; continue; } displayIds[s] = infoIt->second.displayInfoId; invTypes[s] = static_cast(infoIt->second.inventoryType); resolved++; @@ -3196,18 +3196,18 @@ void InventoryHandler::emitOtherPlayerEquipment(uint64_t guid) { return; } - owner_.playerEquipmentCallback_(guid, displayIds, invTypes); - owner_.otherPlayerVisibleDirty_.erase(guid); + owner_.playerEquipmentCallbackRef()(guid, displayIds, invTypes); + owner_.otherPlayerVisibleDirtyRef().erase(guid); // If we had entries but couldn't resolve any templates, also try inspect as a fallback. if (anyEntry && !resolved) { - owner_.pendingAutoInspect_.insert(guid); + owner_.pendingAutoInspectRef().insert(guid); } } void InventoryHandler::emitAllOtherPlayerEquipment() { - if (!owner_.playerEquipmentCallback_) return; - for (const auto& [guid, _] : owner_.otherPlayerVisibleItemEntries_) { + if (!owner_.playerEquipmentCallbackRef()) return; + for (const auto& [guid, _] : owner_.otherPlayerVisibleItemEntriesRef()) { emitOtherPlayerEquipment(guid); } } @@ -3219,8 +3219,8 @@ void InventoryHandler::emitAllOtherPlayerEquipment() { void InventoryHandler::handleTrainerBuySucceeded(network::Packet& packet) { /*uint64_t guid =*/ packet.readUInt64(); uint32_t spellId = packet.readUInt32(); - if (owner_.spellHandler_ && !owner_.spellHandler_->knownSpells_.count(spellId)) { - owner_.spellHandler_->knownSpells_.insert(spellId); + if (owner_.getSpellHandler() && !owner_.getSpellHandler()->knownSpells_.count(spellId)) { + owner_.getSpellHandler()->knownSpells_.insert(spellId); } const std::string& name = owner_.getSpellName(spellId); if (!name.empty()) @@ -3269,7 +3269,7 @@ void InventoryHandler::initiateTrade(uint64_t targetGuid) { } auto packet = InitiateTradePacket::build(targetGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Requesting trade with target."); LOG_INFO("Initiated trade with target: 0x", std::hex, targetGuid, std::dec); } @@ -3278,7 +3278,7 @@ uint32_t InventoryHandler::getTempEnchantRemainingMs(uint32_t slot) const { uint64_t nowMs = static_cast( std::chrono::duration_cast( std::chrono::steady_clock::now().time_since_epoch()).count()); - for (const auto& t : owner_.tempEnchantTimers_) { + for (const auto& t : owner_.tempEnchantTimersRef()) { if (t.slot == slot) { return (t.expireMs > nowMs) ? static_cast(t.expireMs - nowMs) : 0u; @@ -3289,7 +3289,7 @@ uint32_t InventoryHandler::getTempEnchantRemainingMs(uint32_t slot) const { void InventoryHandler::addMoneyCopper(uint32_t amount) { if (amount == 0) return; - owner_.playerMoneyCopper_ += amount; + owner_.playerMoneyCopperRef() += amount; uint32_t gold = amount / 10000; uint32_t silver = (amount / 100) % 100; uint32_t copper = amount % 100; @@ -3339,15 +3339,15 @@ void InventoryHandler::loadRepairDbc() const { } uint32_t InventoryHandler::estimateItemRepairCost(uint64_t itemGuid) const { - auto itemIt = owner_.onlineItems_.find(itemGuid); - if (itemIt == owner_.onlineItems_.end()) return 0; + auto itemIt = owner_.onlineItemsRef().find(itemGuid); + if (itemIt == owner_.onlineItemsRef().end()) return 0; const auto& item = itemIt->second; if (item.maxDurability == 0 || item.curDurability >= item.maxDurability) return 0; uint32_t lostDur = item.maxDurability - item.curDurability; - auto infoIt = owner_.itemInfoCache_.find(item.entry); - if (infoIt == owner_.itemInfoCache_.end()) return 0; + auto infoIt = owner_.itemInfoCacheRef().find(item.entry); + if (infoIt == owner_.itemInfoCacheRef().end()) return 0; const auto& info = infoIt->second; loadRepairDbc(); @@ -3384,7 +3384,7 @@ uint32_t InventoryHandler::estimateItemRepairCost(uint64_t itemGuid) const { uint32_t InventoryHandler::estimateRepairAllCost() const { uint32_t total = 0; - for (const auto& [guid, info] : owner_.onlineItems_) { + for (const auto& [guid, info] : owner_.onlineItemsRef()) { total += estimateItemRepairCost(guid); } return total; diff --git a/src/game/movement_handler.cpp b/src/game/movement_handler.cpp index c06a45a2..1cb45d52 100644 --- a/src/game/movement_handler.cpp +++ b/src/game/movement_handler.cpp @@ -23,7 +23,7 @@ namespace wowee { namespace game { MovementHandler::MovementHandler(GameHandler& owner) - : owner_(owner), movementInfo(owner.movementInfo) {} + : owner_(owner), movementInfo(owner.movementInfoRef()) {} void MovementHandler::registerOpcodes(DispatchTable& table) { // Creature movement @@ -51,8 +51,8 @@ void MovementHandler::registerOpcodes(DispatchTable& table) { return [this, synthFlags](network::Packet& packet) { if (!packet.hasRemaining(1)) return; uint64_t guid = packet.readPackedGuid(); - if (guid == 0 || guid == owner_.playerGuid || !owner_.unitMoveFlagsCallback_) return; - owner_.unitMoveFlagsCallback_(guid, synthFlags); + if (guid == 0 || guid == owner_.getPlayerGuid() || !owner_.unitMoveFlagsCallbackRef()) return; + owner_.unitMoveFlagsCallbackRef()(guid, synthFlags); }; }; table[Opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE] = makeSynthHandler(0x00000100u); @@ -70,7 +70,7 @@ void MovementHandler::registerOpcodes(DispatchTable& table) { uint64_t guid = packet.readPackedGuid(); if (!packet.hasRemaining(4)) return; float speed = packet.readFloat(); - if (guid == owner_.playerGuid && std::isfinite(speed) && speed > 0.01f && speed < 200.0f) + if (guid == owner_.getPlayerGuid() && std::isfinite(speed) && speed > 0.01f && speed < 200.0f) this->*member = speed; }; }; @@ -198,8 +198,8 @@ void MovementHandler::registerOpcodes(DispatchTable& table) { // PackedGuid + synthesised move-flags=0 → clears flying animation. if (!packet.hasRemaining(1)) return; uint64_t guid = packet.readPackedGuid(); - if (guid == 0 || guid == owner_.playerGuid || !owner_.unitMoveFlagsCallback_) return; - owner_.unitMoveFlagsCallback_(guid, 0u); // clear flying/CAN_FLY + if (guid == 0 || guid == owner_.getPlayerGuid() || !owner_.unitMoveFlagsCallbackRef()) return; + owner_.unitMoveFlagsCallbackRef()(guid, 0u); // clear flying/CAN_FLY }; // Remaining spline speed opcodes — same factory as above. @@ -277,7 +277,7 @@ void MovementHandler::handleClientControlUpdate(network::Packet& packet) { } } bool allowMovement = (packet.readUInt8() != 0); - if (controlGuid == 0 || controlGuid == owner_.playerGuid) { + if (controlGuid == 0 || controlGuid == owner_.getPlayerGuid()) { bool changed = (serverMovementAllowed_ != allowMovement); serverMovementAllowed_ = allowMovement; if (changed && !allowMovement) { @@ -332,8 +332,8 @@ uint32_t MovementHandler::nextMovementTimestampMs() { // ============================================================ void MovementHandler::sendMovement(Opcode opcode) { - if (owner_.state != WorldState::IN_WORLD) { - LOG_WARNING("Cannot send movement in state: ", (int)owner_.state); + if (owner_.getState() != WorldState::IN_WORLD) { + LOG_WARNING("Cannot send movement in state: ", (int)owner_.getState()); return; } @@ -347,7 +347,7 @@ void MovementHandler::sendMovement(Opcode opcode) { (opcode == Opcode::MSG_MOVE_STOP_SWIM); if (!serverMovementAllowed_ && !taxiAllowed) return; if ((onTaxiFlight_ || taxiMountActive_) && !taxiAllowed) return; - if (owner_.resurrectPending_ && !taxiAllowed) return; + if (owner_.resurrectPendingRef() && !taxiAllowed) return; // Always send a strictly increasing non-zero client movement clock value. const uint32_t movementTime = nextMovementTimestampMs(); @@ -477,10 +477,10 @@ void MovementHandler::sendMovement(Opcode opcode) { // Fire PLAYER_STARTED/STOPPED_MOVING on movement state transitions { const bool isMoving = (movementInfo.flags & kMoveMask) != 0; - if (isMoving && !wasMoving && owner_.addonEventCallback_) - owner_.addonEventCallback_("PLAYER_STARTED_MOVING", {}); - else if (!isMoving && wasMoving && owner_.addonEventCallback_) - owner_.addonEventCallback_("PLAYER_STOPPED_MOVING", {}); + if (isMoving && !wasMoving && owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("PLAYER_STARTED_MOVING", {}); + else if (!isMoving && wasMoving && owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("PLAYER_STOPPED_MOVING", {}); } if (opcode == Opcode::MSG_MOVE_SET_FACING) { @@ -507,32 +507,32 @@ void MovementHandler::sendMovement(Opcode opcode) { } bool includeTransportInWire = owner_.isOnTransport(); - if (includeTransportInWire && owner_.transportManager_) { - if (auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_); tr && tr->isM2) { + if (includeTransportInWire && owner_.getTransportManager()) { + if (auto* tr = owner_.getTransportManager()->getTransport(owner_.playerTransportGuidRef()); tr && tr->isM2) { includeTransportInWire = false; } } // Add transport data if player is on a server-recognized transport if (includeTransportInWire) { - if (owner_.transportManager_) { - glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition(owner_.playerTransportGuid_, owner_.playerTransportOffset_); + if (owner_.getTransportManager()) { + glm::vec3 composed = owner_.getTransportManager()->getPlayerWorldPosition(owner_.playerTransportGuidRef(), owner_.playerTransportOffsetRef()); movementInfo.x = composed.x; movementInfo.y = composed.y; movementInfo.z = composed.z; } movementInfo.flags |= static_cast(MovementFlags::ONTRANSPORT); - movementInfo.transportGuid = owner_.playerTransportGuid_; - movementInfo.transportX = owner_.playerTransportOffset_.x; - movementInfo.transportY = owner_.playerTransportOffset_.y; - movementInfo.transportZ = owner_.playerTransportOffset_.z; + movementInfo.transportGuid = owner_.playerTransportGuidRef(); + movementInfo.transportX = owner_.playerTransportOffsetRef().x; + movementInfo.transportY = owner_.playerTransportOffsetRef().y; + movementInfo.transportZ = owner_.playerTransportOffsetRef().z; movementInfo.transportTime = movementInfo.time; movementInfo.transportSeat = -1; movementInfo.transportTime2 = movementInfo.time; float transportYawCanonical = 0.0f; - if (owner_.transportManager_) { - if (auto* tr = owner_.transportManager_->getTransport(owner_.playerTransportGuid_); tr) { + if (owner_.getTransportManager()) { + if (auto* tr = owner_.getTransportManager()->getTransport(owner_.playerTransportGuidRef()); tr) { if (tr->hasServerYaw) { transportYawCanonical = tr->serverYaw; } else { @@ -621,10 +621,10 @@ void MovementHandler::sendMovement(Opcode opcode) { } // Build and send movement packet (expansion-specific format) - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildMovementPacket(opcode, wireInfo, owner_.playerGuid) - : MovementPacket::build(opcode, wireInfo, owner_.playerGuid); - owner_.socket->send(packet); + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildMovementPacket(opcode, wireInfo, owner_.getPlayerGuid()) + : MovementPacket::build(opcode, wireInfo, owner_.getPlayerGuid()); + owner_.getSocket()->send(packet); if (opcode == Opcode::MSG_MOVE_HEARTBEAT) { lastHeartbeatSendTimeMs_ = movementInfo.time; @@ -678,13 +678,13 @@ void MovementHandler::forceClearTaxiAndMovementState() { taxiStartGrace_ = 0.0f; onTaxiFlight_ = false; - if (taxiMountActive_ && owner_.mountCallback_) { - owner_.mountCallback_(0); + if (taxiMountActive_ && owner_.mountCallbackRef()) { + owner_.mountCallbackRef()(0); } taxiMountActive_ = false; taxiMountDisplayId_ = 0; - owner_.currentMountDisplayId_ = 0; - owner_.vehicleId_ = 0; + owner_.currentMountDisplayIdRef() = 0; + owner_.vehicleIdRef() = 0; // Death/resurrect state is intentionally NOT cleared here. // Previously this method reset 10 death-related fields despite being named // "forceClearTaxiAndMovementState", which could cancel pending resurrections @@ -696,7 +696,7 @@ void MovementHandler::forceClearTaxiAndMovementState() { movementInfo.transportGuid = 0; owner_.clearPlayerTransport(); - if (owner_.socket && owner_.state == WorldState::IN_WORLD) { + if (owner_.getSocket() && owner_.getState() == WorldState::IN_WORLD) { sendMovement(Opcode::MSG_MOVE_STOP); sendMovement(Opcode::MSG_MOVE_STOP_STRAFE); sendMovement(Opcode::MSG_MOVE_STOP_TURN); @@ -726,32 +726,32 @@ void MovementHandler::setOrientation(float orientation) { // ============================================================ void MovementHandler::dismount() { - if (!owner_.socket) return; - uint32_t savedMountAura = owner_.mountAuraSpellId_; - if (owner_.currentMountDisplayId_ != 0 || taxiMountActive_) { - if (owner_.mountCallback_) { - owner_.mountCallback_(0); + if (!owner_.getSocket()) return; + uint32_t savedMountAura = owner_.mountAuraSpellIdRef(); + if (owner_.currentMountDisplayIdRef() != 0 || taxiMountActive_) { + if (owner_.mountCallbackRef()) { + owner_.mountCallbackRef()(0); } - owner_.currentMountDisplayId_ = 0; + owner_.currentMountDisplayIdRef() = 0; taxiMountActive_ = false; taxiMountDisplayId_ = 0; - owner_.mountAuraSpellId_ = 0; + owner_.mountAuraSpellIdRef() = 0; LOG_INFO("Dismount: cleared local mount state"); } uint16_t cancelMountWire = wireOpcode(Opcode::CMSG_CANCEL_MOUNT_AURA); if (cancelMountWire != 0xFFFF) { network::Packet pkt(cancelMountWire); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("Sent CMSG_CANCEL_MOUNT_AURA"); } else if (savedMountAura != 0) { auto pkt = CancelAuraPacket::build(savedMountAura); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("Sent CMSG_CANCEL_AURA (mount spell ", savedMountAura, ") — Classic fallback"); } else { for (const auto& a : owner_.getPlayerAuras()) { - if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == owner_.playerGuid) { + if (!a.isEmpty() && a.maxDurationMs < 0 && a.casterGuid == owner_.getPlayerGuid()) { auto pkt = CancelAuraPacket::build(a.spellId); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("Sent CMSG_CANCEL_AURA (spell ", a.spellId, ") — brute force dismount"); } } @@ -771,9 +771,9 @@ network::Packet MovementHandler::buildForceAck(Opcode ackOpcode, uint32_t counte const bool legacyGuid = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); if (legacyGuid) { - ack.writeUInt64(owner_.playerGuid); + ack.writeUInt64(owner_.getPlayerGuid()); } else { - ack.writePackedGuid(owner_.playerGuid); + ack.writePackedGuid(owner_.getPlayerGuid()); } ack.writeUInt32(counter); @@ -794,8 +794,8 @@ network::Packet MovementHandler::buildForceAck(Opcode ackOpcode, uint32_t counte wire.transportY = serverTransport.y; wire.transportZ = serverTransport.z; } - if (owner_.packetParsers_) { - owner_.packetParsers_->writeMovementPayload(ack, wire); + if (owner_.getPacketParsers()) { + owner_.getPacketParsers()->writeMovementPayload(ack, wire); } else { MovementPacket::writeMovementPayload(ack, wire); } @@ -820,7 +820,7 @@ void MovementHandler::handleForceSpeedChange(network::Packet& packet, const char LOG_INFO("SMSG_FORCE_", name, "_CHANGE: guid=0x", std::hex, guid, std::dec, " counter=", counter, " speed=", newSpeed); - if (guid != owner_.playerGuid) return; + if (guid != owner_.getPlayerGuid()) return; // Validate BEFORE sending ACK — if we echo a bad speed back to the server // but don't apply it locally, the client and server desync on movement speed. @@ -829,10 +829,10 @@ void MovementHandler::handleForceSpeedChange(network::Packet& packet, const char return; } - if (owner_.socket) { + if (owner_.getSocket()) { auto ack = buildForceAck(ackOpcode, counter); ack.writeFloat(newSpeed); - owner_.socket->send(ack); + owner_.getSocket()->send(ack); } if (speedStorage) *speedStorage = newSpeed; @@ -841,12 +841,12 @@ void MovementHandler::handleForceSpeedChange(network::Packet& packet, const char void MovementHandler::handleForceRunSpeedChange(network::Packet& packet) { handleForceSpeedChange(packet, "RUN_SPEED", Opcode::CMSG_FORCE_RUN_SPEED_CHANGE_ACK, &serverRunSpeed_); - if (!onTaxiFlight_ && !taxiMountActive_ && owner_.currentMountDisplayId_ != 0 && serverRunSpeed_ <= 8.5f) { + if (!onTaxiFlight_ && !taxiMountActive_ && owner_.currentMountDisplayIdRef() != 0 && serverRunSpeed_ <= 8.5f) { LOG_INFO("Auto-clearing mount from speed change: speed=", serverRunSpeed_, - " displayId=", owner_.currentMountDisplayId_); - owner_.currentMountDisplayId_ = 0; - if (owner_.mountCallback_) { - owner_.mountCallback_(0); + " displayId=", owner_.currentMountDisplayIdRef()); + owner_.currentMountDisplayIdRef() = 0; + if (owner_.mountCallbackRef()) { + owner_.mountCallbackRef()(0); } } } @@ -862,7 +862,7 @@ void MovementHandler::handleForceMoveRootState(network::Packet& packet, bool roo LOG_INFO(rooted ? "SMSG_FORCE_MOVE_ROOT" : "SMSG_FORCE_MOVE_UNROOT", ": guid=0x", std::hex, guid, std::dec, " counter=", counter); - if (guid != owner_.playerGuid) return; + if (guid != owner_.getPlayerGuid()) return; if (rooted) { movementInfo.flags |= static_cast(MovementFlags::ROOT); @@ -870,10 +870,10 @@ void MovementHandler::handleForceMoveRootState(network::Packet& packet, bool roo movementInfo.flags &= ~static_cast(MovementFlags::ROOT); } - if (!owner_.socket) return; + if (!owner_.getSocket()) return; Opcode ackOp = rooted ? Opcode::CMSG_FORCE_MOVE_ROOT_ACK : Opcode::CMSG_FORCE_MOVE_UNROOT_ACK; if (wireOpcode(ackOp) == 0xFFFF) return; - owner_.socket->send(buildForceAck(ackOp, counter)); + owner_.getSocket()->send(buildForceAck(ackOp, counter)); } void MovementHandler::handleForceMoveFlagChange(network::Packet& packet, const char* name, @@ -887,7 +887,7 @@ void MovementHandler::handleForceMoveFlagChange(network::Packet& packet, const c LOG_INFO("SMSG_FORCE_", name, ": guid=0x", std::hex, guid, std::dec, " counter=", counter); - if (guid != owner_.playerGuid) return; + if (guid != owner_.getPlayerGuid()) return; if (flag != 0) { if (set) { @@ -897,9 +897,9 @@ void MovementHandler::handleForceMoveFlagChange(network::Packet& packet, const c } } - if (!owner_.socket) return; + if (!owner_.getSocket()) return; if (wireOpcode(ackOpcode) == 0xFFFF) return; - owner_.socket->send(buildForceAck(ackOpcode, counter)); + owner_.getSocket()->send(buildForceAck(ackOpcode, counter)); } void MovementHandler::handleMoveSetCollisionHeight(network::Packet& packet) { @@ -913,15 +913,15 @@ void MovementHandler::handleMoveSetCollisionHeight(network::Packet& packet) { LOG_INFO("SMSG_MOVE_SET_COLLISION_HGT: guid=0x", std::hex, guid, std::dec, " counter=", counter, " height=", height); - if (guid != owner_.playerGuid) return; - if (!owner_.socket) return; + if (guid != owner_.getPlayerGuid()) return; + if (!owner_.getSocket()) return; if (wireOpcode(Opcode::CMSG_MOVE_SET_COLLISION_HGT_ACK) == 0xFFFF) return; // buildForceAck now handles transport coordinate conversion, fixing the // previous omission that caused desync when riding boats/zeppelins. auto ack = buildForceAck(Opcode::CMSG_MOVE_SET_COLLISION_HGT_ACK, counter); ack.writeFloat(height); - owner_.socket->send(ack); + owner_.getSocket()->send(ack); } void MovementHandler::handleMoveKnockBack(network::Packet& packet) { @@ -940,15 +940,15 @@ void MovementHandler::handleMoveKnockBack(network::Packet& packet) { " counter=", counter, " vcos=", vcos, " vsin=", vsin, " hspeed=", hspeed, " vspeed=", vspeed); - if (guid != owner_.playerGuid) return; + if (guid != owner_.getPlayerGuid()) return; - if (owner_.knockBackCallback_) { - owner_.knockBackCallback_(vcos, vsin, hspeed, vspeed); + if (owner_.knockBackCallbackRef()) { + owner_.knockBackCallbackRef()(vcos, vsin, hspeed, vspeed); } - if (!owner_.socket) return; + if (!owner_.getSocket()) return; if (wireOpcode(Opcode::CMSG_MOVE_KNOCK_BACK_ACK) == 0xFFFF) return; - owner_.socket->send(buildForceAck(Opcode::CMSG_MOVE_KNOCK_BACK_ACK, counter)); + owner_.getSocket()->send(buildForceAck(Opcode::CMSG_MOVE_KNOCK_BACK_ACK, counter)); } // ============================================================ @@ -969,7 +969,7 @@ void MovementHandler::handleMoveSetSpeed(network::Packet& packet) { float speed = packet.readFloat(); if (!std::isfinite(speed) || speed <= 0.01f || speed > 200.0f) return; - if (moverGuid != owner_.playerGuid) return; + if (moverGuid != owner_.getPlayerGuid()) return; const uint16_t wireOp = packet.getOpcode(); if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_SPEED)) serverRunSpeed_ = speed; else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_BACK_SPEED)) serverRunBackSpeed_ = speed; @@ -984,13 +984,13 @@ void MovementHandler::handleOtherPlayerMovement(network::Packet& packet) { const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); uint64_t moverGuid = otherMoveTbc ? packet.readUInt64() : packet.readPackedGuid(); - if (moverGuid == owner_.playerGuid || moverGuid == 0) { + if (moverGuid == owner_.getPlayerGuid() || moverGuid == 0) { return; } MovementInfo info = {}; info.flags = packet.readUInt32(); - uint8_t flags2Size = owner_.packetParsers_ ? owner_.packetParsers_->movementFlags2Size() : 2; + uint8_t flags2Size = owner_.getPacketParsers() ? owner_.getPacketParsers()->movementFlags2Size() : 2; if (flags2Size == 2) info.flags2 = packet.readUInt16(); else if (flags2Size == 1) info.flags2 = packet.readUInt8(); info.time = packet.readUInt32(); @@ -999,7 +999,7 @@ void MovementHandler::handleOtherPlayerMovement(network::Packet& packet) { info.z = packet.readFloat(); info.orientation = packet.readFloat(); - const uint32_t wireTransportFlag = owner_.packetParsers_ ? owner_.packetParsers_->wireOnTransportFlag() : 0x00000200; + const uint32_t wireTransportFlag = owner_.getPacketParsers() ? owner_.getPacketParsers()->wireOnTransportFlag() : 0x00000200; const bool onTransport = (info.flags & wireTransportFlag) != 0; uint64_t transportGuid = 0; float tLocalX = 0, tLocalY = 0, tLocalZ = 0, tLocalO = 0; @@ -1028,11 +1028,11 @@ void MovementHandler::handleOtherPlayerMovement(network::Packet& packet) { glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(info.x, info.y, info.z)); float canYaw = core::coords::serverToCanonicalYaw(info.orientation); - if (onTransport && transportGuid != 0 && owner_.transportManager_) { + if (onTransport && transportGuid != 0 && owner_.getTransportManager()) { glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(tLocalX, tLocalY, tLocalZ)); owner_.setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, true, core::coords::serverToCanonicalYaw(tLocalO)); - glm::vec3 worldPos = owner_.transportManager_->getPlayerWorldPosition(transportGuid, localCanonical); + glm::vec3 worldPos = owner_.getTransportManager()->getPlayerWorldPosition(transportGuid, localCanonical); canonical = worldPos; } else if (!onTransport) { owner_.clearTransportAttachment(moverGuid); @@ -1065,17 +1065,17 @@ void MovementHandler::handleOtherPlayerMovement(network::Packet& packet) { const float entityDuration = isStopOpcode ? 0.0f : (durationMs / 1000.0f); entity->startMoveTo(canonical.x, canonical.y, canonical.z, canYaw, entityDuration); - if (owner_.creatureMoveCallback_) { + if (owner_.creatureMoveCallbackRef()) { const uint32_t notifyDuration = isStopOpcode ? 0u : durationMs; - owner_.creatureMoveCallback_(moverGuid, canonical.x, canonical.y, canonical.z, notifyDuration); + owner_.creatureMoveCallbackRef()(moverGuid, canonical.x, canonical.y, canonical.z, notifyDuration); } - if (owner_.unitAnimHintCallback_ && isJumpOpcode) { - owner_.unitAnimHintCallback_(moverGuid, 38u); + if (owner_.unitAnimHintCallbackRef() && isJumpOpcode) { + owner_.unitAnimHintCallbackRef()(moverGuid, 38u); } - if (owner_.unitMoveFlagsCallback_) { - owner_.unitMoveFlagsCallback_(moverGuid, info.flags); + if (owner_.unitMoveFlagsCallbackRef()) { + owner_.unitMoveFlagsCallbackRef()(moverGuid, info.flags); } } @@ -1250,7 +1250,7 @@ void MovementHandler::handleCompressedMoves(network::Packet& packet) { handleMonsterMove(subPacket); } else if (entry.opcode == monsterMoveTransportWire) { handleMonsterMoveTransport(subPacket); - } else if (owner_.state == WorldState::IN_WORLD && + } else if (owner_.getState() == WorldState::IN_WORLD && std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), entry.opcode) != kMoveOpcodes.end()) { handleOtherPlayerMovement(subPacket); } else { @@ -1341,13 +1341,13 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { bool parsed = false; if (hasWrappedForm) { network::Packet wrappedPacket(packet.getOpcode(), stripped); - if (owner_.packetParsers_->parseMonsterMove(wrappedPacket, data)) { + if (owner_.getPacketParsers()->parseMonsterMove(wrappedPacket, data)) { parsed = true; } } if (!parsed) { network::Packet decompPacket(packet.getOpcode(), decompressed); - if (owner_.packetParsers_->parseMonsterMove(decompPacket, data)) { + if (owner_.getPacketParsers()->parseMonsterMove(decompPacket, data)) { parsed = true; } } @@ -1363,11 +1363,11 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { } return; } - } else if (!owner_.packetParsers_->parseMonsterMove(packet, data)) { + } else if (!owner_.getPacketParsers()->parseMonsterMove(packet, data)) { std::vector stripped; if (stripWrappedSubpacket(rawData, stripped)) { network::Packet wrappedPacket(packet.getOpcode(), stripped); - if (owner_.packetParsers_->parseMonsterMove(wrappedPacket, data)) { + if (owner_.getPacketParsers()->parseMonsterMove(wrappedPacket, data)) { logWrappedUncompressedFallbackUsed(); } else { logMonsterMoveParseFailure("Failed to parse SMSG_MONSTER_MOVE"); @@ -1428,8 +1428,8 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { entity->startMoveTo(destCanonical.x, destCanonical.y, destCanonical.z, orientation, data.duration / 1000.0f); - if (owner_.creatureMoveCallback_) { - owner_.creatureMoveCallback_(data.guid, + if (owner_.creatureMoveCallbackRef()) { + owner_.creatureMoveCallbackRef()(data.guid, destCanonical.x, destCanonical.y, destCanonical.z, data.duration); } @@ -1439,8 +1439,8 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z, entity->getOrientation()); - if (owner_.creatureMoveCallback_) { - owner_.creatureMoveCallback_(data.guid, + if (owner_.creatureMoveCallbackRef()) { + owner_.creatureMoveCallbackRef()(data.guid, posCanonical.x, posCanonical.y, posCanonical.z, 0); } } else if (data.moveType == 4) { @@ -1448,8 +1448,8 @@ void MovementHandler::handleMonsterMove(network::Packet& packet) { glm::vec3 posCanonical = core::coords::serverToCanonical( glm::vec3(data.x, data.y, data.z)); entity->setPosition(posCanonical.x, posCanonical.y, posCanonical.z, orientation); - if (owner_.creatureMoveCallback_) { - owner_.creatureMoveCallback_(data.guid, + if (owner_.creatureMoveCallbackRef()) { + owner_.creatureMoveCallbackRef()(data.guid, posCanonical.x, posCanonical.y, posCanonical.z, 0); } } else if (data.moveType == 3 && data.facingTarget != 0) { @@ -1479,13 +1479,13 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) { if (!entity) return; if (packet.getReadPos() + 5 > packet.getSize()) { - if (owner_.transportManager_) { + if (owner_.getTransportManager()) { glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ)); owner_.setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, false, 0.0f); - glm::vec3 worldPos = owner_.transportManager_->getPlayerWorldPosition(transportGuid, localCanonical); + glm::vec3 worldPos = owner_.getTransportManager()->getPlayerWorldPosition(transportGuid, localCanonical); entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation()); - if (entity->getType() == ObjectType::UNIT && owner_.creatureMoveCallback_) - owner_.creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0); + if (entity->getType() == ObjectType::UNIT && owner_.creatureMoveCallbackRef()) + owner_.creatureMoveCallbackRef()(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0); } return; } @@ -1494,13 +1494,13 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) { uint8_t moveType = packet.readUInt8(); if (moveType == 1) { - if (owner_.transportManager_) { + if (owner_.getTransportManager()) { glm::vec3 localCanonical = core::coords::serverToCanonical(glm::vec3(localX, localY, localZ)); owner_.setTransportAttachment(moverGuid, entity->getType(), transportGuid, localCanonical, false, 0.0f); - glm::vec3 worldPos = owner_.transportManager_->getPlayerWorldPosition(transportGuid, localCanonical); + glm::vec3 worldPos = owner_.getTransportManager()->getPlayerWorldPosition(transportGuid, localCanonical); entity->setPosition(worldPos.x, worldPos.y, worldPos.z, entity->getOrientation()); - if (entity->getType() == ObjectType::UNIT && owner_.creatureMoveCallback_) - owner_.creatureMoveCallback_(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0); + if (entity->getType() == ObjectType::UNIT && owner_.creatureMoveCallbackRef()) + owner_.creatureMoveCallbackRef()(moverGuid, worldPos.x, worldPos.y, worldPos.z, 0); } return; } @@ -1575,7 +1575,7 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) { } } - if (!owner_.transportManager_) { + if (!owner_.getTransportManager()) { LOG_WARNING("SMSG_MONSTER_MOVE_TRANSPORT: TransportManager not available for mover 0x", std::hex, moverGuid, std::dec); return; @@ -1585,7 +1585,7 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) { if (hasDest && duration > 0) { glm::vec3 destLocalCanonical = core::coords::serverToCanonical(glm::vec3(destLocalX, destLocalY, destLocalZ)); - glm::vec3 destWorld = owner_.transportManager_->getPlayerWorldPosition(transportGuid, destLocalCanonical); + glm::vec3 destWorld = owner_.getTransportManager()->getPlayerWorldPosition(transportGuid, destLocalCanonical); if (moveType == 0) { float dx = destLocalCanonical.x - startLocalCanonical.x; @@ -1597,18 +1597,18 @@ void MovementHandler::handleMonsterMoveTransport(network::Packet& packet) { owner_.setTransportAttachment(moverGuid, entity->getType(), transportGuid, destLocalCanonical, false, 0.0f); entity->startMoveTo(destWorld.x, destWorld.y, destWorld.z, facingAngle, duration / 1000.0f); - if (entity->getType() == ObjectType::UNIT && owner_.creatureMoveCallback_) - owner_.creatureMoveCallback_(moverGuid, destWorld.x, destWorld.y, destWorld.z, duration); + if (entity->getType() == ObjectType::UNIT && owner_.creatureMoveCallbackRef()) + owner_.creatureMoveCallbackRef()(moverGuid, destWorld.x, destWorld.y, destWorld.z, duration); LOG_DEBUG("SMSG_MONSTER_MOVE_TRANSPORT: mover=0x", std::hex, moverGuid, " transport=0x", transportGuid, std::dec, " dur=", duration, "ms dest=(", destWorld.x, ",", destWorld.y, ",", destWorld.z, ")"); } else { - glm::vec3 startWorld = owner_.transportManager_->getPlayerWorldPosition(transportGuid, startLocalCanonical); + glm::vec3 startWorld = owner_.getTransportManager()->getPlayerWorldPosition(transportGuid, startLocalCanonical); owner_.setTransportAttachment(moverGuid, entity->getType(), transportGuid, startLocalCanonical, false, 0.0f); entity->setPosition(startWorld.x, startWorld.y, startWorld.z, facingAngle); - if (entity->getType() == ObjectType::UNIT && owner_.creatureMoveCallback_) - owner_.creatureMoveCallback_(moverGuid, startWorld.x, startWorld.y, startWorld.z, 0); + if (entity->getType() == ObjectType::UNIT && owner_.creatureMoveCallbackRef()) + owner_.creatureMoveCallbackRef()(moverGuid, startWorld.x, startWorld.y, startWorld.z, 0); } } @@ -1656,25 +1656,25 @@ void MovementHandler::handleTeleportAck(network::Packet& packet) { movementInfo.flags = 0; // Clear cast bar on teleport — SpellHandler owns the casting_ flag - if (owner_.spellHandler_) owner_.spellHandler_->resetCastState(); + if (owner_.getSpellHandler()) owner_.getSpellHandler()->resetCastState(); - if (owner_.socket) { + if (owner_.getSocket()) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_TELEPORT_ACK)); const bool legacyGuidAck = isActiveExpansion("classic") || isActiveExpansion("tbc") || isActiveExpansion("turtle"); if (legacyGuidAck) { - ack.writeUInt64(owner_.playerGuid); + ack.writeUInt64(owner_.getPlayerGuid()); } else { - ack.writePackedGuid(owner_.playerGuid); + ack.writePackedGuid(owner_.getPlayerGuid()); } ack.writeUInt32(counter); ack.writeUInt32(moveTime); - owner_.socket->send(ack); + owner_.getSocket()->send(ack); LOG_INFO("Sent MSG_MOVE_TELEPORT_ACK response"); } - if (owner_.worldEntryCallback_) { - owner_.worldEntryCallback_(owner_.currentMapId_, serverX, serverY, serverZ, false); + if (owner_.worldEntryCallbackRef()) { + owner_.worldEntryCallbackRef()(owner_.currentMapIdRef(), serverX, serverY, serverZ, false); } } @@ -1694,8 +1694,8 @@ void MovementHandler::handleNewWorld(network::Packet& packet) { " pos=(", serverX, ", ", serverY, ", ", serverZ, ")", " orient=", orientation); - const bool isSameMap = (mapId == owner_.currentMapId_); - const bool isResurrection = owner_.resurrectPending_; + const bool isSameMap = (mapId == owner_.currentMapIdRef()); + const bool isResurrection = owner_.resurrectPendingRef(); if (isSameMap && isResurrection) { LOG_INFO("SMSG_NEW_WORLD same-map resurrection — skipping world reload"); @@ -1707,32 +1707,32 @@ void MovementHandler::handleNewWorld(network::Packet& packet) { movementInfo.flags = 0; movementInfo.flags2 = 0; - owner_.resurrectPending_ = false; - owner_.resurrectRequestPending_ = false; - owner_.releasedSpirit_ = false; - owner_.playerDead_ = false; - owner_.repopPending_ = false; - owner_.pendingSpiritHealerGuid_ = 0; - owner_.resurrectCasterGuid_ = 0; - owner_.corpseMapId_ = 0; - owner_.corpseGuid_ = 0; + owner_.resurrectPendingRef() = false; + owner_.resurrectRequestPendingRef() = false; + owner_.releasedSpiritRef() = false; + owner_.playerDeadRef() = false; + owner_.repopPendingRef() = false; + owner_.pendingSpiritHealerGuidRef() = 0; + owner_.resurrectCasterGuidRef() = 0; + owner_.corpseMapIdRef() = 0; + owner_.corpseGuidRef() = 0; owner_.clearHostileAttackers(); owner_.stopAutoAttack(); - owner_.tabCycleStale = true; + owner_.tabCycleStaleRef() = true; owner_.resetCastState(); - if (owner_.socket) { + if (owner_.getSocket()) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK)); - owner_.socket->send(ack); + owner_.getSocket()->send(ack); LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK (resurrection)"); } return; } - owner_.currentMapId_ = mapId; - owner_.inInstance_ = false; - if (owner_.socket) { - owner_.socket->tracePacketsFor(std::chrono::seconds(12), "new_world"); + owner_.currentMapIdRef() = mapId; + owner_.inInstanceRef() = false; + if (owner_.getSocket()) { + owner_.getSocket()->tracePacketsFor(std::chrono::seconds(12), "new_world"); } glm::vec3 canonical = core::coords::serverToCanonical(glm::vec3(serverX, serverY, serverZ)); @@ -1743,8 +1743,8 @@ void MovementHandler::handleNewWorld(network::Packet& packet) { movementInfo.flags = 0; movementInfo.flags2 = 0; serverMovementAllowed_ = true; - owner_.resurrectPending_ = false; - owner_.resurrectRequestPending_ = false; + owner_.resurrectPendingRef() = false; + owner_.resurrectRequestPendingRef() = false; onTaxiFlight_ = false; taxiMountActive_ = false; taxiActivatePending_ = false; @@ -1752,59 +1752,59 @@ void MovementHandler::handleNewWorld(network::Packet& packet) { taxiClientPath_.clear(); taxiRecoverPending_ = false; taxiStartGrace_ = 0.0f; - owner_.currentMountDisplayId_ = 0; + owner_.currentMountDisplayIdRef() = 0; taxiMountDisplayId_ = 0; - if (owner_.mountCallback_) { - owner_.mountCallback_(0); + if (owner_.mountCallbackRef()) { + owner_.mountCallbackRef()(0); } for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) { - if (guid == owner_.playerGuid) continue; - if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallback_) { - owner_.creatureDespawnCallback_(guid); - } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallback_) { - owner_.playerDespawnCallback_(guid); - } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallback_) { - owner_.gameObjectDespawnCallback_(guid); + if (guid == owner_.getPlayerGuid()) continue; + if (entity->getType() == ObjectType::UNIT && owner_.creatureDespawnCallbackRef()) { + owner_.creatureDespawnCallbackRef()(guid); + } else if (entity->getType() == ObjectType::PLAYER && owner_.playerDespawnCallbackRef()) { + owner_.playerDespawnCallbackRef()(guid); + } else if (entity->getType() == ObjectType::GAMEOBJECT && owner_.gameObjectDespawnCallbackRef()) { + owner_.gameObjectDespawnCallbackRef()(guid); } } - owner_.otherPlayerVisibleItemEntries_.clear(); - owner_.otherPlayerVisibleDirty_.clear(); + owner_.otherPlayerVisibleItemEntriesRef().clear(); + owner_.otherPlayerVisibleDirtyRef().clear(); otherPlayerMoveTimeMs_.clear(); - if (owner_.spellHandler_) owner_.spellHandler_->clearUnitCastStates(); - owner_.unitAurasCache_.clear(); + if (owner_.getSpellHandler()) owner_.getSpellHandler()->clearUnitCastStates(); + owner_.unitAurasCacheRef().clear(); owner_.clearCombatText(); owner_.getEntityManager().clear(); owner_.clearHostileAttackers(); - owner_.worldStates_.clear(); - owner_.gossipPois_.clear(); - owner_.worldStateMapId_ = mapId; - owner_.worldStateZoneId_ = 0; - owner_.activeAreaTriggers_.clear(); - owner_.areaTriggerCheckTimer_ = -5.0f; - owner_.areaTriggerSuppressFirst_ = true; + owner_.worldStatesRef().clear(); + owner_.gossipPoisRef().clear(); + owner_.worldStateMapIdRef() = mapId; + owner_.worldStateZoneIdRef() = 0; + owner_.activeAreaTriggersRef().clear(); + owner_.areaTriggerCheckTimerRef() = -5.0f; + owner_.areaTriggerSuppressFirstRef() = true; owner_.stopAutoAttack(); owner_.resetCastState(); - if (owner_.socket) { + if (owner_.getSocket()) { network::Packet ack(wireOpcode(Opcode::MSG_MOVE_WORLDPORT_ACK)); - owner_.socket->send(ack); + owner_.getSocket()->send(ack); LOG_INFO("Sent MSG_MOVE_WORLDPORT_ACK"); } - owner_.timeSinceLastPing = 0.0f; - if (owner_.socket) { + owner_.timeSinceLastPingRef() = 0.0f; + if (owner_.getSocket()) { LOG_WARNING("World transfer keepalive: sending immediate ping after MSG_MOVE_WORLDPORT_ACK"); owner_.sendPing(); } - if (owner_.worldEntryCallback_) { - owner_.worldEntryCallback_(mapId, serverX, serverY, serverZ, isSameMap); + if (owner_.worldEntryCallbackRef()) { + owner_.worldEntryCallbackRef()(mapId, serverX, serverY, serverZ, isSameMap); } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("PLAYER_ENTERING_WORLD", {"0"}); - owner_.addonEventCallback_("ZONE_CHANGED_NEW_AREA", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("PLAYER_ENTERING_WORLD", {"0"}); + owner_.addonEventCallbackRef()("ZONE_CHANGED_NEW_AREA", {}); } } @@ -1964,11 +1964,11 @@ void MovementHandler::handleShowTaxiNodes(network::Packet& packet) { } void MovementHandler::applyTaxiMountForCurrentNode() { - if (taxiMountActive_ || !owner_.mountCallback_) return; + if (taxiMountActive_ || !owner_.mountCallbackRef()) return; auto it = taxiNodes_.find(currentTaxiData_.nearestNode); if (it == taxiNodes_.end()) { bool isAlliance = true; - switch (owner_.playerRace_) { + switch (owner_.playerRaceRef()) { case Race::ORC: case Race::UNDEAD: case Race::TAUREN: case Race::TROLL: case Race::GOBLIN: case Race::BLOOD_ELF: isAlliance = false; break; @@ -1978,12 +1978,12 @@ void MovementHandler::applyTaxiMountForCurrentNode() { taxiMountDisplayId_ = mountId; taxiMountActive_ = true; LOG_INFO("Taxi mount fallback (node ", currentTaxiData_.nearestNode, " not in DBC): displayId=", mountId); - owner_.mountCallback_(mountId); + owner_.mountCallbackRef()(mountId); return; } bool isAlliance = true; - switch (owner_.playerRace_) { + switch (owner_.playerRaceRef()) { case Race::ORC: case Race::UNDEAD: case Race::TAUREN: @@ -2029,7 +2029,7 @@ void MovementHandler::applyTaxiMountForCurrentNode() { taxiMountDisplayId_ = mountId; taxiMountActive_ = true; LOG_INFO("Taxi mount apply: displayId=", mountId); - owner_.mountCallback_(mountId); + owner_.mountCallbackRef()(mountId); } } @@ -2109,13 +2109,13 @@ void MovementHandler::startClientTaxiPath(const std::vector& pathNodes movementInfo.orientation = initialOrientation; sanitizeMovementForTaxi(); - auto playerEntity = owner_.getEntityManager().getEntity(owner_.playerGuid); + auto playerEntity = owner_.getEntityManager().getEntity(owner_.getPlayerGuid()); if (playerEntity) { playerEntity->setPosition(start.x, start.y, start.z, initialOrientation); } - if (owner_.taxiOrientationCallback_) { - owner_.taxiOrientationCallback_(initialRenderYaw, initialPitch, initialRoll); + if (owner_.taxiOrientationCallbackRef()) { + owner_.taxiOrientationCallbackRef()(initialRenderYaw, initialPitch, initialRoll); } LOG_INFO("Taxi flight started with ", taxiClientPath_.size(), " spline waypoints"); @@ -2124,7 +2124,7 @@ void MovementHandler::startClientTaxiPath(const std::vector& pathNodes void MovementHandler::updateClientTaxi(float deltaTime) { if (!taxiClientActive_ || taxiClientPath_.size() < 2) return; - auto playerEntity = owner_.getEntityManager().getEntity(owner_.playerGuid); + auto playerEntity = owner_.getEntityManager().getEntity(owner_.getPlayerGuid()); auto finishTaxiFlight = [&]() { if (!taxiClientPath_.empty()) { @@ -2142,17 +2142,17 @@ void MovementHandler::updateClientTaxi(float deltaTime) { taxiClientActive_ = false; onTaxiFlight_ = false; taxiLandingCooldown_ = 2.0f; - if (taxiMountActive_ && owner_.mountCallback_) { - owner_.mountCallback_(0); + if (taxiMountActive_ && owner_.mountCallbackRef()) { + owner_.mountCallbackRef()(0); } taxiMountActive_ = false; taxiMountDisplayId_ = 0; - owner_.currentMountDisplayId_ = 0; + owner_.currentMountDisplayIdRef() = 0; taxiClientPath_.clear(); taxiRecoverPending_ = false; movementInfo.flags = 0; movementInfo.flags2 = 0; - if (owner_.socket) { + if (owner_.getSocket()) { sendMovement(Opcode::MSG_MOVE_STOP); sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } @@ -2252,10 +2252,10 @@ void MovementHandler::updateClientTaxi(float deltaTime) { movementInfo.z = nextPos.z; movementInfo.orientation = smoothOrientation; - if (owner_.taxiOrientationCallback_) { + if (owner_.taxiOrientationCallbackRef()) { glm::vec3 renderTangent = core::coords::canonicalToRender(tangent); float renderYaw = std::atan2(renderTangent.y, renderTangent.x); - owner_.taxiOrientationCallback_(renderYaw, pitch, roll); + owner_.taxiOrientationCallbackRef()(renderYaw, pitch, roll); } } @@ -2282,7 +2282,7 @@ void MovementHandler::handleActivateTaxiReply(network::Packet& packet) { taxiActivatePending_ = false; taxiActivateTimer_ = 0.0f; applyTaxiMountForCurrentNode(); - if (owner_.socket) { + if (owner_.getSocket()) { sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } LOG_INFO("Taxi flight started!"); @@ -2297,8 +2297,8 @@ void MovementHandler::handleActivateTaxiReply(network::Packet& packet) { owner_.addSystemChatMessage("Cannot take that flight path."); taxiActivatePending_ = false; taxiActivateTimer_ = 0.0f; - if (taxiMountActive_ && owner_.mountCallback_) { - owner_.mountCallback_(0); + if (taxiMountActive_ && owner_.mountCallbackRef()) { + owner_.mountCallbackRef()(0); } taxiMountActive_ = false; taxiMountDisplayId_ = 0; @@ -2313,8 +2313,8 @@ void MovementHandler::closeTaxi() { return; } - if (taxiMountActive_ && owner_.mountCallback_) { - owner_.mountCallback_(0); + if (taxiMountActive_ && owner_.mountCallbackRef()) { + owner_.mountCallbackRef()(0); } taxiMountActive_ = false; taxiMountDisplayId_ = 0; @@ -2358,7 +2358,7 @@ uint32_t MovementHandler::getTaxiCostTo(uint32_t destNodeId) const { } void MovementHandler::activateTaxi(uint32_t destNodeId) { - if (!owner_.socket || owner_.state != WorldState::IN_WORLD) return; + if (!owner_.getSocket() || owner_.getState() != WorldState::IN_WORLD) return; if (taxiActivatePending_ || onTaxiFlight_) { return; @@ -2369,8 +2369,8 @@ void MovementHandler::activateTaxi(uint32_t destNodeId) { if (owner_.isMounted()) { LOG_INFO("Taxi activate: dismounting current mount"); - if (owner_.mountCallback_) owner_.mountCallback_(0); - owner_.currentMountDisplayId_ = 0; + if (owner_.mountCallbackRef()) owner_.mountCallbackRef()(0); + owner_.currentMountDisplayIdRef() = 0; dismount(); } @@ -2439,7 +2439,7 @@ void MovementHandler::activateTaxi(uint32_t destNodeId) { LOG_INFO("Taxi activate: start=", startNode, " dest=", destNodeId, " cost=", totalCost); auto basicPkt = ActivateTaxiPacket::build(taxiNpcGuid_, startNode, destNodeId); - owner_.socket->send(basicPkt); + owner_.getSocket()->send(basicPkt); taxiWindowOpen_ = false; taxiActivatePending_ = true; @@ -2450,11 +2450,11 @@ void MovementHandler::activateTaxi(uint32_t destNodeId) { sanitizeMovementForTaxi(); applyTaxiMountForCurrentNode(); } - if (owner_.socket) { + if (owner_.getSocket()) { sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } - if (owner_.taxiPrecacheCallback_) { + if (owner_.taxiPrecacheCallbackRef()) { std::vector previewPath; for (size_t i = 0; i + 1 < path.size(); i++) { uint32_t fromNode = path[i]; @@ -2477,12 +2477,12 @@ void MovementHandler::activateTaxi(uint32_t destNodeId) { } } if (previewPath.size() >= 2) { - owner_.taxiPrecacheCallback_(previewPath); + owner_.taxiPrecacheCallbackRef()(previewPath); } } - if (owner_.taxiFlightStartCallback_) { - owner_.taxiFlightStartCallback_(); + if (owner_.taxiFlightStartCallbackRef()) { + owner_.taxiFlightStartCallbackRef()(); } startClientTaxiPath(path); } @@ -2492,8 +2492,8 @@ void MovementHandler::activateTaxi(uint32_t destNodeId) { // ============================================================ void MovementHandler::loadAreaTriggerDbc() { - if (owner_.areaTriggerDbcLoaded_) return; - owner_.areaTriggerDbcLoaded_ = true; + if (owner_.areaTriggerDbcLoadedRef()) return; + owner_.areaTriggerDbcLoadedRef() = true; auto* am = owner_.services().assetManager; if (!am || !am->isInitialized()) return; @@ -2504,7 +2504,7 @@ void MovementHandler::loadAreaTriggerDbc() { return; } - owner_.areaTriggers_.reserve(dbc->getRecordCount()); + owner_.areaTriggersRef().reserve(dbc->getRecordCount()); for (uint32_t i = 0; i < dbc->getRecordCount(); i++) { GameHandler::AreaTriggerEntry at; at.id = dbc->getUInt32(i, 0); @@ -2518,10 +2518,10 @@ void MovementHandler::loadAreaTriggerDbc() { at.boxWidth = dbc->getFloat(i, 7); at.boxHeight = dbc->getFloat(i, 8); at.boxYaw = dbc->getFloat(i, 9); - owner_.areaTriggers_.push_back(at); + owner_.areaTriggersRef().push_back(at); } - LOG_WARNING("Loaded ", owner_.areaTriggers_.size(), " area triggers from AreaTrigger.dbc"); + LOG_WARNING("Loaded ", owner_.areaTriggersRef().size(), " area triggers from AreaTrigger.dbc"); } void MovementHandler::checkAreaTriggers() { @@ -2529,7 +2529,7 @@ void MovementHandler::checkAreaTriggers() { if (onTaxiFlight_ || taxiClientActive_) return; loadAreaTriggerDbc(); - if (owner_.areaTriggers_.empty()) return; + if (owner_.areaTriggersRef().empty()) return; const float px = movementInfo.x; const float py = movementInfo.y; @@ -2537,13 +2537,13 @@ void MovementHandler::checkAreaTriggers() { // On first check after map transfer, just mark which triggers we're inside // without firing them — prevents exit portal from immediately sending us back - bool suppressFirst = owner_.areaTriggerSuppressFirst_; + bool suppressFirst = owner_.areaTriggerSuppressFirstRef(); if (suppressFirst) { - owner_.areaTriggerSuppressFirst_ = false; + owner_.areaTriggerSuppressFirstRef() = false; } - for (const auto& at : owner_.areaTriggers_) { - if (at.mapId != owner_.currentMapId_) continue; + for (const auto& at : owner_.areaTriggersRef()) { + if (at.mapId != owner_.currentMapIdRef()) continue; bool inside = false; if (at.radius > 0.0f) { @@ -2575,8 +2575,8 @@ void MovementHandler::checkAreaTriggers() { } if (inside) { - if (owner_.activeAreaTriggers_.count(at.id) == 0) { - owner_.activeAreaTriggers_.insert(at.id); + if (owner_.activeAreaTriggersRef().count(at.id) == 0) { + owner_.activeAreaTriggersRef().insert(at.id); if (suppressFirst) { // After map transfer: mark triggers we're inside of, but don't fire them. @@ -2589,13 +2589,13 @@ void MovementHandler::checkAreaTriggers() { // server we're somewhere we're not and can cause rogue teleports. network::Packet pkt(wireOpcode(Opcode::CMSG_AREATRIGGER)); pkt.writeUInt32(at.id); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_DEBUG("Fired CMSG_AREATRIGGER: id=", at.id); } } } else { // Player left the trigger — allow re-fire on re-entry - owner_.activeAreaTriggers_.erase(at.id); + owner_.activeAreaTriggersRef().erase(at.id); } } } @@ -2611,7 +2611,7 @@ void MovementHandler::setTransportAttachment(uint64_t childGuid, ObjectType type return; } - GameHandler::TransportAttachment& attachment = owner_.transportAttachments_[childGuid]; + GameHandler::TransportAttachment& attachment = owner_.transportAttachmentsRef()[childGuid]; attachment.type = type; attachment.transportGuid = transportGuid; attachment.localOffset = localOffset; @@ -2623,11 +2623,11 @@ void MovementHandler::clearTransportAttachment(uint64_t childGuid) { if (childGuid == 0) { return; } - owner_.transportAttachments_.erase(childGuid); + owner_.transportAttachmentsRef().erase(childGuid); } void MovementHandler::updateAttachedTransportChildren(float /*deltaTime*/) { - if (!owner_.transportManager_ || owner_.transportAttachments_.empty()) { + if (!owner_.getTransportManager() || owner_.transportAttachmentsRef().empty()) { return; } @@ -2636,19 +2636,19 @@ void MovementHandler::updateAttachedTransportChildren(float /*deltaTime*/) { std::vector stale; stale.reserve(8); - for (const auto& [childGuid, attachment] : owner_.transportAttachments_) { + for (const auto& [childGuid, attachment] : owner_.transportAttachmentsRef()) { auto entity = owner_.getEntityManager().getEntity(childGuid); if (!entity) { stale.push_back(childGuid); continue; } - ActiveTransport* transport = owner_.transportManager_->getTransport(attachment.transportGuid); + ActiveTransport* transport = owner_.getTransportManager()->getTransport(attachment.transportGuid); if (!transport) { continue; } - glm::vec3 composed = owner_.transportManager_->getPlayerWorldPosition( + glm::vec3 composed = owner_.getTransportManager()->getPlayerWorldPosition( attachment.transportGuid, attachment.localOffset); float composedOrientation = entity->getOrientation(); @@ -2669,18 +2669,18 @@ void MovementHandler::updateAttachedTransportChildren(float /*deltaTime*/) { entity->setPosition(composed.x, composed.y, composed.z, composedOrientation); if (attachment.type == ObjectType::UNIT) { - if (owner_.creatureMoveCallback_) { - owner_.creatureMoveCallback_(childGuid, composed.x, composed.y, composed.z, 0); + if (owner_.creatureMoveCallbackRef()) { + owner_.creatureMoveCallbackRef()(childGuid, composed.x, composed.y, composed.z, 0); } } else if (attachment.type == ObjectType::GAMEOBJECT) { - if (owner_.gameObjectMoveCallback_) { - owner_.gameObjectMoveCallback_(childGuid, composed.x, composed.y, composed.z, composedOrientation); + if (owner_.gameObjectMoveCallbackRef()) { + owner_.gameObjectMoveCallbackRef()(childGuid, composed.x, composed.y, composed.z, composedOrientation); } } } for (uint64_t guid : stale) { - owner_.transportAttachments_.erase(guid); + owner_.transportAttachmentsRef().erase(guid); } } @@ -2689,12 +2689,12 @@ void MovementHandler::updateAttachedTransportChildren(float /*deltaTime*/) { // ============================================================ void MovementHandler::followTarget() { - if (owner_.state != WorldState::IN_WORLD) { + if (owner_.getState() != WorldState::IN_WORLD) { LOG_WARNING("Cannot follow: not in world"); return; } - if (owner_.targetGuid == 0) { + if (owner_.getTargetGuid() == 0) { owner_.addSystemChatMessage("You must target someone to follow."); return; } @@ -2706,14 +2706,14 @@ void MovementHandler::followTarget() { } // Set follow target - owner_.followTargetGuid_ = owner_.targetGuid; + owner_.followTargetGuidRef() = owner_.getTargetGuid(); // Initialize render-space position from entity's canonical coords - owner_.followRenderPos_ = core::coords::canonicalToRender(glm::vec3(target->getX(), target->getY(), target->getZ())); + owner_.followRenderPosRef() = core::coords::canonicalToRender(glm::vec3(target->getX(), target->getY(), target->getZ())); // Tell camera controller to start auto-following - if (owner_.autoFollowCallback_) { - owner_.autoFollowCallback_(&owner_.followRenderPos_); + if (owner_.autoFollowCallbackRef()) { + owner_.autoFollowCallbackRef()(&owner_.followRenderPosRef()); } // Get target name @@ -2729,17 +2729,17 @@ void MovementHandler::followTarget() { } owner_.addSystemChatMessage("Now following " + targetName + "."); - LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, owner_.targetGuid, std::dec, ")"); + LOG_INFO("Following target: ", targetName, " (GUID: 0x", std::hex, owner_.getTargetGuid(), std::dec, ")"); owner_.fireAddonEvent("AUTOFOLLOW_BEGIN", {}); } void MovementHandler::cancelFollow() { - if (owner_.followTargetGuid_ == 0) { + if (owner_.followTargetGuidRef() == 0) { return; } - owner_.followTargetGuid_ = 0; - if (owner_.autoFollowCallback_) { - owner_.autoFollowCallback_(nullptr); + owner_.followTargetGuidRef() = 0; + if (owner_.autoFollowCallbackRef()) { + owner_.autoFollowCallbackRef()(nullptr); } owner_.addSystemChatMessage("You stop following."); owner_.fireAddonEvent("AUTOFOLLOW_END", {}); diff --git a/src/game/quest_handler.cpp b/src/game/quest_handler.cpp index 4b32ae33..6b5b12c4 100644 --- a/src/game/quest_handler.cpp +++ b/src/game/quest_handler.cpp @@ -338,7 +338,7 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { table[Opcode::SMSG_QUESTGIVER_STATUS] = [this](network::Packet& packet) { if (packet.hasRemaining(9)) { uint64_t npcGuid = packet.readUInt64(); - uint8_t status = owner_.packetParsers_->readQuestGiverStatus(packet); + uint8_t status = owner_.getPacketParsers()->readQuestGiverStatus(packet); npcQuestStatus_[npcGuid] = static_cast(status); } }; @@ -350,7 +350,7 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { for (uint32_t i = 0; i < count; ++i) { if (!packet.hasRemaining(9)) break; uint64_t npcGuid = packet.readUInt64(); - uint8_t status = owner_.packetParsers_->readQuestGiverStatus(packet); + uint8_t status = owner_.getPacketParsers()->readQuestGiverStatus(packet); npcQuestStatus_[npcGuid] = static_cast(status); } }; @@ -466,8 +466,8 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { for (auto it = questLog_.begin(); it != questLog_.end(); ++it) { if (it->questId == questId) { // Fire toast callback before erasing - if (owner_.questCompleteCallback_) { - owner_.questCompleteCallback_(questId, it->title); + if (owner_.questCompleteCallbackRef()) { + owner_.questCompleteCallbackRef()(questId, it->title); } // Play quest-complete sound if (auto* ac = owner_.services().audioCoordinator) { @@ -476,25 +476,25 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { } questLog_.erase(it); LOG_INFO(" Removed quest ", questId, " from quest log"); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("QUEST_TURNED_IN", {std::to_string(questId)}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("QUEST_TURNED_IN", {std::to_string(questId)}); break; } } } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("QUEST_LOG_UPDATE", {}); - owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("QUEST_LOG_UPDATE", {}); + owner_.addonEventCallbackRef()("UNIT_QUEST_LOG_CHANGED", {"player"}); } // Re-query all nearby quest giver NPCs so markers refresh - if (owner_.socket) { + if (owner_.getSocket()) { for (const auto& [guid, entity] : owner_.getEntityManager().getEntities()) { if (entity->getType() != ObjectType::UNIT) continue; auto unit = std::static_pointer_cast(entity); if (unit->getNpcFlags() & 0x02) { network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); qsPkt.writeUInt64(guid); - owner_.socket->send(qsPkt); + owner_.getSocket()->send(qsPkt); } } } @@ -548,13 +548,13 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { progressMsg += std::to_string(count) + "/" + std::to_string(reqCount); owner_.addSystemChatMessage(progressMsg); - if (owner_.questProgressCallback_) { - owner_.questProgressCallback_(quest.title, creatureName, count, reqCount); + if (owner_.questProgressCallbackRef()) { + owner_.questProgressCallbackRef()(quest.title, creatureName, count, reqCount); } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("QUEST_WATCH_UPDATE", {std::to_string(questId)}); - owner_.addonEventCallback_("QUEST_LOG_UPDATE", {}); - owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("QUEST_WATCH_UPDATE", {std::to_string(questId)}); + owner_.addonEventCallbackRef()("QUEST_LOG_UPDATE", {}); + owner_.addonEventCallbackRef()("UNIT_QUEST_LOG_CHANGED", {"player"}); } LOG_INFO("Updated kill count for quest ", questId, ": ", @@ -614,7 +614,7 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { } owner_.addSystemChatMessage("Quest item: " + buildItemLink(itemId, questItemQuality, itemLabel) + " (" + std::to_string(count) + ")"); - if (owner_.questProgressCallback_ && updatedAny) { + if (owner_.questProgressCallbackRef() && updatedAny) { for (const auto& quest : questLog_) { if (quest.complete) continue; if (quest.itemCounts.count(itemId) == 0) continue; @@ -627,15 +627,15 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { } } if (required == 0) required = count; - owner_.questProgressCallback_(quest.title, itemLabel, count, required); + owner_.questProgressCallbackRef()(quest.title, itemLabel, count, required); break; } } - if (owner_.addonEventCallback_ && updatedAny) { - owner_.addonEventCallback_("QUEST_WATCH_UPDATE", {}); - owner_.addonEventCallback_("QUEST_LOG_UPDATE", {}); - owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"}); + if (owner_.addonEventCallbackRef() && updatedAny) { + owner_.addonEventCallbackRef()("QUEST_WATCH_UPDATE", {}); + owner_.addonEventCallbackRef()("QUEST_LOG_UPDATE", {}); + owner_.addonEventCallbackRef()("UNIT_QUEST_LOG_CHANGED", {"player"}); } LOG_INFO("Quest item update: itemId=", itemId, " count=", count, " trackedQuestsUpdated=", updatedAny); @@ -690,12 +690,12 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { // WotLK uses this opcode as SMSG_SET_REST_START if (!isClassicLikeExpansion() && !isActiveExpansion("tbc")) { bool nowResting = (value != 0); - if (nowResting != owner_.isResting_) { - owner_.isResting_ = nowResting; - owner_.addSystemChatMessage(owner_.isResting_ ? "You are now resting." + if (nowResting != owner_.isRestingRef()) { + owner_.isRestingRef() = nowResting; + owner_.addSystemChatMessage(owner_.isRestingRef() ? "You are now resting." : "You are no longer resting."); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("PLAYER_UPDATE_RESTING", {}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("PLAYER_UPDATE_RESTING", {}); } return; } @@ -740,10 +740,10 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { } else { owner_.addSystemChatMessage("Quest removed (ID " + std::to_string(questId) + ")."); } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("QUEST_LOG_UPDATE", {}); - owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"}); - owner_.addonEventCallback_("QUEST_REMOVED", {std::to_string(questId)}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("QUEST_LOG_UPDATE", {}); + owner_.addonEventCallbackRef()("UNIT_QUEST_LOG_CHANGED", {"player"}); + owner_.addonEventCallbackRef()("QUEST_REMOVED", {std::to_string(questId)}); } } }; @@ -758,7 +758,7 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { uint32_t questId = packet.readUInt32(); packet.readUInt32(); // questMethod - const bool isClassicLayout = owner_.packetParsers_ && owner_.packetParsers_->questLogStride() <= 4; + const bool isClassicLayout = owner_.getPacketParsers() && owner_.getPacketParsers()->questLogStride() <= 4; const QuestQueryTextCandidate parsed = pickBestQuestQueryTexts(packet.getData(), isClassicLayout); const QuestQueryObjectives objs = extractQuestQueryObjectives(packet.getData(), isClassicLayout); const QuestQueryRewards rwds = tryParseQuestRewards(packet.getData(), isClassicLayout); @@ -880,7 +880,7 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { for (uint32_t i = 0; i < count; ++i) { if (!packet.hasRemaining(4)) break; uint32_t questId = packet.readUInt32(); - owner_.completedQuests_.insert(questId); + owner_.completedQuestsRef().insert(questId); } LOG_DEBUG("SMSG_QUERY_QUESTS_COMPLETED_RESPONSE: ", count, " completed quests"); } @@ -894,13 +894,13 @@ void QuestHandler::registerOpcodes(DispatchTable& table) { // --------------------------------------------------------------------------- void QuestHandler::selectGossipOption(uint32_t optionId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || !gossipWindowOpen_) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || !gossipWindowOpen_) return; LOG_INFO("selectGossipOption: optionId=", optionId, " npcGuid=0x", std::hex, currentGossip_.npcGuid, std::dec, " menuId=", currentGossip_.menuId, " numOptions=", currentGossip_.options.size()); auto packet = GossipSelectOptionPacket::build(currentGossip_.npcGuid, currentGossip_.menuId, optionId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); for (const auto& opt : currentGossip_.options) { if (opt.id != optionId) continue; @@ -919,21 +919,21 @@ void QuestHandler::selectGossipOption(uint32_t optionId) { if (opt.icon == 6) { auto pkt = BankerActivatePacket::build(currentGossip_.npcGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); sentBanker = true; LOG_INFO("Sent CMSG_BANKER_ACTIVATE (icon) for npc=0x", std::hex, currentGossip_.npcGuid, std::dec); } if (!sentAuction && (text == "GOSSIP_OPTION_AUCTIONEER" || textLower.find("auction") != std::string::npos)) { auto pkt = AuctionHelloPacket::build(currentGossip_.npcGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); sentAuction = true; LOG_INFO("Sent MSG_AUCTION_HELLO for npc=0x", std::hex, currentGossip_.npcGuid, std::dec); } if (!sentBanker && (text == "GOSSIP_OPTION_BANKER" || textLower.find("deposit box") != std::string::npos)) { auto pkt = BankerActivatePacket::build(currentGossip_.npcGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); sentBanker = true; LOG_INFO("Sent CMSG_BANKER_ACTIVATE (text) for npc=0x", std::hex, currentGossip_.npcGuid, std::dec); } @@ -947,14 +947,14 @@ void QuestHandler::selectGossipOption(uint32_t optionId) { owner_.setVendorCanRepair(true); } auto pkt = ListInventoryPacket::build(currentGossip_.npcGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_DEBUG("Sent CMSG_LIST_INVENTORY (gossip) to npc=0x", std::hex, currentGossip_.npcGuid, std::dec); } if (textLower.find("make this inn your home") != std::string::npos || textLower.find("set your home") != std::string::npos) { auto bindPkt = BinderActivatePacket::build(currentGossip_.npcGuid); - owner_.socket->send(bindPkt); + owner_.getSocket()->send(bindPkt); LOG_INFO("Sent CMSG_BINDER_ACTIVATE for npc=0x", std::hex, currentGossip_.npcGuid, std::dec); } @@ -962,10 +962,10 @@ void QuestHandler::selectGossipOption(uint32_t optionId) { if (text == "GOSSIP_OPTION_STABLE" || textLower.find("stable") != std::string::npos || textLower.find("my pet") != std::string::npos) { - owner_.stableMasterGuid_ = currentGossip_.npcGuid; - owner_.stableWindowOpen_ = false; + owner_.stableMasterGuidRef() = currentGossip_.npcGuid; + owner_.stableWindowOpenRef() = false; auto listPkt = ListStabledPetsPacket::build(currentGossip_.npcGuid); - owner_.socket->send(listPkt); + owner_.getSocket()->send(listPkt); LOG_INFO("Sent MSG_LIST_STABLED_PETS (gossip) to npc=0x", std::hex, currentGossip_.npcGuid, std::dec); } @@ -974,7 +974,7 @@ void QuestHandler::selectGossipOption(uint32_t optionId) { } void QuestHandler::selectGossipQuest(uint32_t questId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || !gossipWindowOpen_) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || !gossipWindowOpen_) return; const QuestLogEntry* activeQuest = nullptr; for (const auto& q : questLog_) { @@ -986,11 +986,11 @@ void QuestHandler::selectGossipQuest(uint32_t questId) { // Validate against server-auth quest slot fields auto questInServerLogSlots = [&](uint32_t qid) -> bool { - if (qid == 0 || owner_.lastPlayerFields_.empty()) return false; + if (qid == 0 || owner_.lastPlayerFieldsRef().empty()) return false; const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); - const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5; + const uint8_t qStride = owner_.getPacketParsers() ? owner_.getPacketParsers()->questLogStride() : 5; const uint16_t ufQuestEnd = ufQuestStart + 25 * qStride; - for (const auto& [key, val] : owner_.lastPlayerFields_) { + for (const auto& [key, val] : owner_.lastPlayerFieldsRef()) { if (key < ufQuestStart || key >= ufQuestEnd) continue; if ((key - ufQuestStart) % qStride != 0) continue; if (val == qid) return true; @@ -1015,37 +1015,37 @@ void QuestHandler::selectGossipQuest(uint32_t questId) { pendingTurnInNpcGuid_ = currentGossip_.npcGuid; pendingTurnInRewardRequest_ = activeQuest ? activeQuest->complete : false; auto packet = QuestgiverCompleteQuestPacket::build(currentGossip_.npcGuid, questId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } else { pendingTurnInQuestId_ = 0; pendingTurnInNpcGuid_ = 0; pendingTurnInRewardRequest_ = false; - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildQueryQuestPacket(currentGossip_.npcGuid, questId) + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildQueryQuestPacket(currentGossip_.npcGuid, questId) : QuestgiverQueryQuestPacket::build(currentGossip_.npcGuid, questId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } gossipWindowOpen_ = false; } bool QuestHandler::requestQuestQuery(uint32_t questId, bool force) { - if (questId == 0 || owner_.state != WorldState::IN_WORLD || !owner_.socket) return false; + if (questId == 0 || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return false; if (!force && pendingQuestQueryIds_.count(questId)) return false; network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_QUERY)); pkt.writeUInt32(questId); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); pendingQuestQueryIds_.insert(questId); // WotLK supports CMSG_QUEST_POI_QUERY to get objective map locations. - if (owner_.packetParsers_ && owner_.packetParsers_->questLogStride() == 5) { + if (owner_.getPacketParsers() && owner_.getPacketParsers()->questLogStride() == 5) { const uint32_t wirePoiQuery = wireOpcode(Opcode::CMSG_QUEST_POI_QUERY); if (wirePoiQuery != 0xFFFF) { network::Packet poiPkt(static_cast(wirePoiQuery)); poiPkt.writeUInt32(1); // count = 1 poiPkt.writeUInt32(questId); - owner_.socket->send(poiPkt); + owner_.getSocket()->send(poiPkt); } } return true; @@ -1060,7 +1060,7 @@ void QuestHandler::setQuestTracked(uint32_t questId, bool tracked) { } void QuestHandler::acceptQuest() { - if (!questDetailsOpen_ || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (!questDetailsOpen_ || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; const uint32_t questId = currentQuestDetails_.questId; if (questId == 0) return; uint64_t npcGuid = currentQuestDetails_.npcGuid; @@ -1087,10 +1087,10 @@ void QuestHandler::acceptQuest() { std::erase_if(questLog_, [&](const QuestLogEntry& q) { return q.questId == questId; }); } - network::Packet packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildAcceptQuestPacket(npcGuid, questId) + network::Packet packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildAcceptQuestPacket(npcGuid, questId) : QuestgiverAcceptQuestPacket::build(npcGuid, questId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); pendingQuestAcceptTimeouts_[questId] = 5.0f; pendingQuestAcceptNpcGuids_[questId] = npcGuid; @@ -1108,7 +1108,7 @@ void QuestHandler::acceptQuest() { if (npcGuid) { network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); qsPkt.writeUInt64(npcGuid); - owner_.socket->send(qsPkt); + owner_.getSocket()->send(qsPkt); } } @@ -1120,12 +1120,12 @@ void QuestHandler::declineQuest() { void QuestHandler::closeGossip() { gossipWindowOpen_ = false; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("GOSSIP_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("GOSSIP_CLOSED", {}); currentGossip_ = GossipMessageData{}; } void QuestHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (itemGuid == 0 || questId == 0) { owner_.addSystemChatMessage("Cannot start quest right now."); return; @@ -1133,23 +1133,23 @@ void QuestHandler::offerQuestFromItem(uint64_t itemGuid, uint32_t questId) { // Send CMSG_QUESTGIVER_QUERY_QUEST with the item GUID as the "questgiver." // The server responds with SMSG_QUESTGIVER_QUEST_DETAILS which handleQuestDetails() // picks up and opens the Accept/Decline dialog. - auto queryPkt = owner_.packetParsers_ - ? owner_.packetParsers_->buildQueryQuestPacket(itemGuid, questId) + auto queryPkt = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildQueryQuestPacket(itemGuid, questId) : QuestgiverQueryQuestPacket::build(itemGuid, questId); - owner_.socket->send(queryPkt); + owner_.getSocket()->send(queryPkt); LOG_INFO("offerQuestFromItem: itemGuid=0x", std::hex, itemGuid, std::dec, " questId=", questId); } void QuestHandler::completeQuest() { - if (!questRequestItemsOpen_ || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (!questRequestItemsOpen_ || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; pendingTurnInQuestId_ = currentQuestRequestItems_.questId; pendingTurnInNpcGuid_ = currentQuestRequestItems_.npcGuid; pendingTurnInRewardRequest_ = currentQuestRequestItems_.isCompletable(); auto packet = QuestgiverCompleteQuestPacket::build( currentQuestRequestItems_.npcGuid, currentQuestRequestItems_.questId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); questRequestItemsOpen_ = false; currentQuestRequestItems_ = QuestRequestItemsData{}; } @@ -1161,13 +1161,13 @@ void QuestHandler::closeQuestRequestItems() { } void QuestHandler::chooseQuestReward(uint32_t rewardIndex) { - if (!questOfferRewardOpen_ || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (!questOfferRewardOpen_ || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; uint64_t npcGuid = currentQuestOfferReward_.npcGuid; LOG_INFO("Completing quest: questId=", currentQuestOfferReward_.questId, " npcGuid=", npcGuid, " rewardIndex=", rewardIndex); auto packet = QuestgiverChooseRewardPacket::build( npcGuid, currentQuestOfferReward_.questId, rewardIndex); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); pendingTurnInQuestId_ = 0; pendingTurnInNpcGuid_ = 0; pendingTurnInRewardRequest_ = false; @@ -1178,7 +1178,7 @@ void QuestHandler::chooseQuestReward(uint32_t rewardIndex) { if (npcGuid) { network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); qsPkt.writeUInt64(npcGuid); - owner_.socket->send(qsPkt); + owner_.getSocket()->send(qsPkt); } } @@ -1205,10 +1205,10 @@ void QuestHandler::abandonQuest(uint32_t questId) { } if (slotIndex >= 0 && slotIndex < 25) { - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { network::Packet pkt(wireOpcode(Opcode::CMSG_QUESTLOG_REMOVE_QUEST)); pkt.writeUInt8(static_cast(slotIndex)); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } } else { LOG_WARNING("Abandon quest failed: no quest-log slot found for questId=", questId); @@ -1216,10 +1216,10 @@ void QuestHandler::abandonQuest(uint32_t questId) { if (localIndex >= 0) { questLog_.erase(questLog_.begin() + static_cast(localIndex)); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("QUEST_LOG_UPDATE", {}); - owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"}); - owner_.addonEventCallback_("QUEST_REMOVED", {std::to_string(questId)}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("QUEST_LOG_UPDATE", {}); + owner_.addonEventCallbackRef()("UNIT_QUEST_LOG_CHANGED", {"player"}); + owner_.addonEventCallbackRef()("QUEST_REMOVED", {std::to_string(questId)}); } } @@ -1231,7 +1231,7 @@ void QuestHandler::abandonQuest(uint32_t questId) { } void QuestHandler::shareQuestWithParty(uint32_t questId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) { + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) { owner_.addSystemChatMessage("Cannot share quest: not in world."); return; } @@ -1241,7 +1241,7 @@ void QuestHandler::shareQuestWithParty(uint32_t questId) { } network::Packet pkt(wireOpcode(Opcode::CMSG_PUSHQUESTTOPARTY)); pkt.writeUInt32(questId); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); // Local feedback: find quest title for (const auto& q : questLog_) { if (q.questId == questId && !q.title.empty()) { @@ -1253,11 +1253,11 @@ void QuestHandler::shareQuestWithParty(uint32_t questId) { } void QuestHandler::acceptSharedQuest() { - if (!pendingSharedQuest_ || !owner_.socket) return; + if (!pendingSharedQuest_ || !owner_.getSocket()) return; pendingSharedQuest_ = false; network::Packet pkt(wireOpcode(Opcode::CMSG_QUEST_CONFIRM_ACCEPT)); pkt.writeUInt32(sharedQuestId_); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); owner_.addSystemChatMessage("Accepted: " + sharedQuestTitle_); } @@ -1278,13 +1278,13 @@ bool QuestHandler::hasQuestInLog(uint32_t questId) const { } int QuestHandler::findQuestLogSlotIndexFromServer(uint32_t questId) const { - if (questId == 0 || owner_.lastPlayerFields_.empty()) return -1; + if (questId == 0 || owner_.lastPlayerFieldsRef().empty()) return -1; const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); - const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5; + const uint8_t qStride = owner_.getPacketParsers() ? owner_.getPacketParsers()->questLogStride() : 5; for (uint16_t slot = 0; slot < 25; ++slot) { const uint16_t idField = ufQuestStart + slot * qStride; - auto it = owner_.lastPlayerFields_.find(idField); - if (it != owner_.lastPlayerFields_.end() && it->second == questId) { + auto it = owner_.lastPlayerFieldsRef().find(idField); + if (it != owner_.lastPlayerFieldsRef().end() && it->second == questId) { return static_cast(slot); } } @@ -1298,18 +1298,18 @@ void QuestHandler::addQuestToLocalLogIfMissing(uint32_t questId, const std::stri entry.title = title.empty() ? ("Quest #" + std::to_string(questId)) : title; entry.objectives = objectives; questLog_.push_back(std::move(entry)); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("QUEST_ACCEPTED", {std::to_string(questId)}); - owner_.addonEventCallback_("QUEST_LOG_UPDATE", {}); - owner_.addonEventCallback_("UNIT_QUEST_LOG_CHANGED", {"player"}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("QUEST_ACCEPTED", {std::to_string(questId)}); + owner_.addonEventCallbackRef()("QUEST_LOG_UPDATE", {}); + owner_.addonEventCallbackRef()("UNIT_QUEST_LOG_CHANGED", {"player"}); } } bool QuestHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { - if (owner_.lastPlayerFields_.empty()) return false; + if (owner_.lastPlayerFieldsRef().empty()) return false; const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); - const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5; + const uint8_t qStride = owner_.getPacketParsers() ? owner_.getPacketParsers()->questLogStride() : 5; static constexpr uint32_t kQuestStatusComplete = 1; @@ -1318,15 +1318,15 @@ bool QuestHandler::resyncQuestLogFromServerSlots(bool forceQueryMetadata) { for (uint16_t slot = 0; slot < 25; ++slot) { const uint16_t idField = ufQuestStart + slot * qStride; const uint16_t stateField = ufQuestStart + slot * qStride + 1; - auto it = owner_.lastPlayerFields_.find(idField); - if (it == owner_.lastPlayerFields_.end()) continue; + auto it = owner_.lastPlayerFieldsRef().find(idField); + if (it == owner_.lastPlayerFieldsRef().end()) continue; uint32_t questId = it->second; if (questId == 0) continue; bool complete = false; if (qStride >= 2) { - auto stateIt = owner_.lastPlayerFields_.find(stateField); - if (stateIt != owner_.lastPlayerFields_.end()) { + auto stateIt = owner_.lastPlayerFieldsRef().find(stateField); + if (stateIt != owner_.lastPlayerFieldsRef().end()) { uint32_t state = stateIt->second & 0xFF; complete = (state == kQuestStatusComplete); } @@ -1378,7 +1378,7 @@ void QuestHandler::applyQuestStateFromFields(const std::map& const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); if (ufQuestStart == 0xFFFF || questLog_.empty()) return; - const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5; + const uint8_t qStride = owner_.getPacketParsers() ? owner_.getPacketParsers()->questLogStride() : 5; if (qStride < 2) return; static constexpr uint32_t kQuestStatusComplete = 1; @@ -1407,12 +1407,12 @@ void QuestHandler::applyQuestStateFromFields(const std::map& } void QuestHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { - if (owner_.lastPlayerFields_.empty()) return; + if (owner_.lastPlayerFieldsRef().empty()) return; const uint16_t ufQuestStart = fieldIndex(UF::PLAYER_QUEST_LOG_START); if (ufQuestStart == 0xFFFF) return; - const uint8_t qStride = owner_.packetParsers_ ? owner_.packetParsers_->questLogStride() : 5; + const uint8_t qStride = owner_.getPacketParsers() ? owner_.getPacketParsers()->questLogStride() : 5; if (qStride < 3) return; int slot = findQuestLogSlotIndexFromServer(quest.questId); @@ -1423,14 +1423,14 @@ void QuestHandler::applyPackedKillCountsFromFields(QuestLogEntry& quest) { ? static_cast(countField1 + 1) : static_cast(0xFFFF); - auto f1It = owner_.lastPlayerFields_.find(countField1); - if (f1It == owner_.lastPlayerFields_.end()) return; + auto f1It = owner_.lastPlayerFieldsRef().find(countField1); + if (f1It == owner_.lastPlayerFieldsRef().end()) return; const uint32_t packed1 = f1It->second; uint32_t packed2 = 0; if (countField2 != 0xFFFF) { - auto f2It = owner_.lastPlayerFields_.find(countField2); - if (f2It != owner_.lastPlayerFields_.end()) packed2 = f2It->second; + auto f2It = owner_.lastPlayerFieldsRef().find(countField2); + if (f2It != owner_.lastPlayerFieldsRef().end()) packed2 = f2It->second; } auto unpack6 = [](uint32_t word, int idx) -> uint8_t { @@ -1474,7 +1474,7 @@ void QuestHandler::clearPendingQuestAccept(uint32_t questId) { } void QuestHandler::triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, const char* reason) { - if (questId == 0 || !owner_.socket || owner_.state != WorldState::IN_WORLD) return; + if (questId == 0 || !owner_.getSocket() || owner_.getState() != WorldState::IN_WORLD) return; LOG_INFO("Quest accept resync: questId=", questId, " reason=", reason ? reason : "unknown"); requestQuestQuery(questId, true); @@ -1482,12 +1482,12 @@ void QuestHandler::triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, if (npcGuid != 0) { network::Packet qsPkt(wireOpcode(Opcode::CMSG_QUESTGIVER_STATUS_QUERY)); qsPkt.writeUInt64(npcGuid); - owner_.socket->send(qsPkt); + owner_.getSocket()->send(qsPkt); - auto queryPkt = owner_.packetParsers_ - ? owner_.packetParsers_->buildQueryQuestPacket(npcGuid, questId) + auto queryPkt = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildQueryQuestPacket(npcGuid, questId) : QuestgiverQueryQuestPacket::build(npcGuid, questId); - owner_.socket->send(queryPkt); + owner_.getSocket()->send(queryPkt); } } @@ -1496,23 +1496,23 @@ void QuestHandler::triggerQuestAcceptResync(uint32_t questId, uint64_t npcGuid, // --------------------------------------------------------------------------- void QuestHandler::handleGossipMessage(network::Packet& packet) { - bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseGossipMessage(packet, currentGossip_) + bool ok = owner_.getPacketParsers() ? owner_.getPacketParsers()->parseGossipMessage(packet, currentGossip_) : GossipMessageParser::parse(packet, currentGossip_); if (!ok) return; if (questDetailsOpen_) return; // Don't reopen gossip while viewing quest gossipWindowOpen_ = true; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("GOSSIP_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("GOSSIP_SHOW", {}); owner_.closeVendor(); // Close vendor if gossip opens // Classify gossip quests and update quest log + overhead NPC markers. classifyGossipQuests(true); // Play NPC greeting voice - if (owner_.npcGreetingCallback_ && currentGossip_.npcGuid != 0) { + if (owner_.npcGreetingCallbackRef() && currentGossip_.npcGuid != 0) { auto entity = owner_.getEntityManager().getEntity(currentGossip_.npcGuid); if (entity) { glm::vec3 npcPos(entity->getX(), entity->getY(), entity->getZ()); - owner_.npcGreetingCallback_(currentGossip_.npcGuid, npcPos); + owner_.npcGreetingCallbackRef()(currentGossip_.npcGuid, npcPos); } } } @@ -1563,7 +1563,7 @@ void QuestHandler::handleQuestgiverQuestList(network::Packet& packet) { currentGossip_ = std::move(data); gossipWindowOpen_ = true; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("GOSSIP_SHOW", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("GOSSIP_SHOW", {}); owner_.closeVendor(); classifyGossipQuests(false); @@ -1617,16 +1617,16 @@ void QuestHandler::handleGossipComplete(network::Packet& packet) { (void)packet; // Play farewell sound before closing - if (owner_.npcFarewellCallback_ && currentGossip_.npcGuid != 0) { + if (owner_.npcFarewellCallbackRef() && currentGossip_.npcGuid != 0) { auto entity = owner_.getEntityManager().getEntity(currentGossip_.npcGuid); if (entity && entity->getType() == ObjectType::UNIT) { glm::vec3 pos(entity->getX(), entity->getY(), entity->getZ()); - owner_.npcFarewellCallback_(currentGossip_.npcGuid, pos); + owner_.npcFarewellCallbackRef()(currentGossip_.npcGuid, pos); } } gossipWindowOpen_ = false; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("GOSSIP_CLOSED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("GOSSIP_CLOSED", {}); currentGossip_ = GossipMessageData{}; } @@ -1687,7 +1687,7 @@ void QuestHandler::handleQuestPoiQueryResponse(network::Packet& packet) { sumY += static_cast(py); } // Skip POIs for maps other than the player's current map. - if (mapId != owner_.currentMapId_) continue; + if (mapId != owner_.currentMapIdRef()) continue; GossipPoi poi; poi.x = sumX / static_cast(pointCount); poi.y = sumY / static_cast(pointCount); @@ -1704,7 +1704,7 @@ void QuestHandler::handleQuestPoiQueryResponse(network::Packet& packet) { void QuestHandler::handleQuestDetails(network::Packet& packet) { QuestDetailsData data; - bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseQuestDetails(packet, data) + bool ok = owner_.getPacketParsers() ? owner_.getPacketParsers()->parseQuestDetails(packet, data) : QuestDetailsParser::parse(packet, data); if (!ok) { LOG_WARNING("Failed to parse SMSG_QUESTGIVER_QUEST_DETAILS"); @@ -1727,7 +1727,7 @@ void QuestHandler::handleQuestDetails(network::Packet& packet) { // Delay opening the window slightly to allow item queries to complete questDetailsOpenTime_ = std::chrono::steady_clock::now() + std::chrono::milliseconds(100); gossipWindowOpen_ = false; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("QUEST_DETAIL", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("QUEST_DETAIL", {}); } void QuestHandler::handleQuestRequestItems(network::Packet& packet) { @@ -1742,9 +1742,9 @@ void QuestHandler::handleQuestRequestItems(network::Packet& packet) { data.questId == pendingTurnInQuestId_ && data.npcGuid == pendingTurnInNpcGuid_ && data.isCompletable() && - owner_.socket) { + owner_.getSocket()) { auto rewardReq = QuestgiverRequestRewardPacket::build(data.npcGuid, data.questId); - owner_.socket->send(rewardReq); + owner_.getSocket()->send(rewardReq); pendingTurnInRewardRequest_ = false; } @@ -1809,7 +1809,7 @@ void QuestHandler::handleQuestOfferReward(network::Packet& packet) { gossipWindowOpen_ = false; questDetailsOpen_ = false; questDetailsOpenTime_ = std::chrono::steady_clock::time_point{}; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("QUEST_COMPLETE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("QUEST_COMPLETE", {}); // Query item names for reward items for (const auto& item : data.choiceRewards) diff --git a/src/game/social_handler.cpp b/src/game/social_handler.cpp index 230bb689..bfc39923 100644 --- a/src/game/social_handler.cpp +++ b/src/game/social_handler.cpp @@ -103,12 +103,12 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { // Names are resolved via SMSG_NAME_QUERY_RESPONSE after the list arrives. if (!packet.hasRemaining(1)) return; uint8_t ignCount = packet.readUInt8(); - owner_.ignoreListGuids_.clear(); + owner_.ignoreListGuidsRef().clear(); for (uint8_t i = 0; i < ignCount; ++i) { if (!packet.hasRemaining(8)) break; uint64_t ignGuid = packet.readUInt64(); if (ignGuid != 0) { - owner_.ignoreListGuids_.insert(ignGuid); + owner_.ignoreListGuidsRef().insert(ignGuid); // Query name so UI can display it later owner_.queryPlayerName(ignGuid); } @@ -137,9 +137,9 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { partyData.leaderGuid = 0; owner_.addUIError("Your party has been disbanded."); owner_.addSystemChatMessage("Your party has been disbanded."); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("GROUP_ROSTER_UPDATE", {}); - owner_.addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("GROUP_ROSTER_UPDATE", {}); + owner_.addonEventCallbackRef()("PARTY_MEMBERS_CHANGED", {}); } }; table[Opcode::SMSG_GROUP_CANCEL] = [this](network::Packet& /*packet*/) { @@ -171,8 +171,8 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { owner_.addSystemChatMessage(readyCheckInitiator_.empty() ? "Ready check initiated!" : readyCheckInitiator_ + " initiated a ready check!"); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("READY_CHECK", {readyCheckInitiator_}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("READY_CHECK", {readyCheckInitiator_}); }; table[Opcode::MSG_RAID_READY_CHECK_CONFIRM] = [this](network::Packet& packet) { if (!packet.hasRemaining(9)) { packet.skipAll(); return; } @@ -199,10 +199,10 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); owner_.addSystemChatMessage(rbuf); } - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { char guidBuf[32]; snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)respGuid); - owner_.addonEventCallback_("READY_CHECK_CONFIRM", {guidBuf, isReady ? "1" : "0"}); + owner_.addonEventCallbackRef()("READY_CHECK_CONFIRM", {guidBuf, isReady ? "1" : "0"}); } }; table[Opcode::MSG_RAID_READY_CHECK_FINISHED] = [this](network::Packet& /*packet*/) { @@ -214,7 +214,7 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { readyCheckReadyCount_ = 0; readyCheckNotReadyCount_ = 0; readyCheckResults_.clear(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("READY_CHECK_FINISHED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("READY_CHECK_FINISHED", {}); }; table[Opcode::SMSG_RAID_INSTANCE_INFO] = [this](network::Packet& packet) { handleRaidInstanceInfo(packet); }; @@ -414,7 +414,7 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { owner_.addSystemChatMessage("Cannot reset " + mapLabel + ": " + reasonMsg); }; table[Opcode::SMSG_INSTANCE_LOCK_WARNING_QUERY] = [this](network::Packet& packet) { - if (!owner_.socket || !packet.hasRemaining(17)) return; + if (!owner_.getSocket() || !packet.hasRemaining(17)) return; uint32_t ilMapId = packet.readUInt32(); uint32_t ilDiff = packet.readUInt32(); uint32_t ilTimeLeft = packet.readUInt32(); @@ -432,7 +432,7 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { owner_.addSystemChatMessage(ilMsg); network::Packet resp(wireOpcode(Opcode::CMSG_INSTANCE_LOCK_RESPONSE)); resp.writeUInt8(1); - owner_.socket->send(resp); + owner_.getSocket()->send(resp); }; // ---- LFG ---- @@ -475,7 +475,7 @@ void SocialHandler::registerOpcodes(DispatchTable& table) { } table[Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER] = [this](network::Packet& packet) { packet.skipAll(); - if (owner_.openLfgCallback_) owner_.openLfgCallback_(); + if (owner_.openLfgCallbackRef()) owner_.openLfgCallbackRef()(); }; // ---- Arena ---- @@ -537,11 +537,11 @@ const std::string& SocialHandler::lookupGuildName(uint32_t guildId) { // ============================================================ void SocialHandler::inspectTarget() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) { + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) { LOG_WARNING("Cannot inspect: not in world or not connected"); return; } - if (owner_.targetGuid == 0) { + if (owner_.getTargetGuid() == 0) { owner_.addSystemChatMessage("You must target a player to inspect."); return; } @@ -550,16 +550,16 @@ void SocialHandler::inspectTarget() { owner_.addSystemChatMessage("You can only inspect players."); return; } - auto packet = InspectPacket::build(owner_.targetGuid); - owner_.socket->send(packet); + auto packet = InspectPacket::build(owner_.getTargetGuid()); + owner_.getSocket()->send(packet); if (isActiveExpansion("wotlk")) { - auto achPkt = QueryInspectAchievementsPacket::build(owner_.targetGuid); - owner_.socket->send(achPkt); + auto achPkt = QueryInspectAchievementsPacket::build(owner_.getTargetGuid()); + owner_.getSocket()->send(achPkt); } auto player = std::static_pointer_cast(target); std::string name = player->getName().empty() ? "Target" : player->getName(); owner_.addSystemChatMessage("Inspecting " + name + "..."); - LOG_INFO("Sent inspect request for player: ", name, " (GUID: 0x", std::hex, owner_.targetGuid, std::dec, ")"); + LOG_INFO("Sent inspect request for player: ", name, " (GUID: 0x", std::hex, owner_.getTargetGuid(), std::dec, ")"); } void SocialHandler::handleInspectResults(network::Packet& packet) { @@ -576,30 +576,30 @@ void SocialHandler::handleInspectResults(network::Packet& packet) { uint8_t talentGroupCount = packet.readUInt8(); uint8_t activeTalentGroup = packet.readUInt8(); if (activeTalentGroup > 1) activeTalentGroup = 0; - owner_.activeTalentSpec_ = activeTalentGroup; + owner_.activeTalentSpecRef() = activeTalentGroup; for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { if (!packet.hasRemaining(1)) break; uint8_t talentCount = packet.readUInt8(); - owner_.learnedTalents_[g].clear(); + owner_.learnedTalentsArr()[g].clear(); for (uint8_t t = 0; t < talentCount; ++t) { if (!packet.hasRemaining(5)) break; uint32_t talentId = packet.readUInt32(); uint8_t rank = packet.readUInt8(); - owner_.learnedTalents_[g][talentId] = rank + 1u; + owner_.learnedTalentsArr()[g][talentId] = rank + 1u; } if (!packet.hasRemaining(1)) break; - owner_.learnedGlyphs_[g].fill(0); + owner_.learnedGlyphsRef()[g].fill(0); uint8_t glyphCount = packet.readUInt8(); for (uint8_t gl = 0; gl < glyphCount; ++gl) { if (!packet.hasRemaining(2)) break; uint16_t glyphId = packet.readUInt16(); - if (gl < GameHandler::MAX_GLYPH_SLOTS) owner_.learnedGlyphs_[g][gl] = glyphId; + if (gl < GameHandler::MAX_GLYPH_SLOTS) owner_.learnedGlyphsRef()[g][gl] = glyphId; } } - owner_.unspentTalentPoints_[activeTalentGroup] = static_cast( + owner_.unspentTalentPointsArr()[activeTalentGroup] = static_cast( unspentTalents > 255 ? 255 : unspentTalents); - if (!owner_.talentsInitialized_) { - owner_.talentsInitialized_ = true; + if (!owner_.talentsInitializedRef()) { + owner_.talentsInitializedRef() = true; if (unspentTalents > 0) { owner_.addSystemChatMessage("You have " + std::to_string(unspentTalents) + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); @@ -607,7 +607,7 @@ void SocialHandler::handleInspectResults(network::Packet& packet) { } LOG_INFO("SMSG_TALENTS_INFO type=0: unspent=", unspentTalents, " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, - " learned=", owner_.learnedTalents_[activeTalentGroup].size()); + " learned=", owner_.learnedTalentsArr()[activeTalentGroup].size()); return; } @@ -685,8 +685,8 @@ void SocialHandler::handleInspectResults(network::Packet& packet) { inspectResult_.activeTalentGroup = activeTalentGroup; inspectResult_.enchantIds = enchantIds; - auto gearIt = owner_.inspectedPlayerItemEntries_.find(guid); - if (gearIt != owner_.inspectedPlayerItemEntries_.end()) { + auto gearIt = owner_.inspectedPlayerItemEntriesRef().find(guid); + if (gearIt != owner_.inspectedPlayerItemEntriesRef().end()) { inspectResult_.itemEntries = gearIt->second; } else { inspectResult_.itemEntries = {}; @@ -694,10 +694,10 @@ void SocialHandler::handleInspectResults(network::Packet& packet) { LOG_INFO("Inspect results for ", playerName, ": ", totalTalents, " talents, ", unspentTalents, " unspent, ", (int)talentGroupCount, " specs"); - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { char guidBuf[32]; snprintf(guidBuf, sizeof(guidBuf), "0x%016llX", (unsigned long long)guid); - owner_.addonEventCallback_("INSPECT_READY", {guidBuf}); + owner_.addonEventCallbackRef()("INSPECT_READY", {guidBuf}); } } @@ -706,97 +706,97 @@ void SocialHandler::handleInspectResults(network::Packet& packet) { // ============================================================ void SocialHandler::queryServerTime() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = QueryTimePacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Requested server time"); } void SocialHandler::requestPlayedTime() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = RequestPlayedTimePacket::build(true); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Requested played time"); } void SocialHandler::queryWho(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = WhoPacket::build(0, 0, playerName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Sent WHO query", playerName.empty() ? "" : " for: " + playerName); } void SocialHandler::addFriend(const std::string& playerName, const std::string& note) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name."); return; } auto packet = AddFriendPacket::build(playerName, note); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Sending friend request to " + playerName + "..."); LOG_INFO("Sent friend request to: ", playerName); } void SocialHandler::removeFriend(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name."); return; } - auto it = owner_.friendsCache.find(playerName); - if (it == owner_.friendsCache.end()) { + auto it = owner_.friendsCacheRef().find(playerName); + if (it == owner_.friendsCacheRef().end()) { owner_.addSystemChatMessage(playerName + " is not in your friends list."); return; } auto packet = DelFriendPacket::build(it->second); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Removing " + playerName + " from friends list..."); LOG_INFO("Sent remove friend request for: ", playerName); } void SocialHandler::setFriendNote(const std::string& playerName, const std::string& note) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name."); return; } - auto it = owner_.friendsCache.find(playerName); - if (it == owner_.friendsCache.end()) { + auto it = owner_.friendsCacheRef().find(playerName); + if (it == owner_.friendsCacheRef().end()) { owner_.addSystemChatMessage(playerName + " is not in your friends list."); return; } auto packet = SetContactNotesPacket::build(it->second, note); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Updated note for " + playerName); LOG_INFO("Set friend note for: ", playerName); } void SocialHandler::addIgnore(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name."); return; } auto packet = AddIgnorePacket::build(playerName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Adding " + playerName + " to ignore list..."); LOG_INFO("Sent ignore request for: ", playerName); } void SocialHandler::removeIgnore(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name."); return; } - auto it = owner_.ignoreCache.find(playerName); - if (it == owner_.ignoreCache.end()) { + auto it = owner_.ignoreCacheRef().find(playerName); + if (it == owner_.ignoreCacheRef().end()) { owner_.addSystemChatMessage(playerName + " is not in your ignore list."); return; } auto packet = DelIgnorePacket::build(it->second); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Removing " + playerName + " from ignore list..."); // Don't erase from ignoreCache here — wait for the server's SMSG_IGNORE_LIST // response to confirm. Erasing optimistically desyncs the cache if the server // rejects the request. (Compare with removeFriend which also waits for // SMSG_FRIEND_STATUS before updating its cache.) - owner_.ignoreListGuids_.erase(it->second); + owner_.ignoreListGuidsRef().erase(it->second); LOG_INFO("Sent remove ignore request for: ", playerName); } void SocialHandler::randomRoll(uint32_t minRoll, uint32_t maxRoll) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (minRoll > maxRoll) std::swap(minRoll, maxRoll); if (maxRoll > 10000) maxRoll = 10000; auto packet = RandomRollPacket::build(minRoll, maxRoll); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Rolled ", minRoll, "-", maxRoll); } @@ -805,19 +805,19 @@ void SocialHandler::randomRoll(uint32_t minRoll, uint32_t maxRoll) { // ============================================================ void SocialHandler::requestLogout() { - if (!owner_.socket) return; + if (!owner_.getSocket()) return; if (loggingOut_) { owner_.addSystemChatMessage("Already logging out."); return; } auto packet = LogoutRequestPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); loggingOut_ = true; LOG_INFO("Sent logout request"); } void SocialHandler::cancelLogout() { - if (!owner_.socket) return; + if (!owner_.getSocket()) return; if (!loggingOut_) { owner_.addSystemChatMessage("Not currently logging out."); return; } auto packet = LogoutCancelPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); loggingOut_ = false; logoutCountdown_ = 0.0f; owner_.addSystemChatMessage("Logout cancelled."); @@ -829,153 +829,153 @@ void SocialHandler::cancelLogout() { // ============================================================ void SocialHandler::requestGuildInfo() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildInfoPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::requestGuildRoster() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildRosterPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Requesting guild roster..."); } void SocialHandler::setGuildMotd(const std::string& motd) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildMotdPacket::build(motd); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Guild MOTD updated."); } void SocialHandler::promoteGuildMember(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name."); return; } auto packet = GuildPromotePacket::build(playerName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Promoting " + playerName + "..."); } void SocialHandler::demoteGuildMember(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name."); return; } auto packet = GuildDemotePacket::build(playerName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Demoting " + playerName + "..."); } void SocialHandler::leaveGuild() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildLeavePacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Leaving guild..."); } void SocialHandler::inviteToGuild(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name."); return; } auto packet = GuildInvitePacket::build(playerName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Inviting " + playerName + " to guild..."); } void SocialHandler::kickGuildMember(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildRemovePacket::build(playerName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::disbandGuild() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildDisbandPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::setGuildLeader(const std::string& name) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildLeaderPacket::build(name); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::setGuildPublicNote(const std::string& name, const std::string& note) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildSetPublicNotePacket::build(name, note); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::setGuildOfficerNote(const std::string& name, const std::string& note) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildSetOfficerNotePacket::build(name, note); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::acceptGuildInvite() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; pendingGuildInvite_ = false; auto packet = GuildAcceptPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::declineGuildInvite() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; pendingGuildInvite_ = false; auto packet = GuildDeclineInvitationPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::queryGuildInfo(uint32_t guildId) { // Allow guild queries at the character screen too — the socket is // connected and the server accepts CMSG_GUILD_QUERY before login. - if (!owner_.socket) return; + if (!owner_.getSocket()) return; auto packet = GuildQueryPacket::build(guildId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::createGuild(const std::string& guildName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildCreatePacket::build(guildName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::addGuildRank(const std::string& rankName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildAddRankPacket::build(rankName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); requestGuildRoster(); } void SocialHandler::deleteGuildRank() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GuildDelRankPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); requestGuildRoster(); } void SocialHandler::requestPetitionShowlist(uint64_t npcGuid) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = PetitionShowlistPacket::build(npcGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::buyPetition(uint64_t npcGuid, const std::string& guildName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = PetitionBuyPacket::build(npcGuid, guildName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::signPetition(uint64_t petitionGuid) { - if (!owner_.socket || owner_.getState() != WorldState::IN_WORLD) return; + if (!owner_.getSocket() || owner_.getState() != WorldState::IN_WORLD) return; network::Packet pkt(wireOpcode(Opcode::CMSG_PETITION_SIGN)); pkt.writeUInt64(petitionGuid); pkt.writeUInt8(0); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void SocialHandler::turnInPetition(uint64_t petitionGuid) { - if (!owner_.socket || owner_.getState() != WorldState::IN_WORLD) return; + if (!owner_.getSocket() || owner_.getState() != WorldState::IN_WORLD) return; network::Packet pkt(wireOpcode(Opcode::CMSG_TURN_IN_PETITION)); pkt.writeUInt64(petitionGuid); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } // ============================================================ @@ -983,17 +983,17 @@ void SocialHandler::turnInPetition(uint64_t petitionGuid) { // ============================================================ void SocialHandler::initiateReadyCheck() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (!isInGroup()) { owner_.addSystemChatMessage("You must be in a group to initiate a ready check."); return; } auto packet = ReadyCheckPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Ready check initiated."); } void SocialHandler::respondToReadyCheck(bool ready) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = ReadyCheckConfirmPacket::build(ready); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage(ready ? "You are ready." : "You are not ready."); } @@ -1002,37 +1002,37 @@ void SocialHandler::respondToReadyCheck(bool ready) { // ============================================================ void SocialHandler::acceptDuel() { - if (!pendingDuelRequest_ || owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (!pendingDuelRequest_ || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; pendingDuelRequest_ = false; auto pkt = DuelAcceptPacket::build(); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); owner_.addSystemChatMessage("You accept the duel."); } void SocialHandler::forfeitDuel() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; pendingDuelRequest_ = false; auto packet = DuelCancelPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("You have forfeited the duel."); } void SocialHandler::proposeDuel(uint64_t targetGuid) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (targetGuid == 0) { owner_.addSystemChatMessage("You must target a player to challenge to a duel."); return; } auto packet = DuelProposedPacket::build(targetGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("You have challenged your target to a duel."); } void SocialHandler::reportPlayer(uint64_t targetGuid, const std::string& reason) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (targetGuid == 0) { owner_.addSystemChatMessage("You must target a player to report."); return; } auto packet = ComplainPacket::build(targetGuid, reason); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Player report submitted."); LOG_INFO("Reported player: 0x", std::hex, targetGuid, std::dec, " reason=", reason); } @@ -1058,7 +1058,7 @@ void SocialHandler::handleDuelRequested(network::Packet& packet) { owner_.addSystemChatMessage(duelChallengerName_ + " challenges you to a duel!"); if (auto* ac = owner_.services().audioCoordinator) if (auto* sfx = ac->getUiSoundManager()) sfx->playTargetSelect(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("DUEL_REQUESTED", {duelChallengerName_}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("DUEL_REQUESTED", {duelChallengerName_}); } void SocialHandler::handleDuelComplete(network::Packet& packet) { @@ -1067,7 +1067,7 @@ void SocialHandler::handleDuelComplete(network::Packet& packet) { pendingDuelRequest_ = false; duelCountdownMs_ = 0; if (!started) owner_.addSystemChatMessage("The duel was cancelled."); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("DUEL_FINISHED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("DUEL_FINISHED", {}); } void SocialHandler::handleDuelWinner(network::Packet& packet) { @@ -1086,40 +1086,40 @@ void SocialHandler::handleDuelWinner(network::Packet& packet) { // ============================================================ void SocialHandler::inviteToGroup(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; LOG_WARNING(">>> Sending CMSG_GROUP_INVITE to '", playerName, "'"); auto packet = GroupInvitePacket::build(playerName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::acceptGroupInvite() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; LOG_WARNING(">>> Sending CMSG_GROUP_ACCEPT"); pendingGroupInvite = false; auto packet = GroupAcceptPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::declineGroupInvite() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; pendingGroupInvite = false; auto packet = GroupDeclinePacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SocialHandler::leaveGroup() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = GroupDisbandPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); partyData = GroupListData{}; - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("GROUP_ROSTER_UPDATE", {}); - owner_.addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("GROUP_ROSTER_UPDATE", {}); + owner_.addonEventCallbackRef()("PARTY_MEMBERS_CHANGED", {}); } } void SocialHandler::convertToRaid() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (!isInGroup()) { owner_.addSystemChatMessage("You are not in a group."); return; @@ -1133,22 +1133,22 @@ void SocialHandler::convertToRaid() { return; } auto packet = GroupRaidConvertPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Sent CMSG_GROUP_RAID_CONVERT"); } void SocialHandler::sendSetLootMethod(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = SetLootMethodPacket::build(method, threshold, masterLooterGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("sendSetLootMethod: method=", method, " threshold=", threshold); } void SocialHandler::uninvitePlayer(const std::string& playerName) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (playerName.empty()) { owner_.addSystemChatMessage("You must specify a player name to uninvite."); return; } auto packet = GroupUninvitePacket::build(playerName); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Removed " + playerName + " from the group."); } @@ -1161,55 +1161,55 @@ void SocialHandler::leaveParty() { } void SocialHandler::setMainTank(uint64_t targetGuid) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (targetGuid == 0) { owner_.addSystemChatMessage("You must have a target selected."); return; } auto packet = RaidTargetUpdatePacket::build(0, targetGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Main tank set."); } void SocialHandler::setMainAssist(uint64_t targetGuid) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (targetGuid == 0) { owner_.addSystemChatMessage("You must have a target selected."); return; } auto packet = RaidTargetUpdatePacket::build(1, targetGuid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Main assist set."); } void SocialHandler::clearMainTank() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = RaidTargetUpdatePacket::build(0, 0); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Main tank cleared."); } void SocialHandler::clearMainAssist() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = RaidTargetUpdatePacket::build(1, 0); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Main assist cleared."); } void SocialHandler::setRaidMark(uint64_t guid, uint8_t icon) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (icon == 0xFF) { for (int i = 0; i < 8; ++i) { if (raidTargetGuids_[i] == guid) { auto packet = RaidTargetUpdatePacket::build(static_cast(i), 0); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); break; } } } else if (icon < 8) { auto packet = RaidTargetUpdatePacket::build(icon, guid); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } } void SocialHandler::requestRaidInfo() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = RequestRaidInfoPacket::build(); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); owner_.addSystemChatMessage("Requesting raid lockout information..."); } @@ -1226,8 +1226,8 @@ void SocialHandler::handleGroupInvite(network::Packet& packet) { owner_.addSystemChatMessage(data.inviterName + " has invited you to a group."); if (auto* ac = owner_.services().audioCoordinator) if (auto* sfx = ac->getUiSoundManager()) sfx->playTargetSelect(); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("PARTY_INVITE_REQUEST", {data.inviterName}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("PARTY_INVITE_REQUEST", {data.inviterName}); } void SocialHandler::handleGroupDecline(network::Packet& packet) { @@ -1264,21 +1264,21 @@ void SocialHandler::handleGroupList(network::Packet& packet) { const char* methodName = (partyData.lootMethod < 5) ? kLootMethods[partyData.lootMethod] : "Unknown"; owner_.addSystemChatMessage(std::string("Loot method changed to ") + methodName + "."); } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("GROUP_ROSTER_UPDATE", {}); - owner_.addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("GROUP_ROSTER_UPDATE", {}); + owner_.addonEventCallbackRef()("PARTY_MEMBERS_CHANGED", {}); if (partyData.groupType == 1) - owner_.addonEventCallback_("RAID_ROSTER_UPDATE", {}); + owner_.addonEventCallbackRef()("RAID_ROSTER_UPDATE", {}); } } void SocialHandler::handleGroupUninvite(network::Packet& packet) { (void)packet; partyData = GroupListData{}; - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("GROUP_ROSTER_UPDATE", {}); - owner_.addonEventCallback_("PARTY_MEMBERS_CHANGED", {}); - owner_.addonEventCallback_("RAID_ROSTER_UPDATE", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("GROUP_ROSTER_UPDATE", {}); + owner_.addonEventCallbackRef()("PARTY_MEMBERS_CHANGED", {}); + owner_.addonEventCallbackRef()("RAID_ROSTER_UPDATE", {}); } owner_.addUIError("You have been removed from the group."); owner_.addSystemChatMessage("You have been removed from the group."); @@ -1375,8 +1375,8 @@ void SocialHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) if (a.spellId != 0) newAuras.push_back(a); } } - if (memberGuid != 0 && memberGuid != owner_.playerGuid && memberGuid != owner_.targetGuid) { - owner_.unitAurasCache_[memberGuid] = std::move(newAuras); + if (memberGuid != 0 && memberGuid != owner_.getPlayerGuid() && memberGuid != owner_.getTargetGuid()) { + owner_.unitAurasCacheRef()[memberGuid] = std::move(newAuras); } } } @@ -1404,7 +1404,7 @@ void SocialHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) member->hasPartyStats = true; - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string unitId; if (partyData.groupType == 1) { for (size_t i = 0; i < partyData.members.size(); ++i) { @@ -1413,15 +1413,15 @@ void SocialHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) } else { int found = 0; for (const auto& m : partyData.members) { - if (m.guid == owner_.playerGuid) continue; + if (m.guid == owner_.getPlayerGuid()) continue; ++found; if (m.guid == memberGuid) { unitId = "party" + std::to_string(found); break; } } } if (!unitId.empty()) { - if (updateFlags & (0x0002 | 0x0004)) owner_.addonEventCallback_("UNIT_HEALTH", {unitId}); - if (updateFlags & (0x0010 | 0x0020)) owner_.addonEventCallback_("UNIT_POWER", {unitId}); - if (updateFlags & 0x0200) owner_.addonEventCallback_("UNIT_AURA", {unitId}); + if (updateFlags & (0x0002 | 0x0004)) owner_.addonEventCallbackRef()("UNIT_HEALTH", {unitId}); + if (updateFlags & (0x0010 | 0x0020)) owner_.addonEventCallbackRef()("UNIT_POWER", {unitId}); + if (updateFlags & 0x0200) owner_.addonEventCallbackRef()("UNIT_AURA", {unitId}); } } } @@ -1441,15 +1441,15 @@ void SocialHandler::handleGuildInfo(network::Packet& packet) { void SocialHandler::handleGuildRoster(network::Packet& packet) { GuildRosterData data; - if (!owner_.packetParsers_->parseGuildRoster(packet, data)) return; + if (!owner_.getPacketParsers()->parseGuildRoster(packet, data)) return; guildRoster_ = std::move(data); hasGuildRoster_ = true; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("GUILD_ROSTER_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("GUILD_ROSTER_UPDATE", {}); } void SocialHandler::handleGuildQueryResponse(network::Packet& packet) { GuildQueryResponseData data; - if (!owner_.packetParsers_->parseGuildQueryResponse(packet, data)) return; + if (!owner_.getPacketParsers()->parseGuildQueryResponse(packet, data)) return; if (data.guildId != 0 && !data.guildName.empty()) { guildNameCache_[data.guildId] = data.guildName; pendingGuildNameQueries_.erase(data.guildId); @@ -1464,7 +1464,7 @@ void SocialHandler::handleGuildQueryResponse(network::Packet& packet) { for (uint32_t i = 0; i < 10; ++i) guildRankNames_.push_back(data.rankNames[i]); if (wasUnknown && !guildName_.empty()) { owner_.addSystemChatMessage("Guild: <" + guildName_ + ">"); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("PLAYER_GUILD_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("PLAYER_GUILD_UPDATE", {}); } } } @@ -1507,7 +1507,7 @@ void SocialHandler::handleGuildEvent(network::Packet& packet) { guildRankNames_.clear(); guildRoster_ = GuildRosterData{}; hasGuildRoster_ = false; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("PLAYER_GUILD_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("PLAYER_GUILD_UPDATE", {}); break; case GuildEvent::SIGNED_ON: if (data.numStrings >= 1) msg = "[Guild] " + data.strings[0] + " has come online."; @@ -1530,17 +1530,17 @@ void SocialHandler::handleGuildEvent(network::Packet& packet) { owner_.addLocalChatMessage(chatMsg); } - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { switch (data.eventType) { case GuildEvent::MOTD: - owner_.addonEventCallback_("GUILD_MOTD", {data.numStrings >= 1 ? data.strings[0] : ""}); + owner_.addonEventCallbackRef()("GUILD_MOTD", {data.numStrings >= 1 ? data.strings[0] : ""}); break; case GuildEvent::SIGNED_ON: case GuildEvent::SIGNED_OFF: case GuildEvent::PROMOTION: case GuildEvent::DEMOTION: case GuildEvent::JOINED: case GuildEvent::LEFT: case GuildEvent::REMOVED: case GuildEvent::LEADER_CHANGED: case GuildEvent::DISBANDED: - owner_.addonEventCallback_("GUILD_ROSTER_UPDATE", {}); + owner_.addonEventCallbackRef()("GUILD_ROSTER_UPDATE", {}); break; default: break; } @@ -1563,8 +1563,8 @@ void SocialHandler::handleGuildInvite(network::Packet& packet) { pendingGuildInviterName_ = data.inviterName; pendingGuildInviteGuildName_ = data.guildName; owner_.addSystemChatMessage(data.inviterName + " has invited you to join " + data.guildName + "."); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("GUILD_INVITE_REQUEST", {data.inviterName, data.guildName}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("GUILD_INVITE_REQUEST", {data.inviterName, data.guildName}); } void SocialHandler::handleGuildCommandResult(network::Packet& packet) { @@ -1758,8 +1758,8 @@ void SocialHandler::handleFriendList(network::Packet& packet) { auto rem = [&]() { return packet.getRemainingSize(); }; if (rem() < 1) return; uint8_t count = packet.readUInt8(); - owner_.contacts_.erase(std::remove_if(owner_.contacts_.begin(), owner_.contacts_.end(), - [](const ContactEntry& e){ return e.isFriend(); }), owner_.contacts_.end()); + owner_.contactsRef().erase(std::remove_if(owner_.contactsRef().begin(), owner_.contactsRef().end(), + [](const ContactEntry& e){ return e.isFriend(); }), owner_.contactsRef().end()); for (uint8_t i = 0; i < count && rem() >= 9; ++i) { uint64_t guid = packet.readUInt64(); uint8_t status = packet.readUInt8(); @@ -1769,30 +1769,30 @@ void SocialHandler::handleFriendList(network::Packet& packet) { level = packet.readUInt32(); classId = packet.readUInt32(); } - owner_.friendGuids_.insert(guid); + owner_.friendGuidsRef().insert(guid); auto nit = owner_.getPlayerNameCache().find(guid); std::string name; if (nit != owner_.getPlayerNameCache().end()) { name = nit->second; - owner_.friendsCache[name] = guid; + owner_.friendsCacheRef()[name] = guid; } else { owner_.queryPlayerName(guid); } ContactEntry entry; entry.guid = guid; entry.name = name; entry.flags = 0x1; entry.status = status; entry.areaId = area; entry.level = level; entry.classId = classId; - owner_.contacts_.push_back(std::move(entry)); + owner_.contactsRef().push_back(std::move(entry)); } - if (owner_.addonEventCallback_) owner_.addonEventCallback_("FRIENDLIST_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("FRIENDLIST_UPDATE", {}); } void SocialHandler::handleContactList(network::Packet& packet) { auto rem = [&]() { return packet.getRemainingSize(); }; if (rem() < 8) { packet.skipAll(); return; } - owner_.lastContactListMask_ = packet.readUInt32(); - owner_.lastContactListCount_ = packet.readUInt32(); - owner_.contacts_.clear(); - for (uint32_t i = 0; i < owner_.lastContactListCount_ && rem() >= 8; ++i) { + owner_.lastContactListMaskRef() = packet.readUInt32(); + owner_.lastContactListCountRef() = packet.readUInt32(); + owner_.contactsRef().clear(); + for (uint32_t i = 0; i < owner_.lastContactListCountRef() && rem() >= 8; ++i) { uint64_t guid = packet.readUInt64(); if (rem() < 4) break; uint32_t flags = packet.readUInt32(); @@ -1804,9 +1804,9 @@ void SocialHandler::handleContactList(network::Packet& packet) { if (status != 0 && rem() >= 12) { areaId = packet.readUInt32(); level = packet.readUInt32(); classId = packet.readUInt32(); } - owner_.friendGuids_.insert(guid); + owner_.friendGuidsRef().insert(guid); auto nit = owner_.getPlayerNameCache().find(guid); - if (nit != owner_.getPlayerNameCache().end()) owner_.friendsCache[nit->second] = guid; + if (nit != owner_.getPlayerNameCache().end()) owner_.friendsCacheRef()[nit->second] = guid; else owner_.queryPlayerName(guid); } ContactEntry entry; @@ -1814,11 +1814,11 @@ void SocialHandler::handleContactList(network::Packet& packet) { entry.status = status; entry.areaId = areaId; entry.level = level; entry.classId = classId; auto nit = owner_.getPlayerNameCache().find(guid); if (nit != owner_.getPlayerNameCache().end()) entry.name = nit->second; - owner_.contacts_.push_back(std::move(entry)); + owner_.contactsRef().push_back(std::move(entry)); } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("FRIENDLIST_UPDATE", {}); - if (owner_.lastContactListMask_ & 0x2) owner_.addonEventCallback_("IGNORELIST_UPDATE", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("FRIENDLIST_UPDATE", {}); + if (owner_.lastContactListMaskRef() & 0x2) owner_.addonEventCallbackRef()("IGNORELIST_UPDATE", {}); } } @@ -1827,12 +1827,12 @@ void SocialHandler::handleFriendStatus(network::Packet& packet) { if (!FriendStatusParser::parse(packet, data)) return; // Single lookup — reuse iterator for name resolution and update/erase below - auto cit = std::find_if(owner_.contacts_.begin(), owner_.contacts_.end(), + auto cit = std::find_if(owner_.contactsRef().begin(), owner_.contactsRef().end(), [&](const ContactEntry& e){ return e.guid == data.guid; }); // Look up player name: contacts_ (populated by SMSG_FRIEND_LIST) > playerNameCache std::string playerName; - if (cit != owner_.contacts_.end() && !cit->name.empty()) { + if (cit != owner_.contactsRef().end() && !cit->name.empty()) { playerName = cit->name; } else { auto it = owner_.getPlayerNameCache().find(data.guid); @@ -1842,22 +1842,22 @@ void SocialHandler::handleFriendStatus(network::Packet& packet) { // Only update friendsCache when we have a resolved name — inserting an empty // key creates a phantom entry that masks the real one when the name arrives. if (!playerName.empty()) { - if (data.status == 1 || data.status == 2) owner_.friendsCache[playerName] = data.guid; - else if (data.status == 0) owner_.friendsCache.erase(playerName); + if (data.status == 1 || data.status == 2) owner_.friendsCacheRef()[playerName] = data.guid; + else if (data.status == 0) owner_.friendsCacheRef().erase(playerName); } if (data.status == 0) { - if (cit != owner_.contacts_.end()) - owner_.contacts_.erase(cit); + if (cit != owner_.contactsRef().end()) + owner_.contactsRef().erase(cit); } else { - if (cit != owner_.contacts_.end()) { + if (cit != owner_.contactsRef().end()) { if (!playerName.empty() && playerName != "Unknown") cit->name = playerName; 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; entry.status = (data.status == 2) ? 1 : 0; - owner_.contacts_.push_back(std::move(entry)); + owner_.contactsRef().push_back(std::move(entry)); } } switch (data.status) { @@ -1871,18 +1871,18 @@ void SocialHandler::handleFriendStatus(network::Packet& packet) { case 7: owner_.addSystemChatMessage(playerName + " is ignoring you."); break; default: break; } - if (owner_.addonEventCallback_) owner_.addonEventCallback_("FRIENDLIST_UPDATE", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("FRIENDLIST_UPDATE", {}); } void SocialHandler::handleRandomRoll(network::Packet& packet) { RandomRollData data; if (!RandomRollParser::parse(packet, data)) return; - std::string rollerName = (data.rollerGuid == owner_.playerGuid) ? "You" : "Someone"; - if (data.rollerGuid != owner_.playerGuid) { + std::string rollerName = (data.rollerGuid == owner_.getPlayerGuid()) ? "You" : "Someone"; + if (data.rollerGuid != owner_.getPlayerGuid()) { auto it = owner_.getPlayerNameCache().find(data.rollerGuid); if (it != owner_.getPlayerNameCache().end()) rollerName = it->second; } - std::string msg = rollerName + ((data.rollerGuid == owner_.playerGuid) ? " roll " : " rolls "); + std::string msg = rollerName + ((data.rollerGuid == owner_.getPlayerGuid()) ? " roll " : " rolls "); msg += std::to_string(data.result) + " (" + std::to_string(data.minRoll) + "-" + std::to_string(data.maxRoll) + ")"; owner_.addSystemChatMessage(msg); } @@ -1897,7 +1897,7 @@ void SocialHandler::handleLogoutResponse(network::Packet& packet) { if (data.result == 0) { if (data.instant) { owner_.addSystemChatMessage("Logging out..."); logoutCountdown_ = 0.0f; } else { owner_.addSystemChatMessage("Logging out in 20 seconds..."); logoutCountdown_ = 20.0f; } - if (owner_.addonEventCallback_) owner_.addonEventCallback_("PLAYER_LOGOUT", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("PLAYER_LOGOUT", {}); } else { owner_.addSystemChatMessage("Cannot logout right now."); loggingOut_ = false; logoutCountdown_ = 0.0f; @@ -1978,7 +1978,7 @@ void SocialHandler::handleBattlefieldStatus(network::Packet& packet) { case 3: owner_.addSystemChatMessage("Entered " + bgName + "."); break; default: break; } - if (owner_.addonEventCallback_) owner_.addonEventCallback_("UPDATE_BATTLEFIELD_STATUS", {std::to_string(statusId)}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("UPDATE_BATTLEFIELD_STATUS", {std::to_string(statusId)}); } void SocialHandler::handleBattlefieldList(network::Packet& packet) { @@ -2008,7 +2008,7 @@ bool SocialHandler::hasPendingBgInvite() const { } void SocialHandler::acceptBattlefield(uint32_t queueSlot) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; const BgQueueSlot* slot = nullptr; if (queueSlot == 0xFFFFFFFF) { for (const auto& s : bgQueues_) { if (s.statusId == 2) { slot = &s; break; } } } else if (queueSlot < bgQueues_.size() && bgQueues_[queueSlot].statusId == 2) slot = &bgQueues_[queueSlot]; @@ -2016,14 +2016,14 @@ void SocialHandler::acceptBattlefield(uint32_t queueSlot) { network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT)); pkt.writeUInt8(slot->arenaType); pkt.writeUInt8(0x00); pkt.writeUInt32(slot->bgTypeId); pkt.writeUInt16(0x0000); pkt.writeUInt8(1); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); uint32_t clearSlot = slot->queueSlot; if (clearSlot < bgQueues_.size()) bgQueues_[clearSlot].statusId = 3; owner_.addSystemChatMessage("Accepting battleground invitation..."); } void SocialHandler::declineBattlefield(uint32_t queueSlot) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; const BgQueueSlot* slot = nullptr; if (queueSlot == 0xFFFFFFFF) { for (const auto& s : bgQueues_) { if (s.statusId == 2) { slot = &s; break; } } } else if (queueSlot < bgQueues_.size() && bgQueues_[queueSlot].statusId == 2) slot = &bgQueues_[queueSlot]; @@ -2031,16 +2031,16 @@ void SocialHandler::declineBattlefield(uint32_t queueSlot) { network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_PORT)); pkt.writeUInt8(slot->arenaType); pkt.writeUInt8(0x00); pkt.writeUInt32(slot->bgTypeId); pkt.writeUInt16(0x0000); pkt.writeUInt8(0); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); uint32_t clearSlot = slot->queueSlot; if (clearSlot < bgQueues_.size()) bgQueues_[clearSlot] = BgQueueSlot{}; owner_.addSystemChatMessage("Battleground invitation declined."); } void SocialHandler::requestPvpLog() { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::MSG_PVP_LOG_DATA)); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } // ============================================================ @@ -2247,51 +2247,51 @@ void SocialHandler::handleLfgTeleportDenied(network::Packet& packet) { // ============================================================ void SocialHandler::lfgJoin(uint32_t dungeonId, uint8_t roles) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_JOIN)); pkt.writeUInt8(roles); pkt.writeUInt8(0); pkt.writeUInt8(0); pkt.writeUInt8(1); pkt.writeUInt32(dungeonId); pkt.writeString(""); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void SocialHandler::lfgLeave() { - if (!owner_.socket) return; + if (!owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_LEAVE)); pkt.writeUInt32(0); pkt.writeUInt32(0); pkt.writeUInt32(0); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); lfgState_ = LfgState::None; } void SocialHandler::lfgSetRoles(uint8_t roles) { - if (owner_.getState() != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; const uint32_t wire = wireOpcode(Opcode::CMSG_LFG_SET_ROLES); if (wire == 0xFFFF) return; network::Packet pkt(static_cast(wire)); pkt.writeUInt8(roles); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void SocialHandler::lfgAcceptProposal(uint32_t proposalId, bool accept) { - if (!owner_.socket) return; + if (!owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_PROPOSAL_RESULT)); pkt.writeUInt32(proposalId); pkt.writeUInt8(accept ? 1 : 0); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void SocialHandler::lfgTeleport(bool toLfgDungeon) { - if (!owner_.socket) return; + if (!owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_LFG_TELEPORT)); pkt.writeUInt8(toLfgDungeon ? 0 : 1); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void SocialHandler::lfgSetBootVote(bool vote) { - if (!owner_.socket) return; + if (!owner_.getSocket()) return; uint16_t wireOp = wireOpcode(Opcode::CMSG_LFG_SET_BOOT_VOTE); if (wireOp == 0xFFFF) return; network::Packet pkt(wireOp); pkt.writeUInt8(vote ? 1 : 0); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } // ============================================================ @@ -2381,10 +2381,10 @@ void SocialHandler::handleArenaTeamStats(network::Packet& packet) { } void SocialHandler::requestArenaTeamRoster(uint32_t teamId) { - if (!owner_.socket) return; + if (!owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_ARENA_TEAM_ROSTER)); pkt.writeUInt32(teamId); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); } void SocialHandler::handleArenaError(network::Packet& packet) { @@ -2463,13 +2463,13 @@ void SocialHandler::handleInitializeFactions(network::Packet& packet) { uint32_t count = packet.readUInt32(); size_t needed = static_cast(count) * 5; if (!packet.hasRemaining(needed)) { packet.skipAll(); return; } - owner_.initialFactions_.clear(); - owner_.initialFactions_.reserve(count); + owner_.initialFactionsRef().clear(); + owner_.initialFactionsRef().reserve(count); for (uint32_t i = 0; i < count; ++i) { GameHandler::FactionStandingInit fs{}; fs.flags = packet.readUInt8(); fs.standing = static_cast(packet.readUInt32()); - owner_.initialFactions_.push_back(fs); + owner_.initialFactionsRef().push_back(fs); } } @@ -2483,9 +2483,9 @@ void SocialHandler::handleSetFactionStanding(network::Packet& packet) { uint32_t factionId = packet.readUInt32(); int32_t standing = static_cast(packet.readUInt32()); int32_t oldStanding = 0; - auto it = owner_.factionStandings_.find(factionId); - if (it != owner_.factionStandings_.end()) oldStanding = it->second; - owner_.factionStandings_[factionId] = standing; + auto it = owner_.factionStandingsRef().find(factionId); + if (it != owner_.factionStandingsRef().end()) oldStanding = it->second; + owner_.factionStandingsRef()[factionId] = standing; int32_t delta = standing - oldStanding; if (delta != 0) { std::string name = owner_.getFactionName(factionId); @@ -2493,8 +2493,8 @@ void SocialHandler::handleSetFactionStanding(network::Packet& packet) { std::snprintf(buf, sizeof(buf), "Reputation with %s %s by %d.", name.c_str(), delta > 0 ? "increased" : "decreased", std::abs(delta)); owner_.addSystemChatMessage(buf); - owner_.watchedFactionId_ = factionId; - if (owner_.repChangeCallback_) owner_.repChangeCallback_(name, delta, standing); + owner_.watchedFactionIdRef() = factionId; + if (owner_.repChangeCallbackRef()) owner_.repChangeCallbackRef()(name, delta, standing); // These events fire unconditionally on any rep change (not gated by callback). owner_.fireAddonEvent("UPDATE_FACTION", {}); owner_.fireAddonEvent("CHAT_MSG_COMBAT_FACTION_CHANGE", {std::string(buf)}); @@ -2506,11 +2506,11 @@ void SocialHandler::handleSetFactionAtWar(network::Packet& packet) { if (!packet.hasRemaining(5)) { packet.skipAll(); return; } uint32_t repListId = packet.readUInt32(); uint8_t setAtWar = packet.readUInt8(); - if (repListId < owner_.initialFactions_.size()) { + if (repListId < owner_.initialFactionsRef().size()) { if (setAtWar) - owner_.initialFactions_[repListId].flags |= GameHandler::FACTION_FLAG_AT_WAR; + owner_.initialFactionsRef()[repListId].flags |= GameHandler::FACTION_FLAG_AT_WAR; else - owner_.initialFactions_[repListId].flags &= ~GameHandler::FACTION_FLAG_AT_WAR; + owner_.initialFactionsRef()[repListId].flags &= ~GameHandler::FACTION_FLAG_AT_WAR; } } @@ -2518,11 +2518,11 @@ void SocialHandler::handleSetFactionVisible(network::Packet& packet) { if (!packet.hasRemaining(5)) { packet.skipAll(); return; } uint32_t repListId = packet.readUInt32(); uint8_t visible = packet.readUInt8(); - if (repListId < owner_.initialFactions_.size()) { + if (repListId < owner_.initialFactionsRef().size()) { if (visible) - owner_.initialFactions_[repListId].flags |= GameHandler::FACTION_FLAG_VISIBLE; + owner_.initialFactionsRef()[repListId].flags |= GameHandler::FACTION_FLAG_VISIBLE; else - owner_.initialFactions_[repListId].flags &= ~GameHandler::FACTION_FLAG_VISIBLE; + owner_.initialFactionsRef()[repListId].flags &= ~GameHandler::FACTION_FLAG_VISIBLE; } } @@ -2544,7 +2544,7 @@ void SocialHandler::handleGroupSetLeader(network::Packet& packet) { // ============================================================ void SocialHandler::sendMinimapPing(float wowX, float wowY) { - if (owner_.state != WorldState::IN_WORLD) return; + if (owner_.getState() != WorldState::IN_WORLD) return; // MSG_MINIMAP_PING (CMSG direction): float posX + float posY // Server convention: posX = east/west axis = canonical Y (west) @@ -2555,15 +2555,15 @@ void SocialHandler::sendMinimapPing(float wowX, float wowY) { network::Packet pkt(wireOpcode(Opcode::MSG_MINIMAP_PING)); pkt.writeFloat(serverX); pkt.writeFloat(serverY); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); // Add ping locally so the sender sees their own ping immediately GameHandler::MinimapPing localPing; - localPing.senderGuid = owner_.activeCharacterGuid_; + localPing.senderGuid = owner_.activeCharacterGuidRef(); localPing.wowX = wowX; localPing.wowY = wowY; localPing.age = 0.0f; - owner_.minimapPings_.push_back(localPing); + owner_.minimapPingsRef().push_back(localPing); } // ============================================================ @@ -2573,53 +2573,53 @@ void SocialHandler::sendMinimapPing(float wowX, float wowY) { void SocialHandler::handleSummonRequest(network::Packet& packet) { if (!packet.hasRemaining(16)) return; - owner_.summonerGuid_ = packet.readUInt64(); + owner_.summonerGuidRef() = packet.readUInt64(); uint32_t zoneId = packet.readUInt32(); uint32_t timeoutMs = packet.readUInt32(); - owner_.summonTimeoutSec_ = timeoutMs / 1000.0f; - owner_.pendingSummonRequest_= true; + owner_.summonTimeoutSecRef() = timeoutMs / 1000.0f; + owner_.pendingSummonRequestRef()= true; - owner_.summonerName_.clear(); - if (auto* unit = owner_.getUnitByGuid(owner_.summonerGuid_)) { - owner_.summonerName_ = unit->getName(); + owner_.summonerNameRef().clear(); + if (auto* unit = owner_.getUnitByGuid(owner_.summonerGuidRef())) { + owner_.summonerNameRef() = unit->getName(); } - if (owner_.summonerName_.empty()) { - owner_.summonerName_ = owner_.lookupName(owner_.summonerGuid_); + if (owner_.summonerNameRef().empty()) { + owner_.summonerNameRef() = owner_.lookupName(owner_.summonerGuidRef()); } - if (owner_.summonerName_.empty()) { + if (owner_.summonerNameRef().empty()) { char tmp[32]; std::snprintf(tmp, sizeof(tmp), "0x%llX", - static_cast(owner_.summonerGuid_)); - owner_.summonerName_ = tmp; + static_cast(owner_.summonerGuidRef())); + owner_.summonerNameRef() = tmp; } - std::string msg = owner_.summonerName_ + " is summoning you"; + std::string msg = owner_.summonerNameRef() + " is summoning you"; std::string zoneName = owner_.getAreaName(zoneId); if (!zoneName.empty()) msg += " to " + zoneName; msg += '.'; owner_.addSystemChatMessage(msg); - LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", owner_.summonerName_, - " zoneId=", zoneId, " timeout=", owner_.summonTimeoutSec_, "s"); + LOG_INFO("SMSG_SUMMON_REQUEST: summoner=", owner_.summonerNameRef(), + " zoneId=", zoneId, " timeout=", owner_.summonTimeoutSecRef(), "s"); owner_.fireAddonEvent("CONFIRM_SUMMON", {}); } void SocialHandler::acceptSummon() { - if (!owner_.pendingSummonRequest_ || !owner_.socket) return; - owner_.pendingSummonRequest_ = false; + if (!owner_.pendingSummonRequestRef() || !owner_.getSocket()) return; + owner_.pendingSummonRequestRef() = false; network::Packet pkt(wireOpcode(Opcode::CMSG_SUMMON_RESPONSE)); pkt.writeUInt8(1); // 1 = accept - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); owner_.addSystemChatMessage("Accepting summon..."); - LOG_INFO("Accepted summon from ", owner_.summonerName_); + LOG_INFO("Accepted summon from ", owner_.summonerNameRef()); } void SocialHandler::declineSummon() { - if (!owner_.socket) return; - owner_.pendingSummonRequest_ = false; + if (!owner_.getSocket()) return; + owner_.pendingSummonRequestRef() = false; network::Packet pkt(wireOpcode(Opcode::CMSG_SUMMON_RESPONSE)); pkt.writeUInt8(0); // 0 = decline - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); owner_.addSystemChatMessage("Summon declined."); } @@ -2628,22 +2628,22 @@ void SocialHandler::declineSummon() { // ============================================================ void SocialHandler::acceptBfMgrInvite() { - if (!owner_.bfMgrInvitePending_ || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (!owner_.bfMgrInvitePendingRef() || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 1 network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); pkt.writeUInt8(1); // accepted - owner_.socket->send(pkt); - owner_.bfMgrInvitePending_ = false; + owner_.getSocket()->send(pkt); + owner_.bfMgrInvitePendingRef() = false; LOG_INFO("acceptBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=1"); } void SocialHandler::declineBfMgrInvite() { - if (!owner_.bfMgrInvitePending_ || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (!owner_.bfMgrInvitePendingRef() || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 0 network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); pkt.writeUInt8(0); // declined - owner_.socket->send(pkt); - owner_.bfMgrInvitePending_ = false; + owner_.getSocket()->send(pkt); + owner_.bfMgrInvitePendingRef() = false; LOG_INFO("declineBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=0"); } @@ -2655,11 +2655,11 @@ void SocialHandler::requestCalendar() { if (!owner_.isInWorld()) return; // CMSG_CALENDAR_GET_CALENDAR has no payload network::Packet pkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_CALENDAR)); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("requestCalendar: sent CMSG_CALENDAR_GET_CALENDAR"); // Also request pending invite count network::Packet numPkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_NUM_PENDING)); - owner_.socket->send(numPkt); + owner_.getSocket()->send(numPkt); } // ============================================================ @@ -2674,7 +2674,7 @@ void SocialHandler::sendSetDifficulty(uint32_t difficulty) { network::Packet packet(wireOpcode(Opcode::CMSG_CHANGEPLAYER_DIFFICULTY)); packet.writeUInt32(difficulty); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("CMSG_CHANGEPLAYER_DIFFICULTY sent: difficulty=", difficulty); } @@ -2684,11 +2684,11 @@ void SocialHandler::toggleHelm() { return; } - owner_.helmVisible_ = !owner_.helmVisible_; - auto packet = ShowingHelmPacket::build(owner_.helmVisible_); - owner_.socket->send(packet); - owner_.addSystemChatMessage(owner_.helmVisible_ ? "Helm is now visible." : "Helm is now hidden."); - LOG_INFO("Helm visibility toggled: ", owner_.helmVisible_); + owner_.helmVisibleRef() = !owner_.helmVisibleRef(); + auto packet = ShowingHelmPacket::build(owner_.helmVisibleRef()); + owner_.getSocket()->send(packet); + owner_.addSystemChatMessage(owner_.helmVisibleRef() ? "Helm is now visible." : "Helm is now hidden."); + LOG_INFO("Helm visibility toggled: ", owner_.helmVisibleRef()); } void SocialHandler::toggleCloak() { @@ -2697,11 +2697,11 @@ void SocialHandler::toggleCloak() { return; } - owner_.cloakVisible_ = !owner_.cloakVisible_; - auto packet = ShowingCloakPacket::build(owner_.cloakVisible_); - owner_.socket->send(packet); - owner_.addSystemChatMessage(owner_.cloakVisible_ ? "Cloak is now visible." : "Cloak is now hidden."); - LOG_INFO("Cloak visibility toggled: ", owner_.cloakVisible_); + owner_.cloakVisibleRef() = !owner_.cloakVisibleRef(); + auto packet = ShowingCloakPacket::build(owner_.cloakVisibleRef()); + owner_.getSocket()->send(packet); + owner_.addSystemChatMessage(owner_.cloakVisibleRef() ? "Cloak is now visible." : "Cloak is now hidden."); + LOG_INFO("Cloak visibility toggled: ", owner_.cloakVisibleRef()); } void SocialHandler::setStandState(uint8_t standState) { @@ -2711,23 +2711,23 @@ void SocialHandler::setStandState(uint8_t standState) { } auto packet = StandStateChangePacket::build(standState); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Changed stand state to: ", static_cast(standState)); } void SocialHandler::sendAlterAppearance(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { if (!owner_.isInWorld()) return; auto pkt = AlterAppearancePacket::build(hairStyle, hairColor, facialHair); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("sendAlterAppearance: hair=", hairStyle, " color=", hairColor, " facial=", facialHair); } void SocialHandler::deleteGmTicket() { if (!owner_.isInWorld()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET)); - owner_.socket->send(pkt); - owner_.gmTicketActive_ = false; - owner_.gmTicketText_.clear(); + owner_.getSocket()->send(pkt); + owner_.gmTicketActiveRef() = false; + owner_.gmTicketTextRef().clear(); LOG_INFO("Deleting GM ticket"); } @@ -2735,7 +2735,7 @@ void SocialHandler::requestGmTicket() { if (!owner_.isInWorld()) return; // CMSG_GMTICKET_GETTICKET has no payload — server responds with SMSG_GMTICKET_GETTICKET network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_GETTICKET)); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_DEBUG("Sent CMSG_GMTICKET_GETTICKET — querying open ticket status"); } diff --git a/src/game/spell_handler.cpp b/src/game/spell_handler.cpp index f458a610..fd2823ec 100644 --- a/src/game/spell_handler.cpp +++ b/src/game/spell_handler.cpp @@ -4,6 +4,7 @@ #include "game/packet_parsers.hpp" #include "game/entity.hpp" #include "rendering/renderer.hpp" +#include "rendering/spell_visual_system.hpp" #include "audio/audio_coordinator.hpp" #include "audio/spell_sound_manager.hpp" #include "audio/combat_sound_manager.hpp" @@ -159,31 +160,31 @@ void SpellHandler::registerOpcodes(DispatchTable& table) { // ============================================================ bool SpellHandler::isGameObjectInteractionCasting() const { - return casting_ && currentCastSpellId_ == 0 && owner_.pendingGameObjectInteractGuid_ != 0; + return casting_ && currentCastSpellId_ == 0 && owner_.pendingGameObjectInteractGuidRef() != 0; } bool SpellHandler::isTargetCasting() const { - return getUnitCastState(owner_.targetGuid) != nullptr; + return getUnitCastState(owner_.getTargetGuid()) != nullptr; } uint32_t SpellHandler::getTargetCastSpellId() const { - auto* s = getUnitCastState(owner_.targetGuid); + auto* s = getUnitCastState(owner_.getTargetGuid()); return s ? s->spellId : 0; } float SpellHandler::getTargetCastProgress() const { - auto* s = getUnitCastState(owner_.targetGuid); + auto* s = getUnitCastState(owner_.getTargetGuid()); return (s && s->timeTotal > 0.0f) ? (s->timeTotal - s->timeRemaining) / s->timeTotal : 0.0f; } float SpellHandler::getTargetCastTimeRemaining() const { - auto* s = getUnitCastState(owner_.targetGuid); + auto* s = getUnitCastState(owner_.getTargetGuid()); return s ? s->timeRemaining : 0.0f; } bool SpellHandler::isTargetCastInterruptible() const { - auto* s = getUnitCastState(owner_.targetGuid); + auto* s = getUnitCastState(owner_.getTargetGuid()); return s ? s->interruptible : true; } @@ -191,7 +192,7 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { LOG_DEBUG("castSpell: spellId=", spellId, " target=0x", std::hex, targetGuid, std::dec); // Attack (6603) routes to auto-attack instead of cast if (spellId == 6603) { - uint64_t target = targetGuid != 0 ? targetGuid : owner_.targetGuid; + uint64_t target = targetGuid != 0 ? targetGuid : owner_.getTargetGuid(); if (target != 0) { if (owner_.isAutoAttacking()) { owner_.stopAutoAttack(); @@ -202,7 +203,7 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { return; } - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; // Casting any spell while mounted → dismount instead if (owner_.isMounted()) { @@ -215,7 +216,7 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { // store the spell so it fires automatically when the cast finishes. if (!castIsChannel_ && castTimeRemaining_ > 0.0f && castTimeRemaining_ <= 0.4f) { queuedSpellId_ = spellId; - queuedSpellTarget_ = targetGuid != 0 ? targetGuid : owner_.targetGuid; + queuedSpellTarget_ = targetGuid != 0 ? targetGuid : owner_.getTargetGuid(); LOG_INFO("Spell queue: queued spellId=", spellId, " (", castTimeRemaining_ * 1000.0f, "ms remaining)"); } @@ -223,14 +224,14 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { } // Stop movement before casting — servers reject cast-time spells while moving - const uint32_t moveFlags = owner_.movementInfo.flags; + const uint32_t moveFlags = owner_.movementInfoRef().flags; const bool isMoving = (moveFlags & 0x0Fu) != 0; // FORWARD|BACKWARD|STRAFE_LEFT|STRAFE_RIGHT if (isMoving) { - owner_.movementInfo.flags &= ~0x0Fu; + owner_.movementInfoRef().flags &= ~0x0Fu; owner_.sendMovement(Opcode::MSG_MOVE_STOP); } - uint64_t target = targetGuid != 0 ? targetGuid : owner_.targetGuid; + uint64_t target = targetGuid != 0 ? targetGuid : owner_.getTargetGuid(); // Self-targeted spells like hearthstone should not send a target if (spellId == 8690) target = 0; @@ -250,9 +251,9 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { return; } float tx = entity->getX(), ty = entity->getY(), tz = entity->getZ(); - float dx = tx - owner_.movementInfo.x; - float dy = ty - owner_.movementInfo.y; - float dz = tz - owner_.movementInfo.z; + float dx = tx - owner_.movementInfoRef().x; + float dy = ty - owner_.movementInfoRef().y; + float dz = tz - owner_.movementInfoRef().z; float dist = std::sqrt(dx * dx + dy * dy + dz * dz); if (dist < 8.0f) { owner_.addSystemChatMessage("Target is too close."); @@ -263,10 +264,10 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { return; } float yaw = std::atan2(-dy, dx); - owner_.movementInfo.orientation = yaw; + owner_.movementInfoRef().orientation = yaw; owner_.sendMovement(Opcode::MSG_MOVE_SET_FACING); - if (owner_.chargeCallback_) { - owner_.chargeCallback_(target, tx, ty, tz); + if (owner_.chargeCallbackRef()) { + owner_.chargeCallbackRef()(target, tx, ty, tz); } facingHandled = true; } @@ -274,21 +275,21 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { // Instant melee abilities: client-side range + facing check if (!facingHandled) { owner_.loadSpellNameCache(); - auto cacheIt = owner_.spellNameCache_.find(spellId); - bool isMeleeAbility = (cacheIt != owner_.spellNameCache_.end() && cacheIt->second.schoolMask == 1); + auto cacheIt = owner_.spellNameCacheRef().find(spellId); + bool isMeleeAbility = (cacheIt != owner_.spellNameCacheRef().end() && cacheIt->second.schoolMask == 1); if (isMeleeAbility && target != 0) { auto entity = owner_.getEntityManager().getEntity(target); if (entity) { - float dx = entity->getX() - owner_.movementInfo.x; - float dy = entity->getY() - owner_.movementInfo.y; - float dz = entity->getZ() - owner_.movementInfo.z; + float dx = entity->getX() - owner_.movementInfoRef().x; + float dy = entity->getY() - owner_.movementInfoRef().y; + float dz = entity->getZ() - owner_.movementInfoRef().z; float dist = std::sqrt(dx * dx + dy * dy + dz * dz); if (dist > 8.0f) { owner_.addSystemChatMessage("Out of range."); return; } float yaw = std::atan2(-dy, dx); - owner_.movementInfo.orientation = yaw; + owner_.movementInfoRef().orientation = yaw; owner_.sendMovement(Opcode::MSG_MOVE_SET_FACING); facingHandled = true; } @@ -301,12 +302,12 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { if (!facingHandled && target != 0) { auto entity = owner_.getEntityManager().getEntity(target); if (entity) { - float dx = entity->getX() - owner_.movementInfo.x; - float dy = entity->getY() - owner_.movementInfo.y; + float dx = entity->getX() - owner_.movementInfoRef().x; + float dy = entity->getY() - owner_.movementInfoRef().y; float lenSq = dx * dx + dy * dy; if (lenSq > 0.01f) { float canonYaw = std::atan2(-dy, dx); - owner_.movementInfo.orientation = canonYaw; + owner_.movementInfoRef().orientation = canonYaw; owner_.sendMovement(Opcode::MSG_MOVE_SET_FACING); } } @@ -316,19 +317,19 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { owner_.sendMovement(Opcode::MSG_MOVE_HEARTBEAT); } - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildCastSpell(spellId, target, ++castCount_) + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildCastSpell(spellId, target, ++castCount_) : CastSpellPacket::build(spellId, target, ++castCount_); LOG_DEBUG("CMSG_CAST_SPELL: spellId=", spellId, " target=0x", std::hex, target, std::dec, " castCount=", static_cast(castCount_), " packetSize=", packet.getSize()); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); LOG_INFO("Casting spell: ", spellId, " on 0x", std::hex, target, std::dec); // Fire UNIT_SPELLCAST_SENT for cast bar addons - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string targetName; if (target != 0) targetName = owner_.lookupName(target); - owner_.addonEventCallback_("UNIT_SPELLCAST_SENT", {"player", targetName, std::to_string(spellId)}); + owner_.addonEventCallbackRef()("UNIT_SPELLCAST_SENT", {"player", targetName, std::to_string(spellId)}); } // Optimistically start GCD immediately on cast @@ -341,14 +342,14 @@ void SpellHandler::castSpell(uint32_t spellId, uint64_t targetGuid) { void SpellHandler::cancelCast() { if (!casting_) return; // GameObject interaction cast is client-side timing only. - if (owner_.pendingGameObjectInteractGuid_ == 0 && - owner_.state == WorldState::IN_WORLD && owner_.socket && + if (owner_.pendingGameObjectInteractGuidRef() == 0 && + owner_.getState() == WorldState::IN_WORLD && owner_.getSocket() && currentCastSpellId_ != 0) { auto packet = CancelCastPacket::build(currentCastSpellId_); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } - owner_.pendingGameObjectInteractGuid_ = 0; - owner_.lastInteractedGoGuid_ = 0; + owner_.pendingGameObjectInteractGuidRef() = 0; + owner_.lastInteractedGoGuidRef() = 0; casting_ = false; castIsChannel_ = false; currentCastSpellId_ = 0; @@ -357,8 +358,8 @@ void SpellHandler::cancelCast() { craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("UNIT_SPELLCAST_STOP", {"player"}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("UNIT_SPELLCAST_STOP", {"player"}); } void SpellHandler::startCraftQueue(uint32_t spellId, int count) { @@ -373,9 +374,9 @@ void SpellHandler::cancelCraftQueue() { } void SpellHandler::cancelAura(uint32_t spellId) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; auto packet = CancelAuraPacket::build(spellId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } float SpellHandler::getSpellCooldown(uint32_t spellId) const { @@ -384,7 +385,7 @@ float SpellHandler::getSpellCooldown(uint32_t spellId) const { } void SpellHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) { + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) { LOG_WARNING("learnTalent: Not in world or no socket connection"); return; } @@ -392,7 +393,7 @@ void SpellHandler::learnTalent(uint32_t talentId, uint32_t requestedRank) { LOG_INFO("Requesting to learn talent: id=", talentId, " rank=", requestedRank); auto packet = LearnTalentPacket::build(talentId, requestedRank); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } void SpellHandler::switchTalentSpec(uint8_t newSpec) { @@ -406,9 +407,9 @@ void SpellHandler::switchTalentSpec(uint8_t newSpec) { return; } - if (owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { auto pkt = ActivateTalentGroupPacket::build(static_cast(newSpec)); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("Sent CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE: group=", (int)newSpec); } activeTalentSpec_ = newSpec; @@ -430,11 +431,11 @@ void SpellHandler::confirmTalentWipe() { if (!talentWipePending_) return; talentWipePending_ = false; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM)); pkt.writeUInt64(talentWipeNpcGuid_); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("confirmTalentWipe: sent confirm for npc=0x", std::hex, talentWipeNpcGuid_, std::dec); owner_.addSystemChatMessage("Talent reset confirmed. The server will update your talents."); @@ -445,10 +446,10 @@ void SpellHandler::confirmTalentWipe() { void SpellHandler::confirmPetUnlearn() { if (!petUnlearnPending_) return; petUnlearnPending_ = false; - if (owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; network::Packet pkt(wireOpcode(Opcode::CMSG_PET_UNLEARN_TALENTS)); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_INFO("confirmPetUnlearn: sent CMSG_PET_UNLEARN_TALENTS"); owner_.addSystemChatMessage("Pet talent reset confirmed."); petUnlearnGuid_ = 0; @@ -468,37 +469,37 @@ uint32_t SpellHandler::findOnUseSpellId(uint32_t itemId) const { } void SpellHandler::useItemBySlot(int backpackIndex) { - if (backpackIndex < 0 || backpackIndex >= owner_.inventory.getBackpackSize()) return; - const auto& slot = owner_.inventory.getBackpackSlot(backpackIndex); + if (backpackIndex < 0 || backpackIndex >= owner_.inventoryRef().getBackpackSize()) return; + const auto& slot = owner_.inventoryRef().getBackpackSlot(backpackIndex); if (slot.empty()) return; - uint64_t itemGuid = owner_.backpackSlotGuids_[backpackIndex]; + uint64_t itemGuid = owner_.backpackSlotGuidsRef()[backpackIndex]; if (itemGuid == 0) { itemGuid = owner_.resolveOnlineItemGuid(slot.item.itemId); } - if (itemGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (itemGuid != 0 && owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { uint32_t useSpellId = findOnUseSpellId(slot.item.itemId); - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildUseItem(0xFF, static_cast(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId) + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildUseItem(0xFF, static_cast(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId) : UseItemPacket::build(0xFF, static_cast(Inventory::NUM_EQUIP_SLOTS + backpackIndex), itemGuid, useSpellId); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } else if (itemGuid == 0) { owner_.addSystemChatMessage("Cannot use that item right now."); } } void SpellHandler::useItemInBag(int bagIndex, int slotIndex) { - if (bagIndex < 0 || bagIndex >= owner_.inventory.NUM_BAG_SLOTS) return; - if (slotIndex < 0 || slotIndex >= owner_.inventory.getBagSize(bagIndex)) return; - const auto& slot = owner_.inventory.getBagSlot(bagIndex, slotIndex); + if (bagIndex < 0 || bagIndex >= owner_.inventoryRef().NUM_BAG_SLOTS) return; + if (slotIndex < 0 || slotIndex >= owner_.inventoryRef().getBagSize(bagIndex)) return; + const auto& slot = owner_.inventoryRef().getBagSlot(bagIndex, slotIndex); if (slot.empty()) return; uint64_t itemGuid = 0; - uint64_t bagGuid = owner_.equipSlotGuids_[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; + uint64_t bagGuid = owner_.equipSlotGuidsRef()[Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex]; if (bagGuid != 0) { - auto it = owner_.containerContents_.find(bagGuid); - if (it != owner_.containerContents_.end() && slotIndex < static_cast(it->second.numSlots)) { + auto it = owner_.containerContentsRef().find(bagGuid); + if (it != owner_.containerContentsRef().end() && slotIndex < static_cast(it->second.numSlots)) { itemGuid = it->second.slotGuids[slotIndex]; } } @@ -509,15 +510,15 @@ void SpellHandler::useItemInBag(int bagIndex, int slotIndex) { LOG_INFO("useItemInBag: bag=", bagIndex, " slot=", slotIndex, " itemId=", slot.item.itemId, " itemGuid=0x", std::hex, itemGuid, std::dec); - if (itemGuid != 0 && owner_.state == WorldState::IN_WORLD && owner_.socket) { + if (itemGuid != 0 && owner_.getState() == WorldState::IN_WORLD && owner_.getSocket()) { uint32_t useSpellId = findOnUseSpellId(slot.item.itemId); uint8_t wowBag = static_cast(Inventory::FIRST_BAG_EQUIP_SLOT + bagIndex); - auto packet = owner_.packetParsers_ - ? owner_.packetParsers_->buildUseItem(wowBag, static_cast(slotIndex), itemGuid, useSpellId) + auto packet = owner_.getPacketParsers() + ? owner_.getPacketParsers()->buildUseItem(wowBag, static_cast(slotIndex), itemGuid, useSpellId) : UseItemPacket::build(wowBag, static_cast(slotIndex), itemGuid, useSpellId); LOG_INFO("useItemInBag: sending CMSG_USE_ITEM, bag=", (int)wowBag, " slot=", slotIndex, " packetSize=", packet.getSize()); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } else if (itemGuid == 0) { LOG_WARNING("Use item in bag failed: missing item GUID for bag ", bagIndex, " slot ", slotIndex); owner_.addSystemChatMessage("Cannot use that item right now."); @@ -527,18 +528,18 @@ void SpellHandler::useItemInBag(int bagIndex, int slotIndex) { void SpellHandler::useItemById(uint32_t itemId) { if (itemId == 0) return; LOG_DEBUG("useItemById: searching for itemId=", itemId); - for (int i = 0; i < owner_.inventory.getBackpackSize(); i++) { - const auto& slot = owner_.inventory.getBackpackSlot(i); + for (int i = 0; i < owner_.inventoryRef().getBackpackSize(); i++) { + const auto& slot = owner_.inventoryRef().getBackpackSlot(i); if (!slot.empty() && slot.item.itemId == itemId) { LOG_DEBUG("useItemById: found itemId=", itemId, " at backpack slot ", i); useItemBySlot(i); return; } } - for (int bag = 0; bag < owner_.inventory.NUM_BAG_SLOTS; bag++) { - int bagSize = owner_.inventory.getBagSize(bag); + for (int bag = 0; bag < owner_.inventoryRef().NUM_BAG_SLOTS; bag++) { + int bagSize = owner_.inventoryRef().getBagSize(bag); for (int slot = 0; slot < bagSize; slot++) { - const auto& bagSlot = owner_.inventory.getBagSlot(bag, slot); + const auto& bagSlot = owner_.inventoryRef().getBagSlot(bag, slot); if (!bagSlot.empty() && bagSlot.item.itemId == itemId) { LOG_DEBUG("useItemById: found itemId=", itemId, " in bag ", bag, " slot ", slot); useItemInBag(bag, slot); @@ -565,11 +566,11 @@ const std::vector& SpellHandler::getSpellBookTabs() std::vector general; for (uint32_t spellId : knownSpells_) { - auto slIt = owner_.spellToSkillLine_.find(spellId); - if (slIt != owner_.spellToSkillLine_.end()) { + auto slIt = owner_.spellToSkillLineRef().find(spellId); + if (slIt != owner_.spellToSkillLineRef().end()) { uint32_t skillLineId = slIt->second; - auto catIt = owner_.skillLineCategories_.find(skillLineId); - if (catIt != owner_.skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { + auto catIt = owner_.skillLineCategoriesRef().find(skillLineId); + if (catIt != owner_.skillLineCategoriesRef().end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { bySkillLine[skillLineId].push_back(spellId); continue; } @@ -588,8 +589,8 @@ const std::vector& SpellHandler::getSpellBookTabs() std::vector>> named; for (auto& [skillLineId, spells] : bySkillLine) { - auto nameIt = owner_.skillLineNames_.find(skillLineId); - std::string tabName = (nameIt != owner_.skillLineNames_.end()) ? nameIt->second : "Unknown"; + auto nameIt = owner_.skillLineNamesRef().find(skillLineId); + std::string tabName = (nameIt != owner_.skillLineNamesRef().end()) ? nameIt->second : "Unknown"; std::sort(spells.begin(), spells.end(), byName); named.emplace_back(std::move(tabName), std::move(spells)); } @@ -724,7 +725,7 @@ void SpellHandler::updateTimers(float dt) { void SpellHandler::handleInitialSpells(network::Packet& packet) { InitialSpellsData data; - if (!owner_.packetParsers_->parseInitialSpells(packet, data)) return; + if (!owner_.getPacketParsers()->parseInitialSpells(packet, data)) return; knownSpells_ = {data.spellIds.begin(), data.spellIds.end()}; @@ -744,14 +745,14 @@ void SpellHandler::handleInitialSpells(network::Packet& packet) { } // Load saved action bar or use defaults - owner_.actionBar[0].type = ActionBarSlot::SPELL; - owner_.actionBar[0].id = 6603; // Attack - owner_.actionBar[11].type = ActionBarSlot::SPELL; - owner_.actionBar[11].id = 8690; // Hearthstone + owner_.actionBarRef()[0].type = ActionBarSlot::SPELL; + owner_.actionBarRef()[0].id = 6603; // Attack + owner_.actionBarRef()[11].type = ActionBarSlot::SPELL; + owner_.actionBarRef()[11].id = 8690; // Hearthstone owner_.loadCharacterConfig(); // Sync login-time cooldowns into action bar slot overlays - for (auto& slot : owner_.actionBar) { + for (auto& slot : owner_.actionBarRef()) { if (slot.type == ActionBarSlot::SPELL && slot.id != 0) { auto it = spellCooldowns_.find(slot.id); if (it != spellCooldowns_.end() && it->second > 0.0f) { @@ -780,15 +781,15 @@ void SpellHandler::handleInitialSpells(network::Packet& packet) { LOG_INFO("Learned ", knownSpells_.size(), " spells"); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("SPELLS_CHANGED", {}); - owner_.addonEventCallback_("LEARNED_SPELL_IN_TAB", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("SPELLS_CHANGED", {}); + owner_.addonEventCallbackRef()("LEARNED_SPELL_IN_TAB", {}); } } void SpellHandler::handleCastFailed(network::Packet& packet) { CastFailedData data; - bool ok = owner_.packetParsers_ ? owner_.packetParsers_->parseCastFailed(packet, data) + bool ok = owner_.getPacketParsers() ? owner_.getPacketParsers()->parseCastFailed(packet, data) : CastFailedParser::parse(packet, data); if (!ok) return; @@ -796,8 +797,8 @@ void SpellHandler::handleCastFailed(network::Packet& packet) { castIsChannel_ = false; currentCastSpellId_ = 0; castTimeRemaining_ = 0.0f; - owner_.lastInteractedGoGuid_ = 0; - owner_.pendingGameObjectInteractGuid_ = 0; + owner_.lastInteractedGoGuidRef() = 0; + owner_.pendingGameObjectInteractGuidRef() = 0; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; @@ -812,7 +813,7 @@ void SpellHandler::handleCastFailed(network::Packet& packet) { // Show failure reason int powerType = -1; - auto playerEntity = owner_.getEntityManager().getEntity(owner_.playerGuid); + auto playerEntity = owner_.getEntityManager().getEntity(owner_.getPlayerGuid()); if (auto playerUnit = std::dynamic_pointer_cast(playerEntity)) { powerType = playerUnit->getPowerType(); } @@ -831,16 +832,16 @@ void SpellHandler::handleCastFailed(network::Packet& packet) { sfx->playError(); } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("UNIT_SPELLCAST_FAILED", {"player", std::to_string(data.spellId)}); - owner_.addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("UNIT_SPELLCAST_FAILED", {"player", std::to_string(data.spellId)}); + owner_.addonEventCallbackRef()("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)}); } - if (owner_.spellCastFailedCallback_) owner_.spellCastFailedCallback_(data.spellId); + if (owner_.spellCastFailedCallbackRef()) owner_.spellCastFailedCallbackRef()(data.spellId); } void SpellHandler::handleSpellStart(network::Packet& packet) { SpellStartData data; - if (!owner_.packetParsers_->parseSpellStart(packet, data)) { + if (!owner_.getPacketParsers()->parseSpellStart(packet, data)) { LOG_WARNING("Failed to parse SMSG_SPELL_START, size=", packet.getSize()); return; } @@ -860,7 +861,7 @@ void SpellHandler::handleSpellStart(network::Packet& packet) { const SpellCastType castType = classifyCast(data.targetGuid, data.casterUnit); // Track cast bar for any non-player caster - if (data.casterUnit != owner_.playerGuid && data.castTime > 0) { + if (data.casterUnit != owner_.getPlayerGuid() && data.castTime > 0) { auto& s = unitCastStates_[data.casterUnit]; s.casting = true; s.isChannel = false; @@ -869,33 +870,33 @@ void SpellHandler::handleSpellStart(network::Packet& packet) { s.timeRemaining = s.timeTotal; s.interruptible = owner_.isSpellInterruptible(data.spellId); s.castType = castType; - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(data.casterUnit, true, false, castType); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(data.casterUnit, true, false, castType); } } // Player's own cast - if (data.casterUnit == owner_.playerGuid && data.castTime > 0) { + if (data.casterUnit == owner_.getPlayerGuid() && data.castTime > 0) { // Cancel pending GO retries - owner_.pendingGameObjectLootRetries_.erase( - std::remove_if(owner_.pendingGameObjectLootRetries_.begin(), owner_.pendingGameObjectLootRetries_.end(), + owner_.pendingGameObjectLootRetriesRef().erase( + std::remove_if(owner_.pendingGameObjectLootRetriesRef().begin(), owner_.pendingGameObjectLootRetriesRef().end(), [](const GameHandler::PendingLootRetry&) { return true; }), - owner_.pendingGameObjectLootRetries_.end()); + owner_.pendingGameObjectLootRetriesRef().end()); casting_ = true; castIsChannel_ = false; currentCastSpellId_ = data.spellId; castTimeTotal_ = data.castTime / 1000.0f; castTimeRemaining_ = castTimeTotal_; - if (owner_.addonEventCallback_) owner_.addonEventCallback_("CURRENT_SPELL_CAST_CHANGED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("CURRENT_SPELL_CAST_CHANGED", {}); // Play precast sound — skip profession/tradeskill spells if (!owner_.isProfessionSpell(data.spellId)) { if (auto* ac = owner_.services().audioCoordinator) { if (auto* ssm = ac->getSpellSoundManager()) { owner_.loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(data.spellId); - auto school = (it != owner_.spellNameCache_.end() && it->second.schoolMask) + auto it = owner_.spellNameCacheRef().find(data.spellId); + auto school = (it != owner_.spellNameCacheRef().end() && it->second.schoolMask) ? schoolMaskToMagicSchool(it->second.schoolMask) : audio::SpellSoundManager::MagicSchool::ARCANE; ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); @@ -903,37 +904,37 @@ void SpellHandler::handleSpellStart(network::Packet& packet) { } } - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(owner_.playerGuid, true, false, castType); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(owner_.getPlayerGuid(), true, false, castType); } // Hearthstone: pre-load terrain at bind point const bool isHearthstone = (data.spellId == 6948 || data.spellId == 8690); - if (isHearthstone && owner_.hasHomeBind_ && owner_.hearthstonePreloadCallback_) { - owner_.hearthstonePreloadCallback_(owner_.homeBindMapId_, owner_.homeBindPos_.x, owner_.homeBindPos_.y, owner_.homeBindPos_.z); + if (isHearthstone && owner_.hasHomeBindRef() && owner_.hearthstonePreloadCallbackRef()) { + owner_.hearthstonePreloadCallbackRef()(owner_.homeBindMapIdRef(), owner_.homeBindPosRef().x, owner_.homeBindPosRef().y, owner_.homeBindPosRef().z); } } // Fire UNIT_SPELLCAST_START - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string unitId = owner_.guidToUnitId(data.casterUnit); if (!unitId.empty()) - owner_.addonEventCallback_("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)}); + owner_.addonEventCallbackRef()("UNIT_SPELLCAST_START", {unitId, std::to_string(data.spellId)}); } } void SpellHandler::handleSpellGo(network::Packet& packet) { SpellGoData data; - if (!owner_.packetParsers_->parseSpellGo(packet, data)) return; + if (!owner_.getPacketParsers()->parseSpellGo(packet, data)) return; - if (data.casterUnit == owner_.playerGuid) { + if (data.casterUnit == owner_.getPlayerGuid()) { // Play cast-complete sound if (!owner_.isProfessionSpell(data.spellId)) { if (auto* ac = owner_.services().audioCoordinator) { if (auto* ssm = ac->getSpellSoundManager()) { owner_.loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(data.spellId); - auto school = (it != owner_.spellNameCache_.end() && it->second.schoolMask) + auto it = owner_.spellNameCacheRef().find(data.spellId); + auto school = (it != owner_.spellNameCacheRef().end() && it->second.schoolMask) ? schoolMaskToMagicSchool(it->second.schoolMask) : audio::SpellSoundManager::MagicSchool::ARCANE; ssm->playCast(school); @@ -946,13 +947,13 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { bool isMeleeAbility = false; if (!owner_.isProfessionSpell(sid)) { owner_.loadSpellNameCache(); - auto cacheIt = owner_.spellNameCache_.find(sid); - if (cacheIt != owner_.spellNameCache_.end() && cacheIt->second.schoolMask == 1) { + auto cacheIt = owner_.spellNameCacheRef().find(sid); + if (cacheIt != owner_.spellNameCacheRef().end() && cacheIt->second.schoolMask == 1) { isMeleeAbility = (currentCastSpellId_ != sid); } } if (isMeleeAbility) { - if (owner_.meleeSwingCallback_) owner_.meleeSwingCallback_(sid); + if (owner_.meleeSwingCallbackRef()) owner_.meleeSwingCallbackRef()(sid); if (auto* ac = owner_.services().audioCoordinator) { if (auto* csm = ac->getCombatSoundManager()) { csm->playWeaponSwing(audio::CombatSoundManager::WeaponSize::MEDIUM, false); @@ -973,16 +974,16 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { goType = SpellCastType::DIRECTED; else if (data.targetGuid == 0 && data.hitCount > 1) goType = SpellCastType::AREA; - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(owner_.playerGuid, true, false, goType); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(owner_.getPlayerGuid(), true, false, goType); } } LOG_WARNING("[GO-DIAG] SPELL_GO: spellId=", data.spellId, " casting=", casting_, " currentCast=", currentCastSpellId_, " wasInTimedCast=", wasInTimedCast, - " lastGoGuid=0x", std::hex, owner_.lastInteractedGoGuid_, - " pendingGoGuid=0x", owner_.pendingGameObjectInteractGuid_, std::dec); + " lastGoGuid=0x", std::hex, owner_.lastInteractedGoGuidRef(), + " pendingGoGuid=0x", owner_.pendingGameObjectInteractGuidRef(), std::dec); casting_ = false; castIsChannel_ = false; @@ -990,23 +991,23 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { castTimeRemaining_ = 0.0f; // Gather node looting: re-send CMSG_LOOT now that the cast completed. - if (wasInTimedCast && owner_.lastInteractedGoGuid_ != 0) { + if (wasInTimedCast && owner_.lastInteractedGoGuidRef() != 0) { LOG_WARNING("[GO-DIAG] Sending CMSG_LOOT for GO 0x", std::hex, - owner_.lastInteractedGoGuid_, std::dec); - owner_.lootTarget(owner_.lastInteractedGoGuid_); - owner_.lastInteractedGoGuid_ = 0; + owner_.lastInteractedGoGuidRef(), std::dec); + owner_.lootTarget(owner_.lastInteractedGoGuidRef()); + owner_.lastInteractedGoGuidRef() = 0; } // Clear the GO interaction guard so future cancelCast() calls work // normally. Without this, pendingGameObjectInteractGuid_ stays stale // and suppresses CMSG_CANCEL_CAST for ALL subsequent spell casts. - owner_.pendingGameObjectInteractGuid_ = 0; + owner_.pendingGameObjectInteractGuidRef() = 0; - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(owner_.playerGuid, false, false, SpellCastType::OMNI); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(owner_.getPlayerGuid(), false, false, SpellCastType::OMNI); } - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("UNIT_SPELLCAST_STOP", {"player", std::to_string(data.spellId)}); // Spell queue: fire the next queued spell if (queuedSpellId_ != 0) { @@ -1028,22 +1029,22 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { npcGoType = SpellCastType::DIRECTED; else if (data.targetGuid == 0 && data.hitCount > 1) npcGoType = SpellCastType::AREA; - if (!wasTrackedCast && owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(data.casterUnit, true, false, npcGoType); + if (!wasTrackedCast && owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(data.casterUnit, true, false, npcGoType); } - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(data.casterUnit, false, false, SpellCastType::OMNI); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(data.casterUnit, false, false, SpellCastType::OMNI); } bool targetsPlayer = false; for (const auto& tgt : data.hitTargets) { - if (tgt == owner_.playerGuid) { targetsPlayer = true; break; } + if (tgt == owner_.getPlayerGuid()) { targetsPlayer = true; break; } } if (targetsPlayer) { if (auto* ac = owner_.services().audioCoordinator) { if (auto* ssm = ac->getSpellSoundManager()) { owner_.loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(data.spellId); - auto school = (it != owner_.spellNameCache_.end() && it->second.schoolMask) + auto it = owner_.spellNameCacheRef().find(data.spellId); + auto school = (it != owner_.spellNameCacheRef().end() && it->second.schoolMask) ? schoolMaskToMagicSchool(it->second.schoolMask) : audio::SpellSoundManager::MagicSchool::ARCANE; ssm->playCast(school); @@ -1058,10 +1059,10 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { // Miss combat text if (!data.missTargets.empty()) { const uint64_t spellCasterGuid = data.casterUnit != 0 ? data.casterUnit : data.casterGuid; - const bool playerIsCaster = (spellCasterGuid == owner_.playerGuid); + const bool playerIsCaster = (spellCasterGuid == owner_.getPlayerGuid()); for (const auto& m : data.missTargets) { - if (!playerIsCaster && m.targetGuid != owner_.playerGuid) { + if (!playerIsCaster && m.targetGuid != owner_.getPlayerGuid()) { continue; } CombatTextEntry::Type ct = combatTextTypeFromSpellMissInfo(m.missType); @@ -1073,23 +1074,23 @@ void SpellHandler::handleSpellGo(network::Packet& packet) { bool playerIsHit = false; bool playerHitEnemy = false; for (const auto& tgt : data.hitTargets) { - if (tgt == owner_.playerGuid) { playerIsHit = true; } - if (data.casterUnit == owner_.playerGuid && tgt != owner_.playerGuid && tgt != 0) { playerHitEnemy = true; } + if (tgt == owner_.getPlayerGuid()) { playerIsHit = true; } + if (data.casterUnit == owner_.getPlayerGuid() && tgt != owner_.getPlayerGuid() && tgt != 0) { playerHitEnemy = true; } } // Fire UNIT_SPELLCAST_SUCCEEDED - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string unitId = owner_.guidToUnitId(data.casterUnit); if (!unitId.empty()) - owner_.addonEventCallback_("UNIT_SPELLCAST_SUCCEEDED", {unitId, std::to_string(data.spellId)}); + owner_.addonEventCallbackRef()("UNIT_SPELLCAST_SUCCEEDED", {unitId, std::to_string(data.spellId)}); } if (playerIsHit || playerHitEnemy) { if (auto* ac = owner_.services().audioCoordinator) { if (auto* ssm = ac->getSpellSoundManager()) { owner_.loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(data.spellId); - auto school = (it != owner_.spellNameCache_.end() && it->second.schoolMask) + auto it = owner_.spellNameCacheRef().find(data.spellId); + auto school = (it != owner_.spellNameCacheRef().end() && it->second.schoolMask) ? schoolMaskToMagicSchool(it->second.schoolMask) : audio::SpellSoundManager::MagicSchool::ARCANE; ssm->playImpact(school, audio::SpellSoundManager::SpellPower::MEDIUM); @@ -1131,7 +1132,7 @@ void SpellHandler::handleSpellCooldown(network::Packet& packet) { } else { it->second = mergeCooldownSeconds(it->second, seconds); } - for (auto& slot : owner_.actionBar) { + for (auto& slot : owner_.actionBarRef()) { bool match = (slot.type == ActionBarSlot::SPELL && slot.id == spellId) || (cdItemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == cdItemId); if (match) { @@ -1148,9 +1149,9 @@ void SpellHandler::handleSpellCooldown(network::Packet& packet) { } LOG_DEBUG("handleSpellCooldown: parsed for ", isClassicFormat ? "Classic" : "TBC/WotLK", " format"); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("SPELL_UPDATE_COOLDOWN", {}); - owner_.addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("SPELL_UPDATE_COOLDOWN", {}); + owner_.addonEventCallbackRef()("ACTIONBAR_UPDATE_COOLDOWN", {}); } } @@ -1160,28 +1161,28 @@ void SpellHandler::handleCooldownEvent(network::Packet& packet) { if (packet.hasRemaining(8)) packet.readUInt64(); spellCooldowns_.erase(spellId); - for (auto& slot : owner_.actionBar) { + for (auto& slot : owner_.actionBarRef()) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { slot.cooldownRemaining = 0.0f; } } - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("SPELL_UPDATE_COOLDOWN", {}); - owner_.addonEventCallback_("ACTIONBAR_UPDATE_COOLDOWN", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("SPELL_UPDATE_COOLDOWN", {}); + owner_.addonEventCallbackRef()("ACTIONBAR_UPDATE_COOLDOWN", {}); } } void SpellHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { AuraUpdateData data; - if (!owner_.packetParsers_->parseAuraUpdate(packet, data, isAll)) return; + if (!owner_.getPacketParsers()->parseAuraUpdate(packet, data, isAll)) return; std::vector* auraList = nullptr; - if (data.guid == owner_.playerGuid) { + if (data.guid == owner_.getPlayerGuid()) { auraList = &playerAuras_; - } else if (data.guid == owner_.targetGuid) { + } else if (data.guid == owner_.getTargetGuid()) { auraList = &targetAuras_; } - if (data.guid != 0 && data.guid != owner_.playerGuid && data.guid != owner_.targetGuid) { + if (data.guid != 0 && data.guid != owner_.getPlayerGuid() && data.guid != owner_.getTargetGuid()) { auraList = &unitAurasCache_[data.guid]; } @@ -1202,28 +1203,28 @@ void SpellHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { (*auraList)[slot] = aura; } - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string unitId; - if (data.guid == owner_.playerGuid) unitId = "player"; - else if (data.guid == owner_.targetGuid) unitId = "target"; - else if (data.guid == owner_.focusGuid) unitId = "focus"; - else if (data.guid == owner_.petGuid_) unitId = "pet"; + if (data.guid == owner_.getPlayerGuid()) unitId = "player"; + else if (data.guid == owner_.getTargetGuid()) unitId = "target"; + else if (data.guid == owner_.focusGuidRef()) unitId = "focus"; + else if (data.guid == owner_.petGuidRef()) unitId = "pet"; if (!unitId.empty()) - owner_.addonEventCallback_("UNIT_AURA", {unitId}); + owner_.addonEventCallbackRef()("UNIT_AURA", {unitId}); } // Mount aura detection - if (data.guid == owner_.playerGuid && owner_.currentMountDisplayId_ != 0 && owner_.mountAuraSpellId_ == 0) { + if (data.guid == owner_.getPlayerGuid() && owner_.currentMountDisplayIdRef() != 0 && owner_.mountAuraSpellIdRef() == 0) { for (const auto& [slot, aura] : data.updates) { - if (!aura.isEmpty() && aura.maxDurationMs < 0 && aura.casterGuid == owner_.playerGuid) { - owner_.mountAuraSpellId_ = aura.spellId; + if (!aura.isEmpty() && aura.maxDurationMs < 0 && aura.casterGuid == owner_.getPlayerGuid()) { + owner_.mountAuraSpellIdRef() = aura.spellId; LOG_INFO("Mount aura detected from aura update: spellId=", aura.spellId); } } } // Sprint aura detection — check if any sprint/dash speed buff is active - if (data.guid == owner_.playerGuid && owner_.sprintAuraCallback_) { + if (data.guid == owner_.getPlayerGuid() && owner_.sprintAuraCallbackRef()) { static const uint32_t sprintSpells[] = { 2983, 8696, 11305, // Rogue Sprint (ranks 1-3) 1850, 9821, 33357, // Druid Dash (ranks 1-3) @@ -1239,7 +1240,7 @@ void SpellHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { } if (hasSprint) break; } - owner_.sprintAuraCallback_(hasSprint); + owner_.sprintAuraCallbackRef()(hasSprint); } } } @@ -1264,9 +1265,9 @@ void SpellHandler::handleLearnedSpell(network::Packet& packet) { LOG_INFO("Talent learned: id=", talentId, " rank=", (int)newRank, " (spell ", spellId, ") in spec ", (int)activeTalentSpec_); isTalentSpell = true; - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("CHARACTER_POINTS_CHANGED", {}); - owner_.addonEventCallback_("PLAYER_TALENT_UPDATE", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("CHARACTER_POINTS_CHANGED", {}); + owner_.addonEventCallbackRef()("PLAYER_TALENT_UPDATE", {}); } break; } @@ -1274,9 +1275,9 @@ void SpellHandler::handleLearnedSpell(network::Packet& packet) { if (isTalentSpell) break; } - if (!alreadyKnown && owner_.addonEventCallback_) { - owner_.addonEventCallback_("LEARNED_SPELL_IN_TAB", {std::to_string(spellId)}); - owner_.addonEventCallback_("SPELLS_CHANGED", {}); + if (!alreadyKnown && owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("LEARNED_SPELL_IN_TAB", {std::to_string(spellId)}); + owner_.addonEventCallbackRef()("SPELLS_CHANGED", {}); } if (isTalentSpell) return; @@ -1298,7 +1299,7 @@ void SpellHandler::handleRemovedSpell(network::Packet& packet) { uint32_t spellId = classicSpellId ? packet.readUInt16() : packet.readUInt32(); knownSpells_.erase(spellId); LOG_INFO("Removed spell: ", spellId); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("SPELLS_CHANGED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("SPELLS_CHANGED", {}); const std::string& name = owner_.getSpellName(spellId); if (!name.empty()) @@ -1307,7 +1308,7 @@ void SpellHandler::handleRemovedSpell(network::Packet& packet) { owner_.addSystemChatMessage("A spell has been removed."); bool barChanged = false; - for (auto& slot : owner_.actionBar) { + for (auto& slot : owner_.actionBarRef()) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { slot = ActionBarSlot{}; barChanged = true; @@ -1332,7 +1333,7 @@ void SpellHandler::handleSupercededSpell(network::Packet& packet) { LOG_INFO("Spell superceded: ", oldSpellId, " -> ", newSpellId); bool barChanged = false; - for (auto& slot : owner_.actionBar) { + for (auto& slot : owner_.actionBarRef()) { if (slot.type == ActionBarSlot::SPELL && slot.id == oldSpellId) { slot.id = newSpellId; slot.cooldownRemaining = 0.0f; @@ -1343,7 +1344,7 @@ void SpellHandler::handleSupercededSpell(network::Packet& packet) { } if (barChanged) { owner_.saveCharacterConfig(); - if (owner_.addonEventCallback_) owner_.addonEventCallback_("ACTIONBAR_SLOT_CHANGED", {}); + if (owner_.addonEventCallbackRef()) owner_.addonEventCallbackRef()("ACTIONBAR_SLOT_CHANGED", {}); } if (!newSpellAlreadyAnnounced) { @@ -1364,7 +1365,7 @@ void SpellHandler::handleUnlearnSpells(network::Packet& packet) { uint32_t spellId = packet.readUInt32(); knownSpells_.erase(spellId); LOG_INFO(" Unlearned spell: ", spellId); - for (auto& slot : owner_.actionBar) { + for (auto& slot : owner_.actionBarRef()) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) { slot = ActionBarSlot{}; barChanged = true; @@ -1425,10 +1426,10 @@ void SpellHandler::handleTalentsInfo(network::Packet& packet) { " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, " learned=", learnedTalents_[activeTalentGroup].size()); - if (owner_.addonEventCallback_) { - owner_.addonEventCallback_("CHARACTER_POINTS_CHANGED", {}); - owner_.addonEventCallback_("ACTIVE_TALENT_GROUP_CHANGED", {}); - owner_.addonEventCallback_("PLAYER_TALENT_UPDATE", {}); + if (owner_.addonEventCallbackRef()) { + owner_.addonEventCallbackRef()("CHARACTER_POINTS_CHANGED", {}); + owner_.addonEventCallbackRef()("ACTIVE_TALENT_GROUP_CHANGED", {}); + owner_.addonEventCallbackRef()("PLAYER_TALENT_UPDATE", {}); } if (!talentsInitialized_) { @@ -1449,11 +1450,11 @@ void SpellHandler::handleAchievementEarned(network::Packet& packet) { uint32_t earnDate = packet.readUInt32(); owner_.loadAchievementNameCache(); - auto nameIt = owner_.achievementNameCache_.find(achievementId); - const std::string& achName = (nameIt != owner_.achievementNameCache_.end()) + auto nameIt = owner_.achievementNameCacheRef().find(achievementId); + const std::string& achName = (nameIt != owner_.achievementNameCacheRef().end()) ? nameIt->second : std::string(); - bool isSelf = (guid == owner_.playerGuid); + bool isSelf = (guid == owner_.getPlayerGuid()); if (isSelf) { char buf[256]; if (!achName.empty()) { @@ -1463,14 +1464,14 @@ void SpellHandler::handleAchievementEarned(network::Packet& packet) { } owner_.addSystemChatMessage(buf); - owner_.earnedAchievements_.insert(achievementId); - owner_.achievementDates_[achievementId] = earnDate; + owner_.earnedAchievementsRef().insert(achievementId); + owner_.achievementDatesRef()[achievementId] = earnDate; if (auto* ac = owner_.services().audioCoordinator) { if (auto* sfx = ac->getUiSoundManager()) sfx->playAchievementAlert(); } - if (owner_.achievementEarnedCallback_) { - owner_.achievementEarnedCallback_(achievementId, achName); + if (owner_.achievementEarnedCallbackRef()) { + owner_.achievementEarnedCallbackRef()(achievementId, achName); } } else { std::string senderName; @@ -1500,8 +1501,8 @@ void SpellHandler::handleAchievementEarned(network::Packet& packet) { LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec, " achievementId=", achievementId, " self=", isSelf, achName.empty() ? "" : " name=", achName); - if (owner_.addonEventCallback_) - owner_.addonEventCallback_("ACHIEVEMENT_EARNED", {std::to_string(achievementId)}); + if (owner_.addonEventCallbackRef()) + owner_.addonEventCallbackRef()("ACHIEVEMENT_EARNED", {std::to_string(achievementId)}); } // SMSG_EQUIPMENT_SET_LIST — moved to InventoryHandler @@ -1513,20 +1514,20 @@ void SpellHandler::handleAchievementEarned(network::Packet& packet) { void SpellHandler::handlePetSpells(network::Packet& packet) { const size_t remaining = packet.getRemainingSize(); if (remaining < 8) { - owner_.petGuid_ = 0; - owner_.petSpellList_.clear(); - owner_.petAutocastSpells_.clear(); - memset(owner_.petActionSlots_, 0, sizeof(owner_.petActionSlots_)); + owner_.petGuidRef() = 0; + owner_.petSpellListRef().clear(); + owner_.petAutocastSpellsRef().clear(); + memset(owner_.petActionSlotsRef(), 0, sizeof(owner_.petActionSlotsRef())); LOG_INFO("SMSG_PET_SPELLS: pet cleared"); owner_.fireAddonEvent("UNIT_PET", {"player"}); return; } - owner_.petGuid_ = packet.readUInt64(); - if (owner_.petGuid_ == 0) { - owner_.petSpellList_.clear(); - owner_.petAutocastSpells_.clear(); - memset(owner_.petActionSlots_, 0, sizeof(owner_.petActionSlots_)); + owner_.petGuidRef() = packet.readUInt64(); + if (owner_.petGuidRef() == 0) { + owner_.petSpellListRef().clear(); + owner_.petAutocastSpellsRef().clear(); + memset(owner_.petActionSlotsRef(), 0, sizeof(owner_.petActionSlotsRef())); LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)"); owner_.fireAddonEvent("UNIT_PET", {"player"}); return; @@ -1539,72 +1540,72 @@ void SpellHandler::handlePetSpells(network::Packet& packet) { /*uint16_t timer =*/ packet.readUInt16(); if (!packet.hasRemaining(2)) break; - owner_.petReact_ = packet.readUInt8(); - owner_.petCommand_ = packet.readUInt8(); + owner_.petReactRef() = packet.readUInt8(); + owner_.petCommandRef() = packet.readUInt8(); if (!packet.hasRemaining(GameHandler::PET_ACTION_BAR_SLOTS * 4u)) break; for (int i = 0; i < GameHandler::PET_ACTION_BAR_SLOTS; ++i) { - owner_.petActionSlots_[i] = packet.readUInt32(); + owner_.petActionSlotsRef()[i] = packet.readUInt32(); } if (!packet.hasRemaining(1)) break; uint8_t spellCount = packet.readUInt8(); - owner_.petSpellList_.clear(); - owner_.petAutocastSpells_.clear(); + owner_.petSpellListRef().clear(); + owner_.petAutocastSpellsRef().clear(); for (uint8_t i = 0; i < spellCount; ++i) { if (!packet.hasRemaining(6)) break; uint32_t spellId = packet.readUInt32(); uint16_t activeFlags = packet.readUInt16(); - owner_.petSpellList_.push_back(spellId); + owner_.petSpellListRef().push_back(spellId); if (activeFlags & 0x0001) { - owner_.petAutocastSpells_.insert(spellId); + owner_.petAutocastSpellsRef().insert(spellId); } } } while (false); - LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, owner_.petGuid_, std::dec, - " react=", static_cast(owner_.petReact_), " command=", static_cast(owner_.petCommand_), - " spells=", owner_.petSpellList_.size()); + LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, owner_.petGuidRef(), std::dec, + " react=", static_cast(owner_.petReactRef()), " command=", static_cast(owner_.petCommandRef()), + " spells=", owner_.petSpellListRef().size()); owner_.fireAddonEvent("UNIT_PET", {"player"}); owner_.fireAddonEvent("PET_BAR_UPDATE", {}); } void SpellHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { - if (!owner_.hasPet() || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; - auto pkt = PetActionPacket::build(owner_.petGuid_, action, targetGuid); - owner_.socket->send(pkt); - LOG_DEBUG("sendPetAction: petGuid=0x", std::hex, owner_.petGuid_, + if (!owner_.hasPet() || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; + auto pkt = PetActionPacket::build(owner_.petGuidRef(), action, targetGuid); + owner_.getSocket()->send(pkt); + LOG_DEBUG("sendPetAction: petGuid=0x", std::hex, owner_.petGuidRef(), " action=0x", action, " target=0x", targetGuid, std::dec); } void SpellHandler::dismissPet() { - if (owner_.petGuid_ == 0 || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; - auto packet = PetActionPacket::build(owner_.petGuid_, 0x07000000); - owner_.socket->send(packet); + if (owner_.petGuidRef() == 0 || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; + auto packet = PetActionPacket::build(owner_.petGuidRef(), 0x07000000); + owner_.getSocket()->send(packet); } void SpellHandler::togglePetSpellAutocast(uint32_t spellId) { - if (owner_.petGuid_ == 0 || spellId == 0 || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; - bool currentlyOn = owner_.petAutocastSpells_.count(spellId) != 0; + if (owner_.petGuidRef() == 0 || spellId == 0 || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; + bool currentlyOn = owner_.petAutocastSpellsRef().count(spellId) != 0; uint8_t newState = currentlyOn ? 0 : 1; network::Packet pkt(wireOpcode(Opcode::CMSG_PET_SPELL_AUTOCAST)); - pkt.writeUInt64(owner_.petGuid_); + pkt.writeUInt64(owner_.petGuidRef()); pkt.writeUInt32(spellId); pkt.writeUInt8(newState); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); if (newState) - owner_.petAutocastSpells_.insert(spellId); + owner_.petAutocastSpellsRef().insert(spellId); else - owner_.petAutocastSpells_.erase(spellId); + owner_.petAutocastSpellsRef().erase(spellId); LOG_DEBUG("togglePetSpellAutocast: spellId=", spellId, " autocast=", static_cast(newState)); } void SpellHandler::renamePet(const std::string& newName) { - if (owner_.petGuid_ == 0 || owner_.state != WorldState::IN_WORLD || !owner_.socket) return; + if (owner_.petGuidRef() == 0 || owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket()) return; if (newName.empty() || newName.size() > 12) return; - auto packet = PetRenamePacket::build(owner_.petGuid_, newName, 0); - owner_.socket->send(packet); - LOG_INFO("Sent CMSG_PET_RENAME: petGuid=0x", std::hex, owner_.petGuid_, std::dec, " name='", newName, "'"); + auto packet = PetRenamePacket::build(owner_.petGuidRef(), newName, 0); + owner_.getSocket()->send(packet); + LOG_INFO("Sent CMSG_PET_RENAME: petGuid=0x", std::hex, owner_.petGuidRef(), std::dec, " name='", newName, "'"); } void SpellHandler::handleListStabledPets(network::Packet& packet) { @@ -1613,12 +1614,12 @@ void SpellHandler::handleListStabledPets(network::Packet& packet) { LOG_WARNING("MSG_LIST_STABLED_PETS: packet too short (", packet.getSize(), ")"); return; } - owner_.stableMasterGuid_ = packet.readUInt64(); + owner_.stableMasterGuidRef() = packet.readUInt64(); uint8_t petCount = packet.readUInt8(); - owner_.stableNumSlots_ = packet.readUInt8(); + owner_.stableNumSlotsRef() = packet.readUInt8(); - owner_.stabledPets_.clear(); - owner_.stabledPets_.reserve(petCount); + owner_.stabledPetsRef().clear(); + owner_.stabledPetsRef().reserve(petCount); for (uint8_t i = 0; i < petCount; ++i) { // petNumber(4) + entry(4) + level(4) = 12 bytes before the name string @@ -1632,13 +1633,13 @@ void SpellHandler::handleListStabledPets(network::Packet& packet) { if (!packet.hasRemaining(5)) break; pet.displayId = packet.readUInt32(); pet.isActive = (packet.readUInt8() != 0); - owner_.stabledPets_.push_back(std::move(pet)); + owner_.stabledPetsRef().push_back(std::move(pet)); } - owner_.stableWindowOpen_ = true; - LOG_INFO("MSG_LIST_STABLED_PETS: stableMasterGuid=0x", std::hex, owner_.stableMasterGuid_, std::dec, - " petCount=", static_cast(petCount), " numSlots=", static_cast(owner_.stableNumSlots_)); - for (const auto& p : owner_.stabledPets_) { + owner_.stableWindowOpenRef() = true; + LOG_INFO("MSG_LIST_STABLED_PETS: stableMasterGuid=0x", std::hex, owner_.stableMasterGuidRef(), std::dec, + " petCount=", static_cast(petCount), " numSlots=", static_cast(owner_.stableNumSlotsRef())); + for (const auto& p : owner_.stabledPetsRef()) { LOG_DEBUG(" Pet: number=", p.petNumber, " entry=", p.entry, " level=", p.level, " name='", p.name, "' displayId=", p.displayId, " active=", p.isActive); @@ -1659,9 +1660,9 @@ void SpellHandler::stopCasting() { return; } - if (owner_.pendingGameObjectInteractGuid_ == 0 && currentCastSpellId_ != 0) { + if (owner_.pendingGameObjectInteractGuidRef() == 0 && currentCastSpellId_ != 0) { auto packet = CancelCastPacket::build(currentCastSpellId_); - owner_.socket->send(packet); + owner_.getSocket()->send(packet); } casting_ = false; @@ -1669,8 +1670,8 @@ void SpellHandler::stopCasting() { currentCastSpellId_ = 0; castTimeRemaining_ = 0.0f; castTimeTotal_ = 0.0f; - owner_.pendingGameObjectInteractGuid_ = 0; - owner_.lastInteractedGoGuid_ = 0; + owner_.pendingGameObjectInteractGuidRef() = 0; + owner_.lastInteractedGoGuidRef() = 0; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; @@ -1689,7 +1690,7 @@ void SpellHandler::resetCastState() { craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; - owner_.pendingGameObjectInteractGuid_ = 0; + owner_.pendingGameObjectInteractGuidRef() = 0; // lastInteractedGoGuid_ is intentionally NOT cleared here — it must survive // until handleSpellGo sends CMSG_LOOT after the server-side cast completes. // handleSpellGo clears it after use (line 958). Previously this was cleared @@ -1744,8 +1745,8 @@ void SpellHandler::handleUpdateAuraDuration(uint8_t slot, uint32_t durationMs) { static const std::string SPELL_EMPTY_STRING; void SpellHandler::loadSpellNameCache() const { - if (owner_.spellNameCacheLoaded_) return; - owner_.spellNameCacheLoaded_ = true; + if (owner_.spellNameCacheLoadedRef()) return; + owner_.spellNameCacheLoadedRef() = true; auto* am = owner_.services().assetManager; if (!am || !am->isInitialized()) return; @@ -1832,7 +1833,7 @@ void SpellHandler::loadSpellNameCache() const { // Duration: read DurationIndex and resolve via SpellDuration.dbc later if (durIdxField != 0xFFFFFFFF) entry.durationSec = static_cast(dbc->getUInt32(i, durIdxField)); // store index temporarily - owner_.spellNameCache_[id] = std::move(entry); + owner_.spellNameCacheRef()[id] = std::move(entry); } } auto durDbc = am->loadDBC("SpellDuration.dbc"); @@ -1844,7 +1845,7 @@ void SpellHandler::loadSpellNameCache() const { if (baseMs > 0 && baseMs < 100000000) durMap[durId] = baseMs / 1000.0f; } - for (auto& [sid, entry] : owner_.spellNameCache_) { + for (auto& [sid, entry] : owner_.spellNameCacheRef()) { uint32_t durIdx = static_cast(entry.durationSec); if (durIdx > 0) { auto it = durMap.find(durIdx); @@ -1852,12 +1853,12 @@ void SpellHandler::loadSpellNameCache() const { } } } - LOG_INFO("Trainer: Loaded ", owner_.spellNameCache_.size(), " spell names from Spell.dbc"); + LOG_INFO("Trainer: Loaded ", owner_.spellNameCacheRef().size(), " spell names from Spell.dbc"); } void SpellHandler::loadSkillLineAbilityDbc() { - if (owner_.skillLineAbilityLoaded_) return; - owner_.skillLineAbilityLoaded_ = true; + if (owner_.skillLineAbilityLoadedRef()) return; + owner_.skillLineAbilityLoadedRef() = true; auto* am = owner_.services().assetManager; if (!am || !am->isInitialized()) return; @@ -1871,27 +1872,27 @@ void SpellHandler::loadSkillLineAbilityDbc() { uint32_t skillLineId = slaDbc->getUInt32(i, slaSkillField); uint32_t spellId = slaDbc->getUInt32(i, slaSpellField); if (spellId > 0 && skillLineId > 0) { - owner_.spellToSkillLine_[spellId] = skillLineId; + owner_.spellToSkillLineRef()[spellId] = skillLineId; } } - LOG_INFO("Trainer: Loaded ", owner_.spellToSkillLine_.size(), " skill line abilities"); + LOG_INFO("Trainer: Loaded ", owner_.spellToSkillLineRef().size(), " skill line abilities"); } } void SpellHandler::categorizeTrainerSpells() { - owner_.trainerTabs_.clear(); + owner_.trainerTabsRef().clear(); static constexpr uint32_t SKILLLINE_CATEGORY_CLASS = 7; std::map> specialtySpells; std::vector generalSpells; - for (const auto& spell : owner_.currentTrainerList_.spells) { - auto slIt = owner_.spellToSkillLine_.find(spell.spellId); - if (slIt != owner_.spellToSkillLine_.end()) { + for (const auto& spell : owner_.currentTrainerListRef().spells) { + auto slIt = owner_.spellToSkillLineRef().find(spell.spellId); + if (slIt != owner_.spellToSkillLineRef().end()) { uint32_t skillLineId = slIt->second; - auto catIt = owner_.skillLineCategories_.find(skillLineId); - if (catIt != owner_.skillLineCategories_.end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { + auto catIt = owner_.skillLineCategoriesRef().find(skillLineId); + if (catIt != owner_.skillLineCategoriesRef().end() && catIt->second == SKILLLINE_CATEGORY_CLASS) { specialtySpells[skillLineId].push_back(&spell); continue; } @@ -1905,8 +1906,8 @@ void SpellHandler::categorizeTrainerSpells() { std::vector>> named; for (auto& [skillLineId, spells] : specialtySpells) { - auto nameIt = owner_.skillLineNames_.find(skillLineId); - std::string tabName = (nameIt != owner_.skillLineNames_.end()) ? nameIt->second : "Specialty"; + auto nameIt = owner_.skillLineNamesRef().find(skillLineId); + std::string tabName = (nameIt != owner_.skillLineNamesRef().end()) ? nameIt->second : "Specialty"; std::sort(spells.begin(), spells.end(), byName); named.push_back({std::move(tabName), std::move(spells)}); } @@ -1914,27 +1915,27 @@ void SpellHandler::categorizeTrainerSpells() { [](const auto& a, const auto& b) { return a.first < b.first; }); for (auto& [name, spells] : named) { - owner_.trainerTabs_.push_back({std::move(name), std::move(spells)}); + owner_.trainerTabsRef().push_back({std::move(name), std::move(spells)}); } if (!generalSpells.empty()) { std::sort(generalSpells.begin(), generalSpells.end(), byName); - owner_.trainerTabs_.push_back({"General", std::move(generalSpells)}); + owner_.trainerTabsRef().push_back({"General", std::move(generalSpells)}); } - LOG_INFO("Trainer: Categorized into ", owner_.trainerTabs_.size(), " tabs"); + LOG_INFO("Trainer: Categorized into ", owner_.trainerTabsRef().size(), " tabs"); } const int32_t* SpellHandler::getSpellEffectBasePoints(uint32_t spellId) const { loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(spellId); - return (it != owner_.spellNameCache_.end()) ? it->second.effectBasePoints : nullptr; + auto it = owner_.spellNameCacheRef().find(spellId); + return (it != owner_.spellNameCacheRef().end()) ? it->second.effectBasePoints : nullptr; } float SpellHandler::getSpellDuration(uint32_t spellId) const { loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(spellId); - return (it != owner_.spellNameCache_.end()) ? it->second.durationSec : 0.0f; + auto it = owner_.spellNameCacheRef().find(spellId); + return (it != owner_.spellNameCacheRef().end()) ? it->second.durationSec : 0.0f; } const std::string& SpellHandler::getSpellName(uint32_t spellId) const { @@ -1942,20 +1943,20 @@ const std::string& SpellHandler::getSpellName(uint32_t spellId) const { // Every other DBC-backed getter (getSpellDescription, getSpellSchoolMask, etc.) // already does this; these two were missed. loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(spellId); - return (it != owner_.spellNameCache_.end()) ? it->second.name : SPELL_EMPTY_STRING; + auto it = owner_.spellNameCacheRef().find(spellId); + return (it != owner_.spellNameCacheRef().end()) ? it->second.name : SPELL_EMPTY_STRING; } const std::string& SpellHandler::getSpellRank(uint32_t spellId) const { loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(spellId); - return (it != owner_.spellNameCache_.end()) ? it->second.rank : SPELL_EMPTY_STRING; + auto it = owner_.spellNameCacheRef().find(spellId); + return (it != owner_.spellNameCacheRef().end()) ? it->second.rank : SPELL_EMPTY_STRING; } const std::string& SpellHandler::getSpellDescription(uint32_t spellId) const { loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(spellId); - return (it != owner_.spellNameCache_.end()) ? it->second.description : SPELL_EMPTY_STRING; + auto it = owner_.spellNameCacheRef().find(spellId); + return (it != owner_.spellNameCacheRef().end()) ? it->second.description : SPELL_EMPTY_STRING; } std::string SpellHandler::getEnchantName(uint32_t enchantId) const { @@ -1974,30 +1975,30 @@ std::string SpellHandler::getEnchantName(uint32_t enchantId) const { uint8_t SpellHandler::getSpellDispelType(uint32_t spellId) const { loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(spellId); - return (it != owner_.spellNameCache_.end()) ? it->second.dispelType : 0; + auto it = owner_.spellNameCacheRef().find(spellId); + return (it != owner_.spellNameCacheRef().end()) ? it->second.dispelType : 0; } bool SpellHandler::isSpellInterruptible(uint32_t spellId) const { if (spellId == 0) return true; loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(spellId); - if (it == owner_.spellNameCache_.end()) return true; + auto it = owner_.spellNameCacheRef().find(spellId); + if (it == owner_.spellNameCacheRef().end()) return true; return (it->second.attrEx & 0x00000010u) == 0; } uint32_t SpellHandler::getSpellSchoolMask(uint32_t spellId) const { if (spellId == 0) return 0; loadSpellNameCache(); - auto it = owner_.spellNameCache_.find(spellId); - return (it != owner_.spellNameCache_.end()) ? it->second.schoolMask : 0; + auto it = owner_.spellNameCacheRef().find(spellId); + return (it != owner_.spellNameCacheRef().end()) ? it->second.schoolMask : 0; } const std::string& SpellHandler::getSkillLineName(uint32_t spellId) const { - auto slIt = owner_.spellToSkillLine_.find(spellId); - if (slIt == owner_.spellToSkillLine_.end()) return SPELL_EMPTY_STRING; - auto nameIt = owner_.skillLineNames_.find(slIt->second); - return (nameIt != owner_.skillLineNames_.end()) ? nameIt->second : SPELL_EMPTY_STRING; + auto slIt = owner_.spellToSkillLineRef().find(spellId); + if (slIt == owner_.spellToSkillLineRef().end()) return SPELL_EMPTY_STRING; + auto nameIt = owner_.skillLineNamesRef().find(slIt->second); + return (nameIt != owner_.skillLineNamesRef().end()) ? nameIt->second : SPELL_EMPTY_STRING; } // ============================================================ @@ -2005,8 +2006,8 @@ const std::string& SpellHandler::getSkillLineName(uint32_t spellId) const { // ============================================================ void SpellHandler::loadSkillLineDbc() { - if (owner_.skillLineDbcLoaded_) return; - owner_.skillLineDbcLoaded_ = true; + if (owner_.skillLineDbcLoadedRef()) return; + owner_.skillLineDbcLoadedRef() = true; auto* am = owner_.services().assetManager; if (!am || !am->isInitialized()) return; @@ -2026,11 +2027,11 @@ void SpellHandler::loadSkillLineDbc() { uint32_t category = dbc->getUInt32(i, slCatField); std::string name = dbc->getString(i, slNameField); if (id > 0 && !name.empty()) { - owner_.skillLineNames_[id] = name; - owner_.skillLineCategories_[id] = category; + owner_.skillLineNamesRef()[id] = name; + owner_.skillLineCategoriesRef()[id] = category; } } - LOG_INFO("GameHandler: Loaded ", owner_.skillLineNames_.size(), " skill line names"); + LOG_INFO("GameHandler: Loaded ", owner_.skillLineNamesRef().size(), " skill line names"); } void SpellHandler::extractSkillFields(const std::map& fields) { @@ -2077,10 +2078,10 @@ void SpellHandler::extractSkillFields(const std::map& fields for (const auto& [skillId, skill] : newSkills) { if (skill.value == 0) continue; - auto oldIt = owner_.playerSkills_.find(skillId); - if (oldIt != owner_.playerSkills_.end() && skill.value > oldIt->second.value) { - auto catIt = owner_.skillLineCategories_.find(skillId); - if (catIt != owner_.skillLineCategories_.end()) { + auto oldIt = owner_.playerSkillsRef().find(skillId); + if (oldIt != owner_.playerSkillsRef().end() && skill.value > oldIt->second.value) { + auto catIt = owner_.skillLineCategoriesRef().find(skillId); + if (catIt != owner_.skillLineCategoriesRef().end()) { uint32_t category = catIt->second; if (category == 5 || category == 10 || category == 12) { continue; @@ -2093,28 +2094,28 @@ void SpellHandler::extractSkillFields(const std::map& fields } } - bool skillsChanged = (newSkills.size() != owner_.playerSkills_.size()); + bool skillsChanged = (newSkills.size() != owner_.playerSkillsRef().size()); if (!skillsChanged) { for (const auto& [id, sk] : newSkills) { - auto it = owner_.playerSkills_.find(id); - if (it == owner_.playerSkills_.end() || it->second.value != sk.value) { + auto it = owner_.playerSkillsRef().find(id); + if (it == owner_.playerSkillsRef().end() || it->second.value != sk.value) { skillsChanged = true; break; } } } - owner_.playerSkills_ = std::move(newSkills); + owner_.playerSkillsRef() = std::move(newSkills); if (skillsChanged) owner_.fireAddonEvent("SKILL_LINES_CHANGED", {}); } void SpellHandler::extractExploredZoneFields(const std::map& fields) { - const size_t zoneCount = owner_.packetParsers_ - ? static_cast(owner_.packetParsers_->exploredZonesCount()) + const size_t zoneCount = owner_.getPacketParsers() + ? static_cast(owner_.getPacketParsers()->exploredZonesCount()) : GameHandler::PLAYER_EXPLORED_ZONES_COUNT; - if (owner_.playerExploredZones_.size() != GameHandler::PLAYER_EXPLORED_ZONES_COUNT) { - owner_.playerExploredZones_.assign(GameHandler::PLAYER_EXPLORED_ZONES_COUNT, 0u); + if (owner_.playerExploredZonesRef().size() != GameHandler::PLAYER_EXPLORED_ZONES_COUNT) { + owner_.playerExploredZonesRef().assign(GameHandler::PLAYER_EXPLORED_ZONES_COUNT, 0u); } bool foundAny = false; @@ -2122,15 +2123,15 @@ void SpellHandler::extractExploredZoneFields(const std::map& const uint16_t fieldIdx = static_cast(fieldIndex(UF::PLAYER_EXPLORED_ZONES_START) + i); auto it = fields.find(fieldIdx); if (it == fields.end()) continue; - owner_.playerExploredZones_[i] = it->second; + owner_.playerExploredZonesRef()[i] = it->second; foundAny = true; } for (size_t i = zoneCount; i < GameHandler::PLAYER_EXPLORED_ZONES_COUNT; i++) { - owner_.playerExploredZones_[i] = 0u; + owner_.playerExploredZonesRef()[i] = 0u; } if (foundAny) { - owner_.hasPlayerExploredZones_ = true; + owner_.hasPlayerExploredZonesRef() = true; } } @@ -2141,15 +2142,15 @@ void SpellHandler::extractExploredZoneFields(const std::map& void SpellHandler::handleCastResult(network::Packet& packet) { uint32_t castResultSpellId = 0; uint8_t castResult = 0; - if (owner_.packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) { + if (owner_.getPacketParsers()->parseCastResult(packet, castResultSpellId, castResult)) { LOG_DEBUG("SMSG_CAST_RESULT: spellId=", castResultSpellId, " result=", static_cast(castResult)); if (castResult != 0) { casting_ = false; castIsChannel_ = false; currentCastSpellId_ = 0; castTimeRemaining_ = 0.0f; - owner_.lastInteractedGoGuid_ = 0; + owner_.lastInteractedGoGuidRef() = 0; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; queuedSpellTarget_ = 0; int playerPowerType = -1; - if (auto pe = owner_.getEntityManager().getEntity(owner_.playerGuid)) { + if (auto pe = owner_.getEntityManager().getEntity(owner_.getPlayerGuid())) { if (auto pu = std::dynamic_pointer_cast(pe)) playerPowerType = static_cast(pu->getPowerType()); } @@ -2157,7 +2158,7 @@ void SpellHandler::handleCastResult(network::Packet& packet) { std::string errMsg = reason ? reason : ("Spell cast failed (error " + std::to_string(castResult) + ")"); owner_.addUIError(errMsg); - if (owner_.spellCastFailedCallback_) owner_.spellCastFailedCallback_(castResultSpellId); + if (owner_.spellCastFailedCallbackRef()) owner_.spellCastFailedCallbackRef()(castResultSpellId); owner_.fireAddonEvent("UNIT_SPELLCAST_FAILED", {"player", std::to_string(castResultSpellId)}); owner_.fireAddonEvent("UNIT_SPELLCAST_STOP", {"player", std::to_string(castResultSpellId)}); MessageChatData msg; @@ -2174,12 +2175,12 @@ void SpellHandler::handleSpellFailedOther(network::Packet& packet) { uint64_t failOtherGuid = tbcLike2 ? (packet.hasRemaining(8) ? packet.readUInt64() : 0) : packet.readPackedGuid(); - if (failOtherGuid != 0 && failOtherGuid != owner_.playerGuid) { + if (failOtherGuid != 0 && failOtherGuid != owner_.getPlayerGuid()) { unitCastStates_.erase(failOtherGuid); - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { std::string unitId; - if (failOtherGuid == owner_.targetGuid) unitId = "target"; - else if (failOtherGuid == owner_.focusGuid) unitId = "focus"; + if (failOtherGuid == owner_.getTargetGuid()) unitId = "target"; + else if (failOtherGuid == owner_.focusGuidRef()) unitId = "focus"; if (!unitId.empty()) { owner_.fireAddonEvent("UNIT_SPELLCAST_FAILED", {unitId}); owner_.fireAddonEvent("UNIT_SPELLCAST_STOP", {unitId}); @@ -2193,7 +2194,7 @@ void SpellHandler::handleClearCooldown(network::Packet& packet) { if (packet.hasRemaining(4)) { uint32_t spellId = packet.readUInt32(); spellCooldowns_.erase(spellId); - for (auto& slot : owner_.actionBar) { + for (auto& slot : owner_.actionBarRef()) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) slot.cooldownRemaining = 0.0f; } @@ -2208,7 +2209,7 @@ void SpellHandler::handleModifyCooldown(network::Packet& packet) { auto it = spellCooldowns_.find(spellId); if (it != spellCooldowns_.end()) { it->second = std::max(0.0f, it->second + diffSec); - for (auto& slot : owner_.actionBar) { + for (auto& slot : owner_.actionBarRef()) { if (slot.type == ActionBarSlot::SPELL && slot.id == spellId) slot.cooldownRemaining = std::max(0.0f, slot.cooldownRemaining + diffSec); } @@ -2224,7 +2225,7 @@ void SpellHandler::handlePlaySpellVisual(network::Packet& packet) { auto* renderer = owner_.services().renderer; if (!renderer) return; glm::vec3 spawnPos; - if (casterGuid == owner_.playerGuid) { + if (casterGuid == owner_.getPlayerGuid()) { spawnPos = renderer->getCharacterPosition(); } else { auto entity = owner_.getEntityManager().getEntity(casterGuid); @@ -2232,11 +2233,11 @@ void SpellHandler::handlePlaySpellVisual(network::Packet& packet) { glm::vec3 canonical(entity->getLatestX(), entity->getLatestY(), entity->getLatestZ()); spawnPos = core::coords::canonicalToRender(canonical); } - renderer->playSpellVisual(visualId, spawnPos); + if (auto* sv = renderer->getSpellVisualSystem()) sv->playSpellVisual(visualId, spawnPos); } void SpellHandler::handleSpellModifier(network::Packet& packet, bool isFlat) { - auto& modMap = isFlat ? owner_.spellFlatMods_ : owner_.spellPctMods_; + auto& modMap = isFlat ? owner_.spellFlatModsRef() : owner_.spellPctModsRef(); while (packet.hasRemaining(6)) { uint8_t groupIndex = packet.readUInt8(); uint8_t modOpRaw = packet.readUInt8(); @@ -2257,7 +2258,7 @@ void SpellHandler::handleSpellDelayed(network::Packet& packet) { uint32_t delayMs = packet.readUInt32(); if (delayMs == 0) return; float delaySec = delayMs / 1000.0f; - if (caster == owner_.playerGuid) { + if (caster == owner_.getPlayerGuid()) { if (casting_) { castTimeRemaining_ += delaySec; castTimeTotal_ += delaySec; @@ -2354,10 +2355,10 @@ void SpellHandler::handleSpellLogMiss(network::Packet& packet) { // For REFLECT, use the reflected spell ID so combat text shows the spell name uint32_t combatSpellId = (ct == CombatTextEntry::REFLECT && miss.reflectSpellId != 0) ? miss.reflectSpellId : spellId; - if (casterGuid == owner_.playerGuid) { + if (casterGuid == owner_.getPlayerGuid()) { // We cast a spell and it missed the target owner_.addCombatText(ct, 0, combatSpellId, true, 0, casterGuid, victimGuid); - } else if (victimGuid == owner_.playerGuid) { + } else if (victimGuid == owner_.getPlayerGuid()) { // Enemy spell missed us (we dodged/parried/blocked/resisted/etc.) owner_.addCombatText(ct, 0, combatSpellId, false, 0, casterGuid, victimGuid); } @@ -2381,10 +2382,10 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) { uint8_t rawFailReason = packet.readUInt8(); // Classic result enum starts at 0=AFFECTING_COMBAT; shift +1 for WotLK table uint8_t failReason = isClassic ? static_cast(rawFailReason + 1) : rawFailReason; - if (failGuid == owner_.playerGuid && failReason != 0) { + if (failGuid == owner_.getPlayerGuid() && failReason != 0) { // Show interruption/failure reason in chat and error overlay for player int pt = -1; - if (auto pe = owner_.getEntityManager().getEntity(owner_.playerGuid)) + if (auto pe = owner_.getEntityManager().getEntity(owner_.getPlayerGuid())) if (auto pu = std::dynamic_pointer_cast(pe)) pt = static_cast(pu->getPowerType()); const char* reason = getSpellCastResultString(failReason, pt); @@ -2403,18 +2404,18 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) { } } // Fire UNIT_SPELLCAST_INTERRUPTED for Lua addons - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { auto unitId = (failGuid == 0) ? std::string("player") : owner_.guidToUnitId(failGuid); if (!unitId.empty()) { owner_.fireAddonEvent("UNIT_SPELLCAST_INTERRUPTED", {unitId}); owner_.fireAddonEvent("UNIT_SPELLCAST_STOP", {unitId}); } } - if (failGuid == owner_.playerGuid || failGuid == 0) { + if (failGuid == owner_.getPlayerGuid() || failGuid == 0) { // Player's own cast failed — clear gather-node loot target so the // next timed cast doesn't try to loot a stale interrupted gather node. casting_ = false; castIsChannel_ = false; currentCastSpellId_ = 0; - owner_.lastInteractedGoGuid_ = 0; + owner_.lastInteractedGoGuidRef() = 0; craftQueueSpellId_ = 0; craftQueueRemaining_ = 0; queuedSpellId_ = 0; @@ -2424,14 +2425,14 @@ void SpellHandler::handleSpellFailure(network::Packet& packet) { ssm->stopPrecast(); } } - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(owner_.playerGuid, false, false, SpellCastType::OMNI); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(owner_.getPlayerGuid(), false, false, SpellCastType::OMNI); } } else { // Another unit's cast failed — clear their tracked cast bar unitCastStates_.erase(failGuid); - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(failGuid, false, false, SpellCastType::OMNI); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(failGuid, false, false, SpellCastType::OMNI); } } } @@ -2455,9 +2456,9 @@ void SpellHandler::handleItemCooldown(network::Packet& packet) { } // Resolve itemId from the GUID so item-type slots are also updated uint32_t itemId = 0; - auto iit = owner_.onlineItems_.find(itemGuid); - if (iit != owner_.onlineItems_.end()) itemId = iit->second.entry; - for (auto& slot : owner_.actionBar) { + auto iit = owner_.onlineItemsRef().find(itemGuid); + if (iit != owner_.onlineItemsRef().end()) itemId = iit->second.entry; + for (auto& slot : owner_.actionBarRef()) { bool match = (spellId != 0 && slot.type == ActionBarSlot::SPELL && slot.id == spellId) || (itemId != 0 && slot.type == ActionBarSlot::ITEM && slot.id == itemId); if (match) { @@ -2505,7 +2506,7 @@ void SpellHandler::handleDispelFailed(network::Packet& packet) { /*uint64_t victim =*/ packet.readPackedGuid(); } // Only show failure to the player who attempted the dispel - if (dispelCasterGuid == owner_.playerGuid) { + if (dispelCasterGuid == owner_.getPlayerGuid()) { const auto& name = owner_.getSpellName(dispelSpellId); char buf[128]; if (!name.empty()) @@ -2532,9 +2533,9 @@ void SpellHandler::handleTotemCreated(network::Packet& packet) { LOG_DEBUG("SMSG_TOTEM_CREATED: slot=", static_cast(slot), " spellId=", spellId, " duration=", duration, "ms"); if (slot < GameHandler::NUM_TOTEM_SLOTS) { - owner_.activeTotemSlots_[slot].spellId = spellId; - owner_.activeTotemSlots_[slot].durationMs = duration; - owner_.activeTotemSlots_[slot].placedAt = std::chrono::steady_clock::now(); + owner_.activeTotemSlotsRef()[slot].spellId = spellId; + owner_.activeTotemSlotsRef()[slot].durationMs = duration; + owner_.activeTotemSlotsRef()[slot].placedAt = std::chrono::steady_clock::now(); } } @@ -2553,8 +2554,8 @@ void SpellHandler::handlePeriodicAuraLog(network::Packet& packet) { if (!packet.hasRemaining(8)) return; uint32_t spellId = packet.readUInt32(); uint32_t count = packet.readUInt32(); - bool isPlayerVictim = (victimGuid == owner_.playerGuid); - bool isPlayerCaster = (casterGuid == owner_.playerGuid); + bool isPlayerVictim = (victimGuid == owner_.getPlayerGuid()); + bool isPlayerCaster = (casterGuid == owner_.getPlayerGuid()); if (!isPlayerVictim && !isPlayerCaster) { packet.skipAll(); return; @@ -2666,8 +2667,8 @@ void SpellHandler::handleSpellEnergizeLog(network::Packet& packet) { uint32_t spellId = packet.readUInt32(); uint8_t energizePowerType = packet.readUInt8(); int32_t amount = static_cast(packet.readUInt32()); - bool isPlayerVictim = (victimGuid == owner_.playerGuid); - bool isPlayerCaster = (casterGuid == owner_.playerGuid); + bool isPlayerVictim = (victimGuid == owner_.getPlayerGuid()); + bool isPlayerCaster = (casterGuid == owner_.getPlayerGuid()); if ((isPlayerVictim || isPlayerCaster) && amount > 0) owner_.addCombatText(CombatTextEntry::ENERGIZE, amount, spellId, isPlayerCaster, energizePowerType, casterGuid, victimGuid); packet.skipAll(); @@ -2683,8 +2684,8 @@ void SpellHandler::handleExtraAuraInfo(network::Packet& packet, bool isInit) { uint8_t count = packet.readUInt8(); std::vector* auraList = nullptr; - if (auraTargetGuid == owner_.playerGuid) auraList = &playerAuras_; - else if (auraTargetGuid == owner_.targetGuid) auraList = &targetAuras_; + if (auraTargetGuid == owner_.getPlayerGuid()) auraList = &playerAuras_; + else if (auraTargetGuid == owner_.getTargetGuid()) auraList = &targetAuras_; else if (auraTargetGuid != 0) auraList = &unitAurasCache_[auraTargetGuid]; if (auraList && isInit) auraList->clear(); @@ -2754,7 +2755,7 @@ void SpellHandler::handleSpellDispelLog(network::Packet& packet) { } } // Show system message if player was victim or caster - if (victimGuid == owner_.playerGuid || casterGuid == owner_.playerGuid) { + if (victimGuid == owner_.getPlayerGuid() || casterGuid == owner_.getPlayerGuid()) { std::vector loggedIds; if (isStolen) { loggedIds.reserve(dispelledIds.size()); @@ -2771,19 +2772,19 @@ void SpellHandler::handleSpellDispelLog(network::Packet& packet) { char buf[256]; const char* passiveVerb = loggedIds.size() == 1 ? "was" : "were"; if (isStolen) { - if (victimGuid == owner_.playerGuid && casterGuid != owner_.playerGuid) + if (victimGuid == owner_.getPlayerGuid() && casterGuid != owner_.getPlayerGuid()) std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), passiveVerb); - else if (casterGuid == owner_.playerGuid) + else if (casterGuid == owner_.getPlayerGuid()) std::snprintf(buf, sizeof(buf), "You steal %s.", displaySpellNames.c_str()); else std::snprintf(buf, sizeof(buf), "%s %s stolen.", displaySpellNames.c_str(), passiveVerb); } else { - if (victimGuid == owner_.playerGuid && casterGuid != owner_.playerGuid) + if (victimGuid == owner_.getPlayerGuid() && casterGuid != owner_.getPlayerGuid()) std::snprintf(buf, sizeof(buf), "%s %s dispelled.", displaySpellNames.c_str(), passiveVerb); - else if (casterGuid == owner_.playerGuid) + else if (casterGuid == owner_.getPlayerGuid()) std::snprintf(buf, sizeof(buf), "You dispel %s.", displaySpellNames.c_str()); else std::snprintf(buf, sizeof(buf), "%s %s dispelled.", @@ -2793,7 +2794,7 @@ void SpellHandler::handleSpellDispelLog(network::Packet& packet) { } // Preserve stolen auras as spellsteal events so the log wording stays accurate. if (!loggedIds.empty()) { - bool isPlayerCaster = (casterGuid == owner_.playerGuid); + bool isPlayerCaster = (casterGuid == owner_.getPlayerGuid()); for (uint32_t dispelledId : loggedIds) { owner_.addCombatText(isStolen ? CombatTextEntry::STEAL : CombatTextEntry::DISPEL, 0, dispelledId, isPlayerCaster, 0, @@ -2844,7 +2845,7 @@ void SpellHandler::handleSpellStealLog(network::Packet& packet) { stolenIds.push_back(stolenId); } } - if (stealCaster == owner_.playerGuid || stealVictim == owner_.playerGuid) { + if (stealCaster == owner_.getPlayerGuid() || stealVictim == owner_.getPlayerGuid()) { std::vector loggedIds; loggedIds.reserve(stolenIds.size()); for (uint32_t stolenId : stolenIds) { @@ -2855,7 +2856,7 @@ void SpellHandler::handleSpellStealLog(network::Packet& packet) { const std::string stealDisplayNames = formatSpellNameList(owner_, loggedIds); if (!stealDisplayNames.empty()) { char buf[256]; - if (stealCaster == owner_.playerGuid) + if (stealCaster == owner_.getPlayerGuid()) std::snprintf(buf, sizeof(buf), "You stole %s.", stealDisplayNames.c_str()); else std::snprintf(buf, sizeof(buf), "%s %s stolen.", stealDisplayNames.c_str(), @@ -2865,7 +2866,7 @@ void SpellHandler::handleSpellStealLog(network::Packet& packet) { // Some servers emit both SPELLDISPELLOG(isStolen=1) and SPELLSTEALLOG // for the same aura. Keep the first event and suppress the duplicate. if (!loggedIds.empty()) { - bool isPlayerCaster = (stealCaster == owner_.playerGuid); + bool isPlayerCaster = (stealCaster == owner_.getPlayerGuid()); for (uint32_t stolenId : loggedIds) { owner_.addCombatText(CombatTextEntry::STEAL, 0, stolenId, isPlayerCaster, 0, stealCaster, stealVictim); @@ -2899,7 +2900,7 @@ void SpellHandler::handleSpellChanceProcLog(network::Packet& packet) { } uint32_t procSpellId = packet.readUInt32(); // Show a "PROC!" floating text when the player triggers the proc - if (procCasterGuid == owner_.playerGuid && procSpellId > 0) + if (procCasterGuid == owner_.getPlayerGuid() && procSpellId > 0) owner_.addCombatText(CombatTextEntry::PROC_TRIGGER, 0, procSpellId, true, 0, procCasterGuid, procTargetGuid); packet.skipAll(); @@ -2928,9 +2929,9 @@ void SpellHandler::handleSpellInstaKillLog(network::Packet& packet) { } uint32_t ikSpell = packet.readUInt32(); // Show kill/death feedback for the local player - if (ikCaster == owner_.playerGuid) { + if (ikCaster == owner_.getPlayerGuid()) { owner_.addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, true, 0, ikCaster, ikVictim); - } else if (ikVictim == owner_.playerGuid) { + } else if (ikVictim == owner_.getPlayerGuid()) { owner_.addCombatText(CombatTextEntry::INSTAKILL, 0, ikSpell, false, 0, ikCaster, ikVictim); owner_.addUIError("You were killed by an instant-kill effect."); owner_.addSystemChatMessage("You were killed by an instant-kill effect."); @@ -2966,7 +2967,7 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) { uint32_t exeEffectCount = packet.readUInt32(); exeEffectCount = std::min(exeEffectCount, 32u); // sanity - const bool isPlayerCaster = (exeCaster == owner_.playerGuid); + const bool isPlayerCaster = (exeCaster == owner_.getPlayerGuid()); for (uint32_t ei = 0; ei < exeEffectCount; ++ei) { if (!packet.hasRemaining(5)) break; uint8_t effectType = packet.readUInt8(); @@ -2987,12 +2988,12 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) { uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic float drainMult = packet.readFloat(); if (drainAmount > 0) { - if (drainTarget == owner_.playerGuid) + if (drainTarget == owner_.getPlayerGuid()) owner_.addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, false, static_cast(drainPower), exeCaster, drainTarget); if (isPlayerCaster) { - if (drainTarget != owner_.playerGuid) { + if (drainTarget != owner_.getPlayerGuid()) { owner_.addCombatText(CombatTextEntry::POWER_DRAIN, static_cast(drainAmount), exeSpellId, true, static_cast(drainPower), exeCaster, drainTarget); } @@ -3024,7 +3025,7 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) { uint32_t leechAmount = packet.readUInt32(); float leechMult = packet.readFloat(); if (leechAmount > 0) { - if (leechTarget == owner_.playerGuid) { + if (leechTarget == owner_.getPlayerGuid()) { owner_.addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false, 0, exeCaster, leechTarget); } else if (isPlayerCaster) { @@ -3087,7 +3088,7 @@ void SpellHandler::handleSpellLogExecute(network::Packet& packet) { // Clear the interrupted unit's cast bar immediately unitCastStates_.erase(icTarget); // Record interrupt in combat log when player is involved - if (isPlayerCaster || icTarget == owner_.playerGuid) + if (isPlayerCaster || icTarget == owner_.getPlayerGuid()) owner_.addCombatText(CombatTextEntry::INTERRUPT, 0, icSpellId, isPlayerCaster, 0, exeCaster, icTarget); LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId, @@ -3124,8 +3125,8 @@ void SpellHandler::handleClearExtraAuraInfo(network::Packet& packet) { uint64_t clearGuid = packet.readUInt64(); uint8_t slot = packet.readUInt8(); std::vector* auraList = nullptr; - if (clearGuid == owner_.playerGuid) auraList = &playerAuras_; - else if (clearGuid == owner_.targetGuid) auraList = &targetAuras_; + if (clearGuid == owner_.getPlayerGuid()) auraList = &playerAuras_; + else if (clearGuid == owner_.getTargetGuid()) auraList = &targetAuras_; if (auraList && slot < auraList->size()) { (*auraList)[slot] = AuraSlot{}; } @@ -3153,17 +3154,17 @@ void SpellHandler::handleItemEnchantTimeUpdate(network::Packet& packet) { if (durationSec == 0) { // Enchant expired / removed — erase the slot entry - owner_.tempEnchantTimers_.erase( - std::remove_if(owner_.tempEnchantTimers_.begin(), owner_.tempEnchantTimers_.end(), + owner_.tempEnchantTimersRef().erase( + std::remove_if(owner_.tempEnchantTimersRef().begin(), owner_.tempEnchantTimersRef().end(), [enchSlot](const GameHandler::TempEnchantTimer& t) { return t.slot == enchSlot; }), - owner_.tempEnchantTimers_.end()); + owner_.tempEnchantTimersRef().end()); } else { uint64_t expireMs = nowMs + static_cast(durationSec) * 1000u; bool found = false; - for (auto& t : owner_.tempEnchantTimers_) { + for (auto& t : owner_.tempEnchantTimersRef()) { if (t.slot == enchSlot) { t.expireMs = expireMs; found = true; break; } } - if (!found) owner_.tempEnchantTimers_.push_back({enchSlot, expireMs}); + if (!found) owner_.tempEnchantTimersRef().push_back({enchSlot, expireMs}); // Warn at important thresholds if (durationSec <= 60 && durationSec > 55) { @@ -3197,7 +3198,7 @@ void SpellHandler::handleResumeCastBar(network::Packet& packet) { uint32_t remainMs = packet.readUInt32(); uint32_t totalMs = packet.readUInt32(); if (totalMs > 0) { - if (caster == owner_.playerGuid) { + if (caster == owner_.getPlayerGuid()) { casting_ = true; castIsChannel_ = false; currentCastSpellId_ = spellId; @@ -3225,7 +3226,7 @@ void SpellHandler::handleChannelStart(network::Packet& packet) { uint32_t chanSpellId = packet.readUInt32(); uint32_t chanTotalMs = packet.readUInt32(); if (chanTotalMs > 0 && chanCaster != 0) { - if (chanCaster == owner_.playerGuid) { + if (chanCaster == owner_.getPlayerGuid()) { casting_ = true; castIsChannel_ = true; currentCastSpellId_ = chanSpellId; @@ -3246,14 +3247,14 @@ void SpellHandler::handleChannelStart(network::Packet& packet) { // Play channeling animation (looping) // Channel packets don't carry targetGuid — use player's current target as hint SpellCastType chanType = SpellCastType::OMNI; - if (chanCaster == owner_.playerGuid && owner_.targetGuid != 0) + if (chanCaster == owner_.getPlayerGuid() && owner_.getTargetGuid() != 0) chanType = SpellCastType::DIRECTED; - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(chanCaster, true, true, chanType); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(chanCaster, true, true, chanType); } // Fire UNIT_SPELLCAST_CHANNEL_START for Lua addons - if (owner_.addonEventCallback_) { + if (owner_.addonEventCallbackRef()) { auto unitId = owner_.guidToUnitId(chanCaster); if (!unitId.empty()) owner_.fireAddonEvent("UNIT_SPELLCAST_CHANNEL_START", {unitId, std::to_string(chanSpellId)}); @@ -3269,7 +3270,7 @@ void SpellHandler::handleChannelUpdate(network::Packet& packet) { : packet.readPackedGuid(); if (!packet.hasRemaining(4)) return; uint32_t chanRemainMs = packet.readUInt32(); - if (chanCaster2 == owner_.playerGuid) { + if (chanCaster2 == owner_.getPlayerGuid()) { castTimeRemaining_ = chanRemainMs / 1000.0f; if (chanRemainMs == 0) { casting_ = false; @@ -3288,8 +3289,8 @@ void SpellHandler::handleChannelUpdate(network::Packet& packet) { // Fire UNIT_SPELLCAST_CHANNEL_STOP when channel ends if (chanRemainMs == 0) { // Stop channeling animation — return to idle - if (owner_.spellCastAnimCallback_) { - owner_.spellCastAnimCallback_(chanCaster2, false, true, SpellCastType::OMNI); + if (owner_.spellCastAnimCallbackRef()) { + owner_.spellCastAnimCallbackRef()(chanCaster2, false, true, SpellCastType::OMNI); } auto unitId = owner_.guidToUnitId(chanCaster2); if (!unitId.empty()) @@ -3302,27 +3303,27 @@ void SpellHandler::handleChannelUpdate(network::Packet& packet) { // ============================================================ void SpellHandler::requestStabledPetList() { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || owner_.stableMasterGuid_ == 0) return; - auto pkt = ListStabledPetsPacket::build(owner_.stableMasterGuid_); - owner_.socket->send(pkt); - LOG_INFO("Sent MSG_LIST_STABLED_PETS to npc=0x", std::hex, owner_.stableMasterGuid_, std::dec); + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || owner_.stableMasterGuidRef() == 0) return; + auto pkt = ListStabledPetsPacket::build(owner_.stableMasterGuidRef()); + owner_.getSocket()->send(pkt); + LOG_INFO("Sent MSG_LIST_STABLED_PETS to npc=0x", std::hex, owner_.stableMasterGuidRef(), std::dec); } void SpellHandler::stablePet(uint8_t slot) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || owner_.stableMasterGuid_ == 0) return; - if (owner_.petGuid_ == 0) { + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || owner_.stableMasterGuidRef() == 0) return; + if (owner_.petGuidRef() == 0) { owner_.addSystemChatMessage("You do not have an active pet to stable."); return; } - auto pkt = StablePetPacket::build(owner_.stableMasterGuid_, slot); - owner_.socket->send(pkt); + auto pkt = StablePetPacket::build(owner_.stableMasterGuidRef(), slot); + owner_.getSocket()->send(pkt); LOG_INFO("Sent CMSG_STABLE_PET: slot=", static_cast(slot)); } void SpellHandler::unstablePet(uint32_t petNumber) { - if (owner_.state != WorldState::IN_WORLD || !owner_.socket || owner_.stableMasterGuid_ == 0 || petNumber == 0) return; - auto pkt = UnstablePetPacket::build(owner_.stableMasterGuid_, petNumber); - owner_.socket->send(pkt); + if (owner_.getState() != WorldState::IN_WORLD || !owner_.getSocket() || owner_.stableMasterGuidRef() == 0 || petNumber == 0) return; + auto pkt = UnstablePetPacket::build(owner_.stableMasterGuidRef(), petNumber); + owner_.getSocket()->send(pkt); LOG_INFO("Sent CMSG_UNSTABLE_PET: petNumber=", petNumber); } diff --git a/src/game/warden_handler.cpp b/src/game/warden_handler.cpp index a5f38714..ca3fa98d 100644 --- a/src/game/warden_handler.cpp +++ b/src/game/warden_handler.cpp @@ -193,8 +193,8 @@ void WardenHandler::update(float deltaTime) { for (uint8_t byte : encrypted) { response.writeUInt8(byte); } - if (owner_.socket && owner_.socket->isConnected()) { - owner_.socket->send(response); + if (owner_.getSocket() && owner_.getSocket()->isConnected()) { + owner_.getSocket()->send(response); LOG_WARNING("Warden: Sent async CHEAT_CHECKS_RESULT (", plaintext.size(), " bytes plaintext)"); } } @@ -202,11 +202,11 @@ void WardenHandler::update(float deltaTime) { } // Post-gate visibility - if (wardenGateSeen_ && owner_.socket && owner_.socket->isConnected()) { + if (wardenGateSeen_ && owner_.getSocket() && owner_.getSocket()->isConnected()) { wardenGateElapsed_ += deltaTime; if (wardenGateElapsed_ >= wardenGateNextStatusLog_) { LOG_DEBUG("Warden gate status: elapsed=", wardenGateElapsed_, - "s connected=", owner_.socket->isConnected() ? "yes" : "no", + "s connected=", owner_.getSocket()->isConnected() ? "yes" : "no", " packetsAfterGate=", wardenPacketsAfterGate_); wardenGateNextStatusLog_ += 30.0f; } @@ -302,12 +302,12 @@ void WardenHandler::handleWardenData(network::Packet& packet) { // Initialize Warden crypto from session key on first packet if (!wardenCrypto_) { wardenCrypto_ = std::make_unique(); - if (owner_.sessionKey.size() != 40) { - LOG_ERROR("Warden: No valid session key (size=", owner_.sessionKey.size(), "), cannot init crypto"); + if (owner_.getSessionKey().size() != 40) { + LOG_ERROR("Warden: No valid session key (size=", owner_.getSessionKey().size(), "), cannot init crypto"); wardenCrypto_.reset(); return; } - if (!wardenCrypto_->initFromSessionKey(owner_.sessionKey)) { + if (!wardenCrypto_->initFromSessionKey(owner_.getSessionKey())) { LOG_ERROR("Warden: Failed to initialize crypto from session key"); wardenCrypto_.reset(); return; @@ -348,8 +348,8 @@ void WardenHandler::handleWardenData(network::Packet& packet) { for (uint8_t byte : encrypted) { response.writeUInt8(byte); } - if (owner_.socket && owner_.socket->isConnected()) { - owner_.socket->send(response); + if (owner_.getSocket() && owner_.getSocket()->isConnected()) { + owner_.getSocket()->send(response); LOG_DEBUG("Warden: Sent response (", plaintext.size(), " bytes plaintext)"); } }; @@ -447,12 +447,12 @@ void WardenHandler::handleWardenData(network::Packet& packet) { wardenLoadedModule_->setCallbackDependencies( wardenCrypto_.get(), [this](const uint8_t* data, size_t len) { - if (!wardenCrypto_ || !owner_.socket) return; + if (!wardenCrypto_ || !owner_.getSocket()) return; std::vector plaintext(data, data + len); auto encrypted = wardenCrypto_->encrypt(plaintext); network::Packet pkt(wireOpcode(Opcode::CMSG_WARDEN_DATA)); for (uint8_t b : encrypted) pkt.writeUInt8(b); - owner_.socket->send(pkt); + owner_.getSocket()->send(pkt); LOG_DEBUG("Warden: Module sendPacket callback sent ", len, " bytes"); }); if (wardenLoadedModule_->load(wardenModuleData_, wardenModuleHash_, wardenModuleKey_)) { // codeql[cpp/weak-cryptographic-algorithm] @@ -533,7 +533,7 @@ void WardenHandler::handleWardenData(network::Packet& packet) { for (auto b : seed) { char s[4]; snprintf(s, 4, "%02x", b); seedHex += s; } bool isTurtle = isActiveExpansion("turtle"); - bool isClassic = (owner_.build <= 6005) && !isTurtle; + bool isClassic = (owner_.getBuild() <= 6005) && !isTurtle; if (!isTurtle && !isClassic) { // WotLK/TBC: don't respond to HASH_REQUEST without a valid CR match. @@ -619,7 +619,7 @@ void WardenHandler::handleWardenData(network::Packet& packet) { // Ensure wardenMemory_ is loaded on main thread before launching async task if (!wardenMemory_) { wardenMemory_ = std::make_unique(); - if (!wardenMemory_->load(static_cast(owner_.build), isActiveExpansion("turtle"))) { + if (!wardenMemory_->load(static_cast(owner_.getBuild()), isActiveExpansion("turtle"))) { LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK"); } } @@ -1054,7 +1054,7 @@ void WardenHandler::handleWardenData(network::Packet& packet) { // Lazy-load WoW.exe PE image on first MEM_CHECK if (!wardenMemory_) { wardenMemory_ = std::make_unique(); - if (!wardenMemory_->load(static_cast(owner_.build), isActiveExpansion("turtle"))) { + if (!wardenMemory_->load(static_cast(owner_.getBuild()), isActiveExpansion("turtle"))) { LOG_WARNING("Warden: Could not load WoW.exe for MEM_CHECK"); } } diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 8df1dec0..025ead10 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1456,4458 +1456,5 @@ bool DestroyObjectParser::parse(network::Packet& packet, DestroyObjectData& data return true; } -network::Packet MessageChatPacket::build(ChatType type, - ChatLanguage language, - const std::string& message, - const std::string& target) { - network::Packet packet(wireOpcode(Opcode::CMSG_MESSAGECHAT)); - - // Write chat type - packet.writeUInt32(static_cast(type)); - - // Write language - packet.writeUInt32(static_cast(language)); - - // Write target (for whispers) or channel name - if (type == ChatType::WHISPER) { - packet.writeString(target); - } else if (type == ChatType::CHANNEL) { - packet.writeString(target); // Channel name - } - - // Write message - packet.writeString(message); - - LOG_DEBUG("Built CMSG_MESSAGECHAT packet"); - LOG_DEBUG(" Type: ", static_cast(type)); - LOG_DEBUG(" Language: ", static_cast(language)); - LOG_DEBUG(" Message: ", message); - - return packet; -} - -bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { - // SMSG_MESSAGECHAT format (WoW 3.3.5a): - // uint8 type - // uint32 language - // uint64 senderGuid - // uint32 unknown (always 0) - // [type-specific data] - // uint32 messageLength - // string message - // uint8 chatTag - - if (packet.getSize() < 15) { - LOG_ERROR("SMSG_MESSAGECHAT packet too small: ", packet.getSize(), " bytes"); - return false; - } - - // Read chat type - uint8_t typeVal = packet.readUInt8(); - data.type = static_cast(typeVal); - - // Read language - uint32_t langVal = packet.readUInt32(); - data.language = static_cast(langVal); - - // Read sender GUID - data.senderGuid = packet.readUInt64(); - - // Read unknown field - packet.readUInt32(); - - auto tryReadSizedCString = [&](std::string& out, uint32_t maxLen, size_t minTrailingBytes) -> bool { - size_t start = packet.getReadPos(); - size_t remaining = packet.getSize() - start; - if (remaining < 4 + minTrailingBytes) return false; - - uint32_t len = packet.readUInt32(); - if (len < 2 || len > maxLen) { - packet.setReadPos(start); - return false; - } - if (!packet.hasRemaining(static_cast(len) + minTrailingBytes)) { - packet.setReadPos(start); - return false; - } - - // Stack buffer for typical messages; heap fallback for oversized ones. - static constexpr uint32_t kStackBufSize = 256; - std::array stackBuf; - std::string heapBuf; - char* buf; - if (len <= kStackBufSize) { - buf = stackBuf.data(); - } else { - heapBuf.resize(len); - buf = heapBuf.data(); - } - - for (uint32_t i = 0; i < len; ++i) { - buf[i] = static_cast(packet.readUInt8()); - } - if (buf[len - 1] != '\0') { - packet.setReadPos(start); - return false; - } - // len >= 2 guaranteed above, so len-1 >= 1 — string body is non-empty. - for (uint32_t i = 0; i < len - 1; ++i) { - auto uc = static_cast(buf[i]); - if (uc < 32 || uc > 126) { - packet.setReadPos(start); - return false; - } - } - - out.assign(buf, len - 1); - return true; - }; - - // Type-specific data - // WoW 3.3.5 SMSG_MESSAGECHAT format: after senderGuid+unk, most types - // have a receiverGuid (uint64). Some types have extra fields before it. - switch (data.type) { - case ChatType::MONSTER_SAY: - case ChatType::MONSTER_YELL: - case ChatType::MONSTER_EMOTE: - case ChatType::MONSTER_WHISPER: - case ChatType::MONSTER_PARTY: - case ChatType::RAID_BOSS_EMOTE: - case ChatType::RAID_BOSS_WHISPER: { - // Read sender name (SizedCString: uint32 len including null + chars) - uint32_t nameLen = packet.readUInt32(); - if (nameLen > packet.getRemainingSize()) return false; - if (nameLen > 0 && nameLen < 256) { - data.senderName.resize(nameLen); - for (uint32_t i = 0; i < nameLen; ++i) { - data.senderName[i] = static_cast(packet.readUInt8()); - } - // Strip trailing null (server includes it in nameLen) - if (!data.senderName.empty() && data.senderName.back() == '\0') { - data.senderName.pop_back(); - } - } - // Read receiver GUID (NamedGuid: guid + optional name for non-player targets) - data.receiverGuid = packet.readUInt64(); - if (data.receiverGuid != 0) { - // WoW GUID type encoding: bits 48-63 identify entity type. - // Players have highGuid=0x0000. Pets use 0xF040 (active pet) or - // 0xF014 (creature treated as pet). Mask 0xF0FF isolates the type - // nibbles while ignoring the server-specific middle bits. - constexpr uint16_t kGuidTypeMask = 0xF0FF; - constexpr uint16_t kGuidTypePet = 0xF040; - constexpr uint16_t kGuidTypeVehicle = 0xF014; - uint16_t highGuid = static_cast(data.receiverGuid >> 48); - bool isPlayer = (highGuid == 0x0000); - bool isPet = ((highGuid & kGuidTypeMask) == kGuidTypePet) || - ((highGuid & kGuidTypeMask) == kGuidTypeVehicle); - if (!isPlayer && !isPet) { - // Read receiver name (SizedCString) - uint32_t recvNameLen = packet.readUInt32(); - if (recvNameLen > 0 && recvNameLen < 256) { - packet.setReadPos(packet.getReadPos() + recvNameLen); - } - } - } - break; - } - - case ChatType::CHANNEL: { - // Read channel name, then receiver GUID - data.channelName = packet.readString(); - data.receiverGuid = packet.readUInt64(); - break; - } - - case ChatType::ACHIEVEMENT: - case ChatType::GUILD_ACHIEVEMENT: { - // Read target GUID - data.receiverGuid = packet.readUInt64(); - break; - } - - case ChatType::WHISPER: - case ChatType::WHISPER_INFORM: { - // Some cores include an explicit sized sender/receiver name for whisper chat. - // Consume it when present so /r has a reliable last whisper sender. - if (data.type == ChatType::WHISPER) { - tryReadSizedCString(data.senderName, 128, 8 + 4 + 1); - } else { - tryReadSizedCString(data.receiverName, 128, 8 + 4 + 1); - } - - data.receiverGuid = packet.readUInt64(); - - // Optional trailing whisper target/source name on some formats. - if (data.type == ChatType::WHISPER && data.receiverName.empty()) { - tryReadSizedCString(data.receiverName, 128, 4 + 1); - } else if (data.type == ChatType::WHISPER_INFORM && data.senderName.empty()) { - tryReadSizedCString(data.senderName, 128, 4 + 1); - } - break; - } - - case ChatType::BG_SYSTEM_NEUTRAL: - case ChatType::BG_SYSTEM_ALLIANCE: - case ChatType::BG_SYSTEM_HORDE: - // BG/Arena system messages — no sender GUID or name field, just message. - // Reclassify as SYSTEM for consistent display. - data.type = ChatType::SYSTEM; - break; - - default: - // SAY, GUILD, PARTY, YELL, WHISPER, WHISPER_INFORM, RAID, etc. - // All have receiverGuid (typically senderGuid repeated) - data.receiverGuid = packet.readUInt64(); - break; - } - - // Read message length - uint32_t messageLen = packet.readUInt32(); - if (messageLen > packet.getRemainingSize()) return false; - - // Read message - if (messageLen > 0 && messageLen < 8192) { - data.message.resize(messageLen); - for (uint32_t i = 0; i < messageLen; ++i) { - data.message[i] = static_cast(packet.readUInt8()); - } - // Strip trailing null terminator (servers include it in messageLen) - if (!data.message.empty() && data.message.back() == '\0') { - data.message.pop_back(); - } - } - - // Read chat tag - data.chatTag = packet.readUInt8(); - - LOG_DEBUG("Parsed SMSG_MESSAGECHAT:"); - LOG_DEBUG(" Type: ", getChatTypeString(data.type)); - LOG_DEBUG(" Language: ", static_cast(data.language)); - LOG_DEBUG(" Sender GUID: 0x", std::hex, data.senderGuid, std::dec); - if (!data.senderName.empty()) { - LOG_DEBUG(" Sender name: ", data.senderName); - } - if (!data.channelName.empty()) { - LOG_DEBUG(" Channel: ", data.channelName); - } - LOG_DEBUG(" Message: ", data.message); - LOG_DEBUG(" Chat tag: 0x", std::hex, static_cast(data.chatTag), std::dec); - - return true; -} - -const char* getChatTypeString(ChatType type) { - switch (type) { - case ChatType::SAY: return "SAY"; - case ChatType::PARTY: return "PARTY"; - case ChatType::RAID: return "RAID"; - case ChatType::GUILD: return "GUILD"; - case ChatType::OFFICER: return "OFFICER"; - case ChatType::YELL: return "YELL"; - case ChatType::WHISPER: return "WHISPER"; - case ChatType::WHISPER_INFORM: return "WHISPER_INFORM"; - case ChatType::EMOTE: return "EMOTE"; - case ChatType::TEXT_EMOTE: return "TEXT_EMOTE"; - case ChatType::SYSTEM: return "SYSTEM"; - case ChatType::MONSTER_SAY: return "MONSTER_SAY"; - case ChatType::MONSTER_YELL: return "MONSTER_YELL"; - case ChatType::MONSTER_EMOTE: return "MONSTER_EMOTE"; - case ChatType::CHANNEL: return "CHANNEL"; - case ChatType::CHANNEL_JOIN: return "CHANNEL_JOIN"; - case ChatType::CHANNEL_LEAVE: return "CHANNEL_LEAVE"; - case ChatType::CHANNEL_LIST: return "CHANNEL_LIST"; - case ChatType::CHANNEL_NOTICE: return "CHANNEL_NOTICE"; - case ChatType::CHANNEL_NOTICE_USER: return "CHANNEL_NOTICE_USER"; - case ChatType::AFK: return "AFK"; - case ChatType::DND: return "DND"; - case ChatType::IGNORED: return "IGNORED"; - case ChatType::SKILL: return "SKILL"; - case ChatType::LOOT: return "LOOT"; - case ChatType::BATTLEGROUND: return "BATTLEGROUND"; - case ChatType::BATTLEGROUND_LEADER: return "BATTLEGROUND_LEADER"; - case ChatType::RAID_LEADER: return "RAID_LEADER"; - case ChatType::RAID_WARNING: return "RAID_WARNING"; - case ChatType::ACHIEVEMENT: return "ACHIEVEMENT"; - case ChatType::GUILD_ACHIEVEMENT: return "GUILD_ACHIEVEMENT"; - default: return "UNKNOWN"; - } -} - -// ============================================================ -// Text Emotes -// ============================================================ - -network::Packet TextEmotePacket::build(uint32_t textEmoteId, uint64_t targetGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_TEXT_EMOTE)); - packet.writeUInt32(textEmoteId); - packet.writeUInt32(0); // emoteNum (unused) - packet.writeUInt64(targetGuid); - LOG_DEBUG("Built CMSG_TEXT_EMOTE: emoteId=", textEmoteId, " target=0x", std::hex, targetGuid, std::dec); - return packet; -} - -bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data, bool legacyFormat) { - size_t bytesLeft = packet.getRemainingSize(); - if (bytesLeft < 20) { - LOG_WARNING("SMSG_TEXT_EMOTE too short: ", bytesLeft, " bytes"); - return false; - } - - if (legacyFormat) { - // Classic 1.12 / TBC 2.4.3: textEmoteId(u32) + emoteNum(u32) + senderGuid(u64) - data.textEmoteId = packet.readUInt32(); - data.emoteNum = packet.readUInt32(); - data.senderGuid = packet.readUInt64(); - } else { - // WotLK 3.3.5a: senderGuid(u64) + textEmoteId(u32) + emoteNum(u32) - data.senderGuid = packet.readUInt64(); - data.textEmoteId = packet.readUInt32(); - data.emoteNum = packet.readUInt32(); - } - - uint32_t nameLen = packet.readUInt32(); - if (nameLen > 0 && nameLen <= 256) { - data.targetName = packet.readString(); - } else if (nameLen > 0) { - // Implausible name length — misaligned read - return false; - } - return true; -} - -// ============================================================ -// Channel System -// ============================================================ - -network::Packet JoinChannelPacket::build(const std::string& channelName, const std::string& password) { - network::Packet packet(wireOpcode(Opcode::CMSG_JOIN_CHANNEL)); - packet.writeUInt32(0); // channelId (unused) - packet.writeUInt8(0); // hasVoice - packet.writeUInt8(0); // joinedByZone - packet.writeString(channelName); - packet.writeString(password); - LOG_DEBUG("Built CMSG_JOIN_CHANNEL: channel=", channelName); - return packet; -} - -network::Packet LeaveChannelPacket::build(const std::string& channelName) { - network::Packet packet(wireOpcode(Opcode::CMSG_LEAVE_CHANNEL)); - packet.writeUInt32(0); // channelId (unused) - packet.writeString(channelName); - LOG_DEBUG("Built CMSG_LEAVE_CHANNEL: channel=", channelName); - return packet; -} - -bool ChannelNotifyParser::parse(network::Packet& packet, ChannelNotifyData& data) { - size_t bytesLeft = packet.getRemainingSize(); - if (bytesLeft < 2) { - LOG_WARNING("SMSG_CHANNEL_NOTIFY too short"); - return false; - } - data.notifyType = static_cast(packet.readUInt8()); - data.channelName = packet.readString(); - // Some notification types have additional fields (guid, etc.) - bytesLeft = packet.getRemainingSize(); - if (bytesLeft >= 8) { - data.senderGuid = packet.readUInt64(); - } - return true; -} - -// ============================================================ -// Phase 1: Foundation — Targeting, Name Queries -// ============================================================ - -network::Packet SetSelectionPacket::build(uint64_t targetGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_SET_SELECTION)); - packet.writeUInt64(targetGuid); - LOG_DEBUG("Built CMSG_SET_SELECTION: target=0x", std::hex, targetGuid, std::dec); - return packet; -} - -network::Packet SetActiveMoverPacket::build(uint64_t guid) { - network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTIVE_MOVER)); - packet.writeUInt64(guid); - LOG_DEBUG("Built CMSG_SET_ACTIVE_MOVER: guid=0x", std::hex, guid, std::dec); - return packet; -} - -network::Packet InspectPacket::build(uint64_t targetGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_INSPECT)); - packet.writePackedGuid(targetGuid); - LOG_DEBUG("Built CMSG_INSPECT: target=0x", std::hex, targetGuid, std::dec); - return packet; -} - -network::Packet QueryInspectAchievementsPacket::build(uint64_t targetGuid) { - // CMSG_QUERY_INSPECT_ACHIEVEMENTS: PackedGuid targetGuid - network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_INSPECT_ACHIEVEMENTS)); - packet.writePackedGuid(targetGuid); - LOG_DEBUG("Built CMSG_QUERY_INSPECT_ACHIEVEMENTS: target=0x", std::hex, targetGuid, std::dec); - return packet; -} - -// ============================================================ -// Server Info Commands -// ============================================================ - -network::Packet QueryTimePacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_TIME)); - LOG_DEBUG("Built CMSG_QUERY_TIME"); - return packet; -} - -bool QueryTimeResponseParser::parse(network::Packet& packet, QueryTimeResponseData& data) { - // Validate minimum packet size: serverTime(4) + timeOffset(4) - if (!packet.hasRemaining(8)) { - LOG_WARNING("SMSG_QUERY_TIME_RESPONSE: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.serverTime = packet.readUInt32(); - data.timeOffset = packet.readUInt32(); - LOG_DEBUG("Parsed SMSG_QUERY_TIME_RESPONSE: time=", data.serverTime, " offset=", data.timeOffset); - return true; -} - -network::Packet RequestPlayedTimePacket::build(bool sendToChat) { - network::Packet packet(wireOpcode(Opcode::CMSG_PLAYED_TIME)); - packet.writeUInt8(sendToChat ? 1 : 0); - LOG_DEBUG("Built CMSG_PLAYED_TIME: sendToChat=", sendToChat); - return packet; -} - -bool PlayedTimeParser::parse(network::Packet& packet, PlayedTimeData& data) { - // Classic/Turtle may omit the trailing trigger-message byte and send only - // totalTime(4) + levelTime(4). Later expansions append triggerMsg(1). - if (!packet.hasRemaining(8)) { - LOG_WARNING("SMSG_PLAYED_TIME: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.totalTimePlayed = packet.readUInt32(); - data.levelTimePlayed = packet.readUInt32(); - data.triggerMessage = (packet.hasRemaining(1)) && (packet.readUInt8() != 0); - LOG_DEBUG("Parsed SMSG_PLAYED_TIME: total=", data.totalTimePlayed, " level=", data.levelTimePlayed); - return true; -} - -network::Packet WhoPacket::build(uint32_t minLevel, uint32_t maxLevel, - const std::string& playerName, - const std::string& guildName, - uint32_t raceMask, uint32_t classMask, - uint32_t zones) { - network::Packet packet(wireOpcode(Opcode::CMSG_WHO)); - packet.writeUInt32(minLevel); - packet.writeUInt32(maxLevel); - packet.writeString(playerName); - packet.writeString(guildName); - packet.writeUInt32(raceMask); - packet.writeUInt32(classMask); - packet.writeUInt32(zones); // Number of zone IDs (0 = no zone filter) - // Zone ID array would go here if zones > 0 - packet.writeUInt32(0); // stringCount (number of search strings) - // String array would go here if stringCount > 0 - LOG_DEBUG("Built CMSG_WHO: player=", playerName); - return packet; -} - -// ============================================================ -// Social Commands -// ============================================================ - -network::Packet AddFriendPacket::build(const std::string& playerName, const std::string& note) { - network::Packet packet(wireOpcode(Opcode::CMSG_ADD_FRIEND)); - packet.writeString(playerName); - packet.writeString(note); - LOG_DEBUG("Built CMSG_ADD_FRIEND: player=", playerName); - return packet; -} - -network::Packet DelFriendPacket::build(uint64_t friendGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_DEL_FRIEND)); - packet.writeUInt64(friendGuid); - LOG_DEBUG("Built CMSG_DEL_FRIEND: guid=0x", std::hex, friendGuid, std::dec); - return packet; -} - -network::Packet SetContactNotesPacket::build(uint64_t friendGuid, const std::string& note) { - network::Packet packet(wireOpcode(Opcode::CMSG_SET_CONTACT_NOTES)); - packet.writeUInt64(friendGuid); - packet.writeString(note); - LOG_DEBUG("Built CMSG_SET_CONTACT_NOTES: guid=0x", std::hex, friendGuid, std::dec); - return packet; -} - -bool FriendStatusParser::parse(network::Packet& packet, FriendStatusData& data) { - // Validate minimum packet size: status(1) + guid(8) - if (!packet.hasRemaining(9)) { - LOG_WARNING("SMSG_FRIEND_STATUS: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.status = packet.readUInt8(); - data.guid = packet.readUInt64(); - if (data.status == 1) { // Online - // Conditional: note (string) + chatFlag (1) - if (packet.hasData()) { - data.note = packet.readString(); - if (packet.hasRemaining(1)) { - data.chatFlag = packet.readUInt8(); - } - } - } - LOG_DEBUG("Parsed SMSG_FRIEND_STATUS: status=", static_cast(data.status), " guid=0x", std::hex, data.guid, std::dec); - return true; -} - -network::Packet AddIgnorePacket::build(const std::string& playerName) { - network::Packet packet(wireOpcode(Opcode::CMSG_ADD_IGNORE)); - packet.writeString(playerName); - LOG_DEBUG("Built CMSG_ADD_IGNORE: player=", playerName); - return packet; -} - -network::Packet DelIgnorePacket::build(uint64_t ignoreGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_DEL_IGNORE)); - packet.writeUInt64(ignoreGuid); - LOG_DEBUG("Built CMSG_DEL_IGNORE: guid=0x", std::hex, ignoreGuid, std::dec); - return packet; -} - -network::Packet ComplainPacket::build(uint64_t targetGuid, const std::string& reason) { - network::Packet packet(wireOpcode(Opcode::CMSG_COMPLAIN)); - packet.writeUInt8(1); // complaintType: 1 = spam - packet.writeUInt64(targetGuid); - packet.writeUInt32(0); // unk - packet.writeUInt32(0); // messageType - packet.writeUInt32(0); // channelId - packet.writeUInt32(static_cast(time(nullptr))); // timestamp - packet.writeString(reason); - LOG_DEBUG("Built CMSG_COMPLAIN: target=0x", std::hex, targetGuid, std::dec, " reason=", reason); - return packet; -} - -// ============================================================ -// Logout Commands -// ============================================================ - -network::Packet LogoutRequestPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_LOGOUT_REQUEST)); - LOG_DEBUG("Built CMSG_LOGOUT_REQUEST"); - return packet; -} - -network::Packet LogoutCancelPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_LOGOUT_CANCEL)); - LOG_DEBUG("Built CMSG_LOGOUT_CANCEL"); - return packet; -} - -bool LogoutResponseParser::parse(network::Packet& packet, LogoutResponseData& data) { - // Validate minimum packet size: result(4) + instant(1) - if (!packet.hasRemaining(5)) { - LOG_WARNING("SMSG_LOGOUT_RESPONSE: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.result = packet.readUInt32(); - data.instant = packet.readUInt8(); - LOG_DEBUG("Parsed SMSG_LOGOUT_RESPONSE: result=", data.result, " instant=", static_cast(data.instant)); - return true; -} - -// ============================================================ -// Stand State -// ============================================================ - -network::Packet StandStateChangePacket::build(uint8_t state) { - network::Packet packet(wireOpcode(Opcode::CMSG_STANDSTATECHANGE)); - packet.writeUInt32(state); - LOG_DEBUG("Built CMSG_STANDSTATECHANGE: state=", static_cast(state)); - return packet; -} - -// ============================================================ -// Action Bar -// ============================================================ - -network::Packet SetActionButtonPacket::build(uint8_t button, uint8_t type, uint32_t id, bool isClassic) { - // Classic/Turtle (1.12): uint8 button + uint16 id + uint8 type + uint8 misc(0) - // type encoding: 0=spell, 1=item, 64=macro - // TBC/WotLK: uint8 button + uint32 packed (type<<24 | id) - // type encoding: 0x00=spell, 0x80=item, 0x40=macro - // packed=0 means clear the slot - network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTION_BUTTON)); - packet.writeUInt8(button); - if (isClassic) { - // Classic: 16-bit id, 8-bit type code, 8-bit misc - // Map ActionBarSlot::Type (0=EMPTY,1=SPELL,2=ITEM,3=MACRO) → classic type byte - uint8_t classicType = 0; // 0 = spell - if (type == 2 /* ITEM */) classicType = 1; - if (type == 3 /* MACRO */) classicType = 64; - packet.writeUInt16(static_cast(id)); - packet.writeUInt8(classicType); - packet.writeUInt8(0); // misc - LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (Classic): button=", static_cast(button), - " id=", id, " type=", static_cast(classicType)); - } else { - // TBC/WotLK: type in bits 24–31, id in bits 0–23; packed=0 clears slot - uint8_t packedType = 0x00; // spell - if (type == 2 /* ITEM */) packedType = 0x80; - if (type == 3 /* MACRO */) packedType = 0x40; - uint32_t packed = (id == 0) ? 0 : (static_cast(packedType) << 24) | (id & 0x00FFFFFF); - packet.writeUInt32(packed); - LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (TBC/WotLK): button=", static_cast(button), - " packed=0x", std::hex, packed, std::dec); - } - return packet; -} - -// ============================================================ -// Display Toggles -// ============================================================ - -network::Packet ShowingHelmPacket::build(bool show) { - network::Packet packet(wireOpcode(Opcode::CMSG_SHOWING_HELM)); - packet.writeUInt8(show ? 1 : 0); - LOG_DEBUG("Built CMSG_SHOWING_HELM: show=", show); - return packet; -} - -network::Packet ShowingCloakPacket::build(bool show) { - network::Packet packet(wireOpcode(Opcode::CMSG_SHOWING_CLOAK)); - packet.writeUInt8(show ? 1 : 0); - LOG_DEBUG("Built CMSG_SHOWING_CLOAK: show=", show); - return packet; -} - -// ============================================================ -// PvP -// ============================================================ - -network::Packet TogglePvpPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_TOGGLE_PVP)); - LOG_DEBUG("Built CMSG_TOGGLE_PVP"); - return packet; -} - -// ============================================================ -// Guild Commands -// ============================================================ - -network::Packet GuildInfoPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_INFO)); - LOG_DEBUG("Built CMSG_GUILD_INFO"); - return packet; -} - -network::Packet GuildRosterPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ROSTER)); - LOG_DEBUG("Built CMSG_GUILD_ROSTER"); - return packet; -} - -network::Packet GuildMotdPacket::build(const std::string& motd) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_MOTD)); - packet.writeString(motd); - LOG_DEBUG("Built CMSG_GUILD_MOTD: ", motd); - return packet; -} - -network::Packet GuildPromotePacket::build(const std::string& playerName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_PROMOTE)); - packet.writeString(playerName); - LOG_DEBUG("Built CMSG_GUILD_PROMOTE: ", playerName); - return packet; -} - -network::Packet GuildDemotePacket::build(const std::string& playerName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DEMOTE)); - packet.writeString(playerName); - LOG_DEBUG("Built CMSG_GUILD_DEMOTE: ", playerName); - return packet; -} - -network::Packet GuildLeavePacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_LEAVE)); - LOG_DEBUG("Built CMSG_GUILD_LEAVE"); - return packet; -} - -network::Packet GuildInvitePacket::build(const std::string& playerName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_INVITE)); - packet.writeString(playerName); - LOG_DEBUG("Built CMSG_GUILD_INVITE: ", playerName); - return packet; -} - -network::Packet GuildQueryPacket::build(uint32_t guildId) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_QUERY)); - packet.writeUInt32(guildId); - LOG_DEBUG("Built CMSG_GUILD_QUERY: guildId=", guildId); - return packet; -} - -network::Packet GuildRemovePacket::build(const std::string& playerName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_REMOVE)); - packet.writeString(playerName); - LOG_DEBUG("Built CMSG_GUILD_REMOVE: ", playerName); - return packet; -} - -network::Packet GuildDisbandPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DISBAND)); - LOG_DEBUG("Built CMSG_GUILD_DISBAND"); - return packet; -} - -network::Packet GuildLeaderPacket::build(const std::string& playerName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_LEADER)); - packet.writeString(playerName); - LOG_DEBUG("Built CMSG_GUILD_LEADER: ", playerName); - return packet; -} - -network::Packet GuildSetPublicNotePacket::build(const std::string& playerName, const std::string& note) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_SET_PUBLIC_NOTE)); - packet.writeString(playerName); - packet.writeString(note); - LOG_DEBUG("Built CMSG_GUILD_SET_PUBLIC_NOTE: ", playerName, " -> ", note); - return packet; -} - -network::Packet GuildSetOfficerNotePacket::build(const std::string& playerName, const std::string& note) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_SET_OFFICER_NOTE)); - packet.writeString(playerName); - packet.writeString(note); - LOG_DEBUG("Built CMSG_GUILD_SET_OFFICER_NOTE: ", playerName, " -> ", note); - return packet; -} - -network::Packet GuildAcceptPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ACCEPT)); - LOG_DEBUG("Built CMSG_GUILD_ACCEPT"); - return packet; -} - -network::Packet GuildDeclineInvitationPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DECLINE)); - LOG_DEBUG("Built CMSG_GUILD_DECLINE"); - return packet; -} - -network::Packet GuildCreatePacket::build(const std::string& guildName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_CREATE)); - packet.writeString(guildName); - LOG_DEBUG("Built CMSG_GUILD_CREATE: ", guildName); - return packet; -} - -network::Packet GuildAddRankPacket::build(const std::string& rankName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ADD_RANK)); - packet.writeString(rankName); - LOG_DEBUG("Built CMSG_GUILD_ADD_RANK: ", rankName); - return packet; -} - -network::Packet GuildDelRankPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DEL_RANK)); - LOG_DEBUG("Built CMSG_GUILD_DEL_RANK"); - return packet; -} - -network::Packet PetitionShowlistPacket::build(uint64_t npcGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_PETITION_SHOWLIST)); - packet.writeUInt64(npcGuid); - LOG_DEBUG("Built CMSG_PETITION_SHOWLIST: guid=", npcGuid); - return packet; -} - -network::Packet PetitionBuyPacket::build(uint64_t npcGuid, const std::string& guildName) { - network::Packet packet(wireOpcode(Opcode::CMSG_PETITION_BUY)); - packet.writeUInt64(npcGuid); // NPC GUID - packet.writeUInt32(0); // unk - packet.writeUInt64(0); // unk - packet.writeString(guildName); // guild name - packet.writeUInt32(0); // body text (empty) - packet.writeUInt32(0); // min sigs - packet.writeUInt32(0); // max sigs - packet.writeUInt32(0); // unk - packet.writeUInt32(0); // unk - packet.writeUInt32(0); // unk - packet.writeUInt32(0); // unk - packet.writeUInt16(0); // unk - packet.writeUInt32(0); // unk - packet.writeUInt32(0); // unk index - packet.writeUInt32(0); // unk - LOG_DEBUG("Built CMSG_PETITION_BUY: npcGuid=", npcGuid, " name=", guildName); - return packet; -} - -bool PetitionShowlistParser::parse(network::Packet& packet, PetitionShowlistData& data) { - if (packet.getSize() < 12) { - LOG_ERROR("SMSG_PETITION_SHOWLIST too small: ", packet.getSize()); - return false; - } - data.npcGuid = packet.readUInt64(); - uint32_t count = packet.readUInt32(); - if (count > 0) { - data.itemId = packet.readUInt32(); - data.displayId = packet.readUInt32(); - data.cost = packet.readUInt32(); - // Skip unused fields if present - if (packet.hasRemaining(8)) { - data.charterType = packet.readUInt32(); - data.requiredSigs = packet.readUInt32(); - } - } - LOG_INFO("Parsed SMSG_PETITION_SHOWLIST: npcGuid=", data.npcGuid, " cost=", data.cost); - return true; -} - -bool TurnInPetitionResultsParser::parse(network::Packet& packet, uint32_t& result) { - if (packet.getSize() < 4) { - LOG_ERROR("SMSG_TURN_IN_PETITION_RESULTS too small: ", packet.getSize()); - return false; - } - result = packet.readUInt32(); - LOG_INFO("Parsed SMSG_TURN_IN_PETITION_RESULTS: result=", result); - return true; -} - -bool GuildQueryResponseParser::parse(network::Packet& packet, GuildQueryResponseData& data) { - if (packet.getSize() < 8) { - LOG_ERROR("SMSG_GUILD_QUERY_RESPONSE too small: ", packet.getSize()); - return false; - } - data.guildId = packet.readUInt32(); - - // Validate before reading guild name - if (!packet.hasData()) { - LOG_WARNING("GuildQueryResponseParser: truncated before guild name"); - data.guildName.clear(); - return true; - } - data.guildName = packet.readString(); - - // Read 10 rank names with validation - for (int i = 0; i < 10; ++i) { - if (!packet.hasData()) { - LOG_WARNING("GuildQueryResponseParser: truncated at rank name ", i); - data.rankNames[i].clear(); - } else { - data.rankNames[i] = packet.readString(); - } - } - - // Validate before reading emblem fields (5 uint32s = 20 bytes) - if (!packet.hasRemaining(20)) { - LOG_WARNING("GuildQueryResponseParser: truncated before emblem data"); - data.emblemStyle = 0; - data.emblemColor = 0; - data.borderStyle = 0; - data.borderColor = 0; - data.backgroundColor = 0; - return true; - } - - data.emblemStyle = packet.readUInt32(); - data.emblemColor = packet.readUInt32(); - data.borderStyle = packet.readUInt32(); - data.borderColor = packet.readUInt32(); - data.backgroundColor = packet.readUInt32(); - - if (packet.hasRemaining(4)) { - data.rankCount = packet.readUInt32(); - } - LOG_INFO("Parsed SMSG_GUILD_QUERY_RESPONSE: guild=", data.guildName, " id=", data.guildId); - return true; -} - -bool GuildInfoParser::parse(network::Packet& packet, GuildInfoData& data) { - if (packet.getSize() < 4) { - LOG_ERROR("SMSG_GUILD_INFO too small: ", packet.getSize()); - return false; - } - data.guildName = packet.readString(); - data.creationDay = packet.readUInt32(); - data.creationMonth = packet.readUInt32(); - data.creationYear = packet.readUInt32(); - data.numMembers = packet.readUInt32(); - data.numAccounts = packet.readUInt32(); - LOG_INFO("Parsed SMSG_GUILD_INFO: ", data.guildName, " members=", data.numMembers); - return true; -} - -bool GuildRosterParser::parse(network::Packet& packet, GuildRosterData& data) { - if (packet.getSize() < 4) { - LOG_ERROR("SMSG_GUILD_ROSTER too small: ", packet.getSize()); - return false; - } - uint32_t numMembers = packet.readUInt32(); - - // Cap members and ranks to prevent unbounded memory allocation - const uint32_t MAX_GUILD_MEMBERS = 1000; - if (numMembers > MAX_GUILD_MEMBERS) { - LOG_WARNING("GuildRosterParser: numMembers capped (requested=", numMembers, ")"); - numMembers = MAX_GUILD_MEMBERS; - } - - data.motd = packet.readString(); - data.guildInfo = packet.readString(); - - if (!packet.hasRemaining(4)) { - LOG_WARNING("GuildRosterParser: truncated before rankCount"); - data.ranks.clear(); - data.members.clear(); - return true; - } - - uint32_t rankCount = packet.readUInt32(); - - // Cap rank count to prevent unbounded allocation - const uint32_t MAX_GUILD_RANKS = 20; - if (rankCount > MAX_GUILD_RANKS) { - LOG_WARNING("GuildRosterParser: rankCount capped (requested=", rankCount, ")"); - rankCount = MAX_GUILD_RANKS; - } - - data.ranks.resize(rankCount); - for (uint32_t i = 0; i < rankCount; ++i) { - // Validate 4 bytes before each rank rights read - if (!packet.hasRemaining(4)) { - LOG_WARNING("GuildRosterParser: truncated rank at index ", i); - break; - } - data.ranks[i].rights = packet.readUInt32(); - if (!packet.hasRemaining(4)) { - data.ranks[i].goldLimit = 0; - } else { - data.ranks[i].goldLimit = packet.readUInt32(); - } - // 6 bank tab flags + 6 bank tab items per day - for (int t = 0; t < 6; ++t) { - if (!packet.hasRemaining(8)) break; - packet.readUInt32(); // tabFlags - packet.readUInt32(); // tabItemsPerDay - } - } - - data.members.resize(numMembers); - for (uint32_t i = 0; i < numMembers; ++i) { - // Validate minimum bytes before reading member (guid+online+name at minimum is 9+ bytes) - if (!packet.hasRemaining(9)) { - LOG_WARNING("GuildRosterParser: truncated member at index ", i); - break; - } - auto& m = data.members[i]; - m.guid = packet.readUInt64(); - m.online = (packet.readUInt8() != 0); - - // Validate before reading name string - if (!packet.hasData()) { - m.name.clear(); - } else { - m.name = packet.readString(); - } - - // Validate before reading rank/level/class/gender/zone - if (!packet.hasRemaining(1)) { - m.rankIndex = 0; - m.level = 1; - m.classId = 0; - m.gender = 0; - m.zoneId = 0; - } else { - m.rankIndex = packet.readUInt32(); - if (!packet.hasRemaining(3)) { - m.level = 1; - m.classId = 0; - m.gender = 0; - } else { - m.level = packet.readUInt8(); - m.classId = packet.readUInt8(); - m.gender = packet.readUInt8(); - } - if (!packet.hasRemaining(4)) { - m.zoneId = 0; - } else { - m.zoneId = packet.readUInt32(); - } - } - - // Online status affects next fields - if (!m.online) { - if (!packet.hasRemaining(4)) { - m.lastOnline = 0.0f; - } else { - m.lastOnline = packet.readFloat(); - } - } - - // Read notes - if (!packet.hasData()) { - m.publicNote.clear(); - m.officerNote.clear(); - } else { - m.publicNote = packet.readString(); - if (!packet.hasData()) { - m.officerNote.clear(); - } else { - m.officerNote = packet.readString(); - } - } - } - LOG_INFO("Parsed SMSG_GUILD_ROSTER: ", numMembers, " members, motd=", data.motd); - return true; -} - -bool GuildEventParser::parse(network::Packet& packet, GuildEventData& data) { - if (packet.getSize() < 2) { - LOG_ERROR("SMSG_GUILD_EVENT too small: ", packet.getSize()); - return false; - } - data.eventType = packet.readUInt8(); - data.numStrings = packet.readUInt8(); - for (uint8_t i = 0; i < data.numStrings && i < 3; ++i) { - data.strings[i] = packet.readString(); - } - if (packet.hasRemaining(8)) { - data.guid = packet.readUInt64(); - } - LOG_INFO("Parsed SMSG_GUILD_EVENT: type=", static_cast(data.eventType), " strings=", static_cast(data.numStrings)); - return true; -} - -bool GuildInviteResponseParser::parse(network::Packet& packet, GuildInviteResponseData& data) { - if (packet.getSize() < 2) { - LOG_ERROR("SMSG_GUILD_INVITE too small: ", packet.getSize()); - return false; - } - data.inviterName = packet.readString(); - data.guildName = packet.readString(); - LOG_INFO("Parsed SMSG_GUILD_INVITE: from=", data.inviterName, " guild=", data.guildName); - return true; -} - -bool GuildCommandResultParser::parse(network::Packet& packet, GuildCommandResultData& data) { - if (packet.getSize() < 8) { - LOG_ERROR("SMSG_GUILD_COMMAND_RESULT too small: ", packet.getSize()); - return false; - } - data.command = packet.readUInt32(); - data.name = packet.readString(); - data.errorCode = packet.readUInt32(); - LOG_INFO("Parsed SMSG_GUILD_COMMAND_RESULT: cmd=", data.command, " error=", data.errorCode); - return true; -} - -// ============================================================ -// Ready Check -// ============================================================ - -network::Packet ReadyCheckPacket::build() { - network::Packet packet(wireOpcode(Opcode::MSG_RAID_READY_CHECK)); - LOG_DEBUG("Built MSG_RAID_READY_CHECK"); - return packet; -} - -network::Packet ReadyCheckConfirmPacket::build(bool ready) { - network::Packet packet(wireOpcode(Opcode::MSG_RAID_READY_CHECK_CONFIRM)); - packet.writeUInt8(ready ? 1 : 0); - LOG_DEBUG("Built MSG_RAID_READY_CHECK_CONFIRM: ready=", ready); - return packet; -} - -// ============================================================ -// Duel -// ============================================================ - -network::Packet DuelAcceptPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_DUEL_ACCEPTED)); - LOG_DEBUG("Built CMSG_DUEL_ACCEPTED"); - return packet; -} - -network::Packet DuelCancelPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_DUEL_CANCELLED)); - LOG_DEBUG("Built CMSG_DUEL_CANCELLED"); - return packet; -} - -// ============================================================ -// Party/Raid Management -// ============================================================ - -network::Packet GroupUninvitePacket::build(const std::string& playerName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_UNINVITE_GUID)); - packet.writeString(playerName); - LOG_DEBUG("Built CMSG_GROUP_UNINVITE_GUID for player: ", playerName); - return packet; -} - -network::Packet GroupDisbandPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_DISBAND)); - LOG_DEBUG("Built CMSG_GROUP_DISBAND"); - return packet; -} - -network::Packet GroupRaidConvertPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_RAID_CONVERT)); - LOG_DEBUG("Built CMSG_GROUP_RAID_CONVERT"); - return packet; -} - -network::Packet SetLootMethodPacket::build(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_METHOD)); - packet.writeUInt32(method); - packet.writeUInt32(threshold); - packet.writeUInt64(masterLooterGuid); - LOG_DEBUG("Built CMSG_LOOT_METHOD: method=", method, " threshold=", threshold, - " masterLooter=0x", std::hex, masterLooterGuid, std::dec); - return packet; -} - -network::Packet RaidTargetUpdatePacket::build(uint8_t targetIndex, uint64_t targetGuid) { - network::Packet packet(wireOpcode(Opcode::MSG_RAID_TARGET_UPDATE)); - packet.writeUInt8(targetIndex); - packet.writeUInt64(targetGuid); - LOG_DEBUG("Built MSG_RAID_TARGET_UPDATE, index: ", static_cast(targetIndex), ", guid: 0x", std::hex, targetGuid, std::dec); - return packet; -} - -network::Packet RequestRaidInfoPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_REQUEST_RAID_INFO)); - LOG_DEBUG("Built CMSG_REQUEST_RAID_INFO"); - return packet; -} - -// ============================================================ -// Combat and Trade -// ============================================================ - -network::Packet DuelProposedPacket::build(uint64_t targetGuid) { - // Duels are initiated via CMSG_CAST_SPELL with spell 7266 (Duel) targeted at the opponent. - // There is no separate CMSG_DUEL_PROPOSED opcode in WoW. - auto packet = CastSpellPacket::build(7266, targetGuid, 0); - LOG_DEBUG("Built duel request (spell 7266) for target: 0x", std::hex, targetGuid, std::dec); - return packet; -} - -network::Packet BeginTradePacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_BEGIN_TRADE)); - LOG_DEBUG("Built CMSG_BEGIN_TRADE"); - return packet; -} - -network::Packet CancelTradePacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_CANCEL_TRADE)); - LOG_DEBUG("Built CMSG_CANCEL_TRADE"); - return packet; -} - -network::Packet AcceptTradePacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_ACCEPT_TRADE)); - LOG_DEBUG("Built CMSG_ACCEPT_TRADE"); - return packet; -} - -network::Packet SetTradeItemPacket::build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { - network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_ITEM)); - packet.writeUInt8(tradeSlot); - packet.writeUInt8(bag); - packet.writeUInt8(bagSlot); - LOG_DEBUG("Built CMSG_SET_TRADE_ITEM slot=", static_cast(tradeSlot), " bag=", static_cast(bag), " bagSlot=", static_cast(bagSlot)); - return packet; -} - -network::Packet ClearTradeItemPacket::build(uint8_t tradeSlot) { - network::Packet packet(wireOpcode(Opcode::CMSG_CLEAR_TRADE_ITEM)); - packet.writeUInt8(tradeSlot); - LOG_DEBUG("Built CMSG_CLEAR_TRADE_ITEM slot=", static_cast(tradeSlot)); - return packet; -} - -network::Packet SetTradeGoldPacket::build(uint64_t copper) { - network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_GOLD)); - packet.writeUInt64(copper); - LOG_DEBUG("Built CMSG_SET_TRADE_GOLD copper=", copper); - return packet; -} - -network::Packet UnacceptTradePacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_UNACCEPT_TRADE)); - LOG_DEBUG("Built CMSG_UNACCEPT_TRADE"); - return packet; -} - -network::Packet InitiateTradePacket::build(uint64_t targetGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_INITIATE_TRADE)); - packet.writeUInt64(targetGuid); - LOG_DEBUG("Built CMSG_INITIATE_TRADE for target: 0x", std::hex, targetGuid, std::dec); - return packet; -} - -network::Packet AttackSwingPacket::build(uint64_t targetGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_ATTACKSWING)); - packet.writeUInt64(targetGuid); - LOG_DEBUG("Built CMSG_ATTACKSWING for target: 0x", std::hex, targetGuid, std::dec); - return packet; -} - -network::Packet AttackStopPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_ATTACKSTOP)); - LOG_DEBUG("Built CMSG_ATTACKSTOP"); - return packet; -} - -network::Packet CancelCastPacket::build(uint32_t spellId) { - network::Packet packet(wireOpcode(Opcode::CMSG_CANCEL_CAST)); - packet.writeUInt32(0); // cast count/sequence - packet.writeUInt32(spellId); - LOG_DEBUG("Built CMSG_CANCEL_CAST for spell: ", spellId); - return packet; -} - -// ============================================================ -// Random Roll -// ============================================================ - -network::Packet RandomRollPacket::build(uint32_t minRoll, uint32_t maxRoll) { - network::Packet packet(wireOpcode(Opcode::MSG_RANDOM_ROLL)); - packet.writeUInt32(minRoll); - packet.writeUInt32(maxRoll); - LOG_DEBUG("Built MSG_RANDOM_ROLL: ", minRoll, "-", maxRoll); - return packet; -} - -bool RandomRollParser::parse(network::Packet& packet, RandomRollData& data) { - // WotLK 3.3.5a format: min(4) + max(4) + result(4) + rollerGuid(8) = 20 bytes. - // Previously read guid first (treating min|max as a uint64 GUID), producing - // garbled roller identity and random numbers in /roll chat messages. - if (!packet.hasRemaining(20)) { - LOG_WARNING("SMSG_RANDOM_ROLL: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.minRoll = packet.readUInt32(); - data.maxRoll = packet.readUInt32(); - data.result = packet.readUInt32(); - data.rollerGuid = packet.readUInt64(); - data.targetGuid = 0; // not present in protocol; kept for struct compatibility - LOG_DEBUG("Parsed SMSG_RANDOM_ROLL: roller=0x", std::hex, data.rollerGuid, std::dec, - " result=", data.result, " (", data.minRoll, "-", data.maxRoll, ")"); - return true; -} - -network::Packet NameQueryPacket::build(uint64_t playerGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_NAME_QUERY)); - packet.writeUInt64(playerGuid); - LOG_DEBUG("Built CMSG_NAME_QUERY: guid=0x", std::hex, playerGuid, std::dec); - return packet; -} - -bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseData& data) { - // 3.3.5a: packedGuid, uint8 found - // If found==0: CString name, CString realmName, uint8 race, uint8 gender, uint8 classId - // Validation: packed GUID (1-8 bytes) + found flag (1 byte minimum) - if (!packet.hasRemaining(2)) return false; // At least 1 for packed GUID + 1 for found - - size_t startPos = packet.getReadPos(); - data.guid = packet.readPackedGuid(); - - // Validate found flag read - if (!packet.hasRemaining(1)) { - packet.setReadPos(startPos); - return false; - } - data.found = packet.readUInt8(); - - if (data.found != 0) { - LOG_DEBUG("Name query: player not found for GUID 0x", std::hex, data.guid, std::dec); - return true; // Valid response, just not found - } - - // Validate strings: need at least 2 null terminators for empty strings - if (!packet.hasRemaining(2)) { - data.name.clear(); - data.realmName.clear(); - return !data.name.empty(); // Fail if name was required - } - - data.name = packet.readString(); - data.realmName = packet.readString(); - - // Validate final 3 uint8 fields (race, gender, classId) - if (!packet.hasRemaining(3)) { - LOG_WARNING("Name query: truncated fields after realmName, expected 3 uint8s"); - data.race = 0; - data.gender = 0; - data.classId = 0; - return !data.name.empty(); - } - - data.race = packet.readUInt8(); - data.gender = packet.readUInt8(); - data.classId = packet.readUInt8(); - - LOG_DEBUG("Name query response: ", data.name, " (race=", static_cast(data.race), - " class=", static_cast(data.classId), ")"); - return true; -} - -network::Packet CreatureQueryPacket::build(uint32_t entry, uint64_t guid) { - network::Packet packet(wireOpcode(Opcode::CMSG_CREATURE_QUERY)); - packet.writeUInt32(entry); - packet.writeUInt64(guid); - LOG_DEBUG("Built CMSG_CREATURE_QUERY: entry=", entry, " guid=0x", std::hex, guid, std::dec); - return packet; -} - -bool CreatureQueryResponseParser::parse(network::Packet& packet, CreatureQueryResponseData& data) { - // Validate minimum packet size: entry(4) - if (packet.getSize() < 4) { - LOG_ERROR("SMSG_CREATURE_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.entry = packet.readUInt32(); - - // High bit set means creature not found - if (data.entry & 0x80000000) { - data.entry &= ~0x80000000; - LOG_DEBUG("Creature query: entry ", data.entry, " not found"); - data.name = ""; - return true; - } - - // 4 name strings (only first is usually populated) - data.name = packet.readString(); - packet.readString(); // name2 - packet.readString(); // name3 - packet.readString(); // name4 - data.subName = packet.readString(); - data.iconName = packet.readString(); - - // WotLK: 4 fixed fields after iconName (typeFlags, creatureType, family, rank) - // Validate minimum size for these fields: 4×4 = 16 bytes - if (!packet.hasRemaining(16)) { - LOG_WARNING("SMSG_CREATURE_QUERY_RESPONSE: truncated before typeFlags (entry=", data.entry, ")"); - data.typeFlags = 0; - data.creatureType = 0; - data.family = 0; - data.rank = 0; - return true; // Have name/sub/icon; base fields are important but optional - } - - data.typeFlags = packet.readUInt32(); - data.creatureType = packet.readUInt32(); - data.family = packet.readUInt32(); - data.rank = packet.readUInt32(); - - // killCredit[2] + displayId[4] = 6 × 4 = 24 bytes - if (!packet.hasRemaining(24)) { - LOG_WARNING("SMSG_CREATURE_QUERY_RESPONSE: truncated before displayIds (entry=", data.entry, ")"); - LOG_DEBUG("Creature query response: ", data.name, " (type=", data.creatureType, - " rank=", data.rank, ")"); - return true; - } - - packet.readUInt32(); // killCredit[0] - packet.readUInt32(); // killCredit[1] - data.displayId[0] = packet.readUInt32(); - data.displayId[1] = packet.readUInt32(); - data.displayId[2] = packet.readUInt32(); - data.displayId[3] = packet.readUInt32(); - - // Skip remaining fields (healthMultiplier, powerMultiplier, racialLeader, questItems, movementId) - - LOG_DEBUG("Creature query response: ", data.name, " (type=", data.creatureType, - " rank=", data.rank, " displayIds=[", data.displayId[0], ",", - data.displayId[1], ",", data.displayId[2], ",", data.displayId[3], "])"); - return true; -} - -// ---- GameObject Query ---- - -network::Packet GameObjectQueryPacket::build(uint32_t entry, uint64_t guid) { - network::Packet packet(wireOpcode(Opcode::CMSG_GAMEOBJECT_QUERY)); - packet.writeUInt32(entry); - packet.writeUInt64(guid); - LOG_DEBUG("Built CMSG_GAMEOBJECT_QUERY: entry=", entry, " guid=0x", std::hex, guid, std::dec); - return packet; -} - -bool GameObjectQueryResponseParser::parse(network::Packet& packet, GameObjectQueryResponseData& data) { - // Validate minimum packet size: entry(4) - if (packet.getSize() < 4) { - LOG_ERROR("SMSG_GAMEOBJECT_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.entry = packet.readUInt32(); - - // High bit set means gameobject not found - if (data.entry & 0x80000000) { - data.entry &= ~0x80000000; - LOG_DEBUG("GameObject query: entry ", data.entry, " not found"); - data.name = ""; - return true; - } - - // Validate minimum size for fixed fields: type(4) + displayId(4) - if (!packet.hasRemaining(8)) { - LOG_ERROR("SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated before names (entry=", data.entry, ")"); - return false; - } - - data.type = packet.readUInt32(); // GameObjectType - data.displayId = packet.readUInt32(); - // 4 name strings (only first is usually populated) - data.name = packet.readString(); - // name2, name3, name4 - packet.readString(); - packet.readString(); - packet.readString(); - - // WotLK: 3 extra strings before data[] (iconName, castBarCaption, unk1) - packet.readString(); // iconName - packet.readString(); // castBarCaption - packet.readString(); // unk1 - - // Read 24 type-specific data fields - size_t remaining = packet.getRemainingSize(); - if (remaining >= 24 * 4) { - for (int i = 0; i < 24; i++) { - data.data[i] = packet.readUInt32(); - } - data.hasData = true; - } else if (remaining > 0) { - // Partial data field; read what we can - uint32_t fieldsToRead = remaining / 4; - for (uint32_t i = 0; i < fieldsToRead && i < 24; i++) { - data.data[i] = packet.readUInt32(); - } - if (fieldsToRead < 24) { - LOG_WARNING("SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated in data fields (", fieldsToRead, - " of 24 read, entry=", data.entry, ")"); - } - } - - LOG_DEBUG("GameObject query response: ", data.name, " (type=", data.type, " entry=", data.entry, ")"); - return true; -} - -network::Packet PageTextQueryPacket::build(uint32_t pageId, uint64_t guid) { - network::Packet packet(wireOpcode(Opcode::CMSG_PAGE_TEXT_QUERY)); - packet.writeUInt32(pageId); - packet.writeUInt64(guid); - return packet; -} - -bool PageTextQueryResponseParser::parse(network::Packet& packet, PageTextQueryResponseData& data) { - if (!packet.hasRemaining(4)) return false; - data.pageId = packet.readUInt32(); - data.text = normalizeWowTextTokens(packet.readString()); - if (packet.hasRemaining(4)) { - data.nextPageId = packet.readUInt32(); - } else { - data.nextPageId = 0; - } - return data.isValid(); -} - -// ---- Item Query ---- - -network::Packet ItemQueryPacket::build(uint32_t entry, uint64_t guid) { - network::Packet packet(wireOpcode(Opcode::CMSG_ITEM_QUERY_SINGLE)); - packet.writeUInt32(entry); - packet.writeUInt64(guid); - LOG_DEBUG("Built CMSG_ITEM_QUERY_SINGLE: entry=", entry, " guid=0x", std::hex, guid, std::dec); - return packet; -} - -const char* getItemSubclassName(uint32_t itemClass, uint32_t subClass) { - if (itemClass == 2) { // Weapon - switch (subClass) { - case 0: return "Axe"; case 1: return "Axe"; - case 2: return "Bow"; case 3: return "Gun"; - case 4: return "Mace"; case 5: return "Mace"; - case 6: return "Polearm"; case 7: return "Sword"; - case 8: return "Sword"; case 9: return "Obsolete"; - case 10: return "Staff"; case 13: return "Fist Weapon"; - case 15: return "Dagger"; case 16: return "Thrown"; - case 18: return "Crossbow"; case 19: return "Wand"; - case 20: return "Fishing Pole"; - default: return "Weapon"; - } - } - if (itemClass == 4) { // Armor - switch (subClass) { - case 0: return "Miscellaneous"; case 1: return "Cloth"; - case 2: return "Leather"; case 3: return "Mail"; - case 4: return "Plate"; case 6: return "Shield"; - default: return "Armor"; - } - } - return ""; -} - -bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseData& data) { - // Validate minimum packet size: entry(4) + item not found check - if (packet.getSize() < 4) { - LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.entry = packet.readUInt32(); - - // High bit set means item not found - if (data.entry & 0x80000000) { - data.entry &= ~0x80000000; - LOG_DEBUG("Item query: entry ", data.entry, " not found"); - return true; - } - - // Validate minimum size for fixed fields before reading: itemClass(4) + subClass(4) + soundOverride(4) - // + 4 name strings + displayInfoId(4) + quality(4) = at least 24 bytes more - if (!packet.hasRemaining(24)) { - LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before displayInfoId (entry=", data.entry, ")"); - return false; - } - - uint32_t itemClass = packet.readUInt32(); - uint32_t subClass = packet.readUInt32(); - data.itemClass = itemClass; - data.subClass = subClass; - packet.readUInt32(); // SoundOverrideSubclass - - data.subclassName = getItemSubclassName(itemClass, subClass); - - // 4 name strings - data.name = packet.readString(); - packet.readString(); // name2 - packet.readString(); // name3 - packet.readString(); // name4 - - data.displayInfoId = packet.readUInt32(); - data.quality = packet.readUInt32(); - - // WotLK 3.3.5a (TrinityCore/AzerothCore): Flags, Flags2, BuyCount, BuyPrice, SellPrice - // Some server variants omit BuyCount (4 fields instead of 5). - // Read 5 fields and validate InventoryType; if it looks implausible, rewind and try 4. - const size_t postQualityPos = packet.getReadPos(); - if (!packet.hasRemaining(24)) { - LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before flags (entry=", data.entry, ")"); - return false; - } - data.itemFlags = packet.readUInt32(); // Flags - packet.readUInt32(); // Flags2 - packet.readUInt32(); // BuyCount - packet.readUInt32(); // BuyPrice - data.sellPrice = packet.readUInt32(); // SellPrice - data.inventoryType = packet.readUInt32(); - - if (data.inventoryType > 28) { - // inventoryType out of range — BuyCount probably not present; rewind and try 4 fields - packet.setReadPos(postQualityPos); - data.itemFlags = packet.readUInt32(); // Flags - packet.readUInt32(); // Flags2 - packet.readUInt32(); // BuyPrice - data.sellPrice = packet.readUInt32(); // SellPrice - data.inventoryType = packet.readUInt32(); - } - - // Validate minimum size for remaining fixed fields before inventoryType through containerSlots: 13×4 = 52 bytes - if (!packet.hasRemaining(52)) { - LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")"); - return false; - } - data.allowableClass = packet.readUInt32(); // AllowableClass - data.allowableRace = packet.readUInt32(); // AllowableRace - data.itemLevel = packet.readUInt32(); - data.requiredLevel = packet.readUInt32(); - data.requiredSkill = packet.readUInt32(); // RequiredSkill - data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank - packet.readUInt32(); // RequiredSpell - packet.readUInt32(); // RequiredHonorRank - packet.readUInt32(); // RequiredCityRank - data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction - data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank - data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) - data.maxStack = static_cast(packet.readUInt32()); // Stackable - data.containerSlots = packet.readUInt32(); - - // Read statsCount with bounds validation - if (!packet.hasRemaining(4)) { - LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated at statsCount (entry=", data.entry, ")"); - return true; // Have enough for core fields; stats are optional - } - uint32_t statsCount = packet.readUInt32(); - - // Cap statsCount to prevent excessive iteration - constexpr uint32_t kMaxItemStats = 10; - if (statsCount > kMaxItemStats) { - LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: statsCount=", statsCount, " exceeds max ", - kMaxItemStats, " (entry=", data.entry, "), capping"); - statsCount = kMaxItemStats; - } - - // Server sends exactly statsCount stat pairs (not always 10). - uint32_t statsToRead = std::min(statsCount, 10u); - for (uint32_t i = 0; i < statsToRead; i++) { - // Each stat is 2 uint32s (type + value) = 8 bytes - if (!packet.hasRemaining(8)) { - LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")"); - break; - } - uint32_t statType = packet.readUInt32(); - int32_t statValue = static_cast(packet.readUInt32()); - switch (statType) { - case 3: data.agility = statValue; break; - case 4: data.strength = statValue; break; - case 5: data.intellect = statValue; break; - case 6: data.spirit = statValue; break; - case 7: data.stamina = statValue; break; - default: - if (statValue != 0) - data.extraStats.push_back({statType, statValue}); - break; - } - } - - // ScalingStatDistribution and ScalingStatValue - if (!packet.hasRemaining(8)) { - LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before scaling stats (entry=", data.entry, ")"); - return true; // Have core fields; scaling is optional - } - packet.readUInt32(); // ScalingStatDistribution - packet.readUInt32(); // ScalingStatValue - - // WotLK 3.3.5a: 2 damage entries (12 bytes each) + armor + 6 resists + delay + ammoType + rangedModRange - // = 24 + 36 + 4 = 64 bytes minimum. Guard here because the section above - // returns early on truncation, and every other section has its own guard. - if (!packet.hasRemaining(64)) { - LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before damage/armor (entry=", data.entry, ")"); - return true; - } - - // WotLK 3.3.5a: MAX_ITEM_PROTO_DAMAGES = 2 - bool haveWeaponDamage = false; - for (int i = 0; i < 2; i++) { - float dmgMin = packet.readFloat(); - float dmgMax = packet.readFloat(); - uint32_t damageType = packet.readUInt32(); - if (!haveWeaponDamage && dmgMax > 0.0f) { - if (damageType == 0 || data.damageMax <= 0.0f) { - data.damageMin = dmgMin; - data.damageMax = dmgMax; - haveWeaponDamage = (damageType == 0); - } - } - } - - data.armor = static_cast(packet.readUInt32()); - data.holyRes = static_cast(packet.readUInt32()); // HolyRes - data.fireRes = static_cast(packet.readUInt32()); // FireRes - data.natureRes = static_cast(packet.readUInt32()); // NatureRes - data.frostRes = static_cast(packet.readUInt32()); // FrostRes - data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes - data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes - data.delayMs = packet.readUInt32(); - packet.readUInt32(); // AmmoType - packet.readFloat(); // RangedModRange - - // 5 item spells: SpellId, SpellTrigger, SpellCharges, SpellCooldown, SpellCategory, SpellCategoryCooldown - for (int i = 0; i < 5; i++) { - if (!packet.hasRemaining(24)) break; - data.spells[i].spellId = packet.readUInt32(); - data.spells[i].spellTrigger = packet.readUInt32(); - packet.readUInt32(); // SpellCharges - packet.readUInt32(); // SpellCooldown - packet.readUInt32(); // SpellCategory - packet.readUInt32(); // SpellCategoryCooldown - } - - // Bonding type (0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ) - if (packet.hasRemaining(4)) - data.bindType = packet.readUInt32(); - - // Flavor/lore text (Description cstring) - if (packet.hasData()) - data.description = packet.readString(); - - // Post-description fields: PageText, LanguageID, PageMaterial, StartQuest - if (packet.hasRemaining(16)) { - packet.readUInt32(); // PageText - packet.readUInt32(); // LanguageID - packet.readUInt32(); // PageMaterial - data.startQuestId = packet.readUInt32(); // StartQuest - } - - // WotLK 3.3.5a: additional fields after StartQuest (read up to socket data) - // LockID(4), Material(4), Sheath(4), RandomProperty(4), RandomSuffix(4), - // Block(4), ItemSet(4), MaxDurability(4), Area(4), Map(4), BagFamily(4), - // TotemCategory(4) = 48 bytes before sockets - constexpr size_t kPreSocketSkip = 48; - if (packet.getReadPos() + kPreSocketSkip + 28 <= packet.getSize()) { - // LockID(0), Material(1), Sheath(2), RandomProperty(3), RandomSuffix(4), Block(5) - for (size_t i = 0; i < 6; ++i) packet.readUInt32(); - data.itemSetId = packet.readUInt32(); // ItemSet(6) - // MaxDurability(7), Area(8), Map(9), BagFamily(10), TotemCategory(11) - for (size_t i = 0; i < 5; ++i) packet.readUInt32(); - // 3 socket slots: socketColor (4 bytes each) - data.socketColor[0] = packet.readUInt32(); - data.socketColor[1] = packet.readUInt32(); - data.socketColor[2] = packet.readUInt32(); - // 3 socket content (gem enchantment IDs — skip, not currently displayed) - packet.readUInt32(); - packet.readUInt32(); - packet.readUInt32(); - // socketBonus (enchantmentId) - data.socketBonus = packet.readUInt32(); - } - - data.valid = !data.name.empty(); - return true; -} - -// ============================================================ -// Creature Movement -// ============================================================ - -bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { - // PackedGuid - data.guid = packet.readPackedGuid(); - if (data.guid == 0) return false; - - // uint8 unk (toggle for MOVEMENTFLAG2_UNK7) - if (!packet.hasData()) return false; - packet.readUInt8(); - - // Current position (server coords: float x, y, z) - if (!packet.hasRemaining(12)) return false; - data.x = packet.readFloat(); - data.y = packet.readFloat(); - data.z = packet.readFloat(); - - // uint32 splineId - if (!packet.hasRemaining(4)) return false; - packet.readUInt32(); - - // uint8 moveType - if (!packet.hasData()) return false; - data.moveType = packet.readUInt8(); - - if (data.moveType == 1) { - // Stop - no more required data - data.destX = data.x; - data.destY = data.y; - data.destZ = data.z; - data.hasDest = false; - return true; - } - - // Read facing data based on move type - if (data.moveType == 2) { - // FacingSpot: float x, y, z - if (!packet.hasRemaining(12)) return false; - packet.readFloat(); packet.readFloat(); packet.readFloat(); - } else if (data.moveType == 3) { - // FacingTarget: uint64 guid - if (!packet.hasRemaining(8)) return false; - data.facingTarget = packet.readUInt64(); - } else if (data.moveType == 4) { - // FacingAngle: float angle - if (!packet.hasRemaining(4)) return false; - data.facingAngle = packet.readFloat(); - } - - // uint32 splineFlags - if (!packet.hasRemaining(4)) return false; - data.splineFlags = packet.readUInt32(); - - // WotLK 3.3.5a SplineFlags (from TrinityCore/MaNGOS MoveSplineFlag.h): - // Animation = 0x00400000 - // Parabolic = 0x00000800 - // Catmullrom = 0x00080000 \ either means uncompressed (absolute) waypoints - // Flying = 0x00002000 / - - // [if Animation] uint8 animationType + int32 effectStartTime (5 bytes) - if (data.splineFlags & 0x00400000) { - if (!packet.hasRemaining(5)) return false; - packet.readUInt8(); // animationType - packet.readUInt32(); // effectStartTime (int32, read as uint32 same size) - } - - // uint32 duration - if (!packet.hasRemaining(4)) return false; - data.duration = packet.readUInt32(); - - // [if Parabolic] float verticalAcceleration + int32 effectStartTime (8 bytes) - if (data.splineFlags & 0x00000800) { - if (!packet.hasRemaining(8)) return false; - packet.readFloat(); // verticalAcceleration - packet.readUInt32(); // effectStartTime - } - - // uint32 pointCount - if (!packet.hasRemaining(4)) return false; - uint32_t pointCount = packet.readUInt32(); - - if (pointCount == 0) return true; - - constexpr uint32_t kMaxSplinePoints = 1000; - if (pointCount > kMaxSplinePoints) { - LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints, - " (guid=0x", std::hex, data.guid, std::dec, ")"); - return false; - } - - // Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed). - // Otherwise: first float3 is final destination, remaining are packed deltas. - bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; - - if (uncompressed) { - // Read last point as destination - // Skip to last point: each point is 12 bytes - if (pointCount > 1) { - for (uint32_t i = 0; i < pointCount - 1; i++) { - if (!packet.hasRemaining(12)) return true; - packet.readFloat(); packet.readFloat(); packet.readFloat(); - } - } - if (!packet.hasRemaining(12)) return true; - data.destX = packet.readFloat(); - data.destY = packet.readFloat(); - data.destZ = packet.readFloat(); - data.hasDest = true; - } else { - // Compressed: first 3 floats are the destination (final point) - if (!packet.hasRemaining(12)) return true; - data.destX = packet.readFloat(); - data.destY = packet.readFloat(); - data.destZ = packet.readFloat(); - data.hasDest = true; - } - - LOG_DEBUG("MonsterMove: guid=0x", std::hex, data.guid, std::dec, - " type=", static_cast(data.moveType), " dur=", data.duration, "ms", - " dest=(", data.destX, ",", data.destY, ",", data.destZ, ")"); - - return true; -} - -bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& data) { - data.guid = packet.readPackedGuid(); - if (data.guid == 0) return false; - - if (!packet.hasRemaining(12)) return false; - data.x = packet.readFloat(); - data.y = packet.readFloat(); - data.z = packet.readFloat(); - - // Turtle WoW movement payload uses a spline-style layout after XYZ: - // uint32 splineIdOrTick - // uint8 moveType - // [if moveType 2/3/4] facing payload - // uint32 splineFlags - // [if Animation] uint8 + uint32 - // uint32 duration - // [if Parabolic] float + uint32 - // uint32 pointCount - // float[3] dest - // uint32 packedPoints[pointCount-1] - if (!packet.hasRemaining(4)) return false; - /*uint32_t splineIdOrTick =*/ packet.readUInt32(); - - if (!packet.hasData()) return false; - data.moveType = packet.readUInt8(); - - if (data.moveType == 1) { - data.destX = data.x; - data.destY = data.y; - data.destZ = data.z; - data.hasDest = false; - return true; - } - - if (data.moveType == 2) { - if (!packet.hasRemaining(12)) return false; - packet.readFloat(); packet.readFloat(); packet.readFloat(); - } else if (data.moveType == 3) { - if (!packet.hasRemaining(8)) return false; - data.facingTarget = packet.readUInt64(); - } else if (data.moveType == 4) { - if (!packet.hasRemaining(4)) return false; - data.facingAngle = packet.readFloat(); - } - - if (!packet.hasRemaining(4)) return false; - data.splineFlags = packet.readUInt32(); - - // Animation flag (same bit as WotLK MoveSplineFlag::Animation) - if (data.splineFlags & 0x00400000) { - if (!packet.hasRemaining(5)) return false; - packet.readUInt8(); - packet.readUInt32(); - } - - if (!packet.hasRemaining(4)) return false; - data.duration = packet.readUInt32(); - - // Parabolic flag (same bit as WotLK MoveSplineFlag::Parabolic) - if (data.splineFlags & 0x00000800) { - if (!packet.hasRemaining(8)) return false; - packet.readFloat(); - packet.readUInt32(); - } - - if (!packet.hasRemaining(4)) return false; - uint32_t pointCount = packet.readUInt32(); - - if (pointCount == 0) return true; - - // Reject extreme point counts from malformed packets. - constexpr uint32_t kMaxSplinePoints = 1000; - if (pointCount > kMaxSplinePoints) { - return false; - } - - size_t requiredBytes = 12; - if (pointCount > 1) { - requiredBytes += static_cast(pointCount - 1) * 4ull; - } - if (!packet.hasRemaining(requiredBytes)) return false; - - // First float[3] is destination. - data.destX = packet.readFloat(); - data.destY = packet.readFloat(); - data.destZ = packet.readFloat(); - data.hasDest = true; - - // Remaining waypoints are packed as uint32 deltas. - if (pointCount > 1) { - size_t skipBytes = static_cast(pointCount - 1) * 4; - size_t newPos = packet.getReadPos() + skipBytes; - if (newPos > packet.getSize()) return false; - packet.setReadPos(newPos); - } - - LOG_DEBUG("MonsterMove(turtle): guid=0x", std::hex, data.guid, std::dec, - " type=", static_cast(data.moveType), " dur=", data.duration, "ms", - " dest=(", data.destX, ",", data.destY, ",", data.destZ, ")"); - - return true; -} - - -// ============================================================ -// Phase 2: Combat Core -// ============================================================ - -bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) { - if (packet.getSize() < 16) return false; - data.attackerGuid = packet.readUInt64(); - data.victimGuid = packet.readUInt64(); - LOG_DEBUG("Attack started: 0x", std::hex, data.attackerGuid, - " -> 0x", data.victimGuid, std::dec); - return true; -} - -bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) { - data.attackerGuid = packet.readPackedGuid(); - data.victimGuid = packet.readPackedGuid(); - if (packet.hasData()) { - data.unknown = packet.readUInt32(); - } - LOG_DEBUG("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec); - return true; -} - -bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpdateData& data) { - // Upfront validation: hitInfo(4) + packed GUIDs(1-8 each) + totalDamage(4) + subDamageCount(1) = 13 bytes minimum - if (!packet.hasRemaining(13)) return false; - - size_t startPos = packet.getReadPos(); - data.hitInfo = packet.readUInt32(); - if (!packet.hasFullPackedGuid()) { - packet.setReadPos(startPos); - return false; - } - data.attackerGuid = packet.readPackedGuid(); - if (!packet.hasFullPackedGuid()) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = packet.readPackedGuid(); - - // Validate totalDamage + subDamageCount can be read (5 bytes) - if (!packet.hasRemaining(5)) { - packet.setReadPos(startPos); - return false; - } - - data.totalDamage = static_cast(packet.readUInt32()); - data.subDamageCount = packet.readUInt8(); - - // Cap subDamageCount: each entry is 20 bytes. If the claimed count - // exceeds what the remaining bytes can hold, a GUID was mis-parsed - // (off by one byte), causing the school-mask byte to be read as count. - // In that case clamp to the number of full entries that fit. - { - size_t remaining = packet.getRemainingSize(); - size_t maxFit = remaining / 20; - if (data.subDamageCount > maxFit) { - data.subDamageCount = static_cast(std::min(maxFit, 64)); - } else if (data.subDamageCount > 64) { - data.subDamageCount = 64; - } - } - if (data.subDamageCount == 0) return false; - - data.subDamages.reserve(data.subDamageCount); - for (uint8_t i = 0; i < data.subDamageCount; ++i) { - // Each sub-damage entry needs 20 bytes: schoolMask(4) + damage(4) + intDamage(4) + absorbed(4) + resisted(4) - if (!packet.hasRemaining(20)) { - data.subDamageCount = i; - break; - } - SubDamage sub; - sub.schoolMask = packet.readUInt32(); - sub.damage = packet.readFloat(); - sub.intDamage = packet.readUInt32(); - sub.absorbed = packet.readUInt32(); - sub.resisted = packet.readUInt32(); - data.subDamages.push_back(sub); - } - - // Validate victimState + overkill fields (8 bytes) - if (!packet.hasRemaining(8)) { - data.victimState = 0; - data.overkill = 0; - return !data.subDamages.empty(); - } - - data.victimState = packet.readUInt32(); - // WotLK (AzerothCore): two unknown uint32 fields follow victimState before overkill. - // Older parsers omitted these, reading overkill from the wrong offset. - auto rem = [&]() { return packet.getRemainingSize(); }; - if (rem() >= 4) packet.readUInt32(); // unk1 (always 0) - if (rem() >= 4) packet.readUInt32(); // unk2 (melee spell ID, 0 for auto-attack) - data.overkill = (rem() >= 4) ? static_cast(packet.readUInt32()) : -1; - - // hitInfo-conditional fields: HITINFO_BLOCK(0x2000), RAGE_GAIN(0x20000), FAKE_DAMAGE(0x40) - if ((data.hitInfo & 0x2000) && rem() >= 4) data.blocked = packet.readUInt32(); - else data.blocked = 0; - // RAGE_GAIN and FAKE_DAMAGE both add a uint32 we can skip - if ((data.hitInfo & 0x20000) && rem() >= 4) packet.readUInt32(); // rage gain - if ((data.hitInfo & 0x40) && rem() >= 4) packet.readUInt32(); // fake damage total - - LOG_DEBUG("Melee hit: ", data.totalDamage, " damage", - data.isCrit() ? " (CRIT)" : "", - data.isMiss() ? " (MISS)" : ""); - return true; -} - -bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& data) { - // Upfront validation: - // packed GUIDs(1-8 each) + spellId(4) + damage(4) + overkill(4) + schoolMask(1) - // + absorbed(4) + resisted(4) + periodicLog(1) + unused(1) + blocked(4) + flags(4) - // = 33 bytes minimum. - if (!packet.hasRemaining(33)) return false; - - size_t startPos = packet.getReadPos(); - if (!packet.hasFullPackedGuid()) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = packet.readPackedGuid(); - if (!packet.hasFullPackedGuid()) { - packet.setReadPos(startPos); - return false; - } - data.attackerGuid = packet.readPackedGuid(); - - // Validate core fields (spellId + damage + overkill + schoolMask + absorbed + resisted = 21 bytes) - if (!packet.hasRemaining(21)) { - packet.setReadPos(startPos); - return false; - } - - data.spellId = packet.readUInt32(); - data.damage = packet.readUInt32(); - data.overkill = packet.readUInt32(); - data.schoolMask = packet.readUInt8(); - data.absorbed = packet.readUInt32(); - data.resisted = packet.readUInt32(); - - // Remaining fields are required for a complete event. - // Reject truncated packets so we do not emit partial/incorrect combat entries. - if (!packet.hasRemaining(10)) { - packet.setReadPos(startPos); - return false; - } - - (void)packet.readUInt8(); // periodicLog (not displayed) - packet.readUInt8(); // unused - packet.readUInt32(); // blocked - uint32_t flags = packet.readUInt32(); // flags IS used — bit 0x02 = crit - data.isCrit = (flags & 0x02) != 0; - - LOG_DEBUG("Spell damage: spellId=", data.spellId, " dmg=", data.damage, - data.isCrit ? " CRIT" : ""); - return true; -} - -bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) { - // Upfront validation: packed GUIDs(1-8 each) + spellId(4) + heal(4) + overheal(4) + absorbed(4) + critFlag(1) = 21 bytes minimum - if (!packet.hasRemaining(21)) return false; - - size_t startPos = packet.getReadPos(); - if (!packet.hasFullPackedGuid()) { - packet.setReadPos(startPos); - return false; - } - data.targetGuid = packet.readPackedGuid(); - if (!packet.hasFullPackedGuid()) { - packet.setReadPos(startPos); - return false; - } - data.casterGuid = packet.readPackedGuid(); - - // Validate remaining fields (spellId + heal + overheal + absorbed + critFlag = 17 bytes) - if (!packet.hasRemaining(17)) { - packet.setReadPos(startPos); - return false; - } - - data.spellId = packet.readUInt32(); - data.heal = packet.readUInt32(); - data.overheal = packet.readUInt32(); - data.absorbed = packet.readUInt32(); - uint8_t critFlag = packet.readUInt8(); - data.isCrit = (critFlag != 0); - - LOG_DEBUG("Spell heal: spellId=", data.spellId, " heal=", data.heal, - data.isCrit ? " CRIT" : ""); - return true; -} - -// ============================================================ -// XP Gain -// ============================================================ - -bool XpGainParser::parse(network::Packet& packet, XpGainData& data) { - // Validate minimum packet size: victimGuid(8) + totalXp(4) + type(1) - if (!packet.hasRemaining(13)) { - LOG_WARNING("SMSG_LOG_XPGAIN: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.victimGuid = packet.readUInt64(); - data.totalXp = packet.readUInt32(); - data.type = packet.readUInt8(); - if (data.type == 0) { - // Kill XP: float groupRate (1.0 = solo) + uint8 RAF flag - // Validate before reading conditional fields - if (packet.hasRemaining(5)) { - float groupRate = packet.readFloat(); - packet.readUInt8(); // RAF bonus flag - // Group bonus = total - (total / rate); only if grouped (rate > 1) - if (groupRate > 1.0f) { - data.groupBonus = data.totalXp - static_cast(data.totalXp / groupRate); - } - } - } - LOG_DEBUG("XP gain: ", data.totalXp, " xp (type=", static_cast(data.type), ")"); - return data.totalXp > 0; -} - -// ============================================================ -// Phase 3: Spells, Action Bar, Auras -// ============================================================ - -bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data, - bool vanillaFormat) { - // Validate minimum packet size for header: talentSpec(1) + spellCount(2) - if (!packet.hasRemaining(3)) { - LOG_ERROR("SMSG_INITIAL_SPELLS: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.talentSpec = packet.readUInt8(); - uint16_t spellCount = packet.readUInt16(); - - // Cap spell count to prevent excessive iteration. - // WotLK characters with all ranks, mounts, professions, and racials can - // know 400-600 spells; 1024 covers all practical cases with headroom. - constexpr uint16_t kMaxSpells = 1024; - if (spellCount > kMaxSpells) { - LOG_WARNING("SMSG_INITIAL_SPELLS: spellCount=", spellCount, " exceeds max ", kMaxSpells, - ", capping"); - spellCount = kMaxSpells; - } - - LOG_DEBUG("SMSG_INITIAL_SPELLS: spellCount=", spellCount, - vanillaFormat ? " (vanilla uint16 format)" : " (TBC/WotLK uint32 format)"); - - data.spellIds.reserve(spellCount); - for (uint16_t i = 0; i < spellCount; ++i) { - // Vanilla spell: spellId(2) + slot(2) = 4 bytes - // TBC/WotLK spell: spellId(4) + unknown(2) = 6 bytes - size_t spellEntrySize = vanillaFormat ? 4 : 6; - if (!packet.hasRemaining(spellEntrySize)) { - LOG_WARNING("SMSG_INITIAL_SPELLS: spell ", i, " truncated (", spellCount, " expected)"); - break; - } - - uint32_t spellId; - if (vanillaFormat) { - spellId = packet.readUInt16(); - packet.readUInt16(); // slot - } else { - spellId = packet.readUInt32(); - packet.readUInt16(); // unknown (always 0) - } - if (spellId != 0) { - data.spellIds.push_back(spellId); - } - } - - // Validate minimum packet size for cooldownCount (2 bytes) - if (!packet.hasRemaining(2)) { - LOG_WARNING("SMSG_INITIAL_SPELLS: truncated before cooldownCount (parsed ", data.spellIds.size(), - " spells)"); - return true; // Have spells; cooldowns are optional - } - - uint16_t cooldownCount = packet.readUInt16(); - - // Cap cooldown count to prevent excessive iteration. - // Some servers include entries for all spells (even with zero remaining time) - // to communicate category cooldown data, so the count can be high. - constexpr uint16_t kMaxCooldowns = 1024; - if (cooldownCount > kMaxCooldowns) { - LOG_WARNING("SMSG_INITIAL_SPELLS: cooldownCount=", cooldownCount, " exceeds max ", kMaxCooldowns, - ", capping"); - cooldownCount = kMaxCooldowns; - } - - data.cooldowns.reserve(cooldownCount); - for (uint16_t i = 0; i < cooldownCount; ++i) { - // Vanilla cooldown: spellId(2) + itemId(2) + categoryId(2) + cooldownMs(4) + categoryCooldownMs(4) = 14 bytes - // TBC/WotLK cooldown: spellId(4) + itemId(2) + categoryId(2) + cooldownMs(4) + categoryCooldownMs(4) = 16 bytes - size_t cooldownEntrySize = vanillaFormat ? 14 : 16; - if (!packet.hasRemaining(cooldownEntrySize)) { - LOG_WARNING("SMSG_INITIAL_SPELLS: cooldown ", i, " truncated (", cooldownCount, " expected)"); - break; - } - - SpellCooldownEntry entry; - if (vanillaFormat) { - entry.spellId = packet.readUInt16(); - } else { - entry.spellId = packet.readUInt32(); - } - entry.itemId = packet.readUInt16(); - entry.categoryId = packet.readUInt16(); - entry.cooldownMs = packet.readUInt32(); - entry.categoryCooldownMs = packet.readUInt32(); - data.cooldowns.push_back(entry); - } - - LOG_INFO("Initial spells parsed: ", data.spellIds.size(), " spells, ", - data.cooldowns.size(), " cooldowns"); - - 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_DEBUG("Initial spell IDs (first 10): ", first10); - } - - return true; -} - -network::Packet CastSpellPacket::build(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) { - network::Packet packet(wireOpcode(Opcode::CMSG_CAST_SPELL)); - packet.writeUInt8(castCount); - packet.writeUInt32(spellId); - packet.writeUInt8(0x00); // castFlags = 0 for normal cast - - // SpellCastTargets - if (targetGuid != 0) { - packet.writeUInt32(0x02); // TARGET_FLAG_UNIT - - // Write packed GUID - uint8_t mask = 0; - uint8_t bytes[8]; - int byteCount = 0; - uint64_t g = targetGuid; - for (int i = 0; i < 8; ++i) { - uint8_t b = g & 0xFF; - if (b != 0) { - mask |= (1 << i); - bytes[byteCount++] = b; - } - g >>= 8; - } - packet.writeUInt8(mask); - for (int i = 0; i < byteCount; ++i) { - packet.writeUInt8(bytes[i]); - } - } else { - packet.writeUInt32(0x00); // TARGET_FLAG_SELF - } - - LOG_DEBUG("Built CMSG_CAST_SPELL: spell=", spellId, " target=0x", - std::hex, targetGuid, std::dec); - return packet; -} - -network::Packet CancelAuraPacket::build(uint32_t spellId) { - network::Packet packet(wireOpcode(Opcode::CMSG_CANCEL_AURA)); - packet.writeUInt32(spellId); - return packet; -} - -network::Packet PetActionPacket::build(uint64_t petGuid, uint32_t action, uint64_t targetGuid) { - // CMSG_PET_ACTION: petGuid(8) + action(4) + targetGuid(8) - network::Packet packet(wireOpcode(Opcode::CMSG_PET_ACTION)); - packet.writeUInt64(petGuid); - packet.writeUInt32(action); - packet.writeUInt64(targetGuid); - return packet; -} - -bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) { - // WotLK format: castCount(1) + spellId(4) + result(1) = 6 bytes minimum - if (!packet.hasRemaining(6)) return false; - - data.castCount = packet.readUInt8(); - data.spellId = packet.readUInt32(); - data.result = packet.readUInt8(); - LOG_INFO("Cast failed: spell=", data.spellId, " result=", static_cast(data.result)); - return true; -} - -bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { - data = SpellStartData{}; - - // Packed GUIDs are variable-length; only require minimal packet shape up front: - // two GUID masks + castCount(1) + spellId(4) + castFlags(4) + castTime(4). - if (!packet.hasRemaining(15)) return false; - - size_t startPos = packet.getReadPos(); - if (!packet.hasFullPackedGuid()) { - return false; - } - data.casterGuid = packet.readPackedGuid(); - if (!packet.hasFullPackedGuid()) { - packet.setReadPos(startPos); - return false; - } - data.casterUnit = packet.readPackedGuid(); - - // Validate remaining fixed fields (castCount + spellId + castFlags + castTime = 13 bytes) - if (!packet.hasRemaining(13)) { - packet.setReadPos(startPos); - return false; - } - - data.castCount = packet.readUInt8(); - data.spellId = packet.readUInt32(); - data.castFlags = packet.readUInt32(); - data.castTime = packet.readUInt32(); - - // SpellCastTargets starts with target flags and is mandatory. - if (!packet.hasRemaining(4)) { - LOG_WARNING("Spell start: missing targetFlags"); - packet.setReadPos(startPos); - return false; - } - - // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that - // subsequent fields (e.g. school mask, cast flags 0x20 extra data) are not - // misaligned for ground-targeted or AoE spells. - uint32_t targetFlags = packet.readUInt32(); - - auto readPackedTarget = [&](uint64_t* out) -> bool { - if (!packet.hasFullPackedGuid()) return false; - uint64_t g = packet.readPackedGuid(); - if (out) *out = g; - return true; - }; - auto skipPackedAndFloats3 = [&]() -> bool { - if (!packet.hasFullPackedGuid()) return false; - packet.readPackedGuid(); // transport GUID (may be zero) - if (!packet.hasRemaining(12)) return false; - packet.readFloat(); packet.readFloat(); packet.readFloat(); - return true; - }; - - // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share a single object target GUID - if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { - readPackedTarget(&data.targetGuid); // best-effort; ignore failure - } - // ITEM/TRADE_ITEM share a single item target GUID - if (targetFlags & (0x0010u | 0x0100u)) { - readPackedTarget(nullptr); - } - // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z - if (targetFlags & 0x0020u) { - skipPackedAndFloats3(); - } - // DEST_LOCATION: PackedGuid (transport) + float x,y,z - if (targetFlags & 0x0040u) { - skipPackedAndFloats3(); - } - // STRING: null-terminated - if (targetFlags & 0x0200u) { - while (packet.hasData() && packet.readUInt8() != 0) {} - } - - LOG_DEBUG("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); - return true; -} - -bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { - // Always reset output to avoid stale targets when callers reuse buffers. - data = SpellGoData{}; - - // Packed GUIDs are variable-length, so only require the smallest possible - // shape up front: 2 GUID masks + fixed fields through hitCount. - if (!packet.hasRemaining(16)) return false; - - size_t startPos = packet.getReadPos(); - if (!packet.hasFullPackedGuid()) { - return false; - } - data.casterGuid = packet.readPackedGuid(); - if (!packet.hasFullPackedGuid()) { - packet.setReadPos(startPos); - return false; - } - data.casterUnit = packet.readPackedGuid(); - - // Validate remaining fixed fields up to hitCount/missCount - if (!packet.hasRemaining(14)) { // castCount(1) + spellId(4) + castFlags(4) + timestamp(4) + hitCount(1) - packet.setReadPos(startPos); - return false; - } - - data.castCount = packet.readUInt8(); - data.spellId = packet.readUInt32(); - data.castFlags = packet.readUInt32(); - // Timestamp in 3.3.5a - packet.readUInt32(); - - const uint8_t rawHitCount = packet.readUInt8(); - if (rawHitCount > 128) { - LOG_WARNING("Spell go: hitCount capped (requested=", static_cast(rawHitCount), ")"); - } - const uint8_t storedHitLimit = std::min(rawHitCount, 128); - - bool truncatedTargets = false; - - data.hitTargets.reserve(storedHitLimit); - for (uint16_t i = 0; i < rawHitCount; ++i) { - // WotLK 3.3.5a hit targets are full uint64 GUIDs (not PackedGuid). - if (!packet.hasRemaining(8)) { - LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", static_cast(rawHitCount)); - truncatedTargets = true; - break; - } - const uint64_t targetGuid = packet.readUInt64(); - if (i < storedHitLimit) { - data.hitTargets.push_back(targetGuid); - } - } - if (truncatedTargets) { - packet.setReadPos(startPos); - return false; - } - data.hitCount = static_cast(data.hitTargets.size()); - - // missCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation. - if (!packet.hasRemaining(1)) { - LOG_WARNING("Spell go: missing missCount after hit target list"); - packet.setReadPos(startPos); - return false; - } - - const size_t missCountPos = packet.getReadPos(); - const uint8_t rawMissCount = packet.readUInt8(); - if (rawMissCount > 20) { - // Likely offset error — dump context bytes for diagnostics. - const auto& raw = packet.getData(); - std::string hexCtx; - size_t dumpStart = (missCountPos >= 8) ? missCountPos - 8 : startPos; - size_t dumpEnd = std::min(missCountPos + 16, raw.size()); - for (size_t i = dumpStart; i < dumpEnd; ++i) { - char buf[4]; - std::snprintf(buf, sizeof(buf), "%02x ", raw[i]); - hexCtx += buf; - if (i == missCountPos - 1) hexCtx += "["; - if (i == missCountPos) hexCtx += "] "; - } - LOG_WARNING("Spell go: suspect missCount=", static_cast(rawMissCount), - " spell=", data.spellId, " hits=", static_cast(data.hitCount), - " castFlags=0x", std::hex, data.castFlags, std::dec, - " missCountPos=", missCountPos, " pktSize=", packet.getSize(), - " ctx=", hexCtx); - } - if (rawMissCount > 128) { - LOG_WARNING("Spell go: missCount capped (requested=", static_cast(rawMissCount), - ") spell=", data.spellId, " hits=", static_cast(data.hitCount), - " remaining=", packet.getRemainingSize()); - } - const uint8_t storedMissLimit = std::min(rawMissCount, 128); - - data.missTargets.reserve(storedMissLimit); - for (uint16_t i = 0; i < rawMissCount; ++i) { - // WotLK 3.3.5a miss targets are full uint64 GUIDs + uint8 missType. - // REFLECT additionally appends uint8 reflectResult. - if (!packet.hasRemaining(9)) { // 8 GUID + 1 missType - LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", static_cast(rawMissCount), - " spell=", data.spellId, " hits=", static_cast(data.hitCount)); - truncatedTargets = true; - break; - } - SpellGoMissEntry m; - m.targetGuid = packet.readUInt64(); - m.missType = packet.readUInt8(); - if (m.missType == 11) { // SPELL_MISS_REFLECT - if (!packet.hasRemaining(1)) { - LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", static_cast(rawMissCount)); - truncatedTargets = true; - break; - } - (void)packet.readUInt8(); // reflectResult - } - if (i < storedMissLimit) { - data.missTargets.push_back(m); - } - } - data.missCount = static_cast(data.missTargets.size()); - - // If miss targets were truncated, salvage the successfully-parsed hit data - // rather than discarding the entire spell. The server already applied effects; - // we just need the hit list for UI feedback (combat text, health bars). - if (truncatedTargets) { - LOG_DEBUG("Spell go: salvaging ", static_cast(data.hitCount), " hits despite miss truncation"); - packet.skipAll(); // consume remaining bytes - return true; - } - - // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that - // any trailing fields after the target section are not misaligned for - // ground-targeted or AoE spells. Same layout as SpellStartParser. - if (packet.hasData()) { - if (packet.hasRemaining(4)) { - uint32_t targetFlags = packet.readUInt32(); - - auto readPackedTarget = [&](uint64_t* out) -> bool { - if (!packet.hasFullPackedGuid()) return false; - uint64_t g = packet.readPackedGuid(); - if (out) *out = g; - return true; - }; - auto skipPackedAndFloats3 = [&]() -> bool { - if (!packet.hasFullPackedGuid()) return false; - packet.readPackedGuid(); // transport GUID - if (!packet.hasRemaining(12)) return false; - packet.readFloat(); packet.readFloat(); packet.readFloat(); - return true; - }; - - // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share one object target GUID - if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { - readPackedTarget(&data.targetGuid); - } - // ITEM/TRADE_ITEM share one item target GUID - if (targetFlags & (0x0010u | 0x0100u)) { - readPackedTarget(nullptr); - } - // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z - if (targetFlags & 0x0020u) { - skipPackedAndFloats3(); - } - // DEST_LOCATION: PackedGuid (transport) + float x,y,z - if (targetFlags & 0x0040u) { - skipPackedAndFloats3(); - } - // STRING: null-terminated - if (targetFlags & 0x0200u) { - while (packet.hasData() && packet.readUInt8() != 0) {} - } - } - } - - LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", static_cast(data.hitCount), - " misses=", static_cast(data.missCount)); - return true; -} - -bool AuraUpdateParser::parse(network::Packet& packet, AuraUpdateData& data, bool isAll) { - // Validation: packed GUID (1-8 bytes minimum for reading) - if (!packet.hasRemaining(1)) return false; - - data.guid = packet.readPackedGuid(); - - // Cap number of aura entries to prevent unbounded loop DoS - uint32_t maxAuras = isAll ? 512 : 1; - uint32_t auraCount = 0; - - while (packet.hasData() && auraCount < maxAuras) { - // Validate we can read slot (1) + spellId (4) = 5 bytes minimum - if (!packet.hasRemaining(5)) { - LOG_DEBUG("Aura update: truncated entry at position ", auraCount); - break; - } - - uint8_t slot = packet.readUInt8(); - uint32_t spellId = packet.readUInt32(); - auraCount++; - - AuraSlot aura; - if (spellId != 0) { - aura.spellId = spellId; - - // Validate flags + level + charges (3 bytes) - if (!packet.hasRemaining(3)) { - LOG_WARNING("Aura update: truncated flags/level/charges at entry ", auraCount); - aura.flags = 0; - aura.level = 0; - aura.charges = 0; - } else { - aura.flags = packet.readUInt8(); - aura.level = packet.readUInt8(); - aura.charges = packet.readUInt8(); - } - - if (!(aura.flags & 0x08)) { // NOT_CASTER flag - // Validate space for packed GUID read (minimum 1 byte) - if (!packet.hasRemaining(1)) { - aura.casterGuid = 0; - } else { - aura.casterGuid = packet.readPackedGuid(); - } - } - - if (aura.flags & 0x20) { // DURATION - need 8 bytes (two uint32s) - if (!packet.hasRemaining(8)) { - LOG_WARNING("Aura update: truncated duration fields at entry ", auraCount); - aura.maxDurationMs = 0; - aura.durationMs = 0; - } else { - aura.maxDurationMs = static_cast(packet.readUInt32()); - aura.durationMs = static_cast(packet.readUInt32()); - } - } - - if (aura.flags & 0x40) { // EFFECT_AMOUNTS - // Only read amounts for active effect indices (flags 0x01, 0x02, 0x04) - for (int i = 0; i < 3; ++i) { - if (aura.flags & (1 << i)) { - if (packet.hasRemaining(4)) { - packet.readUInt32(); - } else { - LOG_WARNING("Aura update: truncated effect amount ", i, " at entry ", auraCount); - break; - } - } - } - } - } - - data.updates.push_back({slot, aura}); - - // For single update, only one entry - if (!isAll) break; - } - - if (auraCount >= maxAuras && packet.hasData()) { - LOG_WARNING("Aura update: capped at ", maxAuras, " entries, remaining data ignored"); - } - - LOG_DEBUG("Aura update for 0x", std::hex, data.guid, std::dec, - ": ", data.updates.size(), " slots"); - return true; -} - -bool SpellCooldownParser::parse(network::Packet& packet, SpellCooldownData& data) { - // Upfront validation: guid(8) + flags(1) = 9 bytes minimum - if (!packet.hasRemaining(9)) return false; - - data.guid = packet.readUInt64(); - data.flags = packet.readUInt8(); - - // Cap cooldown entries to prevent unbounded memory allocation (each entry is 8 bytes) - uint32_t maxCooldowns = 512; - uint32_t cooldownCount = 0; - - while (packet.hasRemaining(8) && cooldownCount < maxCooldowns) { - uint32_t spellId = packet.readUInt32(); - uint32_t cooldownMs = packet.readUInt32(); - data.cooldowns.push_back({spellId, cooldownMs}); - cooldownCount++; - } - - if (cooldownCount >= maxCooldowns && packet.hasRemaining(8)) { - LOG_WARNING("Spell cooldowns: capped at ", maxCooldowns, " entries, remaining data ignored"); - } - - LOG_DEBUG("Spell cooldowns: ", data.cooldowns.size(), " entries"); - return true; -} - -// ============================================================ -// Phase 4: Group/Party System -// ============================================================ - -network::Packet GroupInvitePacket::build(const std::string& playerName) { - network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_INVITE)); - packet.writeString(playerName); - packet.writeUInt32(0); // unused - LOG_DEBUG("Built CMSG_GROUP_INVITE: ", playerName); - return packet; -} - -bool GroupInviteResponseParser::parse(network::Packet& packet, GroupInviteResponseData& data) { - // Validate minimum packet size: canAccept(1) - if (!packet.hasRemaining(1)) { - LOG_WARNING("SMSG_GROUP_INVITE: packet too small (", packet.getSize(), " bytes)"); - return false; - } - - data.canAccept = packet.readUInt8(); - // Note: inviterName is a string, which is always safe to read even if empty - data.inviterName = packet.readString(); - LOG_INFO("Group invite from: ", data.inviterName, " (canAccept=", static_cast(data.canAccept), ")"); - return true; -} - -network::Packet GroupAcceptPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_ACCEPT)); - packet.writeUInt32(0); // unused in 3.3.5a - return packet; -} - -network::Packet GroupDeclinePacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_DECLINE)); - return packet; -} - -bool GroupListParser::parse(network::Packet& packet, GroupListData& data, bool hasRoles) { - auto rem = [&]() { return packet.getRemainingSize(); }; - - if (rem() < 3) return false; - data.groupType = packet.readUInt8(); - data.subGroup = packet.readUInt8(); - data.flags = packet.readUInt8(); - - // WotLK 3.3.5a added a roles byte (tank/healer/dps) for the dungeon finder. - // Classic 1.12 and TBC 2.4.3 do not have this byte. - if (hasRoles) { - if (rem() < 1) return false; - data.roles = packet.readUInt8(); - } else { - data.roles = 0; - } - - // WotLK: LFG data gated by groupType bit 0x04 (LFD group type) - if (hasRoles && (data.groupType & 0x04)) { - if (rem() < 5) return false; - packet.readUInt8(); // lfg state - packet.readUInt32(); // lfg entry - // WotLK 3.3.5a may or may not send the lfg flags byte — read it only if present - if (rem() >= 13) { // enough for lfgFlags(1)+groupGuid(8)+counter(4) - packet.readUInt8(); // lfg flags - } - } - - if (rem() < 12) return false; - packet.readUInt64(); // group GUID - packet.readUInt32(); // update counter - - if (rem() < 4) return false; - data.memberCount = packet.readUInt32(); - if (data.memberCount > 40) { - LOG_WARNING("GroupListParser: implausible memberCount=", data.memberCount, ", clamping"); - data.memberCount = 40; - } - data.members.reserve(data.memberCount); - - for (uint32_t i = 0; i < data.memberCount; ++i) { - if (rem() == 0) break; - GroupMember member; - member.name = packet.readString(); - if (rem() < 8) break; - member.guid = packet.readUInt64(); - if (rem() < 3) break; - member.isOnline = packet.readUInt8(); - member.subGroup = packet.readUInt8(); - member.flags = packet.readUInt8(); - // WotLK added per-member roles byte; Classic/TBC do not have it. - if (hasRoles) { - if (rem() < 1) break; - member.roles = packet.readUInt8(); - } else { - member.roles = 0; - } - data.members.push_back(member); - } - - if (rem() < 8) { - LOG_INFO("Group list: ", data.memberCount, " members (no leader GUID in packet)"); - return true; - } - data.leaderGuid = packet.readUInt64(); - - if (data.memberCount > 0 && rem() >= 10) { - data.lootMethod = packet.readUInt8(); - data.looterGuid = packet.readUInt64(); - data.lootThreshold = packet.readUInt8(); - // Dungeon difficulty (heroic/normal) — Classic doesn't send this; TBC/WotLK do - if (rem() >= 1) data.difficultyId = packet.readUInt8(); - // Raid difficulty — WotLK only - if (rem() >= 1) data.raidDifficultyId = packet.readUInt8(); - // Extra byte in some 3.3.5a builds - if (hasRoles && rem() >= 1) packet.readUInt8(); - } - - LOG_INFO("Group list: ", data.memberCount, " members, leader=0x", - std::hex, data.leaderGuid, std::dec); - return true; -} - -bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResultData& data) { - // Upfront validation: command(4) + name(var) + result(4) = 8 bytes minimum (plus name string) - if (!packet.hasRemaining(8)) return false; - - data.command = static_cast(packet.readUInt32()); - data.name = packet.readString(); - - // Validate result field exists (4 bytes) - if (!packet.hasRemaining(4)) { - data.result = static_cast(0); - return true; // Partial read is acceptable - } - - data.result = static_cast(packet.readUInt32()); - LOG_DEBUG("Party command result: ", static_cast(data.result)); - return true; -} - -bool GroupDeclineResponseParser::parse(network::Packet& packet, GroupDeclineData& data) { - // Upfront validation: playerName is a CString (minimum 1 null terminator) - if (!packet.hasRemaining(1)) return false; - - data.playerName = packet.readString(); - LOG_INFO("Group decline from: ", data.playerName); - return true; -} - -// ============================================================ -// Phase 5: Loot System -// ============================================================ - -network::Packet LootPacket::build(uint64_t targetGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_LOOT)); - packet.writeUInt64(targetGuid); - LOG_DEBUG("Built CMSG_LOOT: target=0x", std::hex, targetGuid, std::dec); - return packet; -} - -network::Packet AutostoreLootItemPacket::build(uint8_t slotIndex) { - network::Packet packet(wireOpcode(Opcode::CMSG_AUTOSTORE_LOOT_ITEM)); - packet.writeUInt8(slotIndex); - return packet; -} - -network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId) { - network::Packet packet(wireOpcode(Opcode::CMSG_USE_ITEM)); - packet.writeUInt8(bagIndex); - packet.writeUInt8(slotIndex); - packet.writeUInt8(0); // cast count - packet.writeUInt32(spellId); // spell id from item data - packet.writeUInt64(itemGuid); // full 8-byte GUID - packet.writeUInt32(0); // glyph index - packet.writeUInt8(0); // cast flags - // SpellCastTargets: self - packet.writeUInt32(0x00); - return packet; -} - -network::Packet OpenItemPacket::build(uint8_t bagIndex, uint8_t slotIndex) { - network::Packet packet(wireOpcode(Opcode::CMSG_OPEN_ITEM)); - packet.writeUInt8(bagIndex); - packet.writeUInt8(slotIndex); - return packet; -} - -network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { - network::Packet packet(wireOpcode(Opcode::CMSG_AUTOEQUIP_ITEM)); - packet.writeUInt8(srcBag); - packet.writeUInt8(srcSlot); - return packet; -} - -network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot) { - network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_ITEM)); - packet.writeUInt8(dstBag); - packet.writeUInt8(dstSlot); - packet.writeUInt8(srcBag); - packet.writeUInt8(srcSlot); - return packet; -} - -network::Packet SplitItemPacket::build(uint8_t srcBag, uint8_t srcSlot, - uint8_t dstBag, uint8_t dstSlot, uint8_t count) { - network::Packet packet(wireOpcode(Opcode::CMSG_SPLIT_ITEM)); - packet.writeUInt8(srcBag); - packet.writeUInt8(srcSlot); - packet.writeUInt8(dstBag); - packet.writeUInt8(dstSlot); - packet.writeUInt8(count); - return packet; -} - -network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) { - network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_INV_ITEM)); - packet.writeUInt8(srcSlot); - packet.writeUInt8(dstSlot); - return packet; -} - -network::Packet LootMoneyPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_MONEY)); - return packet; -} - -network::Packet LootReleasePacket::build(uint64_t lootGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_RELEASE)); - packet.writeUInt64(lootGuid); - return packet; -} - -bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat) { - data = LootResponseData{}; - size_t avail = packet.getRemainingSize(); - - // Minimum is guid(8)+lootType(1) = 9 bytes. Servers send a short packet with - // lootType=0 (LOOT_NONE) when loot is unavailable (e.g. chest not yet opened, - // needs a key, or another player is looting). We treat this as an empty-loot - // signal and return false so the caller knows not to open the loot window. - if (avail < 9) { - LOG_WARNING("LootResponseParser: packet too short (", avail, " bytes)"); - return false; - } - - data.lootGuid = packet.readUInt64(); - data.lootType = packet.readUInt8(); - - // Short failure packet — no gold/item data follows. - avail = packet.getRemainingSize(); - if (avail < 5) { - LOG_DEBUG("LootResponseParser: lootType=", static_cast(data.lootType), " (empty/failure response)"); - return false; - } - - data.gold = packet.readUInt32(); - uint8_t itemCount = packet.readUInt8(); - - // Per-item wire size is 22 bytes across all expansions: - // slot(1)+itemId(4)+count(4)+displayInfo(4)+randSuffix(4)+randProp(4)+slotType(1) = 22 - constexpr size_t kItemSize = 22u; - - auto parseLootItemList = [&](uint8_t listCount, bool markQuestItems) -> bool { - for (uint8_t i = 0; i < listCount; ++i) { - size_t remaining = packet.getRemainingSize(); - if (remaining < kItemSize) { - return false; - } - - LootItem item; - item.slotIndex = packet.readUInt8(); - item.itemId = packet.readUInt32(); - item.count = packet.readUInt32(); - item.displayInfoId = packet.readUInt32(); - item.randomSuffix = packet.readUInt32(); - item.randomPropertyId = packet.readUInt32(); - item.lootSlotType = packet.readUInt8(); - item.isQuestItem = markQuestItems; - data.items.push_back(item); - } - return true; - }; - - data.items.reserve(itemCount); - if (!parseLootItemList(itemCount, false)) { - LOG_WARNING("LootResponseParser: truncated regular item list"); - return false; - } - - // Quest item section only present in WotLK 3.3.5a - uint8_t questItemCount = 0; - if (isWotlkFormat && packet.hasRemaining(1)) { - questItemCount = packet.readUInt8(); - data.items.reserve(data.items.size() + questItemCount); - if (!parseLootItemList(questItemCount, true)) { - LOG_WARNING("LootResponseParser: truncated quest item list"); - return false; - } - } - - LOG_DEBUG("Loot response: ", static_cast(itemCount), " regular + ", static_cast(questItemCount), - " quest items, ", data.gold, " copper"); - return true; -} - -// ============================================================ -// Phase 5: NPC Gossip -// ============================================================ - -network::Packet GossipHelloPacket::build(uint64_t npcGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_GOSSIP_HELLO)); - packet.writeUInt64(npcGuid); - return packet; -} - -network::Packet QuestgiverHelloPacket::build(uint64_t npcGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_HELLO)); - packet.writeUInt64(npcGuid); - return packet; -} - -network::Packet GossipSelectOptionPacket::build(uint64_t npcGuid, uint32_t menuId, uint32_t optionId, const std::string& code) { - network::Packet packet(wireOpcode(Opcode::CMSG_GOSSIP_SELECT_OPTION)); - packet.writeUInt64(npcGuid); - packet.writeUInt32(menuId); - packet.writeUInt32(optionId); - if (!code.empty()) { - packet.writeString(code); - } - return packet; -} - -network::Packet QuestgiverQueryQuestPacket::build(uint64_t npcGuid, uint32_t questId) { - network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_QUERY_QUEST)); - packet.writeUInt64(npcGuid); - packet.writeUInt32(questId); - packet.writeUInt8(1); // isDialogContinued = 1 (from gossip) - return packet; -} - -network::Packet QuestgiverAcceptQuestPacket::build(uint64_t npcGuid, uint32_t questId) { - network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST)); - packet.writeUInt64(npcGuid); - packet.writeUInt32(questId); - packet.writeUInt32(0); // AzerothCore/WotLK expects trailing unk1 - return packet; -} - -bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) { - if (packet.getSize() < 20) return false; - data.npcGuid = packet.readUInt64(); - - // WotLK has informUnit(u64) before questId; Vanilla/TBC do not. - // Detect: try WotLK first (read informUnit + questId), then check if title - // string looks valid. If not, rewind and try vanilla (questId directly). - size_t preInform = packet.getReadPos(); - /*informUnit*/ packet.readUInt64(); - data.questId = packet.readUInt32(); - data.title = normalizeWowTextTokens(packet.readString()); - if (data.title.empty() || data.questId > 100000) { - // Likely vanilla format — rewind past informUnit - packet.setReadPos(preInform); - data.questId = packet.readUInt32(); - data.title = normalizeWowTextTokens(packet.readString()); - } - data.details = normalizeWowTextTokens(packet.readString()); - data.objectives = normalizeWowTextTokens(packet.readString()); - - if (!packet.hasRemaining(10)) { - LOG_DEBUG("Quest details (short): id=", data.questId, " title='", data.title, "'"); - return true; - } - - /*activateAccept*/ packet.readUInt8(); - /*flags*/ packet.readUInt32(); - data.suggestedPlayers = packet.readUInt32(); - /*isFinished*/ packet.readUInt8(); - - // Reward choice items: server always writes 6 entries (QUEST_REWARD_CHOICES_COUNT) - if (packet.hasRemaining(4)) { - /*choiceCount*/ packet.readUInt32(); - for (int i = 0; i < 6; i++) { - if (!packet.hasRemaining(12)) break; - uint32_t itemId = packet.readUInt32(); - uint32_t count = packet.readUInt32(); - uint32_t dispId = packet.readUInt32(); - if (itemId != 0) { - QuestRewardItem ri; - ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; - ri.choiceSlot = static_cast(i); - data.rewardChoiceItems.push_back(ri); - } - } - } - - // Reward items: server always writes 4 entries (QUEST_REWARDS_COUNT) - if (packet.hasRemaining(4)) { - /*rewardCount*/ packet.readUInt32(); - for (int i = 0; i < 4; i++) { - if (!packet.hasRemaining(12)) break; - uint32_t itemId = packet.readUInt32(); - uint32_t count = packet.readUInt32(); - uint32_t dispId = packet.readUInt32(); - if (itemId != 0) { - QuestRewardItem ri; - ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; - data.rewardItems.push_back(ri); - } - } - } - - // Money and XP rewards - if (packet.hasRemaining(4)) - data.rewardMoney = packet.readUInt32(); - if (packet.hasRemaining(4)) - data.rewardXp = packet.readUInt32(); - - LOG_DEBUG("Quest details: id=", data.questId, " title='", data.title, "'"); - return true; -} - -bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data) { - // Upfront validation: npcGuid(8) + menuId(4) + titleTextId(4) + optionCount(4) = 20 bytes minimum - if (!packet.hasRemaining(20)) return false; - - data.npcGuid = packet.readUInt64(); - data.menuId = packet.readUInt32(); - data.titleTextId = packet.readUInt32(); - uint32_t optionCount = packet.readUInt32(); - - // Cap option count to prevent unbounded memory allocation - const uint32_t MAX_GOSSIP_OPTIONS = 64; - if (optionCount > MAX_GOSSIP_OPTIONS) { - LOG_WARNING("GossipMessageParser: optionCount capped (requested=", optionCount, ")"); - optionCount = MAX_GOSSIP_OPTIONS; - } - - data.options.clear(); - data.options.reserve(optionCount); - for (uint32_t i = 0; i < optionCount; ++i) { - // Each option: id(4) + icon(1) + isCoded(1) + boxMoney(4) + text(var) + boxText(var) - // Minimum: 10 bytes + 2 empty strings (2 null terminators) = 12 bytes - if (!packet.hasRemaining(12)) { - LOG_WARNING("GossipMessageParser: truncated options at index ", i, "/", optionCount); - break; - } - GossipOption opt; - opt.id = packet.readUInt32(); - opt.icon = packet.readUInt8(); - opt.isCoded = (packet.readUInt8() != 0); - opt.boxMoney = packet.readUInt32(); - opt.text = packet.readString(); - opt.boxText = packet.readString(); - data.options.push_back(opt); - } - - // Validate questCount field exists (4 bytes) - if (!packet.hasRemaining(4)) { - LOG_DEBUG("Gossip: ", data.options.size(), " options (no quest data)"); - return true; - } - - uint32_t questCount = packet.readUInt32(); - // Cap quest count to prevent unbounded memory allocation - const uint32_t MAX_GOSSIP_QUESTS = 64; - if (questCount > MAX_GOSSIP_QUESTS) { - LOG_WARNING("GossipMessageParser: questCount capped (requested=", questCount, ")"); - questCount = MAX_GOSSIP_QUESTS; - } - - data.quests.clear(); - data.quests.reserve(questCount); - for (uint32_t i = 0; i < questCount; ++i) { - // Each quest: questId(4) + questIcon(4) + questLevel(4) + questFlags(4) + isRepeatable(1) + title(var) - // Minimum: 17 bytes + empty string (1 null terminator) = 18 bytes - if (!packet.hasRemaining(18)) { - LOG_WARNING("GossipMessageParser: truncated quests at index ", i, "/", questCount); - break; - } - GossipQuestItem quest; - quest.questId = packet.readUInt32(); - quest.questIcon = packet.readUInt32(); - quest.questLevel = static_cast(packet.readUInt32()); - quest.questFlags = packet.readUInt32(); - quest.isRepeatable = packet.readUInt8(); - quest.title = normalizeWowTextTokens(packet.readString()); - data.quests.push_back(quest); - } - - LOG_DEBUG("Gossip: ", data.options.size(), " options, ", data.quests.size(), " quests"); - return true; -} - -// ============================================================ -// Bind Point (Hearthstone) -// ============================================================ - -network::Packet BinderActivatePacket::build(uint64_t npcGuid) { - network::Packet pkt(wireOpcode(Opcode::CMSG_BINDER_ACTIVATE)); - pkt.writeUInt64(npcGuid); - return pkt; -} - -bool BindPointUpdateParser::parse(network::Packet& packet, BindPointUpdateData& data) { - if (packet.getSize() < 20) return false; - data.x = packet.readFloat(); - data.y = packet.readFloat(); - data.z = packet.readFloat(); - data.mapId = packet.readUInt32(); - data.zoneId = packet.readUInt32(); - return true; -} - -bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsData& data) { - if (!packet.hasRemaining(20)) return false; - data.npcGuid = packet.readUInt64(); - data.questId = packet.readUInt32(); - data.title = normalizeWowTextTokens(packet.readString()); - data.completionText = normalizeWowTextTokens(packet.readString()); - - if (!packet.hasRemaining(9)) { - LOG_DEBUG("Quest request items (short): id=", data.questId, " title='", data.title, "'"); - return true; - } - - struct ParsedTail { - uint32_t requiredMoney = 0; - uint32_t completableFlags = 0; - std::vector requiredItems; - bool ok = false; - int score = -1; - }; - - auto parseTail = [&](size_t startPos, size_t prefixSkip) -> ParsedTail { - ParsedTail out; - packet.setReadPos(startPos); - - if (!packet.hasRemaining(prefixSkip)) return out; - packet.setReadPos(packet.getReadPos() + prefixSkip); - - if (!packet.hasRemaining(8)) return out; - out.requiredMoney = packet.readUInt32(); - uint32_t requiredItemCount = packet.readUInt32(); - if (requiredItemCount > 64) return out; // sanity guard against misalignment - - out.requiredItems.reserve(requiredItemCount); - for (uint32_t i = 0; i < requiredItemCount; ++i) { - if (!packet.hasRemaining(12)) return out; - QuestRewardItem item; - item.itemId = packet.readUInt32(); - item.count = packet.readUInt32(); - item.displayInfoId = packet.readUInt32(); - if (item.itemId != 0) out.requiredItems.push_back(item); - } - - if (!packet.hasRemaining(4)) return out; - out.completableFlags = packet.readUInt32(); - out.ok = true; - - // Prefer layouts that produce plausible quest-requirement shapes. - out.score = 0; - if (requiredItemCount <= 6) out.score += 4; - if (out.requiredItems.size() == requiredItemCount) out.score += 3; - if ((out.completableFlags & ~0x3u) == 0) out.score += 5; - if (out.requiredMoney == 0) out.score += 4; - else if (out.requiredMoney <= 100000) out.score += 2; // <=10g is common - else if (out.requiredMoney >= 1000000) out.score -= 3; // implausible for most quests - if (!out.requiredItems.empty()) out.score += 1; - size_t remaining = packet.getRemainingSize(); - if (remaining <= 16) out.score += 3; - else if (remaining <= 32) out.score += 2; - else if (remaining <= 64) out.score += 1; - if (prefixSkip == 0) out.score += 1; - else if (prefixSkip <= 12) out.score += 1; - return out; - }; - - size_t tailStart = packet.getReadPos(); - std::vector candidates; - candidates.reserve(25); - for (size_t skip = 0; skip <= 24; ++skip) { - candidates.push_back(parseTail(tailStart, skip)); - } - - const ParsedTail* chosen = nullptr; - for (const auto& cand : candidates) { - if (!cand.ok) continue; - if (!chosen || cand.score > chosen->score) chosen = &cand; - } - if (!chosen) { - return true; - } - - data.requiredMoney = chosen->requiredMoney; - data.completableFlags = chosen->completableFlags; - data.requiredItems = chosen->requiredItems; - - LOG_DEBUG("Quest request items: id=", data.questId, " title='", data.title, - "' items=", data.requiredItems.size(), " completable=", data.isCompletable()); - return true; -} - -bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData& data) { - if (!packet.hasRemaining(20)) return false; - data.npcGuid = packet.readUInt64(); - data.questId = packet.readUInt32(); - data.title = normalizeWowTextTokens(packet.readString()); - data.rewardText = normalizeWowTextTokens(packet.readString()); - - if (!packet.hasRemaining(8)) { - LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); - return true; - } - - // After the two strings the packet contains a variable prefix (autoFinish + optional fields) - // before the emoteCount. Different expansions and server emulator versions differ: - // Classic 1.12 : uint8 autoFinish + uint32 suggestedPlayers = 5 bytes - // TBC 2.4.3 : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (variable arrays) - // WotLK 3.3.5a : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (fixed 6/4 arrays) - // Some vanilla-family servers omit autoFinish entirely (0 bytes of prefix). - // We scan prefix sizes 0..16 bytes with both fixed and variable array layouts, scoring each. - - struct ParsedTail { - uint32_t rewardMoney = 0; - uint32_t rewardXp = 0; - std::vector choiceRewards; - std::vector fixedRewards; - bool ok = false; - int score = -1000; - size_t prefixSkip = 0; - bool fixedArrays = false; - }; - - auto parseTail = [&](size_t startPos, size_t prefixSkip, bool fixedArrays) -> ParsedTail { - ParsedTail out; - out.prefixSkip = prefixSkip; - out.fixedArrays = fixedArrays; - packet.setReadPos(startPos); - - // Skip the prefix bytes (autoFinish + optional suggestedPlayers before emoteCount) - if (!packet.hasRemaining(prefixSkip)) return out; - packet.setReadPos(packet.getReadPos() + prefixSkip); - - if (!packet.hasRemaining(4)) return out; - uint32_t emoteCount = packet.readUInt32(); - if (emoteCount > 32) return out; // guard against misalignment - for (uint32_t i = 0; i < emoteCount; ++i) { - if (!packet.hasRemaining(8)) return out; - packet.readUInt32(); // delay - packet.readUInt32(); // emote type - } - - if (!packet.hasRemaining(4)) return out; - uint32_t choiceCount = packet.readUInt32(); - if (choiceCount > 6) return out; - uint32_t choiceSlots = fixedArrays ? 6u : choiceCount; - out.choiceRewards.reserve(choiceCount); - uint32_t nonZeroChoice = 0; - for (uint32_t i = 0; i < choiceSlots; ++i) { - if (!packet.hasRemaining(12)) return out; - QuestRewardItem item; - item.itemId = packet.readUInt32(); - item.count = packet.readUInt32(); - item.displayInfoId = packet.readUInt32(); - item.choiceSlot = i; - if (item.itemId > 0) { - out.choiceRewards.push_back(item); - ++nonZeroChoice; - } - } - - if (!packet.hasRemaining(4)) return out; - uint32_t rewardCount = packet.readUInt32(); - if (rewardCount > 4) return out; - uint32_t rewardSlots = fixedArrays ? 4u : rewardCount; - out.fixedRewards.reserve(rewardCount); - uint32_t nonZeroFixed = 0; - for (uint32_t i = 0; i < rewardSlots; ++i) { - if (!packet.hasRemaining(12)) return out; - QuestRewardItem item; - item.itemId = packet.readUInt32(); - item.count = packet.readUInt32(); - item.displayInfoId = packet.readUInt32(); - if (item.itemId > 0) { - out.fixedRewards.push_back(item); - ++nonZeroFixed; - } - } - - if (packet.hasRemaining(4)) - out.rewardMoney = packet.readUInt32(); - if (packet.hasRemaining(4)) - out.rewardXp = packet.readUInt32(); - - out.ok = true; - out.score = 0; - // Prefer the standard WotLK/TBC 8-byte prefix (uint32 autoFinish + uint32 suggestedPlayers) - if (prefixSkip == 8) out.score += 3; - else if (prefixSkip == 5) out.score += 1; // Classic uint8 autoFinish + uint32 suggestedPlayers - // Prefer fixed arrays (WotLK/TBC servers always send 6+4 slots) - if (fixedArrays) out.score += 2; - // Valid counts - if (choiceCount <= 6) out.score += 3; - if (rewardCount <= 4) out.score += 3; - // All non-zero items are within declared counts - if (nonZeroChoice <= choiceCount) out.score += 2; - if (nonZeroFixed <= rewardCount) out.score += 2; - // No bytes left over (or only a few) - size_t remaining = packet.getRemainingSize(); - if (remaining == 0) out.score += 5; - else if (remaining <= 4) out.score += 3; - else if (remaining <= 8) out.score += 2; - else if (remaining <= 16) out.score += 1; - else out.score -= static_cast(remaining / 4); - // Plausible money/XP values - if (out.rewardMoney < 5000000u) out.score += 1; // < 500g - if (out.rewardXp < 200000u) out.score += 1; // < 200k XP - return out; - }; - - size_t tailStart = packet.getReadPos(); - // Try prefix sizes 0..16 bytes with both fixed and variable array layouts - std::vector candidates; - candidates.reserve(34); - for (size_t skip = 0; skip <= 16; ++skip) { - candidates.push_back(parseTail(tailStart, skip, true)); // fixed arrays - candidates.push_back(parseTail(tailStart, skip, false)); // variable arrays - } - - const ParsedTail* best = nullptr; - for (const auto& cand : candidates) { - if (!cand.ok) continue; - if (!best || cand.score > best->score) best = &cand; - } - - if (best) { - data.choiceRewards = best->choiceRewards; - data.fixedRewards = best->fixedRewards; - data.rewardMoney = best->rewardMoney; - data.rewardXp = best->rewardXp; - } - - LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title, - "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size(), - " prefix=", (best ? best->prefixSkip : size_t(0)), - (best && best->fixedArrays ? " fixed" : " var")); - return true; -} - -network::Packet QuestgiverCompleteQuestPacket::build(uint64_t npcGuid, uint32_t questId) { - network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_COMPLETE_QUEST)); - packet.writeUInt64(npcGuid); - packet.writeUInt32(questId); - return packet; -} - -network::Packet QuestgiverRequestRewardPacket::build(uint64_t npcGuid, uint32_t questId) { - network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_REQUEST_REWARD)); - packet.writeUInt64(npcGuid); - packet.writeUInt32(questId); - return packet; -} - -network::Packet QuestgiverChooseRewardPacket::build(uint64_t npcGuid, uint32_t questId, uint32_t rewardIndex) { - network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_CHOOSE_REWARD)); - packet.writeUInt64(npcGuid); - packet.writeUInt32(questId); - packet.writeUInt32(rewardIndex); - return packet; -} - -// ============================================================ -// Phase 5: Vendor -// ============================================================ - -network::Packet ListInventoryPacket::build(uint64_t npcGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_LIST_INVENTORY)); - packet.writeUInt64(npcGuid); - return packet; -} - -network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { - network::Packet packet(wireOpcode(Opcode::CMSG_BUY_ITEM)); - packet.writeUInt64(vendorGuid); - packet.writeUInt32(itemId); // item entry - packet.writeUInt32(slot); // vendor slot index from SMSG_LIST_INVENTORY - packet.writeUInt32(count); - // Note: WotLK/AzerothCore expects a trailing byte; Classic/TBC do not. - // This static helper always adds it (appropriate for CMaNGOS/AzerothCore). - // For Classic/TBC, use the GameHandler::buyItem() path which checks expansion. - packet.writeUInt8(0); - return packet; -} - -network::Packet SellItemPacket::build(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { - network::Packet packet(wireOpcode(Opcode::CMSG_SELL_ITEM)); - packet.writeUInt64(vendorGuid); - packet.writeUInt64(itemGuid); - packet.writeUInt32(count); - return packet; -} - -network::Packet BuybackItemPacket::build(uint64_t vendorGuid, uint32_t slot) { - network::Packet packet(wireOpcode(Opcode::CMSG_BUYBACK_ITEM)); - packet.writeUInt64(vendorGuid); - packet.writeUInt32(slot); - return packet; -} - -bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data) { - // Preserve canRepair — it was set by the gossip handler before this packet - // arrived and is not part of the wire format. - const bool savedCanRepair = data.canRepair; - data = ListInventoryData{}; - data.canRepair = savedCanRepair; - - if (!packet.hasRemaining(9)) { - LOG_WARNING("ListInventoryParser: packet too short"); - return false; - } - - data.vendorGuid = packet.readUInt64(); - uint8_t itemCount = packet.readUInt8(); - - if (itemCount == 0) { - LOG_INFO("Vendor has nothing for sale"); - return true; - } - - // Auto-detect whether server sends 7 fields (28 bytes/item) or 8 fields (32 bytes/item). - // Some servers omit the extendedCost field entirely; reading 8 fields on a 7-field packet - // misaligns every item after the first and produces garbage prices. - size_t remaining = packet.getRemainingSize(); - const size_t bytesPerItemNoExt = 28; - const size_t bytesPerItemWithExt = 32; - bool hasExtendedCost = false; - if (remaining < static_cast(itemCount) * bytesPerItemNoExt) { - LOG_WARNING("ListInventoryParser: truncated packet (items=", static_cast(itemCount), - ", remaining=", remaining, ")"); - return false; - } - if (remaining >= static_cast(itemCount) * bytesPerItemWithExt) { - hasExtendedCost = true; - } - - data.items.reserve(itemCount); - for (uint8_t i = 0; i < itemCount; ++i) { - const size_t perItemBytes = hasExtendedCost ? bytesPerItemWithExt : bytesPerItemNoExt; - if (!packet.hasRemaining(perItemBytes)) { - LOG_WARNING("ListInventoryParser: item ", static_cast(i), " truncated"); - return false; - } - VendorItem item; - item.slot = packet.readUInt32(); - item.itemId = packet.readUInt32(); - item.displayInfoId = packet.readUInt32(); - item.maxCount = static_cast(packet.readUInt32()); - item.buyPrice = packet.readUInt32(); - item.durability = packet.readUInt32(); - item.stackCount = packet.readUInt32(); - item.extendedCost = hasExtendedCost ? packet.readUInt32() : 0; - data.items.push_back(item); - } - - LOG_DEBUG("Vendor inventory: ", static_cast(itemCount), " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")"); - return true; -} - -// ============================================================ -// Trainer -// ============================================================ - -bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data, bool isClassic) { - // WotLK per-entry: spellId(4) + state(1) + cost(4) + profDialog(4) + profButton(4) + - // reqLevel(1) + reqSkill(4) + reqSkillValue(4) + chain×3(12) = 38 bytes - // Classic per-entry: spellId(4) + state(1) + cost(4) + reqLevel(1) + - // reqSkill(4) + reqSkillValue(4) + chain×3(12) + unk(4) = 34 bytes - data = TrainerListData{}; - if (!packet.hasRemaining(16)) return false; // guid(8) + type(4) + count(4) - - data.trainerGuid = packet.readUInt64(); - data.trainerType = packet.readUInt32(); - uint32_t spellCount = packet.readUInt32(); - - if (spellCount > 1000) { - LOG_ERROR("TrainerListParser: unreasonable spell count ", spellCount); - return false; - } - - data.spells.reserve(spellCount); - for (uint32_t i = 0; i < spellCount; ++i) { - // Validate minimum entry size before reading - const size_t minEntrySize = isClassic ? 34 : 38; - if (!packet.hasRemaining(minEntrySize)) { - LOG_WARNING("TrainerListParser: truncated at spell ", i); - break; - } - - TrainerSpell spell; - spell.spellId = packet.readUInt32(); - spell.state = packet.readUInt8(); - spell.spellCost = packet.readUInt32(); - if (isClassic) { - // Classic 1.12: reqLevel immediately after cost; no profDialog/profButton - spell.profDialog = 0; - spell.profButton = 0; - spell.reqLevel = packet.readUInt8(); - } else { - // TBC / WotLK: profDialog + profButton before reqLevel - spell.profDialog = packet.readUInt32(); - spell.profButton = packet.readUInt32(); - spell.reqLevel = packet.readUInt8(); - } - spell.reqSkill = packet.readUInt32(); - spell.reqSkillValue = packet.readUInt32(); - spell.chainNode1 = packet.readUInt32(); - spell.chainNode2 = packet.readUInt32(); - spell.chainNode3 = packet.readUInt32(); - if (isClassic) { - packet.readUInt32(); // trailing unk / sort index - } - data.spells.push_back(spell); - } - - if (!packet.hasData()) { - LOG_WARNING("TrainerListParser: truncated before greeting"); - data.greeting.clear(); - } else { - data.greeting = packet.readString(); - } - - LOG_INFO("Trainer list (", isClassic ? "Classic" : "TBC/WotLK", "): ", - spellCount, " spells, type=", data.trainerType, - ", greeting=\"", data.greeting, "\""); - return true; -} - -network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t spellId) { - network::Packet packet(wireOpcode(Opcode::CMSG_TRAINER_BUY_SPELL)); - packet.writeUInt64(trainerGuid); - packet.writeUInt32(spellId); - return packet; -} - -// ============================================================ -// Talents -// ============================================================ - -bool TalentsInfoParser::parse(network::Packet& packet, TalentsInfoData& data) { - // SMSG_TALENTS_INFO format (AzerothCore variant): - // uint8 activeSpec - // uint8 unspentPoints - // be32 talentCount (metadata, may not match entry count) - // be16 entryCount (actual number of id+rank entries) - // Entry[entryCount]: { le32 id, uint8 rank } - // le32 glyphSlots - // le16 glyphIds[glyphSlots] - - const size_t startPos = packet.getReadPos(); - const size_t remaining = packet.getSize() - startPos; - - if (remaining < 2 + 4 + 2) { - LOG_ERROR("SMSG_TALENTS_INFO: packet too short (remaining=", remaining, ")"); - return false; - } - - data = TalentsInfoData{}; - - // Read header - data.talentSpec = packet.readUInt8(); - data.unspentPoints = packet.readUInt8(); - - // These two counts are big-endian (network byte order) - uint32_t talentCountBE = packet.readUInt32(); - uint32_t talentCount = bswap32(talentCountBE); - - uint16_t entryCountBE = packet.readUInt16(); - uint16_t entryCount = bswap16(entryCountBE); - - // Sanity check: prevent corrupt packets from allocating excessive memory - if (entryCount > 64) { - LOG_ERROR("SMSG_TALENTS_INFO: entryCount too large (", entryCount, "), rejecting packet"); - return false; - } - - LOG_INFO("SMSG_TALENTS_INFO: spec=", static_cast(data.talentSpec), - " unspent=", static_cast(data.unspentPoints), - " talentCount=", talentCount, - " entryCount=", entryCount); - - // Parse learned entries (id + rank pairs) - // These may be talents, glyphs, or other learned abilities - data.talents.clear(); - data.talents.reserve(entryCount); - - for (uint16_t i = 0; i < entryCount; ++i) { - if (!packet.hasRemaining(5)) { - LOG_ERROR("SMSG_TALENTS_INFO: truncated entry list at i=", i); - return false; - } - uint32_t id = packet.readUInt32(); // LE - uint8_t rank = packet.readUInt8(); - data.talents.push_back({id, rank}); - - LOG_INFO(" Entry: id=", id, " rank=", static_cast(rank)); - } - - // Parse glyph tail: glyphSlots + glyphIds[] - if (!packet.hasRemaining(1)) { - LOG_WARNING("SMSG_TALENTS_INFO: no glyph tail data"); - return true; // Not fatal, older formats may not have glyphs - } - - uint8_t glyphSlots = packet.readUInt8(); - - // Sanity check: Wrath has 6 glyph slots, cap at 12 for safety - if (glyphSlots > 12) { - LOG_WARNING("SMSG_TALENTS_INFO: glyphSlots too large (", static_cast(glyphSlots), "), clamping to 12"); - glyphSlots = 12; - } - - LOG_INFO(" GlyphSlots: ", static_cast(glyphSlots)); - - data.glyphs.clear(); - data.glyphs.reserve(glyphSlots); - - for (uint8_t i = 0; i < glyphSlots; ++i) { - if (!packet.hasRemaining(2)) { - LOG_ERROR("SMSG_TALENTS_INFO: truncated glyph list at i=", i); - return false; - } - uint16_t glyphId = packet.readUInt16(); // LE - data.glyphs.push_back(glyphId); - if (glyphId != 0) { - LOG_INFO(" Glyph slot ", i, ": ", glyphId); - } - } - - LOG_INFO("SMSG_TALENTS_INFO: bytesConsumed=", (packet.getReadPos() - startPos), - " bytesRemaining=", (packet.getRemainingSize())); - - return true; -} - -network::Packet LearnTalentPacket::build(uint32_t talentId, uint32_t requestedRank) { - network::Packet packet(wireOpcode(Opcode::CMSG_LEARN_TALENT)); - packet.writeUInt32(talentId); - packet.writeUInt32(requestedRank); - return packet; -} - -network::Packet TalentWipeConfirmPacket::build(bool accept) { - network::Packet packet(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM)); - packet.writeUInt32(accept ? 1 : 0); - return packet; -} - -network::Packet ActivateTalentGroupPacket::build(uint32_t group) { - // CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3 in WotLK 3.3.5a) - // Payload: uint32 group (0 = primary, 1 = secondary) - network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE)); - packet.writeUInt32(group); - return packet; -} - -// ============================================================ -// Death/Respawn -// ============================================================ - -network::Packet RepopRequestPacket::build() { - network::Packet packet(wireOpcode(Opcode::CMSG_REPOP_REQUEST)); - packet.writeUInt8(1); // request release (1 = manual) - return packet; -} - -network::Packet ReclaimCorpsePacket::build(uint64_t guid) { - network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); - packet.writeUInt64(guid); - return packet; -} - -network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_SPIRIT_HEALER_ACTIVATE)); - packet.writeUInt64(npcGuid); - return packet; -} - -network::Packet ResurrectResponsePacket::build(uint64_t casterGuid, bool accept) { - network::Packet packet(wireOpcode(Opcode::CMSG_RESURRECT_RESPONSE)); - packet.writeUInt64(casterGuid); - packet.writeUInt8(accept ? 1 : 0); - return packet; -} - -// ============================================================ -// Taxi / Flight Paths -// ============================================================ - -bool ShowTaxiNodesParser::parse(network::Packet& packet, ShowTaxiNodesData& data) { - // Minimum: windowInfo(4) + npcGuid(8) + nearestNode(4) + at least 1 mask uint32(4) - size_t remaining = packet.getRemainingSize(); - if (remaining < 4 + 8 + 4 + 4) { - LOG_ERROR("ShowTaxiNodesParser: packet too short (", remaining, " bytes)"); - return false; - } - data.windowInfo = packet.readUInt32(); - data.npcGuid = packet.readUInt64(); - data.nearestNode = packet.readUInt32(); - // Read as many mask uint32s as available (Classic/Vanilla=4, WotLK=12) - size_t maskBytes = packet.getRemainingSize(); - uint32_t maskCount = static_cast(maskBytes / 4); - if (maskCount > TLK_TAXI_MASK_SIZE) maskCount = TLK_TAXI_MASK_SIZE; - for (uint32_t i = 0; i < maskCount; ++i) { - data.nodeMask[i] = packet.readUInt32(); - } - LOG_INFO("ShowTaxiNodes: window=", data.windowInfo, " npc=0x", std::hex, data.npcGuid, std::dec, - " nearest=", data.nearestNode, " maskSlots=", maskCount); - return true; -} - -bool ActivateTaxiReplyParser::parse(network::Packet& packet, ActivateTaxiReplyData& data) { - size_t remaining = packet.getRemainingSize(); - if (remaining >= 4) { - data.result = packet.readUInt32(); - } else if (remaining >= 1) { - data.result = packet.readUInt8(); - } else { - LOG_ERROR("ActivateTaxiReplyParser: packet too short"); - return false; - } - LOG_INFO("ActivateTaxiReply: result=", data.result); - return true; -} - -network::Packet ActivateTaxiExpressPacket::build(uint64_t npcGuid, uint32_t totalCost, const std::vector& pathNodes) { - network::Packet packet(wireOpcode(Opcode::CMSG_ACTIVATETAXIEXPRESS)); - packet.writeUInt64(npcGuid); - packet.writeUInt32(totalCost); - packet.writeUInt32(static_cast(pathNodes.size())); - for (uint32_t nodeId : pathNodes) { - packet.writeUInt32(nodeId); - } - LOG_INFO("ActivateTaxiExpress: npc=0x", std::hex, npcGuid, std::dec, - " cost=", totalCost, " nodes=", pathNodes.size()); - return packet; -} - -network::Packet ActivateTaxiPacket::build(uint64_t npcGuid, uint32_t srcNode, uint32_t destNode) { - network::Packet packet(wireOpcode(Opcode::CMSG_ACTIVATETAXI)); - packet.writeUInt64(npcGuid); - packet.writeUInt32(srcNode); - packet.writeUInt32(destNode); - return packet; -} - -network::Packet GameObjectUsePacket::build(uint64_t guid) { - network::Packet packet(wireOpcode(Opcode::CMSG_GAMEOBJ_USE)); - packet.writeUInt64(guid); - return packet; -} - -// ============================================================ -// Mail System -// ============================================================ - -network::Packet GetMailListPacket::build(uint64_t mailboxGuid) { - network::Packet packet(wireOpcode(Opcode::CMSG_GET_MAIL_LIST)); - packet.writeUInt64(mailboxGuid); - return packet; -} - -network::Packet SendMailPacket::build(uint64_t mailboxGuid, const std::string& recipient, - const std::string& subject, const std::string& body, - uint64_t money, uint64_t cod, - const std::vector& itemGuids) { - // WotLK 3.3.5a format - network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL)); - packet.writeUInt64(mailboxGuid); - packet.writeString(recipient); - packet.writeString(subject); - packet.writeString(body); - packet.writeUInt32(0); // stationery - packet.writeUInt32(0); // unknown - uint8_t attachCount = static_cast(itemGuids.size()); - packet.writeUInt8(attachCount); - for (uint8_t i = 0; i < attachCount; ++i) { - packet.writeUInt8(i); // attachment slot index - packet.writeUInt64(itemGuids[i]); - } - packet.writeUInt64(money); - packet.writeUInt64(cod); - return packet; -} - -network::Packet MailTakeMoneyPacket::build(uint64_t mailboxGuid, uint32_t mailId) { - network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_TAKE_MONEY)); - packet.writeUInt64(mailboxGuid); - packet.writeUInt32(mailId); - return packet; -} - -network::Packet MailTakeItemPacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) { - network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_TAKE_ITEM)); - packet.writeUInt64(mailboxGuid); - packet.writeUInt32(mailId); - // WotLK expects attachment item GUID low, not attachment slot index. - packet.writeUInt32(itemGuidLow); - return packet; -} - -network::Packet MailDeletePacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t mailTemplateId) { - network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_DELETE)); - packet.writeUInt64(mailboxGuid); - packet.writeUInt32(mailId); - packet.writeUInt32(mailTemplateId); - return packet; -} - -network::Packet MailMarkAsReadPacket::build(uint64_t mailboxGuid, uint32_t mailId) { - network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_MARK_AS_READ)); - packet.writeUInt64(mailboxGuid); - packet.writeUInt32(mailId); - return packet; -} - -// ============================================================================ -// PacketParsers::parseMailList — WotLK 3.3.5a format (base/default) -// ============================================================================ -bool PacketParsers::parseMailList(network::Packet& packet, std::vector& inbox) { - size_t remaining = packet.getRemainingSize(); - if (remaining < 5) return false; - - uint32_t totalCount = packet.readUInt32(); - uint8_t shownCount = packet.readUInt8(); - (void)totalCount; - - LOG_INFO("SMSG_MAIL_LIST_RESULT (WotLK): total=", totalCount, " shown=", static_cast(shownCount)); - - inbox.clear(); - inbox.reserve(shownCount); - - for (uint8_t i = 0; i < shownCount; ++i) { - remaining = packet.getRemainingSize(); - if (remaining < 2) break; - - uint16_t msgSize = packet.readUInt16(); - size_t startPos = packet.getReadPos(); - - MailMessage msg; - if (remaining < static_cast(msgSize) + 2) { - LOG_WARNING("Mail entry ", i, " truncated"); - break; - } - - msg.messageId = packet.readUInt32(); - msg.messageType = packet.readUInt8(); - - switch (msg.messageType) { - case 0: msg.senderGuid = packet.readUInt64(); break; - case 2: case 3: case 4: case 5: - msg.senderEntry = packet.readUInt32(); break; - default: msg.senderEntry = packet.readUInt32(); break; - } - - msg.cod = packet.readUInt64(); - packet.readUInt32(); // item text id - packet.readUInt32(); // unknown - msg.stationeryId = packet.readUInt32(); - msg.money = packet.readUInt64(); - msg.flags = packet.readUInt32(); - msg.expirationTime = packet.readFloat(); - msg.mailTemplateId = packet.readUInt32(); - msg.subject = packet.readString(); - // WotLK 3.3.5a always includes body text in SMSG_MAIL_LIST_RESULT. - // mailTemplateId != 0 still carries a (possibly empty) body string. - msg.body = packet.readString(); - - uint8_t attachCount = packet.readUInt8(); - msg.attachments.reserve(attachCount); - for (uint8_t j = 0; j < attachCount; ++j) { - MailAttachment att; - att.slot = packet.readUInt8(); - att.itemGuidLow = packet.readUInt32(); - att.itemId = packet.readUInt32(); - for (int e = 0; e < 7; ++e) { - uint32_t enchId = packet.readUInt32(); - packet.readUInt32(); // duration - packet.readUInt32(); // charges - if (e == 0) att.enchantId = enchId; - } - att.randomPropertyId = packet.readUInt32(); - att.randomSuffix = packet.readUInt32(); - att.stackCount = packet.readUInt32(); - att.chargesOrDurability = packet.readUInt32(); - att.maxDurability = packet.readUInt32(); - packet.readUInt32(); // durability/current durability - packet.readUInt8(); // unknown WotLK trailing byte per attachment - msg.attachments.push_back(att); - } - - msg.read = (msg.flags & 0x01) != 0; - inbox.push_back(std::move(msg)); - - // Skip unread bytes - size_t consumed = packet.getReadPos() - startPos; - if (consumed < msgSize) { - size_t skip = msgSize - consumed; - for (size_t s = 0; s < skip && packet.hasData(); ++s) - packet.readUInt8(); - } - } - - LOG_INFO("Parsed ", inbox.size(), " mail messages"); - return true; -} - -// ============================================================ -// Bank System -// ============================================================ - -network::Packet BankerActivatePacket::build(uint64_t guid) { - network::Packet p(wireOpcode(Opcode::CMSG_BANKER_ACTIVATE)); - p.writeUInt64(guid); - return p; -} - -network::Packet BuyBankSlotPacket::build(uint64_t guid) { - network::Packet p(wireOpcode(Opcode::CMSG_BUY_BANK_SLOT)); - p.writeUInt64(guid); - return p; -} - -network::Packet AutoBankItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { - network::Packet p(wireOpcode(Opcode::CMSG_AUTOBANK_ITEM)); - p.writeUInt8(srcBag); - p.writeUInt8(srcSlot); - return p; -} - -network::Packet AutoStoreBankItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { - network::Packet p(wireOpcode(Opcode::CMSG_AUTOSTORE_BANK_ITEM)); - p.writeUInt8(srcBag); - p.writeUInt8(srcSlot); - return p; -} - -// ============================================================ -// Guild Bank System -// ============================================================ - -network::Packet GuildBankerActivatePacket::build(uint64_t guid) { - network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANKER_ACTIVATE)); - p.writeUInt64(guid); - p.writeUInt8(0); // full slots update - return p; -} - -network::Packet GuildBankQueryTabPacket::build(uint64_t guid, uint8_t tabId, bool fullUpdate) { - network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_QUERY_TAB)); - p.writeUInt64(guid); - p.writeUInt8(tabId); - p.writeUInt8(fullUpdate ? 1 : 0); - return p; -} - -network::Packet GuildBankBuyTabPacket::build(uint64_t guid, uint8_t tabId) { - network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_BUY_TAB)); - p.writeUInt64(guid); - p.writeUInt8(tabId); - return p; -} - -network::Packet GuildBankDepositMoneyPacket::build(uint64_t guid, uint32_t amount) { - network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_DEPOSIT_MONEY)); - p.writeUInt64(guid); - p.writeUInt32(amount); - return p; -} - -network::Packet GuildBankWithdrawMoneyPacket::build(uint64_t guid, uint32_t amount) { - network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_WITHDRAW_MONEY)); - p.writeUInt64(guid); - p.writeUInt32(amount); - return p; -} - -network::Packet GuildBankSwapItemsPacket::buildBankToInventory( - uint64_t guid, uint8_t tabId, uint8_t bankSlot, - uint8_t destBag, uint8_t destSlot, uint32_t splitCount) -{ - network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_SWAP_ITEMS)); - p.writeUInt64(guid); - p.writeUInt8(0); // bankToCharacter = false -> bank source - p.writeUInt8(tabId); - p.writeUInt8(bankSlot); - p.writeUInt32(0); // itemEntry (unused client side) - p.writeUInt8(0); // autoStore = false - if (splitCount > 0) { - p.writeUInt8(splitCount); - } - p.writeUInt8(destBag); - p.writeUInt8(destSlot); - return p; -} - -network::Packet GuildBankSwapItemsPacket::buildInventoryToBank( - uint64_t guid, uint8_t tabId, uint8_t bankSlot, - uint8_t srcBag, uint8_t srcSlot, uint32_t splitCount) -{ - network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_SWAP_ITEMS)); - p.writeUInt64(guid); - p.writeUInt8(1); // bankToCharacter = true -> char to bank - p.writeUInt8(tabId); - p.writeUInt8(bankSlot); - p.writeUInt32(0); // itemEntry - p.writeUInt8(0); // autoStore - if (splitCount > 0) { - p.writeUInt8(splitCount); - } - p.writeUInt8(srcBag); - p.writeUInt8(srcSlot); - return p; -} - -bool GuildBankListParser::parse(network::Packet& packet, GuildBankData& data) { - if (!packet.hasRemaining(14)) return false; - - data.money = packet.readUInt64(); - data.tabId = packet.readUInt8(); - data.withdrawAmount = static_cast(packet.readUInt32()); - uint8_t fullUpdate = packet.readUInt8(); - - if (fullUpdate) { - if (!packet.hasRemaining(1)) { - LOG_WARNING("GuildBankListParser: truncated before tabCount"); - data.tabs.clear(); - } else { - uint8_t tabCount = packet.readUInt8(); - // Cap at 8 (normal guild bank tab limit in WoW) - if (tabCount > 8) { - LOG_WARNING("GuildBankListParser: tabCount capped (requested=", static_cast(tabCount), ")"); - tabCount = 8; - } - data.tabs.resize(tabCount); - for (uint8_t i = 0; i < tabCount; ++i) { - // Validate before reading strings - if (!packet.hasData()) { - LOG_WARNING("GuildBankListParser: truncated tab at index ", static_cast(i)); - break; - } - data.tabs[i].tabName = packet.readString(); - if (!packet.hasData()) { - data.tabs[i].tabIcon.clear(); - } else { - data.tabs[i].tabIcon = packet.readString(); - } - } - } - } - - if (!packet.hasRemaining(1)) { - LOG_WARNING("GuildBankListParser: truncated before numSlots"); - data.tabItems.clear(); - return true; - } - - uint8_t numSlots = packet.readUInt8(); - data.tabItems.clear(); - for (uint8_t i = 0; i < numSlots; ++i) { - // Validate minimum bytes before reading slot (slotId(1) + itemEntry(4) = 5) - if (!packet.hasRemaining(5)) { - LOG_WARNING("GuildBankListParser: truncated slot at index ", static_cast(i)); - break; - } - GuildBankItemSlot slot; - slot.slotId = packet.readUInt8(); - slot.itemEntry = packet.readUInt32(); - if (slot.itemEntry != 0) { - // Validate before reading enchant mask - if (!packet.hasRemaining(4)) break; - // Enchant info - uint32_t enchantMask = packet.readUInt32(); - for (int bit = 0; bit < 10; ++bit) { - if (enchantMask & (1u << bit)) { - if (!packet.hasRemaining(12)) { - LOG_WARNING("GuildBankListParser: truncated enchant data"); - break; - } - uint32_t enchId = packet.readUInt32(); - uint32_t enchDur = packet.readUInt32(); - uint32_t enchCharges = packet.readUInt32(); - if (bit == 0) slot.enchantId = enchId; - (void)enchDur; (void)enchCharges; - } - } - // Validate before reading remaining item fields - if (!packet.hasRemaining(12)) { - LOG_WARNING("GuildBankListParser: truncated item fields"); - break; - } - slot.stackCount = packet.readUInt32(); - /*spare=*/ packet.readUInt32(); - slot.randomPropertyId = packet.readUInt32(); - if (slot.randomPropertyId) { - if (!packet.hasRemaining(4)) { - LOG_WARNING("GuildBankListParser: truncated suffix factor"); - break; - } - /*suffixFactor=*/ packet.readUInt32(); - } - } - data.tabItems.push_back(slot); - } - return true; -} - -// ============================================================ -// Auction House System -// ============================================================ - -network::Packet AuctionHelloPacket::build(uint64_t guid) { - network::Packet p(wireOpcode(Opcode::MSG_AUCTION_HELLO)); - p.writeUInt64(guid); - return p; -} - -bool AuctionHelloParser::parse(network::Packet& packet, AuctionHelloData& data) { - size_t remaining = packet.getRemainingSize(); - if (remaining < 12) { - LOG_WARNING("AuctionHelloParser: too small, remaining=", remaining); - return false; - } - data.auctioneerGuid = packet.readUInt64(); - data.auctionHouseId = packet.readUInt32(); - // WotLK has an extra uint8 enabled field; Vanilla does not - if (packet.hasData()) { - data.enabled = packet.readUInt8(); - } else { - data.enabled = 1; - } - return true; -} - -network::Packet AuctionListItemsPacket::build( - uint64_t guid, uint32_t offset, - const std::string& searchName, - uint8_t levelMin, uint8_t levelMax, - uint32_t invTypeMask, uint32_t itemClass, - uint32_t itemSubClass, uint32_t quality, - uint8_t usableOnly, uint8_t exactMatch) -{ - network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_LIST_ITEMS)); - p.writeUInt64(guid); - p.writeUInt32(offset); - p.writeString(searchName); - p.writeUInt8(levelMin); - p.writeUInt8(levelMax); - p.writeUInt32(invTypeMask); - p.writeUInt32(itemClass); - p.writeUInt32(itemSubClass); - p.writeUInt32(quality); - p.writeUInt8(usableOnly); - p.writeUInt8(0); // getAll (0 = normal search) - p.writeUInt8(exactMatch); - // Sort columns (0 = none) - p.writeUInt8(0); - return p; -} - -network::Packet AuctionSellItemPacket::build( - uint64_t auctioneerGuid, uint64_t itemGuid, - uint32_t stackCount, uint32_t bid, - uint32_t buyout, uint32_t duration, - bool preWotlk) -{ - network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_SELL_ITEM)); - p.writeUInt64(auctioneerGuid); - if (!preWotlk) { - // WotLK: itemCount(4) + per-item [guid(8) + stackCount(4)] - p.writeUInt32(1); - p.writeUInt64(itemGuid); - p.writeUInt32(stackCount); - } else { - // Classic/TBC: just itemGuid, no count fields - p.writeUInt64(itemGuid); - } - p.writeUInt32(bid); - p.writeUInt32(buyout); - p.writeUInt32(duration); - return p; -} - -network::Packet AuctionPlaceBidPacket::build(uint64_t auctioneerGuid, uint32_t auctionId, uint32_t amount) { - network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_PLACE_BID)); - p.writeUInt64(auctioneerGuid); - p.writeUInt32(auctionId); - p.writeUInt32(amount); - return p; -} - -network::Packet AuctionRemoveItemPacket::build(uint64_t auctioneerGuid, uint32_t auctionId) { - network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_REMOVE_ITEM)); - p.writeUInt64(auctioneerGuid); - p.writeUInt32(auctionId); - return p; -} - -network::Packet AuctionListOwnerItemsPacket::build(uint64_t auctioneerGuid, uint32_t offset) { - network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_LIST_OWNER_ITEMS)); - p.writeUInt64(auctioneerGuid); - p.writeUInt32(offset); - return p; -} - -network::Packet AuctionListBidderItemsPacket::build( - uint64_t auctioneerGuid, uint32_t offset, - const std::vector& outbiddedIds) -{ - network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_LIST_BIDDER_ITEMS)); - p.writeUInt64(auctioneerGuid); - p.writeUInt32(offset); - p.writeUInt32(static_cast(outbiddedIds.size())); - for (uint32_t id : outbiddedIds) - p.writeUInt32(id); - return p; -} - -bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult& data, int numEnchantSlots) { - // Per-entry fixed size: auctionId(4) + itemEntry(4) + enchantSlots×3×4 + - // randProp(4) + suffix(4) + stack(4) + charges(4) + flags(4) + - // ownerGuid(8) + startBid(4) + outbid(4) + buyout(4) + expire(4) + - // bidderGuid(8) + curBid(4) - // Classic: numEnchantSlots=1 → 80 bytes/entry - // TBC/WotLK: numEnchantSlots=3 → 104 bytes/entry - if (!packet.hasRemaining(4)) return false; - - uint32_t count = packet.readUInt32(); - // Cap auction count to prevent unbounded memory allocation - const uint32_t MAX_AUCTION_RESULTS = 256; - if (count > MAX_AUCTION_RESULTS) { - LOG_WARNING("AuctionListResultParser: count capped (requested=", count, ")"); - count = MAX_AUCTION_RESULTS; - } - - data.auctions.clear(); - data.auctions.reserve(count); - - const size_t minPerEntry = static_cast(8 + numEnchantSlots * 12 + 28 + 8 + 8); - for (uint32_t i = 0; i < count; ++i) { - if (!packet.hasRemaining(minPerEntry)) break; - AuctionEntry e; - e.auctionId = packet.readUInt32(); - e.itemEntry = packet.readUInt32(); - // First enchant slot always present - e.enchantId = packet.readUInt32(); - packet.readUInt32(); // enchant1 duration - packet.readUInt32(); // enchant1 charges - // Extra enchant slots for TBC/WotLK - for (int s = 1; s < numEnchantSlots; ++s) { - packet.readUInt32(); // enchant N id - packet.readUInt32(); // enchant N duration - packet.readUInt32(); // enchant N charges - } - e.randomPropertyId = packet.readUInt32(); - e.suffixFactor = packet.readUInt32(); - e.stackCount = packet.readUInt32(); - packet.readUInt32(); // item charges - packet.readUInt32(); // item flags (unused) - e.ownerGuid = packet.readUInt64(); - e.startBid = packet.readUInt32(); - e.minBidIncrement = packet.readUInt32(); - e.buyoutPrice = packet.readUInt32(); - e.timeLeftMs = packet.readUInt32(); - e.bidderGuid = packet.readUInt64(); - e.currentBid = packet.readUInt32(); - data.auctions.push_back(e); - } - - if (packet.hasRemaining(8)) { - data.totalCount = packet.readUInt32(); - data.searchDelay = packet.readUInt32(); - } - return true; -} - -bool AuctionCommandResultParser::parse(network::Packet& packet, AuctionCommandResult& data) { - if (!packet.hasRemaining(12)) return false; - data.auctionId = packet.readUInt32(); - data.action = packet.readUInt32(); - data.errorCode = packet.readUInt32(); - if (data.errorCode != 0 && data.action == 2 && packet.hasRemaining(4)) { - data.bidError = packet.readUInt32(); - } - return true; -} - -// ============================================================ -// Pet Stable System -// ============================================================ - -network::Packet ListStabledPetsPacket::build(uint64_t stableMasterGuid) { - network::Packet p(wireOpcode(Opcode::MSG_LIST_STABLED_PETS)); - p.writeUInt64(stableMasterGuid); - return p; -} - -network::Packet StablePetPacket::build(uint64_t stableMasterGuid, uint8_t slot) { - network::Packet p(wireOpcode(Opcode::CMSG_STABLE_PET)); - p.writeUInt64(stableMasterGuid); - p.writeUInt8(slot); - return p; -} - -network::Packet UnstablePetPacket::build(uint64_t stableMasterGuid, uint32_t petNumber) { - network::Packet p(wireOpcode(Opcode::CMSG_UNSTABLE_PET)); - p.writeUInt64(stableMasterGuid); - p.writeUInt32(petNumber); - return p; -} - -network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name, uint8_t isDeclined) { - network::Packet p(wireOpcode(Opcode::CMSG_PET_RENAME)); - p.writeUInt64(petGuid); - p.writeString(name); // null-terminated - p.writeUInt8(isDeclined); - return p; -} - -network::Packet SetTitlePacket::build(int32_t titleBit) { - // CMSG_SET_TITLE: int32 titleBit (-1 = remove active title) - network::Packet p(wireOpcode(Opcode::CMSG_SET_TITLE)); - p.writeUInt32(static_cast(titleBit)); - return p; -} - -network::Packet AlterAppearancePacket::build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { - // CMSG_ALTER_APPEARANCE: uint32 hairStyle + uint32 hairColor + uint32 facialHair - network::Packet p(wireOpcode(Opcode::CMSG_ALTER_APPEARANCE)); - p.writeUInt32(hairStyle); - p.writeUInt32(hairColor); - p.writeUInt32(facialHair); - return p; -} - } // namespace game } // namespace wowee diff --git a/src/game/world_packets_economy.cpp b/src/game/world_packets_economy.cpp new file mode 100644 index 00000000..3960dff8 --- /dev/null +++ b/src/game/world_packets_economy.cpp @@ -0,0 +1,666 @@ +#include "game/world_packets.hpp" +#include "game/packet_parsers.hpp" +#include "game/opcodes.hpp" +#include "game/character.hpp" +#include "auth/crypto.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { + +bool ShowTaxiNodesParser::parse(network::Packet& packet, ShowTaxiNodesData& data) { + // Minimum: windowInfo(4) + npcGuid(8) + nearestNode(4) + at least 1 mask uint32(4) + size_t remaining = packet.getRemainingSize(); + if (remaining < 4 + 8 + 4 + 4) { + LOG_ERROR("ShowTaxiNodesParser: packet too short (", remaining, " bytes)"); + return false; + } + data.windowInfo = packet.readUInt32(); + data.npcGuid = packet.readUInt64(); + data.nearestNode = packet.readUInt32(); + // Read as many mask uint32s as available (Classic/Vanilla=4, WotLK=12) + size_t maskBytes = packet.getRemainingSize(); + uint32_t maskCount = static_cast(maskBytes / 4); + if (maskCount > TLK_TAXI_MASK_SIZE) maskCount = TLK_TAXI_MASK_SIZE; + for (uint32_t i = 0; i < maskCount; ++i) { + data.nodeMask[i] = packet.readUInt32(); + } + LOG_INFO("ShowTaxiNodes: window=", data.windowInfo, " npc=0x", std::hex, data.npcGuid, std::dec, + " nearest=", data.nearestNode, " maskSlots=", maskCount); + return true; +} + +bool ActivateTaxiReplyParser::parse(network::Packet& packet, ActivateTaxiReplyData& data) { + size_t remaining = packet.getRemainingSize(); + if (remaining >= 4) { + data.result = packet.readUInt32(); + } else if (remaining >= 1) { + data.result = packet.readUInt8(); + } else { + LOG_ERROR("ActivateTaxiReplyParser: packet too short"); + return false; + } + LOG_INFO("ActivateTaxiReply: result=", data.result); + return true; +} + +network::Packet ActivateTaxiExpressPacket::build(uint64_t npcGuid, uint32_t totalCost, const std::vector& pathNodes) { + network::Packet packet(wireOpcode(Opcode::CMSG_ACTIVATETAXIEXPRESS)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(totalCost); + packet.writeUInt32(static_cast(pathNodes.size())); + for (uint32_t nodeId : pathNodes) { + packet.writeUInt32(nodeId); + } + LOG_INFO("ActivateTaxiExpress: npc=0x", std::hex, npcGuid, std::dec, + " cost=", totalCost, " nodes=", pathNodes.size()); + return packet; +} + +network::Packet ActivateTaxiPacket::build(uint64_t npcGuid, uint32_t srcNode, uint32_t destNode) { + network::Packet packet(wireOpcode(Opcode::CMSG_ACTIVATETAXI)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(srcNode); + packet.writeUInt32(destNode); + return packet; +} + +network::Packet GameObjectUsePacket::build(uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_GAMEOBJ_USE)); + packet.writeUInt64(guid); + return packet; +} + +// ============================================================ +// Mail System +// ============================================================ + +network::Packet GetMailListPacket::build(uint64_t mailboxGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_GET_MAIL_LIST)); + packet.writeUInt64(mailboxGuid); + return packet; +} + +network::Packet SendMailPacket::build(uint64_t mailboxGuid, const std::string& recipient, + const std::string& subject, const std::string& body, + uint64_t money, uint64_t cod, + const std::vector& itemGuids) { + // WotLK 3.3.5a format + network::Packet packet(wireOpcode(Opcode::CMSG_SEND_MAIL)); + packet.writeUInt64(mailboxGuid); + packet.writeString(recipient); + packet.writeString(subject); + packet.writeString(body); + packet.writeUInt32(0); // stationery + packet.writeUInt32(0); // unknown + uint8_t attachCount = static_cast(itemGuids.size()); + packet.writeUInt8(attachCount); + for (uint8_t i = 0; i < attachCount; ++i) { + packet.writeUInt8(i); // attachment slot index + packet.writeUInt64(itemGuids[i]); + } + packet.writeUInt64(money); + packet.writeUInt64(cod); + return packet; +} + +network::Packet MailTakeMoneyPacket::build(uint64_t mailboxGuid, uint32_t mailId) { + network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_TAKE_MONEY)); + packet.writeUInt64(mailboxGuid); + packet.writeUInt32(mailId); + return packet; +} + +network::Packet MailTakeItemPacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t itemGuidLow) { + network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_TAKE_ITEM)); + packet.writeUInt64(mailboxGuid); + packet.writeUInt32(mailId); + // WotLK expects attachment item GUID low, not attachment slot index. + packet.writeUInt32(itemGuidLow); + return packet; +} + +network::Packet MailDeletePacket::build(uint64_t mailboxGuid, uint32_t mailId, uint32_t mailTemplateId) { + network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_DELETE)); + packet.writeUInt64(mailboxGuid); + packet.writeUInt32(mailId); + packet.writeUInt32(mailTemplateId); + return packet; +} + +network::Packet MailMarkAsReadPacket::build(uint64_t mailboxGuid, uint32_t mailId) { + network::Packet packet(wireOpcode(Opcode::CMSG_MAIL_MARK_AS_READ)); + packet.writeUInt64(mailboxGuid); + packet.writeUInt32(mailId); + return packet; +} + +// ============================================================================ +// PacketParsers::parseMailList — WotLK 3.3.5a format (base/default) +// ============================================================================ +bool PacketParsers::parseMailList(network::Packet& packet, std::vector& inbox) { + size_t remaining = packet.getRemainingSize(); + if (remaining < 5) return false; + + uint32_t totalCount = packet.readUInt32(); + uint8_t shownCount = packet.readUInt8(); + (void)totalCount; + + LOG_INFO("SMSG_MAIL_LIST_RESULT (WotLK): total=", totalCount, " shown=", static_cast(shownCount)); + + inbox.clear(); + inbox.reserve(shownCount); + + for (uint8_t i = 0; i < shownCount; ++i) { + remaining = packet.getRemainingSize(); + if (remaining < 2) break; + + uint16_t msgSize = packet.readUInt16(); + size_t startPos = packet.getReadPos(); + + MailMessage msg; + if (remaining < static_cast(msgSize) + 2) { + LOG_WARNING("Mail entry ", i, " truncated"); + break; + } + + msg.messageId = packet.readUInt32(); + msg.messageType = packet.readUInt8(); + + switch (msg.messageType) { + case 0: msg.senderGuid = packet.readUInt64(); break; + case 2: case 3: case 4: case 5: + msg.senderEntry = packet.readUInt32(); break; + default: msg.senderEntry = packet.readUInt32(); break; + } + + msg.cod = packet.readUInt64(); + packet.readUInt32(); // item text id + packet.readUInt32(); // unknown + msg.stationeryId = packet.readUInt32(); + msg.money = packet.readUInt64(); + msg.flags = packet.readUInt32(); + msg.expirationTime = packet.readFloat(); + msg.mailTemplateId = packet.readUInt32(); + msg.subject = packet.readString(); + // WotLK 3.3.5a always includes body text in SMSG_MAIL_LIST_RESULT. + // mailTemplateId != 0 still carries a (possibly empty) body string. + msg.body = packet.readString(); + + uint8_t attachCount = packet.readUInt8(); + msg.attachments.reserve(attachCount); + for (uint8_t j = 0; j < attachCount; ++j) { + MailAttachment att; + att.slot = packet.readUInt8(); + att.itemGuidLow = packet.readUInt32(); + att.itemId = packet.readUInt32(); + for (int e = 0; e < 7; ++e) { + uint32_t enchId = packet.readUInt32(); + packet.readUInt32(); // duration + packet.readUInt32(); // charges + if (e == 0) att.enchantId = enchId; + } + att.randomPropertyId = packet.readUInt32(); + att.randomSuffix = packet.readUInt32(); + att.stackCount = packet.readUInt32(); + att.chargesOrDurability = packet.readUInt32(); + att.maxDurability = packet.readUInt32(); + packet.readUInt32(); // durability/current durability + packet.readUInt8(); // unknown WotLK trailing byte per attachment + msg.attachments.push_back(att); + } + + msg.read = (msg.flags & 0x01) != 0; + inbox.push_back(std::move(msg)); + + // Skip unread bytes + size_t consumed = packet.getReadPos() - startPos; + if (consumed < msgSize) { + size_t skip = msgSize - consumed; + for (size_t s = 0; s < skip && packet.hasData(); ++s) + packet.readUInt8(); + } + } + + LOG_INFO("Parsed ", inbox.size(), " mail messages"); + return true; +} + +// ============================================================ +// Bank System +// ============================================================ + +network::Packet BankerActivatePacket::build(uint64_t guid) { + network::Packet p(wireOpcode(Opcode::CMSG_BANKER_ACTIVATE)); + p.writeUInt64(guid); + return p; +} + +network::Packet BuyBankSlotPacket::build(uint64_t guid) { + network::Packet p(wireOpcode(Opcode::CMSG_BUY_BANK_SLOT)); + p.writeUInt64(guid); + return p; +} + +network::Packet AutoBankItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { + network::Packet p(wireOpcode(Opcode::CMSG_AUTOBANK_ITEM)); + p.writeUInt8(srcBag); + p.writeUInt8(srcSlot); + return p; +} + +network::Packet AutoStoreBankItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { + network::Packet p(wireOpcode(Opcode::CMSG_AUTOSTORE_BANK_ITEM)); + p.writeUInt8(srcBag); + p.writeUInt8(srcSlot); + return p; +} + +// ============================================================ +// Guild Bank System +// ============================================================ + +network::Packet GuildBankerActivatePacket::build(uint64_t guid) { + network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANKER_ACTIVATE)); + p.writeUInt64(guid); + p.writeUInt8(0); // full slots update + return p; +} + +network::Packet GuildBankQueryTabPacket::build(uint64_t guid, uint8_t tabId, bool fullUpdate) { + network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_QUERY_TAB)); + p.writeUInt64(guid); + p.writeUInt8(tabId); + p.writeUInt8(fullUpdate ? 1 : 0); + return p; +} + +network::Packet GuildBankBuyTabPacket::build(uint64_t guid, uint8_t tabId) { + network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_BUY_TAB)); + p.writeUInt64(guid); + p.writeUInt8(tabId); + return p; +} + +network::Packet GuildBankDepositMoneyPacket::build(uint64_t guid, uint32_t amount) { + network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_DEPOSIT_MONEY)); + p.writeUInt64(guid); + p.writeUInt32(amount); + return p; +} + +network::Packet GuildBankWithdrawMoneyPacket::build(uint64_t guid, uint32_t amount) { + network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_WITHDRAW_MONEY)); + p.writeUInt64(guid); + p.writeUInt32(amount); + return p; +} + +network::Packet GuildBankSwapItemsPacket::buildBankToInventory( + uint64_t guid, uint8_t tabId, uint8_t bankSlot, + uint8_t destBag, uint8_t destSlot, uint32_t splitCount) +{ + network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_SWAP_ITEMS)); + p.writeUInt64(guid); + p.writeUInt8(0); // bankToCharacter = false -> bank source + p.writeUInt8(tabId); + p.writeUInt8(bankSlot); + p.writeUInt32(0); // itemEntry (unused client side) + p.writeUInt8(0); // autoStore = false + if (splitCount > 0) { + p.writeUInt8(splitCount); + } + p.writeUInt8(destBag); + p.writeUInt8(destSlot); + return p; +} + +network::Packet GuildBankSwapItemsPacket::buildInventoryToBank( + uint64_t guid, uint8_t tabId, uint8_t bankSlot, + uint8_t srcBag, uint8_t srcSlot, uint32_t splitCount) +{ + network::Packet p(wireOpcode(Opcode::CMSG_GUILD_BANK_SWAP_ITEMS)); + p.writeUInt64(guid); + p.writeUInt8(1); // bankToCharacter = true -> char to bank + p.writeUInt8(tabId); + p.writeUInt8(bankSlot); + p.writeUInt32(0); // itemEntry + p.writeUInt8(0); // autoStore + if (splitCount > 0) { + p.writeUInt8(splitCount); + } + p.writeUInt8(srcBag); + p.writeUInt8(srcSlot); + return p; +} + +bool GuildBankListParser::parse(network::Packet& packet, GuildBankData& data) { + if (!packet.hasRemaining(14)) return false; + + data.money = packet.readUInt64(); + data.tabId = packet.readUInt8(); + data.withdrawAmount = static_cast(packet.readUInt32()); + uint8_t fullUpdate = packet.readUInt8(); + + if (fullUpdate) { + if (!packet.hasRemaining(1)) { + LOG_WARNING("GuildBankListParser: truncated before tabCount"); + data.tabs.clear(); + } else { + uint8_t tabCount = packet.readUInt8(); + // Cap at 8 (normal guild bank tab limit in WoW) + if (tabCount > 8) { + LOG_WARNING("GuildBankListParser: tabCount capped (requested=", static_cast(tabCount), ")"); + tabCount = 8; + } + data.tabs.resize(tabCount); + for (uint8_t i = 0; i < tabCount; ++i) { + // Validate before reading strings + if (!packet.hasData()) { + LOG_WARNING("GuildBankListParser: truncated tab at index ", static_cast(i)); + break; + } + data.tabs[i].tabName = packet.readString(); + if (!packet.hasData()) { + data.tabs[i].tabIcon.clear(); + } else { + data.tabs[i].tabIcon = packet.readString(); + } + } + } + } + + if (!packet.hasRemaining(1)) { + LOG_WARNING("GuildBankListParser: truncated before numSlots"); + data.tabItems.clear(); + return true; + } + + uint8_t numSlots = packet.readUInt8(); + data.tabItems.clear(); + for (uint8_t i = 0; i < numSlots; ++i) { + // Validate minimum bytes before reading slot (slotId(1) + itemEntry(4) = 5) + if (!packet.hasRemaining(5)) { + LOG_WARNING("GuildBankListParser: truncated slot at index ", static_cast(i)); + break; + } + GuildBankItemSlot slot; + slot.slotId = packet.readUInt8(); + slot.itemEntry = packet.readUInt32(); + if (slot.itemEntry != 0) { + // Validate before reading enchant mask + if (!packet.hasRemaining(4)) break; + // Enchant info + uint32_t enchantMask = packet.readUInt32(); + for (int bit = 0; bit < 10; ++bit) { + if (enchantMask & (1u << bit)) { + if (!packet.hasRemaining(12)) { + LOG_WARNING("GuildBankListParser: truncated enchant data"); + break; + } + uint32_t enchId = packet.readUInt32(); + uint32_t enchDur = packet.readUInt32(); + uint32_t enchCharges = packet.readUInt32(); + if (bit == 0) slot.enchantId = enchId; + (void)enchDur; (void)enchCharges; + } + } + // Validate before reading remaining item fields + if (!packet.hasRemaining(12)) { + LOG_WARNING("GuildBankListParser: truncated item fields"); + break; + } + slot.stackCount = packet.readUInt32(); + /*spare=*/ packet.readUInt32(); + slot.randomPropertyId = packet.readUInt32(); + if (slot.randomPropertyId) { + if (!packet.hasRemaining(4)) { + LOG_WARNING("GuildBankListParser: truncated suffix factor"); + break; + } + /*suffixFactor=*/ packet.readUInt32(); + } + } + data.tabItems.push_back(slot); + } + return true; +} + +// ============================================================ +// Auction House System +// ============================================================ + +network::Packet AuctionHelloPacket::build(uint64_t guid) { + network::Packet p(wireOpcode(Opcode::MSG_AUCTION_HELLO)); + p.writeUInt64(guid); + return p; +} + +bool AuctionHelloParser::parse(network::Packet& packet, AuctionHelloData& data) { + size_t remaining = packet.getRemainingSize(); + if (remaining < 12) { + LOG_WARNING("AuctionHelloParser: too small, remaining=", remaining); + return false; + } + data.auctioneerGuid = packet.readUInt64(); + data.auctionHouseId = packet.readUInt32(); + // WotLK has an extra uint8 enabled field; Vanilla does not + if (packet.hasData()) { + data.enabled = packet.readUInt8(); + } else { + data.enabled = 1; + } + return true; +} + +network::Packet AuctionListItemsPacket::build( + uint64_t guid, uint32_t offset, + const std::string& searchName, + uint8_t levelMin, uint8_t levelMax, + uint32_t invTypeMask, uint32_t itemClass, + uint32_t itemSubClass, uint32_t quality, + uint8_t usableOnly, uint8_t exactMatch) +{ + network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_LIST_ITEMS)); + p.writeUInt64(guid); + p.writeUInt32(offset); + p.writeString(searchName); + p.writeUInt8(levelMin); + p.writeUInt8(levelMax); + p.writeUInt32(invTypeMask); + p.writeUInt32(itemClass); + p.writeUInt32(itemSubClass); + p.writeUInt32(quality); + p.writeUInt8(usableOnly); + p.writeUInt8(0); // getAll (0 = normal search) + p.writeUInt8(exactMatch); + // Sort columns (0 = none) + p.writeUInt8(0); + return p; +} + +network::Packet AuctionSellItemPacket::build( + uint64_t auctioneerGuid, uint64_t itemGuid, + uint32_t stackCount, uint32_t bid, + uint32_t buyout, uint32_t duration, + bool preWotlk) +{ + network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_SELL_ITEM)); + p.writeUInt64(auctioneerGuid); + if (!preWotlk) { + // WotLK: itemCount(4) + per-item [guid(8) + stackCount(4)] + p.writeUInt32(1); + p.writeUInt64(itemGuid); + p.writeUInt32(stackCount); + } else { + // Classic/TBC: just itemGuid, no count fields + p.writeUInt64(itemGuid); + } + p.writeUInt32(bid); + p.writeUInt32(buyout); + p.writeUInt32(duration); + return p; +} + +network::Packet AuctionPlaceBidPacket::build(uint64_t auctioneerGuid, uint32_t auctionId, uint32_t amount) { + network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_PLACE_BID)); + p.writeUInt64(auctioneerGuid); + p.writeUInt32(auctionId); + p.writeUInt32(amount); + return p; +} + +network::Packet AuctionRemoveItemPacket::build(uint64_t auctioneerGuid, uint32_t auctionId) { + network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_REMOVE_ITEM)); + p.writeUInt64(auctioneerGuid); + p.writeUInt32(auctionId); + return p; +} + +network::Packet AuctionListOwnerItemsPacket::build(uint64_t auctioneerGuid, uint32_t offset) { + network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_LIST_OWNER_ITEMS)); + p.writeUInt64(auctioneerGuid); + p.writeUInt32(offset); + return p; +} + +network::Packet AuctionListBidderItemsPacket::build( + uint64_t auctioneerGuid, uint32_t offset, + const std::vector& outbiddedIds) +{ + network::Packet p(wireOpcode(Opcode::CMSG_AUCTION_LIST_BIDDER_ITEMS)); + p.writeUInt64(auctioneerGuid); + p.writeUInt32(offset); + p.writeUInt32(static_cast(outbiddedIds.size())); + for (uint32_t id : outbiddedIds) + p.writeUInt32(id); + return p; +} + +bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult& data, int numEnchantSlots) { + // Per-entry fixed size: auctionId(4) + itemEntry(4) + enchantSlots×3×4 + + // randProp(4) + suffix(4) + stack(4) + charges(4) + flags(4) + + // ownerGuid(8) + startBid(4) + outbid(4) + buyout(4) + expire(4) + + // bidderGuid(8) + curBid(4) + // Classic: numEnchantSlots=1 → 80 bytes/entry + // TBC/WotLK: numEnchantSlots=3 → 104 bytes/entry + if (!packet.hasRemaining(4)) return false; + + uint32_t count = packet.readUInt32(); + // Cap auction count to prevent unbounded memory allocation + const uint32_t MAX_AUCTION_RESULTS = 256; + if (count > MAX_AUCTION_RESULTS) { + LOG_WARNING("AuctionListResultParser: count capped (requested=", count, ")"); + count = MAX_AUCTION_RESULTS; + } + + data.auctions.clear(); + data.auctions.reserve(count); + + const size_t minPerEntry = static_cast(8 + numEnchantSlots * 12 + 28 + 8 + 8); + for (uint32_t i = 0; i < count; ++i) { + if (!packet.hasRemaining(minPerEntry)) break; + AuctionEntry e; + e.auctionId = packet.readUInt32(); + e.itemEntry = packet.readUInt32(); + // First enchant slot always present + e.enchantId = packet.readUInt32(); + packet.readUInt32(); // enchant1 duration + packet.readUInt32(); // enchant1 charges + // Extra enchant slots for TBC/WotLK + for (int s = 1; s < numEnchantSlots; ++s) { + packet.readUInt32(); // enchant N id + packet.readUInt32(); // enchant N duration + packet.readUInt32(); // enchant N charges + } + e.randomPropertyId = packet.readUInt32(); + e.suffixFactor = packet.readUInt32(); + e.stackCount = packet.readUInt32(); + packet.readUInt32(); // item charges + packet.readUInt32(); // item flags (unused) + e.ownerGuid = packet.readUInt64(); + e.startBid = packet.readUInt32(); + e.minBidIncrement = packet.readUInt32(); + e.buyoutPrice = packet.readUInt32(); + e.timeLeftMs = packet.readUInt32(); + e.bidderGuid = packet.readUInt64(); + e.currentBid = packet.readUInt32(); + data.auctions.push_back(e); + } + + if (packet.hasRemaining(8)) { + data.totalCount = packet.readUInt32(); + data.searchDelay = packet.readUInt32(); + } + return true; +} + +bool AuctionCommandResultParser::parse(network::Packet& packet, AuctionCommandResult& data) { + if (!packet.hasRemaining(12)) return false; + data.auctionId = packet.readUInt32(); + data.action = packet.readUInt32(); + data.errorCode = packet.readUInt32(); + if (data.errorCode != 0 && data.action == 2 && packet.hasRemaining(4)) { + data.bidError = packet.readUInt32(); + } + return true; +} + +// ============================================================ +// Pet Stable System +// ============================================================ + +network::Packet ListStabledPetsPacket::build(uint64_t stableMasterGuid) { + network::Packet p(wireOpcode(Opcode::MSG_LIST_STABLED_PETS)); + p.writeUInt64(stableMasterGuid); + return p; +} + +network::Packet StablePetPacket::build(uint64_t stableMasterGuid, uint8_t slot) { + network::Packet p(wireOpcode(Opcode::CMSG_STABLE_PET)); + p.writeUInt64(stableMasterGuid); + p.writeUInt8(slot); + return p; +} + +network::Packet UnstablePetPacket::build(uint64_t stableMasterGuid, uint32_t petNumber) { + network::Packet p(wireOpcode(Opcode::CMSG_UNSTABLE_PET)); + p.writeUInt64(stableMasterGuid); + p.writeUInt32(petNumber); + return p; +} + +network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name, uint8_t isDeclined) { + network::Packet p(wireOpcode(Opcode::CMSG_PET_RENAME)); + p.writeUInt64(petGuid); + p.writeString(name); // null-terminated + p.writeUInt8(isDeclined); + return p; +} + +network::Packet SetTitlePacket::build(int32_t titleBit) { + // CMSG_SET_TITLE: int32 titleBit (-1 = remove active title) + network::Packet p(wireOpcode(Opcode::CMSG_SET_TITLE)); + p.writeUInt32(static_cast(titleBit)); + return p; +} + +network::Packet AlterAppearancePacket::build(uint32_t hairStyle, uint32_t hairColor, uint32_t facialHair) { + // CMSG_ALTER_APPEARANCE: uint32 hairStyle + uint32 hairColor + uint32 facialHair + network::Packet p(wireOpcode(Opcode::CMSG_ALTER_APPEARANCE)); + p.writeUInt32(hairStyle); + p.writeUInt32(hairColor); + p.writeUInt32(facialHair); + return p; +} + +} // namespace game +} // namespace wowee diff --git a/src/game/world_packets_entity.cpp b/src/game/world_packets_entity.cpp new file mode 100644 index 00000000..0d328696 --- /dev/null +++ b/src/game/world_packets_entity.cpp @@ -0,0 +1,1246 @@ +#include "game/world_packets.hpp" +#include "game/packet_parsers.hpp" +#include "game/opcodes.hpp" +#include "game/character.hpp" +#include "auth/crypto.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { + +network::Packet AttackSwingPacket::build(uint64_t targetGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_ATTACKSWING)); + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_ATTACKSWING for target: 0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet AttackStopPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_ATTACKSTOP)); + LOG_DEBUG("Built CMSG_ATTACKSTOP"); + return packet; +} + +network::Packet CancelCastPacket::build(uint32_t spellId) { + network::Packet packet(wireOpcode(Opcode::CMSG_CANCEL_CAST)); + packet.writeUInt32(0); // cast count/sequence + packet.writeUInt32(spellId); + LOG_DEBUG("Built CMSG_CANCEL_CAST for spell: ", spellId); + return packet; +} + +// ============================================================ +// Random Roll +// ============================================================ + +network::Packet RandomRollPacket::build(uint32_t minRoll, uint32_t maxRoll) { + network::Packet packet(wireOpcode(Opcode::MSG_RANDOM_ROLL)); + packet.writeUInt32(minRoll); + packet.writeUInt32(maxRoll); + LOG_DEBUG("Built MSG_RANDOM_ROLL: ", minRoll, "-", maxRoll); + return packet; +} + +bool RandomRollParser::parse(network::Packet& packet, RandomRollData& data) { + // WotLK 3.3.5a format: min(4) + max(4) + result(4) + rollerGuid(8) = 20 bytes. + // Previously read guid first (treating min|max as a uint64 GUID), producing + // garbled roller identity and random numbers in /roll chat messages. + if (!packet.hasRemaining(20)) { + LOG_WARNING("SMSG_RANDOM_ROLL: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.minRoll = packet.readUInt32(); + data.maxRoll = packet.readUInt32(); + data.result = packet.readUInt32(); + data.rollerGuid = packet.readUInt64(); + data.targetGuid = 0; // not present in protocol; kept for struct compatibility + LOG_DEBUG("Parsed SMSG_RANDOM_ROLL: roller=0x", std::hex, data.rollerGuid, std::dec, + " result=", data.result, " (", data.minRoll, "-", data.maxRoll, ")"); + return true; +} + +network::Packet NameQueryPacket::build(uint64_t playerGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_NAME_QUERY)); + packet.writeUInt64(playerGuid); + LOG_DEBUG("Built CMSG_NAME_QUERY: guid=0x", std::hex, playerGuid, std::dec); + return packet; +} + +bool NameQueryResponseParser::parse(network::Packet& packet, NameQueryResponseData& data) { + // 3.3.5a: packedGuid, uint8 found + // If found==0: CString name, CString realmName, uint8 race, uint8 gender, uint8 classId + // Validation: packed GUID (1-8 bytes) + found flag (1 byte minimum) + if (!packet.hasRemaining(2)) return false; // At least 1 for packed GUID + 1 for found + + size_t startPos = packet.getReadPos(); + data.guid = packet.readPackedGuid(); + + // Validate found flag read + if (!packet.hasRemaining(1)) { + packet.setReadPos(startPos); + return false; + } + data.found = packet.readUInt8(); + + if (data.found != 0) { + LOG_DEBUG("Name query: player not found for GUID 0x", std::hex, data.guid, std::dec); + return true; // Valid response, just not found + } + + // Validate strings: need at least 2 null terminators for empty strings + if (!packet.hasRemaining(2)) { + data.name.clear(); + data.realmName.clear(); + return !data.name.empty(); // Fail if name was required + } + + data.name = packet.readString(); + data.realmName = packet.readString(); + + // Validate final 3 uint8 fields (race, gender, classId) + if (!packet.hasRemaining(3)) { + LOG_WARNING("Name query: truncated fields after realmName, expected 3 uint8s"); + data.race = 0; + data.gender = 0; + data.classId = 0; + return !data.name.empty(); + } + + data.race = packet.readUInt8(); + data.gender = packet.readUInt8(); + data.classId = packet.readUInt8(); + + LOG_DEBUG("Name query response: ", data.name, " (race=", static_cast(data.race), + " class=", static_cast(data.classId), ")"); + return true; +} + +network::Packet CreatureQueryPacket::build(uint32_t entry, uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_CREATURE_QUERY)); + packet.writeUInt32(entry); + packet.writeUInt64(guid); + LOG_DEBUG("Built CMSG_CREATURE_QUERY: entry=", entry, " guid=0x", std::hex, guid, std::dec); + return packet; +} + +bool CreatureQueryResponseParser::parse(network::Packet& packet, CreatureQueryResponseData& data) { + // Validate minimum packet size: entry(4) + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_CREATURE_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.entry = packet.readUInt32(); + + // High bit set means creature not found + if (data.entry & 0x80000000) { + data.entry &= ~0x80000000; + LOG_DEBUG("Creature query: entry ", data.entry, " not found"); + data.name = ""; + return true; + } + + // 4 name strings (only first is usually populated) + data.name = packet.readString(); + packet.readString(); // name2 + packet.readString(); // name3 + packet.readString(); // name4 + data.subName = packet.readString(); + data.iconName = packet.readString(); + + // WotLK: 4 fixed fields after iconName (typeFlags, creatureType, family, rank) + // Validate minimum size for these fields: 4×4 = 16 bytes + if (!packet.hasRemaining(16)) { + LOG_WARNING("SMSG_CREATURE_QUERY_RESPONSE: truncated before typeFlags (entry=", data.entry, ")"); + data.typeFlags = 0; + data.creatureType = 0; + data.family = 0; + data.rank = 0; + return true; // Have name/sub/icon; base fields are important but optional + } + + data.typeFlags = packet.readUInt32(); + data.creatureType = packet.readUInt32(); + data.family = packet.readUInt32(); + data.rank = packet.readUInt32(); + + // killCredit[2] + displayId[4] = 6 × 4 = 24 bytes + if (!packet.hasRemaining(24)) { + LOG_WARNING("SMSG_CREATURE_QUERY_RESPONSE: truncated before displayIds (entry=", data.entry, ")"); + LOG_DEBUG("Creature query response: ", data.name, " (type=", data.creatureType, + " rank=", data.rank, ")"); + return true; + } + + packet.readUInt32(); // killCredit[0] + packet.readUInt32(); // killCredit[1] + data.displayId[0] = packet.readUInt32(); + data.displayId[1] = packet.readUInt32(); + data.displayId[2] = packet.readUInt32(); + data.displayId[3] = packet.readUInt32(); + + // Skip remaining fields (healthMultiplier, powerMultiplier, racialLeader, questItems, movementId) + + LOG_DEBUG("Creature query response: ", data.name, " (type=", data.creatureType, + " rank=", data.rank, " displayIds=[", data.displayId[0], ",", + data.displayId[1], ",", data.displayId[2], ",", data.displayId[3], "])"); + return true; +} + +// ---- GameObject Query ---- + +network::Packet GameObjectQueryPacket::build(uint32_t entry, uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_GAMEOBJECT_QUERY)); + packet.writeUInt32(entry); + packet.writeUInt64(guid); + LOG_DEBUG("Built CMSG_GAMEOBJECT_QUERY: entry=", entry, " guid=0x", std::hex, guid, std::dec); + return packet; +} + +bool GameObjectQueryResponseParser::parse(network::Packet& packet, GameObjectQueryResponseData& data) { + // Validate minimum packet size: entry(4) + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_GAMEOBJECT_QUERY_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.entry = packet.readUInt32(); + + // High bit set means gameobject not found + if (data.entry & 0x80000000) { + data.entry &= ~0x80000000; + LOG_DEBUG("GameObject query: entry ", data.entry, " not found"); + data.name = ""; + return true; + } + + // Validate minimum size for fixed fields: type(4) + displayId(4) + if (!packet.hasRemaining(8)) { + LOG_ERROR("SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated before names (entry=", data.entry, ")"); + return false; + } + + data.type = packet.readUInt32(); // GameObjectType + data.displayId = packet.readUInt32(); + // 4 name strings (only first is usually populated) + data.name = packet.readString(); + // name2, name3, name4 + packet.readString(); + packet.readString(); + packet.readString(); + + // WotLK: 3 extra strings before data[] (iconName, castBarCaption, unk1) + packet.readString(); // iconName + packet.readString(); // castBarCaption + packet.readString(); // unk1 + + // Read 24 type-specific data fields + size_t remaining = packet.getRemainingSize(); + if (remaining >= 24 * 4) { + for (int i = 0; i < 24; i++) { + data.data[i] = packet.readUInt32(); + } + data.hasData = true; + } else if (remaining > 0) { + // Partial data field; read what we can + uint32_t fieldsToRead = remaining / 4; + for (uint32_t i = 0; i < fieldsToRead && i < 24; i++) { + data.data[i] = packet.readUInt32(); + } + if (fieldsToRead < 24) { + LOG_WARNING("SMSG_GAMEOBJECT_QUERY_RESPONSE: truncated in data fields (", fieldsToRead, + " of 24 read, entry=", data.entry, ")"); + } + } + + LOG_DEBUG("GameObject query response: ", data.name, " (type=", data.type, " entry=", data.entry, ")"); + return true; +} + +network::Packet PageTextQueryPacket::build(uint32_t pageId, uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_PAGE_TEXT_QUERY)); + packet.writeUInt32(pageId); + packet.writeUInt64(guid); + return packet; +} + +bool PageTextQueryResponseParser::parse(network::Packet& packet, PageTextQueryResponseData& data) { + if (!packet.hasRemaining(4)) return false; + data.pageId = packet.readUInt32(); + data.text = normalizeWowTextTokens(packet.readString()); + if (packet.hasRemaining(4)) { + data.nextPageId = packet.readUInt32(); + } else { + data.nextPageId = 0; + } + return data.isValid(); +} + +// ---- Item Query ---- + +network::Packet ItemQueryPacket::build(uint32_t entry, uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_ITEM_QUERY_SINGLE)); + packet.writeUInt32(entry); + packet.writeUInt64(guid); + LOG_DEBUG("Built CMSG_ITEM_QUERY_SINGLE: entry=", entry, " guid=0x", std::hex, guid, std::dec); + return packet; +} + +const char* getItemSubclassName(uint32_t itemClass, uint32_t subClass) { + if (itemClass == 2) { // Weapon + switch (subClass) { + case 0: return "Axe"; case 1: return "Axe"; + case 2: return "Bow"; case 3: return "Gun"; + case 4: return "Mace"; case 5: return "Mace"; + case 6: return "Polearm"; case 7: return "Sword"; + case 8: return "Sword"; case 9: return "Obsolete"; + case 10: return "Staff"; case 13: return "Fist Weapon"; + case 15: return "Dagger"; case 16: return "Thrown"; + case 18: return "Crossbow"; case 19: return "Wand"; + case 20: return "Fishing Pole"; + default: return "Weapon"; + } + } + if (itemClass == 4) { // Armor + switch (subClass) { + case 0: return "Miscellaneous"; case 1: return "Cloth"; + case 2: return "Leather"; case 3: return "Mail"; + case 4: return "Plate"; case 6: return "Shield"; + default: return "Armor"; + } + } + return ""; +} + +bool ItemQueryResponseParser::parse(network::Packet& packet, ItemQueryResponseData& data) { + // Validate minimum packet size: entry(4) + item not found check + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.entry = packet.readUInt32(); + + // High bit set means item not found + if (data.entry & 0x80000000) { + data.entry &= ~0x80000000; + LOG_DEBUG("Item query: entry ", data.entry, " not found"); + return true; + } + + // Validate minimum size for fixed fields before reading: itemClass(4) + subClass(4) + soundOverride(4) + // + 4 name strings + displayInfoId(4) + quality(4) = at least 24 bytes more + if (!packet.hasRemaining(24)) { + LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before displayInfoId (entry=", data.entry, ")"); + return false; + } + + uint32_t itemClass = packet.readUInt32(); + uint32_t subClass = packet.readUInt32(); + data.itemClass = itemClass; + data.subClass = subClass; + packet.readUInt32(); // SoundOverrideSubclass + + data.subclassName = getItemSubclassName(itemClass, subClass); + + // 4 name strings + data.name = packet.readString(); + packet.readString(); // name2 + packet.readString(); // name3 + packet.readString(); // name4 + + data.displayInfoId = packet.readUInt32(); + data.quality = packet.readUInt32(); + + // WotLK 3.3.5a (TrinityCore/AzerothCore): Flags, Flags2, BuyCount, BuyPrice, SellPrice + // Some server variants omit BuyCount (4 fields instead of 5). + // Read 5 fields and validate InventoryType; if it looks implausible, rewind and try 4. + const size_t postQualityPos = packet.getReadPos(); + if (!packet.hasRemaining(24)) { + LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before flags (entry=", data.entry, ")"); + return false; + } + data.itemFlags = packet.readUInt32(); // Flags + packet.readUInt32(); // Flags2 + packet.readUInt32(); // BuyCount + packet.readUInt32(); // BuyPrice + data.sellPrice = packet.readUInt32(); // SellPrice + data.inventoryType = packet.readUInt32(); + + if (data.inventoryType > 28) { + // inventoryType out of range — BuyCount probably not present; rewind and try 4 fields + packet.setReadPos(postQualityPos); + data.itemFlags = packet.readUInt32(); // Flags + packet.readUInt32(); // Flags2 + packet.readUInt32(); // BuyPrice + data.sellPrice = packet.readUInt32(); // SellPrice + data.inventoryType = packet.readUInt32(); + } + + // Validate minimum size for remaining fixed fields before inventoryType through containerSlots: 13×4 = 52 bytes + if (!packet.hasRemaining(52)) { + LOG_ERROR("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before statsCount (entry=", data.entry, ")"); + return false; + } + data.allowableClass = packet.readUInt32(); // AllowableClass + data.allowableRace = packet.readUInt32(); // AllowableRace + data.itemLevel = packet.readUInt32(); + data.requiredLevel = packet.readUInt32(); + data.requiredSkill = packet.readUInt32(); // RequiredSkill + data.requiredSkillRank = packet.readUInt32(); // RequiredSkillRank + packet.readUInt32(); // RequiredSpell + packet.readUInt32(); // RequiredHonorRank + packet.readUInt32(); // RequiredCityRank + data.requiredReputationFaction = packet.readUInt32(); // RequiredReputationFaction + data.requiredReputationRank = packet.readUInt32(); // RequiredReputationRank + data.maxCount = static_cast(packet.readUInt32()); // MaxCount (1 = Unique) + data.maxStack = static_cast(packet.readUInt32()); // Stackable + data.containerSlots = packet.readUInt32(); + + // Read statsCount with bounds validation + if (!packet.hasRemaining(4)) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated at statsCount (entry=", data.entry, ")"); + return true; // Have enough for core fields; stats are optional + } + uint32_t statsCount = packet.readUInt32(); + + // Cap statsCount to prevent excessive iteration + constexpr uint32_t kMaxItemStats = 10; + if (statsCount > kMaxItemStats) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: statsCount=", statsCount, " exceeds max ", + kMaxItemStats, " (entry=", data.entry, "), capping"); + statsCount = kMaxItemStats; + } + + // Server sends exactly statsCount stat pairs (not always 10). + uint32_t statsToRead = std::min(statsCount, 10u); + for (uint32_t i = 0; i < statsToRead; i++) { + // Each stat is 2 uint32s (type + value) = 8 bytes + if (!packet.hasRemaining(8)) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: stat ", i, " truncated (entry=", data.entry, ")"); + break; + } + uint32_t statType = packet.readUInt32(); + int32_t statValue = static_cast(packet.readUInt32()); + switch (statType) { + case 3: data.agility = statValue; break; + case 4: data.strength = statValue; break; + case 5: data.intellect = statValue; break; + case 6: data.spirit = statValue; break; + case 7: data.stamina = statValue; break; + default: + if (statValue != 0) + data.extraStats.push_back({statType, statValue}); + break; + } + } + + // ScalingStatDistribution and ScalingStatValue + if (!packet.hasRemaining(8)) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before scaling stats (entry=", data.entry, ")"); + return true; // Have core fields; scaling is optional + } + packet.readUInt32(); // ScalingStatDistribution + packet.readUInt32(); // ScalingStatValue + + // WotLK 3.3.5a: 2 damage entries (12 bytes each) + armor + 6 resists + delay + ammoType + rangedModRange + // = 24 + 36 + 4 = 64 bytes minimum. Guard here because the section above + // returns early on truncation, and every other section has its own guard. + if (!packet.hasRemaining(64)) { + LOG_WARNING("SMSG_ITEM_QUERY_SINGLE_RESPONSE: truncated before damage/armor (entry=", data.entry, ")"); + return true; + } + + // WotLK 3.3.5a: MAX_ITEM_PROTO_DAMAGES = 2 + bool haveWeaponDamage = false; + for (int i = 0; i < 2; i++) { + float dmgMin = packet.readFloat(); + float dmgMax = packet.readFloat(); + uint32_t damageType = packet.readUInt32(); + if (!haveWeaponDamage && dmgMax > 0.0f) { + if (damageType == 0 || data.damageMax <= 0.0f) { + data.damageMin = dmgMin; + data.damageMax = dmgMax; + haveWeaponDamage = (damageType == 0); + } + } + } + + data.armor = static_cast(packet.readUInt32()); + data.holyRes = static_cast(packet.readUInt32()); // HolyRes + data.fireRes = static_cast(packet.readUInt32()); // FireRes + data.natureRes = static_cast(packet.readUInt32()); // NatureRes + data.frostRes = static_cast(packet.readUInt32()); // FrostRes + data.shadowRes = static_cast(packet.readUInt32()); // ShadowRes + data.arcaneRes = static_cast(packet.readUInt32()); // ArcaneRes + data.delayMs = packet.readUInt32(); + packet.readUInt32(); // AmmoType + packet.readFloat(); // RangedModRange + + // 5 item spells: SpellId, SpellTrigger, SpellCharges, SpellCooldown, SpellCategory, SpellCategoryCooldown + for (int i = 0; i < 5; i++) { + if (!packet.hasRemaining(24)) break; + data.spells[i].spellId = packet.readUInt32(); + data.spells[i].spellTrigger = packet.readUInt32(); + packet.readUInt32(); // SpellCharges + packet.readUInt32(); // SpellCooldown + packet.readUInt32(); // SpellCategory + packet.readUInt32(); // SpellCategoryCooldown + } + + // Bonding type (0=none, 1=BoP, 2=BoE, 3=BoU, 4=BoQ) + if (packet.hasRemaining(4)) + data.bindType = packet.readUInt32(); + + // Flavor/lore text (Description cstring) + if (packet.hasData()) + data.description = packet.readString(); + + // Post-description fields: PageText, LanguageID, PageMaterial, StartQuest + if (packet.hasRemaining(16)) { + packet.readUInt32(); // PageText + packet.readUInt32(); // LanguageID + packet.readUInt32(); // PageMaterial + data.startQuestId = packet.readUInt32(); // StartQuest + } + + // WotLK 3.3.5a: additional fields after StartQuest (read up to socket data) + // LockID(4), Material(4), Sheath(4), RandomProperty(4), RandomSuffix(4), + // Block(4), ItemSet(4), MaxDurability(4), Area(4), Map(4), BagFamily(4), + // TotemCategory(4) = 48 bytes before sockets + constexpr size_t kPreSocketSkip = 48; + if (packet.getReadPos() + kPreSocketSkip + 28 <= packet.getSize()) { + // LockID(0), Material(1), Sheath(2), RandomProperty(3), RandomSuffix(4), Block(5) + for (size_t i = 0; i < 6; ++i) packet.readUInt32(); + data.itemSetId = packet.readUInt32(); // ItemSet(6) + // MaxDurability(7), Area(8), Map(9), BagFamily(10), TotemCategory(11) + for (size_t i = 0; i < 5; ++i) packet.readUInt32(); + // 3 socket slots: socketColor (4 bytes each) + data.socketColor[0] = packet.readUInt32(); + data.socketColor[1] = packet.readUInt32(); + data.socketColor[2] = packet.readUInt32(); + // 3 socket content (gem enchantment IDs — skip, not currently displayed) + packet.readUInt32(); + packet.readUInt32(); + packet.readUInt32(); + // socketBonus (enchantmentId) + data.socketBonus = packet.readUInt32(); + } + + data.valid = !data.name.empty(); + return true; +} + +// ============================================================ +// Creature Movement +// ============================================================ + +bool MonsterMoveParser::parse(network::Packet& packet, MonsterMoveData& data) { + // PackedGuid + data.guid = packet.readPackedGuid(); + if (data.guid == 0) return false; + + // uint8 unk (toggle for MOVEMENTFLAG2_UNK7) + if (!packet.hasData()) return false; + packet.readUInt8(); + + // Current position (server coords: float x, y, z) + if (!packet.hasRemaining(12)) return false; + data.x = packet.readFloat(); + data.y = packet.readFloat(); + data.z = packet.readFloat(); + + // uint32 splineId + if (!packet.hasRemaining(4)) return false; + packet.readUInt32(); + + // uint8 moveType + if (!packet.hasData()) return false; + data.moveType = packet.readUInt8(); + + if (data.moveType == 1) { + // Stop - no more required data + data.destX = data.x; + data.destY = data.y; + data.destZ = data.z; + data.hasDest = false; + return true; + } + + // Read facing data based on move type + if (data.moveType == 2) { + // FacingSpot: float x, y, z + if (!packet.hasRemaining(12)) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } else if (data.moveType == 3) { + // FacingTarget: uint64 guid + if (!packet.hasRemaining(8)) return false; + data.facingTarget = packet.readUInt64(); + } else if (data.moveType == 4) { + // FacingAngle: float angle + if (!packet.hasRemaining(4)) return false; + data.facingAngle = packet.readFloat(); + } + + // uint32 splineFlags + if (!packet.hasRemaining(4)) return false; + data.splineFlags = packet.readUInt32(); + + // WotLK 3.3.5a SplineFlags (from TrinityCore/MaNGOS MoveSplineFlag.h): + // Animation = 0x00400000 + // Parabolic = 0x00000800 + // Catmullrom = 0x00080000 \ either means uncompressed (absolute) waypoints + // Flying = 0x00002000 / + + // [if Animation] uint8 animationType + int32 effectStartTime (5 bytes) + if (data.splineFlags & 0x00400000) { + if (!packet.hasRemaining(5)) return false; + packet.readUInt8(); // animationType + packet.readUInt32(); // effectStartTime (int32, read as uint32 same size) + } + + // uint32 duration + if (!packet.hasRemaining(4)) return false; + data.duration = packet.readUInt32(); + + // [if Parabolic] float verticalAcceleration + int32 effectStartTime (8 bytes) + if (data.splineFlags & 0x00000800) { + if (!packet.hasRemaining(8)) return false; + packet.readFloat(); // verticalAcceleration + packet.readUInt32(); // effectStartTime + } + + // uint32 pointCount + if (!packet.hasRemaining(4)) return false; + uint32_t pointCount = packet.readUInt32(); + + if (pointCount == 0) return true; + + constexpr uint32_t kMaxSplinePoints = 1000; + if (pointCount > kMaxSplinePoints) { + LOG_WARNING("SMSG_MONSTER_MOVE: pointCount=", pointCount, " exceeds max ", kMaxSplinePoints, + " (guid=0x", std::hex, data.guid, std::dec, ")"); + return false; + } + + // Catmullrom or Flying → all waypoints stored as absolute float3 (uncompressed). + // Otherwise: first float3 is final destination, remaining are packed deltas. + bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; + + if (uncompressed) { + // Read last point as destination + // Skip to last point: each point is 12 bytes + if (pointCount > 1) { + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (!packet.hasRemaining(12)) return true; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } + } + if (!packet.hasRemaining(12)) return true; + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + } else { + // Compressed: first 3 floats are the destination (final point) + if (!packet.hasRemaining(12)) return true; + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + } + + LOG_DEBUG("MonsterMove: guid=0x", std::hex, data.guid, std::dec, + " type=", static_cast(data.moveType), " dur=", data.duration, "ms", + " dest=(", data.destX, ",", data.destY, ",", data.destZ, ")"); + + return true; +} + +bool MonsterMoveParser::parseVanilla(network::Packet& packet, MonsterMoveData& data) { + data.guid = packet.readPackedGuid(); + if (data.guid == 0) return false; + + if (!packet.hasRemaining(12)) return false; + data.x = packet.readFloat(); + data.y = packet.readFloat(); + data.z = packet.readFloat(); + + // Turtle WoW movement payload uses a spline-style layout after XYZ: + // uint32 splineIdOrTick + // uint8 moveType + // [if moveType 2/3/4] facing payload + // uint32 splineFlags + // [if Animation] uint8 + uint32 + // uint32 duration + // [if Parabolic] float + uint32 + // uint32 pointCount + // float[3] dest + // uint32 packedPoints[pointCount-1] + if (!packet.hasRemaining(4)) return false; + /*uint32_t splineIdOrTick =*/ packet.readUInt32(); + + if (!packet.hasData()) return false; + data.moveType = packet.readUInt8(); + + if (data.moveType == 1) { + data.destX = data.x; + data.destY = data.y; + data.destZ = data.z; + data.hasDest = false; + return true; + } + + if (data.moveType == 2) { + if (!packet.hasRemaining(12)) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } else if (data.moveType == 3) { + if (!packet.hasRemaining(8)) return false; + data.facingTarget = packet.readUInt64(); + } else if (data.moveType == 4) { + if (!packet.hasRemaining(4)) return false; + data.facingAngle = packet.readFloat(); + } + + if (!packet.hasRemaining(4)) return false; + data.splineFlags = packet.readUInt32(); + + // Animation flag (same bit as WotLK MoveSplineFlag::Animation) + if (data.splineFlags & 0x00400000) { + if (!packet.hasRemaining(5)) return false; + packet.readUInt8(); + packet.readUInt32(); + } + + if (!packet.hasRemaining(4)) return false; + data.duration = packet.readUInt32(); + + // Parabolic flag (same bit as WotLK MoveSplineFlag::Parabolic) + if (data.splineFlags & 0x00000800) { + if (!packet.hasRemaining(8)) return false; + packet.readFloat(); + packet.readUInt32(); + } + + if (!packet.hasRemaining(4)) return false; + uint32_t pointCount = packet.readUInt32(); + + if (pointCount == 0) return true; + + // Reject extreme point counts from malformed packets. + constexpr uint32_t kMaxSplinePoints = 1000; + if (pointCount > kMaxSplinePoints) { + return false; + } + + size_t requiredBytes = 12; + if (pointCount > 1) { + requiredBytes += static_cast(pointCount - 1) * 4ull; + } + if (!packet.hasRemaining(requiredBytes)) return false; + + // First float[3] is destination. + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + + // Remaining waypoints are packed as uint32 deltas. + if (pointCount > 1) { + size_t skipBytes = static_cast(pointCount - 1) * 4; + size_t newPos = packet.getReadPos() + skipBytes; + if (newPos > packet.getSize()) return false; + packet.setReadPos(newPos); + } + + LOG_DEBUG("MonsterMove(turtle): guid=0x", std::hex, data.guid, std::dec, + " type=", static_cast(data.moveType), " dur=", data.duration, "ms", + " dest=(", data.destX, ",", data.destY, ",", data.destZ, ")"); + + return true; +} + + +// ============================================================ +// Phase 2: Combat Core +// ============================================================ + +bool AttackStartParser::parse(network::Packet& packet, AttackStartData& data) { + if (packet.getSize() < 16) return false; + data.attackerGuid = packet.readUInt64(); + data.victimGuid = packet.readUInt64(); + LOG_DEBUG("Attack started: 0x", std::hex, data.attackerGuid, + " -> 0x", data.victimGuid, std::dec); + return true; +} + +bool AttackStopParser::parse(network::Packet& packet, AttackStopData& data) { + data.attackerGuid = packet.readPackedGuid(); + data.victimGuid = packet.readPackedGuid(); + if (packet.hasData()) { + data.unknown = packet.readUInt32(); + } + LOG_DEBUG("Attack stopped: 0x", std::hex, data.attackerGuid, std::dec); + return true; +} + +bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpdateData& data) { + // Upfront validation: hitInfo(4) + packed GUIDs(1-8 each) + totalDamage(4) + subDamageCount(1) = 13 bytes minimum + if (!packet.hasRemaining(13)) return false; + + size_t startPos = packet.getReadPos(); + data.hitInfo = packet.readUInt32(); + if (!packet.hasFullPackedGuid()) { + packet.setReadPos(startPos); + return false; + } + data.attackerGuid = packet.readPackedGuid(); + if (!packet.hasFullPackedGuid()) { + packet.setReadPos(startPos); + return false; + } + data.targetGuid = packet.readPackedGuid(); + + // Validate totalDamage + subDamageCount can be read (5 bytes) + if (!packet.hasRemaining(5)) { + packet.setReadPos(startPos); + return false; + } + + data.totalDamage = static_cast(packet.readUInt32()); + data.subDamageCount = packet.readUInt8(); + + // Cap subDamageCount: each entry is 20 bytes. If the claimed count + // exceeds what the remaining bytes can hold, a GUID was mis-parsed + // (off by one byte), causing the school-mask byte to be read as count. + // In that case clamp to the number of full entries that fit. + { + size_t remaining = packet.getRemainingSize(); + size_t maxFit = remaining / 20; + if (data.subDamageCount > maxFit) { + data.subDamageCount = static_cast(std::min(maxFit, 64)); + } else if (data.subDamageCount > 64) { + data.subDamageCount = 64; + } + } + if (data.subDamageCount == 0) return false; + + data.subDamages.reserve(data.subDamageCount); + for (uint8_t i = 0; i < data.subDamageCount; ++i) { + // Each sub-damage entry needs 20 bytes: schoolMask(4) + damage(4) + intDamage(4) + absorbed(4) + resisted(4) + if (!packet.hasRemaining(20)) { + data.subDamageCount = i; + break; + } + SubDamage sub; + sub.schoolMask = packet.readUInt32(); + sub.damage = packet.readFloat(); + sub.intDamage = packet.readUInt32(); + sub.absorbed = packet.readUInt32(); + sub.resisted = packet.readUInt32(); + data.subDamages.push_back(sub); + } + + // Validate victimState + overkill fields (8 bytes) + if (!packet.hasRemaining(8)) { + data.victimState = 0; + data.overkill = 0; + return !data.subDamages.empty(); + } + + data.victimState = packet.readUInt32(); + // WotLK (AzerothCore): two unknown uint32 fields follow victimState before overkill. + // Older parsers omitted these, reading overkill from the wrong offset. + auto rem = [&]() { return packet.getRemainingSize(); }; + if (rem() >= 4) packet.readUInt32(); // unk1 (always 0) + if (rem() >= 4) packet.readUInt32(); // unk2 (melee spell ID, 0 for auto-attack) + data.overkill = (rem() >= 4) ? static_cast(packet.readUInt32()) : -1; + + // hitInfo-conditional fields: HITINFO_BLOCK(0x2000), RAGE_GAIN(0x20000), FAKE_DAMAGE(0x40) + if ((data.hitInfo & 0x2000) && rem() >= 4) data.blocked = packet.readUInt32(); + else data.blocked = 0; + // RAGE_GAIN and FAKE_DAMAGE both add a uint32 we can skip + if ((data.hitInfo & 0x20000) && rem() >= 4) packet.readUInt32(); // rage gain + if ((data.hitInfo & 0x40) && rem() >= 4) packet.readUInt32(); // fake damage total + + LOG_DEBUG("Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); + return true; +} + +bool SpellDamageLogParser::parse(network::Packet& packet, SpellDamageLogData& data) { + // Upfront validation: + // packed GUIDs(1-8 each) + spellId(4) + damage(4) + overkill(4) + schoolMask(1) + // + absorbed(4) + resisted(4) + periodicLog(1) + unused(1) + blocked(4) + flags(4) + // = 33 bytes minimum. + if (!packet.hasRemaining(33)) return false; + + size_t startPos = packet.getReadPos(); + if (!packet.hasFullPackedGuid()) { + packet.setReadPos(startPos); + return false; + } + data.targetGuid = packet.readPackedGuid(); + if (!packet.hasFullPackedGuid()) { + packet.setReadPos(startPos); + return false; + } + data.attackerGuid = packet.readPackedGuid(); + + // Validate core fields (spellId + damage + overkill + schoolMask + absorbed + resisted = 21 bytes) + if (!packet.hasRemaining(21)) { + packet.setReadPos(startPos); + return false; + } + + data.spellId = packet.readUInt32(); + data.damage = packet.readUInt32(); + data.overkill = packet.readUInt32(); + data.schoolMask = packet.readUInt8(); + data.absorbed = packet.readUInt32(); + data.resisted = packet.readUInt32(); + + // Remaining fields are required for a complete event. + // Reject truncated packets so we do not emit partial/incorrect combat entries. + if (!packet.hasRemaining(10)) { + packet.setReadPos(startPos); + return false; + } + + (void)packet.readUInt8(); // periodicLog (not displayed) + packet.readUInt8(); // unused + packet.readUInt32(); // blocked + uint32_t flags = packet.readUInt32(); // flags IS used — bit 0x02 = crit + data.isCrit = (flags & 0x02) != 0; + + LOG_DEBUG("Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); + return true; +} + +bool SpellHealLogParser::parse(network::Packet& packet, SpellHealLogData& data) { + // Upfront validation: packed GUIDs(1-8 each) + spellId(4) + heal(4) + overheal(4) + absorbed(4) + critFlag(1) = 21 bytes minimum + if (!packet.hasRemaining(21)) return false; + + size_t startPos = packet.getReadPos(); + if (!packet.hasFullPackedGuid()) { + packet.setReadPos(startPos); + return false; + } + data.targetGuid = packet.readPackedGuid(); + if (!packet.hasFullPackedGuid()) { + packet.setReadPos(startPos); + return false; + } + data.casterGuid = packet.readPackedGuid(); + + // Validate remaining fields (spellId + heal + overheal + absorbed + critFlag = 17 bytes) + if (!packet.hasRemaining(17)) { + packet.setReadPos(startPos); + return false; + } + + data.spellId = packet.readUInt32(); + data.heal = packet.readUInt32(); + data.overheal = packet.readUInt32(); + data.absorbed = packet.readUInt32(); + uint8_t critFlag = packet.readUInt8(); + data.isCrit = (critFlag != 0); + + LOG_DEBUG("Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); + return true; +} + +// ============================================================ +// XP Gain +// ============================================================ + +bool XpGainParser::parse(network::Packet& packet, XpGainData& data) { + // Validate minimum packet size: victimGuid(8) + totalXp(4) + type(1) + if (!packet.hasRemaining(13)) { + LOG_WARNING("SMSG_LOG_XPGAIN: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.victimGuid = packet.readUInt64(); + data.totalXp = packet.readUInt32(); + data.type = packet.readUInt8(); + if (data.type == 0) { + // Kill XP: float groupRate (1.0 = solo) + uint8 RAF flag + // Validate before reading conditional fields + if (packet.hasRemaining(5)) { + float groupRate = packet.readFloat(); + packet.readUInt8(); // RAF bonus flag + // Group bonus = total - (total / rate); only if grouped (rate > 1) + if (groupRate > 1.0f) { + data.groupBonus = data.totalXp - static_cast(data.totalXp / groupRate); + } + } + } + LOG_DEBUG("XP gain: ", data.totalXp, " xp (type=", static_cast(data.type), ")"); + return data.totalXp > 0; +} + +// ============================================================ +// Phase 3: Spells, Action Bar, Auras +// ============================================================ + +bool InitialSpellsParser::parse(network::Packet& packet, InitialSpellsData& data, + bool vanillaFormat) { + // Validate minimum packet size for header: talentSpec(1) + spellCount(2) + if (!packet.hasRemaining(3)) { + LOG_ERROR("SMSG_INITIAL_SPELLS: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.talentSpec = packet.readUInt8(); + uint16_t spellCount = packet.readUInt16(); + + // Cap spell count to prevent excessive iteration. + // WotLK characters with all ranks, mounts, professions, and racials can + // know 400-600 spells; 1024 covers all practical cases with headroom. + constexpr uint16_t kMaxSpells = 1024; + if (spellCount > kMaxSpells) { + LOG_WARNING("SMSG_INITIAL_SPELLS: spellCount=", spellCount, " exceeds max ", kMaxSpells, + ", capping"); + spellCount = kMaxSpells; + } + + LOG_DEBUG("SMSG_INITIAL_SPELLS: spellCount=", spellCount, + vanillaFormat ? " (vanilla uint16 format)" : " (TBC/WotLK uint32 format)"); + + data.spellIds.reserve(spellCount); + for (uint16_t i = 0; i < spellCount; ++i) { + // Vanilla spell: spellId(2) + slot(2) = 4 bytes + // TBC/WotLK spell: spellId(4) + unknown(2) = 6 bytes + size_t spellEntrySize = vanillaFormat ? 4 : 6; + if (!packet.hasRemaining(spellEntrySize)) { + LOG_WARNING("SMSG_INITIAL_SPELLS: spell ", i, " truncated (", spellCount, " expected)"); + break; + } + + uint32_t spellId; + if (vanillaFormat) { + spellId = packet.readUInt16(); + packet.readUInt16(); // slot + } else { + spellId = packet.readUInt32(); + packet.readUInt16(); // unknown (always 0) + } + if (spellId != 0) { + data.spellIds.push_back(spellId); + } + } + + // Validate minimum packet size for cooldownCount (2 bytes) + if (!packet.hasRemaining(2)) { + LOG_WARNING("SMSG_INITIAL_SPELLS: truncated before cooldownCount (parsed ", data.spellIds.size(), + " spells)"); + return true; // Have spells; cooldowns are optional + } + + uint16_t cooldownCount = packet.readUInt16(); + + // Cap cooldown count to prevent excessive iteration. + // Some servers include entries for all spells (even with zero remaining time) + // to communicate category cooldown data, so the count can be high. + constexpr uint16_t kMaxCooldowns = 1024; + if (cooldownCount > kMaxCooldowns) { + LOG_WARNING("SMSG_INITIAL_SPELLS: cooldownCount=", cooldownCount, " exceeds max ", kMaxCooldowns, + ", capping"); + cooldownCount = kMaxCooldowns; + } + + data.cooldowns.reserve(cooldownCount); + for (uint16_t i = 0; i < cooldownCount; ++i) { + // Vanilla cooldown: spellId(2) + itemId(2) + categoryId(2) + cooldownMs(4) + categoryCooldownMs(4) = 14 bytes + // TBC/WotLK cooldown: spellId(4) + itemId(2) + categoryId(2) + cooldownMs(4) + categoryCooldownMs(4) = 16 bytes + size_t cooldownEntrySize = vanillaFormat ? 14 : 16; + if (!packet.hasRemaining(cooldownEntrySize)) { + LOG_WARNING("SMSG_INITIAL_SPELLS: cooldown ", i, " truncated (", cooldownCount, " expected)"); + break; + } + + SpellCooldownEntry entry; + if (vanillaFormat) { + entry.spellId = packet.readUInt16(); + } else { + entry.spellId = packet.readUInt32(); + } + entry.itemId = packet.readUInt16(); + entry.categoryId = packet.readUInt16(); + entry.cooldownMs = packet.readUInt32(); + entry.categoryCooldownMs = packet.readUInt32(); + data.cooldowns.push_back(entry); + } + + LOG_INFO("Initial spells parsed: ", data.spellIds.size(), " spells, ", + data.cooldowns.size(), " cooldowns"); + + 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_DEBUG("Initial spell IDs (first 10): ", first10); + } + + return true; +} + +network::Packet CastSpellPacket::build(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) { + network::Packet packet(wireOpcode(Opcode::CMSG_CAST_SPELL)); + packet.writeUInt8(castCount); + packet.writeUInt32(spellId); + packet.writeUInt8(0x00); // castFlags = 0 for normal cast + + // SpellCastTargets + if (targetGuid != 0) { + packet.writeUInt32(0x02); // TARGET_FLAG_UNIT + + // Write packed GUID + uint8_t mask = 0; + uint8_t bytes[8]; + int byteCount = 0; + uint64_t g = targetGuid; + for (int i = 0; i < 8; ++i) { + uint8_t b = g & 0xFF; + if (b != 0) { + mask |= (1 << i); + bytes[byteCount++] = b; + } + g >>= 8; + } + packet.writeUInt8(mask); + for (int i = 0; i < byteCount; ++i) { + packet.writeUInt8(bytes[i]); + } + } else { + packet.writeUInt32(0x00); // TARGET_FLAG_SELF + } + + LOG_DEBUG("Built CMSG_CAST_SPELL: spell=", spellId, " target=0x", + std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet CancelAuraPacket::build(uint32_t spellId) { + network::Packet packet(wireOpcode(Opcode::CMSG_CANCEL_AURA)); + packet.writeUInt32(spellId); + return packet; +} + +network::Packet PetActionPacket::build(uint64_t petGuid, uint32_t action, uint64_t targetGuid) { + // CMSG_PET_ACTION: petGuid(8) + action(4) + targetGuid(8) + network::Packet packet(wireOpcode(Opcode::CMSG_PET_ACTION)); + packet.writeUInt64(petGuid); + packet.writeUInt32(action); + packet.writeUInt64(targetGuid); + return packet; +} + +bool CastFailedParser::parse(network::Packet& packet, CastFailedData& data) { + // WotLK format: castCount(1) + spellId(4) + result(1) = 6 bytes minimum + if (!packet.hasRemaining(6)) return false; + + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.result = packet.readUInt8(); + LOG_INFO("Cast failed: spell=", data.spellId, " result=", static_cast(data.result)); + return true; +} + +bool SpellStartParser::parse(network::Packet& packet, SpellStartData& data) { + data = SpellStartData{}; + + // Packed GUIDs are variable-length; only require minimal packet shape up front: + // two GUID masks + castCount(1) + spellId(4) + castFlags(4) + castTime(4). + if (!packet.hasRemaining(15)) return false; + + size_t startPos = packet.getReadPos(); + if (!packet.hasFullPackedGuid()) { + return false; + } + data.casterGuid = packet.readPackedGuid(); + if (!packet.hasFullPackedGuid()) { + packet.setReadPos(startPos); + return false; + } + data.casterUnit = packet.readPackedGuid(); + + // Validate remaining fixed fields (castCount + spellId + castFlags + castTime = 13 bytes) + if (!packet.hasRemaining(13)) { + packet.setReadPos(startPos); + return false; + } + + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt32(); + data.castTime = packet.readUInt32(); + + // SpellCastTargets starts with target flags and is mandatory. + if (!packet.hasRemaining(4)) { + LOG_WARNING("Spell start: missing targetFlags"); + packet.setReadPos(startPos); + return false; + } + + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that + // subsequent fields (e.g. school mask, cast flags 0x20 extra data) are not + // misaligned for ground-targeted or AoE spells. + uint32_t targetFlags = packet.readUInt32(); + + auto readPackedTarget = [&](uint64_t* out) -> bool { + if (!packet.hasFullPackedGuid()) return false; + uint64_t g = packet.readPackedGuid(); + if (out) *out = g; + return true; + }; + auto skipPackedAndFloats3 = [&]() -> bool { + if (!packet.hasFullPackedGuid()) return false; + packet.readPackedGuid(); // transport GUID (may be zero) + if (!packet.hasRemaining(12)) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + return true; + }; + + // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share a single object target GUID + if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { + readPackedTarget(&data.targetGuid); // best-effort; ignore failure + } + // ITEM/TRADE_ITEM share a single item target GUID + if (targetFlags & (0x0010u | 0x0100u)) { + readPackedTarget(nullptr); + } + // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0020u) { + skipPackedAndFloats3(); + } + // DEST_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0040u) { + skipPackedAndFloats3(); + } + // STRING: null-terminated + if (targetFlags & 0x0200u) { + while (packet.hasData() && packet.readUInt8() != 0) {} + } + + LOG_DEBUG("Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + return true; +} + +} // namespace game +} // namespace wowee diff --git a/src/game/world_packets_social.cpp b/src/game/world_packets_social.cpp new file mode 100644 index 00000000..95ec7fe4 --- /dev/null +++ b/src/game/world_packets_social.cpp @@ -0,0 +1,1214 @@ +#include "game/world_packets.hpp" +#include "game/packet_parsers.hpp" +#include "game/opcodes.hpp" +#include "game/character.hpp" +#include "auth/crypto.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace game { + +network::Packet MessageChatPacket::build(ChatType type, + ChatLanguage language, + const std::string& message, + const std::string& target) { + network::Packet packet(wireOpcode(Opcode::CMSG_MESSAGECHAT)); + + // Write chat type + packet.writeUInt32(static_cast(type)); + + // Write language + packet.writeUInt32(static_cast(language)); + + // Write target (for whispers) or channel name + if (type == ChatType::WHISPER) { + packet.writeString(target); + } else if (type == ChatType::CHANNEL) { + packet.writeString(target); // Channel name + } + + // Write message + packet.writeString(message); + + LOG_DEBUG("Built CMSG_MESSAGECHAT packet"); + LOG_DEBUG(" Type: ", static_cast(type)); + LOG_DEBUG(" Language: ", static_cast(language)); + LOG_DEBUG(" Message: ", message); + + return packet; +} + +bool MessageChatParser::parse(network::Packet& packet, MessageChatData& data) { + // SMSG_MESSAGECHAT format (WoW 3.3.5a): + // uint8 type + // uint32 language + // uint64 senderGuid + // uint32 unknown (always 0) + // [type-specific data] + // uint32 messageLength + // string message + // uint8 chatTag + + if (packet.getSize() < 15) { + LOG_ERROR("SMSG_MESSAGECHAT packet too small: ", packet.getSize(), " bytes"); + return false; + } + + // Read chat type + uint8_t typeVal = packet.readUInt8(); + data.type = static_cast(typeVal); + + // Read language + uint32_t langVal = packet.readUInt32(); + data.language = static_cast(langVal); + + // Read sender GUID + data.senderGuid = packet.readUInt64(); + + // Read unknown field + packet.readUInt32(); + + auto tryReadSizedCString = [&](std::string& out, uint32_t maxLen, size_t minTrailingBytes) -> bool { + size_t start = packet.getReadPos(); + size_t remaining = packet.getSize() - start; + if (remaining < 4 + minTrailingBytes) return false; + + uint32_t len = packet.readUInt32(); + if (len < 2 || len > maxLen) { + packet.setReadPos(start); + return false; + } + if (!packet.hasRemaining(static_cast(len) + minTrailingBytes)) { + packet.setReadPos(start); + return false; + } + + // Stack buffer for typical messages; heap fallback for oversized ones. + static constexpr uint32_t kStackBufSize = 256; + std::array stackBuf; + std::string heapBuf; + char* buf; + if (len <= kStackBufSize) { + buf = stackBuf.data(); + } else { + heapBuf.resize(len); + buf = heapBuf.data(); + } + + for (uint32_t i = 0; i < len; ++i) { + buf[i] = static_cast(packet.readUInt8()); + } + if (buf[len - 1] != '\0') { + packet.setReadPos(start); + return false; + } + // len >= 2 guaranteed above, so len-1 >= 1 — string body is non-empty. + for (uint32_t i = 0; i < len - 1; ++i) { + auto uc = static_cast(buf[i]); + if (uc < 32 || uc > 126) { + packet.setReadPos(start); + return false; + } + } + + out.assign(buf, len - 1); + return true; + }; + + // Type-specific data + // WoW 3.3.5 SMSG_MESSAGECHAT format: after senderGuid+unk, most types + // have a receiverGuid (uint64). Some types have extra fields before it. + switch (data.type) { + case ChatType::MONSTER_SAY: + case ChatType::MONSTER_YELL: + case ChatType::MONSTER_EMOTE: + case ChatType::MONSTER_WHISPER: + case ChatType::MONSTER_PARTY: + case ChatType::RAID_BOSS_EMOTE: + case ChatType::RAID_BOSS_WHISPER: { + // Read sender name (SizedCString: uint32 len including null + chars) + uint32_t nameLen = packet.readUInt32(); + if (nameLen > packet.getRemainingSize()) return false; + if (nameLen > 0 && nameLen < 256) { + data.senderName.resize(nameLen); + for (uint32_t i = 0; i < nameLen; ++i) { + data.senderName[i] = static_cast(packet.readUInt8()); + } + // Strip trailing null (server includes it in nameLen) + if (!data.senderName.empty() && data.senderName.back() == '\0') { + data.senderName.pop_back(); + } + } + // Read receiver GUID (NamedGuid: guid + optional name for non-player targets) + data.receiverGuid = packet.readUInt64(); + if (data.receiverGuid != 0) { + // WoW GUID type encoding: bits 48-63 identify entity type. + // Players have highGuid=0x0000. Pets use 0xF040 (active pet) or + // 0xF014 (creature treated as pet). Mask 0xF0FF isolates the type + // nibbles while ignoring the server-specific middle bits. + constexpr uint16_t kGuidTypeMask = 0xF0FF; + constexpr uint16_t kGuidTypePet = 0xF040; + constexpr uint16_t kGuidTypeVehicle = 0xF014; + uint16_t highGuid = static_cast(data.receiverGuid >> 48); + bool isPlayer = (highGuid == 0x0000); + bool isPet = ((highGuid & kGuidTypeMask) == kGuidTypePet) || + ((highGuid & kGuidTypeMask) == kGuidTypeVehicle); + if (!isPlayer && !isPet) { + // Read receiver name (SizedCString) + uint32_t recvNameLen = packet.readUInt32(); + if (recvNameLen > 0 && recvNameLen < 256) { + packet.setReadPos(packet.getReadPos() + recvNameLen); + } + } + } + break; + } + + case ChatType::CHANNEL: { + // Read channel name, then receiver GUID + data.channelName = packet.readString(); + data.receiverGuid = packet.readUInt64(); + break; + } + + case ChatType::ACHIEVEMENT: + case ChatType::GUILD_ACHIEVEMENT: { + // Read target GUID + data.receiverGuid = packet.readUInt64(); + break; + } + + case ChatType::WHISPER: + case ChatType::WHISPER_INFORM: { + // Some cores include an explicit sized sender/receiver name for whisper chat. + // Consume it when present so /r has a reliable last whisper sender. + if (data.type == ChatType::WHISPER) { + tryReadSizedCString(data.senderName, 128, 8 + 4 + 1); + } else { + tryReadSizedCString(data.receiverName, 128, 8 + 4 + 1); + } + + data.receiverGuid = packet.readUInt64(); + + // Optional trailing whisper target/source name on some formats. + if (data.type == ChatType::WHISPER && data.receiverName.empty()) { + tryReadSizedCString(data.receiverName, 128, 4 + 1); + } else if (data.type == ChatType::WHISPER_INFORM && data.senderName.empty()) { + tryReadSizedCString(data.senderName, 128, 4 + 1); + } + break; + } + + case ChatType::BG_SYSTEM_NEUTRAL: + case ChatType::BG_SYSTEM_ALLIANCE: + case ChatType::BG_SYSTEM_HORDE: + // BG/Arena system messages — no sender GUID or name field, just message. + // Reclassify as SYSTEM for consistent display. + data.type = ChatType::SYSTEM; + break; + + default: + // SAY, GUILD, PARTY, YELL, WHISPER, WHISPER_INFORM, RAID, etc. + // All have receiverGuid (typically senderGuid repeated) + data.receiverGuid = packet.readUInt64(); + break; + } + + // Read message length + uint32_t messageLen = packet.readUInt32(); + if (messageLen > packet.getRemainingSize()) return false; + + // Read message + if (messageLen > 0 && messageLen < 8192) { + data.message.resize(messageLen); + for (uint32_t i = 0; i < messageLen; ++i) { + data.message[i] = static_cast(packet.readUInt8()); + } + // Strip trailing null terminator (servers include it in messageLen) + if (!data.message.empty() && data.message.back() == '\0') { + data.message.pop_back(); + } + } + + // Read chat tag + data.chatTag = packet.readUInt8(); + + LOG_DEBUG("Parsed SMSG_MESSAGECHAT:"); + LOG_DEBUG(" Type: ", getChatTypeString(data.type)); + LOG_DEBUG(" Language: ", static_cast(data.language)); + LOG_DEBUG(" Sender GUID: 0x", std::hex, data.senderGuid, std::dec); + if (!data.senderName.empty()) { + LOG_DEBUG(" Sender name: ", data.senderName); + } + if (!data.channelName.empty()) { + LOG_DEBUG(" Channel: ", data.channelName); + } + LOG_DEBUG(" Message: ", data.message); + LOG_DEBUG(" Chat tag: 0x", std::hex, static_cast(data.chatTag), std::dec); + + return true; +} + +const char* getChatTypeString(ChatType type) { + switch (type) { + case ChatType::SAY: return "SAY"; + case ChatType::PARTY: return "PARTY"; + case ChatType::RAID: return "RAID"; + case ChatType::GUILD: return "GUILD"; + case ChatType::OFFICER: return "OFFICER"; + case ChatType::YELL: return "YELL"; + case ChatType::WHISPER: return "WHISPER"; + case ChatType::WHISPER_INFORM: return "WHISPER_INFORM"; + case ChatType::EMOTE: return "EMOTE"; + case ChatType::TEXT_EMOTE: return "TEXT_EMOTE"; + case ChatType::SYSTEM: return "SYSTEM"; + case ChatType::MONSTER_SAY: return "MONSTER_SAY"; + case ChatType::MONSTER_YELL: return "MONSTER_YELL"; + case ChatType::MONSTER_EMOTE: return "MONSTER_EMOTE"; + case ChatType::CHANNEL: return "CHANNEL"; + case ChatType::CHANNEL_JOIN: return "CHANNEL_JOIN"; + case ChatType::CHANNEL_LEAVE: return "CHANNEL_LEAVE"; + case ChatType::CHANNEL_LIST: return "CHANNEL_LIST"; + case ChatType::CHANNEL_NOTICE: return "CHANNEL_NOTICE"; + case ChatType::CHANNEL_NOTICE_USER: return "CHANNEL_NOTICE_USER"; + case ChatType::AFK: return "AFK"; + case ChatType::DND: return "DND"; + case ChatType::IGNORED: return "IGNORED"; + case ChatType::SKILL: return "SKILL"; + case ChatType::LOOT: return "LOOT"; + case ChatType::BATTLEGROUND: return "BATTLEGROUND"; + case ChatType::BATTLEGROUND_LEADER: return "BATTLEGROUND_LEADER"; + case ChatType::RAID_LEADER: return "RAID_LEADER"; + case ChatType::RAID_WARNING: return "RAID_WARNING"; + case ChatType::ACHIEVEMENT: return "ACHIEVEMENT"; + case ChatType::GUILD_ACHIEVEMENT: return "GUILD_ACHIEVEMENT"; + default: return "UNKNOWN"; + } +} + +// ============================================================ +// Text Emotes +// ============================================================ + +network::Packet TextEmotePacket::build(uint32_t textEmoteId, uint64_t targetGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_TEXT_EMOTE)); + packet.writeUInt32(textEmoteId); + packet.writeUInt32(0); // emoteNum (unused) + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_TEXT_EMOTE: emoteId=", textEmoteId, " target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data, bool legacyFormat) { + size_t bytesLeft = packet.getRemainingSize(); + if (bytesLeft < 20) { + LOG_WARNING("SMSG_TEXT_EMOTE too short: ", bytesLeft, " bytes"); + return false; + } + + if (legacyFormat) { + // Classic 1.12 / TBC 2.4.3: textEmoteId(u32) + emoteNum(u32) + senderGuid(u64) + data.textEmoteId = packet.readUInt32(); + data.emoteNum = packet.readUInt32(); + data.senderGuid = packet.readUInt64(); + } else { + // WotLK 3.3.5a: senderGuid(u64) + textEmoteId(u32) + emoteNum(u32) + data.senderGuid = packet.readUInt64(); + data.textEmoteId = packet.readUInt32(); + data.emoteNum = packet.readUInt32(); + } + + uint32_t nameLen = packet.readUInt32(); + if (nameLen > 0 && nameLen <= 256) { + data.targetName = packet.readString(); + } else if (nameLen > 0) { + // Implausible name length — misaligned read + return false; + } + return true; +} + +// ============================================================ +// Channel System +// ============================================================ + +network::Packet JoinChannelPacket::build(const std::string& channelName, const std::string& password) { + network::Packet packet(wireOpcode(Opcode::CMSG_JOIN_CHANNEL)); + packet.writeUInt32(0); // channelId (unused) + packet.writeUInt8(0); // hasVoice + packet.writeUInt8(0); // joinedByZone + packet.writeString(channelName); + packet.writeString(password); + LOG_DEBUG("Built CMSG_JOIN_CHANNEL: channel=", channelName); + return packet; +} + +network::Packet LeaveChannelPacket::build(const std::string& channelName) { + network::Packet packet(wireOpcode(Opcode::CMSG_LEAVE_CHANNEL)); + packet.writeUInt32(0); // channelId (unused) + packet.writeString(channelName); + LOG_DEBUG("Built CMSG_LEAVE_CHANNEL: channel=", channelName); + return packet; +} + +bool ChannelNotifyParser::parse(network::Packet& packet, ChannelNotifyData& data) { + size_t bytesLeft = packet.getRemainingSize(); + if (bytesLeft < 2) { + LOG_WARNING("SMSG_CHANNEL_NOTIFY too short"); + return false; + } + data.notifyType = static_cast(packet.readUInt8()); + data.channelName = packet.readString(); + // Some notification types have additional fields (guid, etc.) + bytesLeft = packet.getRemainingSize(); + if (bytesLeft >= 8) { + data.senderGuid = packet.readUInt64(); + } + return true; +} + +// ============================================================ +// Phase 1: Foundation — Targeting, Name Queries +// ============================================================ + +network::Packet SetSelectionPacket::build(uint64_t targetGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_SELECTION)); + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_SET_SELECTION: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet SetActiveMoverPacket::build(uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTIVE_MOVER)); + packet.writeUInt64(guid); + LOG_DEBUG("Built CMSG_SET_ACTIVE_MOVER: guid=0x", std::hex, guid, std::dec); + return packet; +} + +network::Packet InspectPacket::build(uint64_t targetGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_INSPECT)); + packet.writePackedGuid(targetGuid); + LOG_DEBUG("Built CMSG_INSPECT: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet QueryInspectAchievementsPacket::build(uint64_t targetGuid) { + // CMSG_QUERY_INSPECT_ACHIEVEMENTS: PackedGuid targetGuid + network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_INSPECT_ACHIEVEMENTS)); + packet.writePackedGuid(targetGuid); + LOG_DEBUG("Built CMSG_QUERY_INSPECT_ACHIEVEMENTS: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +// ============================================================ +// Server Info Commands +// ============================================================ + +network::Packet QueryTimePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_TIME)); + LOG_DEBUG("Built CMSG_QUERY_TIME"); + return packet; +} + +bool QueryTimeResponseParser::parse(network::Packet& packet, QueryTimeResponseData& data) { + // Validate minimum packet size: serverTime(4) + timeOffset(4) + if (!packet.hasRemaining(8)) { + LOG_WARNING("SMSG_QUERY_TIME_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.serverTime = packet.readUInt32(); + data.timeOffset = packet.readUInt32(); + LOG_DEBUG("Parsed SMSG_QUERY_TIME_RESPONSE: time=", data.serverTime, " offset=", data.timeOffset); + return true; +} + +network::Packet RequestPlayedTimePacket::build(bool sendToChat) { + network::Packet packet(wireOpcode(Opcode::CMSG_PLAYED_TIME)); + packet.writeUInt8(sendToChat ? 1 : 0); + LOG_DEBUG("Built CMSG_PLAYED_TIME: sendToChat=", sendToChat); + return packet; +} + +bool PlayedTimeParser::parse(network::Packet& packet, PlayedTimeData& data) { + // Classic/Turtle may omit the trailing trigger-message byte and send only + // totalTime(4) + levelTime(4). Later expansions append triggerMsg(1). + if (!packet.hasRemaining(8)) { + LOG_WARNING("SMSG_PLAYED_TIME: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.totalTimePlayed = packet.readUInt32(); + data.levelTimePlayed = packet.readUInt32(); + data.triggerMessage = (packet.hasRemaining(1)) && (packet.readUInt8() != 0); + LOG_DEBUG("Parsed SMSG_PLAYED_TIME: total=", data.totalTimePlayed, " level=", data.levelTimePlayed); + return true; +} + +network::Packet WhoPacket::build(uint32_t minLevel, uint32_t maxLevel, + const std::string& playerName, + const std::string& guildName, + uint32_t raceMask, uint32_t classMask, + uint32_t zones) { + network::Packet packet(wireOpcode(Opcode::CMSG_WHO)); + packet.writeUInt32(minLevel); + packet.writeUInt32(maxLevel); + packet.writeString(playerName); + packet.writeString(guildName); + packet.writeUInt32(raceMask); + packet.writeUInt32(classMask); + packet.writeUInt32(zones); // Number of zone IDs (0 = no zone filter) + // Zone ID array would go here if zones > 0 + packet.writeUInt32(0); // stringCount (number of search strings) + // String array would go here if stringCount > 0 + LOG_DEBUG("Built CMSG_WHO: player=", playerName); + return packet; +} + +// ============================================================ +// Social Commands +// ============================================================ + +network::Packet AddFriendPacket::build(const std::string& playerName, const std::string& note) { + network::Packet packet(wireOpcode(Opcode::CMSG_ADD_FRIEND)); + packet.writeString(playerName); + packet.writeString(note); + LOG_DEBUG("Built CMSG_ADD_FRIEND: player=", playerName); + return packet; +} + +network::Packet DelFriendPacket::build(uint64_t friendGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_DEL_FRIEND)); + packet.writeUInt64(friendGuid); + LOG_DEBUG("Built CMSG_DEL_FRIEND: guid=0x", std::hex, friendGuid, std::dec); + return packet; +} + +network::Packet SetContactNotesPacket::build(uint64_t friendGuid, const std::string& note) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_CONTACT_NOTES)); + packet.writeUInt64(friendGuid); + packet.writeString(note); + LOG_DEBUG("Built CMSG_SET_CONTACT_NOTES: guid=0x", std::hex, friendGuid, std::dec); + return packet; +} + +bool FriendStatusParser::parse(network::Packet& packet, FriendStatusData& data) { + // Validate minimum packet size: status(1) + guid(8) + if (!packet.hasRemaining(9)) { + LOG_WARNING("SMSG_FRIEND_STATUS: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.status = packet.readUInt8(); + data.guid = packet.readUInt64(); + if (data.status == 1) { // Online + // Conditional: note (string) + chatFlag (1) + if (packet.hasData()) { + data.note = packet.readString(); + if (packet.hasRemaining(1)) { + data.chatFlag = packet.readUInt8(); + } + } + } + LOG_DEBUG("Parsed SMSG_FRIEND_STATUS: status=", static_cast(data.status), " guid=0x", std::hex, data.guid, std::dec); + return true; +} + +network::Packet AddIgnorePacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_ADD_IGNORE)); + packet.writeString(playerName); + LOG_DEBUG("Built CMSG_ADD_IGNORE: player=", playerName); + return packet; +} + +network::Packet DelIgnorePacket::build(uint64_t ignoreGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_DEL_IGNORE)); + packet.writeUInt64(ignoreGuid); + LOG_DEBUG("Built CMSG_DEL_IGNORE: guid=0x", std::hex, ignoreGuid, std::dec); + return packet; +} + +network::Packet ComplainPacket::build(uint64_t targetGuid, const std::string& reason) { + network::Packet packet(wireOpcode(Opcode::CMSG_COMPLAIN)); + packet.writeUInt8(1); // complaintType: 1 = spam + packet.writeUInt64(targetGuid); + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // messageType + packet.writeUInt32(0); // channelId + packet.writeUInt32(static_cast(time(nullptr))); // timestamp + packet.writeString(reason); + LOG_DEBUG("Built CMSG_COMPLAIN: target=0x", std::hex, targetGuid, std::dec, " reason=", reason); + return packet; +} + +// ============================================================ +// Logout Commands +// ============================================================ + +network::Packet LogoutRequestPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_LOGOUT_REQUEST)); + LOG_DEBUG("Built CMSG_LOGOUT_REQUEST"); + return packet; +} + +network::Packet LogoutCancelPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_LOGOUT_CANCEL)); + LOG_DEBUG("Built CMSG_LOGOUT_CANCEL"); + return packet; +} + +bool LogoutResponseParser::parse(network::Packet& packet, LogoutResponseData& data) { + // Validate minimum packet size: result(4) + instant(1) + if (!packet.hasRemaining(5)) { + LOG_WARNING("SMSG_LOGOUT_RESPONSE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.result = packet.readUInt32(); + data.instant = packet.readUInt8(); + LOG_DEBUG("Parsed SMSG_LOGOUT_RESPONSE: result=", data.result, " instant=", static_cast(data.instant)); + return true; +} + +// ============================================================ +// Stand State +// ============================================================ + +network::Packet StandStateChangePacket::build(uint8_t state) { + network::Packet packet(wireOpcode(Opcode::CMSG_STANDSTATECHANGE)); + packet.writeUInt32(state); + LOG_DEBUG("Built CMSG_STANDSTATECHANGE: state=", static_cast(state)); + return packet; +} + +// ============================================================ +// Action Bar +// ============================================================ + +network::Packet SetActionButtonPacket::build(uint8_t button, uint8_t type, uint32_t id, bool isClassic) { + // Classic/Turtle (1.12): uint8 button + uint16 id + uint8 type + uint8 misc(0) + // type encoding: 0=spell, 1=item, 64=macro + // TBC/WotLK: uint8 button + uint32 packed (type<<24 | id) + // type encoding: 0x00=spell, 0x80=item, 0x40=macro + // packed=0 means clear the slot + network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTION_BUTTON)); + packet.writeUInt8(button); + if (isClassic) { + // Classic: 16-bit id, 8-bit type code, 8-bit misc + // Map ActionBarSlot::Type (0=EMPTY,1=SPELL,2=ITEM,3=MACRO) → classic type byte + uint8_t classicType = 0; // 0 = spell + if (type == 2 /* ITEM */) classicType = 1; + if (type == 3 /* MACRO */) classicType = 64; + packet.writeUInt16(static_cast(id)); + packet.writeUInt8(classicType); + packet.writeUInt8(0); // misc + LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (Classic): button=", static_cast(button), + " id=", id, " type=", static_cast(classicType)); + } else { + // TBC/WotLK: type in bits 24–31, id in bits 0–23; packed=0 clears slot + uint8_t packedType = 0x00; // spell + if (type == 2 /* ITEM */) packedType = 0x80; + if (type == 3 /* MACRO */) packedType = 0x40; + uint32_t packed = (id == 0) ? 0 : (static_cast(packedType) << 24) | (id & 0x00FFFFFF); + packet.writeUInt32(packed); + LOG_DEBUG("Built CMSG_SET_ACTION_BUTTON (TBC/WotLK): button=", static_cast(button), + " packed=0x", std::hex, packed, std::dec); + } + return packet; +} + +// ============================================================ +// Display Toggles +// ============================================================ + +network::Packet ShowingHelmPacket::build(bool show) { + network::Packet packet(wireOpcode(Opcode::CMSG_SHOWING_HELM)); + packet.writeUInt8(show ? 1 : 0); + LOG_DEBUG("Built CMSG_SHOWING_HELM: show=", show); + return packet; +} + +network::Packet ShowingCloakPacket::build(bool show) { + network::Packet packet(wireOpcode(Opcode::CMSG_SHOWING_CLOAK)); + packet.writeUInt8(show ? 1 : 0); + LOG_DEBUG("Built CMSG_SHOWING_CLOAK: show=", show); + return packet; +} + +// ============================================================ +// PvP +// ============================================================ + +network::Packet TogglePvpPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_TOGGLE_PVP)); + LOG_DEBUG("Built CMSG_TOGGLE_PVP"); + return packet; +} + +// ============================================================ +// Guild Commands +// ============================================================ + +network::Packet GuildInfoPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_INFO)); + LOG_DEBUG("Built CMSG_GUILD_INFO"); + return packet; +} + +network::Packet GuildRosterPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ROSTER)); + LOG_DEBUG("Built CMSG_GUILD_ROSTER"); + return packet; +} + +network::Packet GuildMotdPacket::build(const std::string& motd) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_MOTD)); + packet.writeString(motd); + LOG_DEBUG("Built CMSG_GUILD_MOTD: ", motd); + return packet; +} + +network::Packet GuildPromotePacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_PROMOTE)); + packet.writeString(playerName); + LOG_DEBUG("Built CMSG_GUILD_PROMOTE: ", playerName); + return packet; +} + +network::Packet GuildDemotePacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DEMOTE)); + packet.writeString(playerName); + LOG_DEBUG("Built CMSG_GUILD_DEMOTE: ", playerName); + return packet; +} + +network::Packet GuildLeavePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_LEAVE)); + LOG_DEBUG("Built CMSG_GUILD_LEAVE"); + return packet; +} + +network::Packet GuildInvitePacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_INVITE)); + packet.writeString(playerName); + LOG_DEBUG("Built CMSG_GUILD_INVITE: ", playerName); + return packet; +} + +network::Packet GuildQueryPacket::build(uint32_t guildId) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_QUERY)); + packet.writeUInt32(guildId); + LOG_DEBUG("Built CMSG_GUILD_QUERY: guildId=", guildId); + return packet; +} + +network::Packet GuildRemovePacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_REMOVE)); + packet.writeString(playerName); + LOG_DEBUG("Built CMSG_GUILD_REMOVE: ", playerName); + return packet; +} + +network::Packet GuildDisbandPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DISBAND)); + LOG_DEBUG("Built CMSG_GUILD_DISBAND"); + return packet; +} + +network::Packet GuildLeaderPacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_LEADER)); + packet.writeString(playerName); + LOG_DEBUG("Built CMSG_GUILD_LEADER: ", playerName); + return packet; +} + +network::Packet GuildSetPublicNotePacket::build(const std::string& playerName, const std::string& note) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_SET_PUBLIC_NOTE)); + packet.writeString(playerName); + packet.writeString(note); + LOG_DEBUG("Built CMSG_GUILD_SET_PUBLIC_NOTE: ", playerName, " -> ", note); + return packet; +} + +network::Packet GuildSetOfficerNotePacket::build(const std::string& playerName, const std::string& note) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_SET_OFFICER_NOTE)); + packet.writeString(playerName); + packet.writeString(note); + LOG_DEBUG("Built CMSG_GUILD_SET_OFFICER_NOTE: ", playerName, " -> ", note); + return packet; +} + +network::Packet GuildAcceptPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ACCEPT)); + LOG_DEBUG("Built CMSG_GUILD_ACCEPT"); + return packet; +} + +network::Packet GuildDeclineInvitationPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DECLINE)); + LOG_DEBUG("Built CMSG_GUILD_DECLINE"); + return packet; +} + +network::Packet GuildCreatePacket::build(const std::string& guildName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_CREATE)); + packet.writeString(guildName); + LOG_DEBUG("Built CMSG_GUILD_CREATE: ", guildName); + return packet; +} + +network::Packet GuildAddRankPacket::build(const std::string& rankName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_ADD_RANK)); + packet.writeString(rankName); + LOG_DEBUG("Built CMSG_GUILD_ADD_RANK: ", rankName); + return packet; +} + +network::Packet GuildDelRankPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GUILD_DEL_RANK)); + LOG_DEBUG("Built CMSG_GUILD_DEL_RANK"); + return packet; +} + +network::Packet PetitionShowlistPacket::build(uint64_t npcGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_PETITION_SHOWLIST)); + packet.writeUInt64(npcGuid); + LOG_DEBUG("Built CMSG_PETITION_SHOWLIST: guid=", npcGuid); + return packet; +} + +network::Packet PetitionBuyPacket::build(uint64_t npcGuid, const std::string& guildName) { + network::Packet packet(wireOpcode(Opcode::CMSG_PETITION_BUY)); + packet.writeUInt64(npcGuid); // NPC GUID + packet.writeUInt32(0); // unk + packet.writeUInt64(0); // unk + packet.writeString(guildName); // guild name + packet.writeUInt32(0); // body text (empty) + packet.writeUInt32(0); // min sigs + packet.writeUInt32(0); // max sigs + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // unk + packet.writeUInt16(0); // unk + packet.writeUInt32(0); // unk + packet.writeUInt32(0); // unk index + packet.writeUInt32(0); // unk + LOG_DEBUG("Built CMSG_PETITION_BUY: npcGuid=", npcGuid, " name=", guildName); + return packet; +} + +bool PetitionShowlistParser::parse(network::Packet& packet, PetitionShowlistData& data) { + if (packet.getSize() < 12) { + LOG_ERROR("SMSG_PETITION_SHOWLIST too small: ", packet.getSize()); + return false; + } + data.npcGuid = packet.readUInt64(); + uint32_t count = packet.readUInt32(); + if (count > 0) { + data.itemId = packet.readUInt32(); + data.displayId = packet.readUInt32(); + data.cost = packet.readUInt32(); + // Skip unused fields if present + if (packet.hasRemaining(8)) { + data.charterType = packet.readUInt32(); + data.requiredSigs = packet.readUInt32(); + } + } + LOG_INFO("Parsed SMSG_PETITION_SHOWLIST: npcGuid=", data.npcGuid, " cost=", data.cost); + return true; +} + +bool TurnInPetitionResultsParser::parse(network::Packet& packet, uint32_t& result) { + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_TURN_IN_PETITION_RESULTS too small: ", packet.getSize()); + return false; + } + result = packet.readUInt32(); + LOG_INFO("Parsed SMSG_TURN_IN_PETITION_RESULTS: result=", result); + return true; +} + +bool GuildQueryResponseParser::parse(network::Packet& packet, GuildQueryResponseData& data) { + if (packet.getSize() < 8) { + LOG_ERROR("SMSG_GUILD_QUERY_RESPONSE too small: ", packet.getSize()); + return false; + } + data.guildId = packet.readUInt32(); + + // Validate before reading guild name + if (!packet.hasData()) { + LOG_WARNING("GuildQueryResponseParser: truncated before guild name"); + data.guildName.clear(); + return true; + } + data.guildName = packet.readString(); + + // Read 10 rank names with validation + for (int i = 0; i < 10; ++i) { + if (!packet.hasData()) { + LOG_WARNING("GuildQueryResponseParser: truncated at rank name ", i); + data.rankNames[i].clear(); + } else { + data.rankNames[i] = packet.readString(); + } + } + + // Validate before reading emblem fields (5 uint32s = 20 bytes) + if (!packet.hasRemaining(20)) { + LOG_WARNING("GuildQueryResponseParser: truncated before emblem data"); + data.emblemStyle = 0; + data.emblemColor = 0; + data.borderStyle = 0; + data.borderColor = 0; + data.backgroundColor = 0; + return true; + } + + data.emblemStyle = packet.readUInt32(); + data.emblemColor = packet.readUInt32(); + data.borderStyle = packet.readUInt32(); + data.borderColor = packet.readUInt32(); + data.backgroundColor = packet.readUInt32(); + + if (packet.hasRemaining(4)) { + data.rankCount = packet.readUInt32(); + } + LOG_INFO("Parsed SMSG_GUILD_QUERY_RESPONSE: guild=", data.guildName, " id=", data.guildId); + return true; +} + +bool GuildInfoParser::parse(network::Packet& packet, GuildInfoData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_GUILD_INFO too small: ", packet.getSize()); + return false; + } + data.guildName = packet.readString(); + data.creationDay = packet.readUInt32(); + data.creationMonth = packet.readUInt32(); + data.creationYear = packet.readUInt32(); + data.numMembers = packet.readUInt32(); + data.numAccounts = packet.readUInt32(); + LOG_INFO("Parsed SMSG_GUILD_INFO: ", data.guildName, " members=", data.numMembers); + return true; +} + +bool GuildRosterParser::parse(network::Packet& packet, GuildRosterData& data) { + if (packet.getSize() < 4) { + LOG_ERROR("SMSG_GUILD_ROSTER too small: ", packet.getSize()); + return false; + } + uint32_t numMembers = packet.readUInt32(); + + // Cap members and ranks to prevent unbounded memory allocation + const uint32_t MAX_GUILD_MEMBERS = 1000; + if (numMembers > MAX_GUILD_MEMBERS) { + LOG_WARNING("GuildRosterParser: numMembers capped (requested=", numMembers, ")"); + numMembers = MAX_GUILD_MEMBERS; + } + + data.motd = packet.readString(); + data.guildInfo = packet.readString(); + + if (!packet.hasRemaining(4)) { + LOG_WARNING("GuildRosterParser: truncated before rankCount"); + data.ranks.clear(); + data.members.clear(); + return true; + } + + uint32_t rankCount = packet.readUInt32(); + + // Cap rank count to prevent unbounded allocation + const uint32_t MAX_GUILD_RANKS = 20; + if (rankCount > MAX_GUILD_RANKS) { + LOG_WARNING("GuildRosterParser: rankCount capped (requested=", rankCount, ")"); + rankCount = MAX_GUILD_RANKS; + } + + data.ranks.resize(rankCount); + for (uint32_t i = 0; i < rankCount; ++i) { + // Validate 4 bytes before each rank rights read + if (!packet.hasRemaining(4)) { + LOG_WARNING("GuildRosterParser: truncated rank at index ", i); + break; + } + data.ranks[i].rights = packet.readUInt32(); + if (!packet.hasRemaining(4)) { + data.ranks[i].goldLimit = 0; + } else { + data.ranks[i].goldLimit = packet.readUInt32(); + } + // 6 bank tab flags + 6 bank tab items per day + for (int t = 0; t < 6; ++t) { + if (!packet.hasRemaining(8)) break; + packet.readUInt32(); // tabFlags + packet.readUInt32(); // tabItemsPerDay + } + } + + data.members.resize(numMembers); + for (uint32_t i = 0; i < numMembers; ++i) { + // Validate minimum bytes before reading member (guid+online+name at minimum is 9+ bytes) + if (!packet.hasRemaining(9)) { + LOG_WARNING("GuildRosterParser: truncated member at index ", i); + break; + } + auto& m = data.members[i]; + m.guid = packet.readUInt64(); + m.online = (packet.readUInt8() != 0); + + // Validate before reading name string + if (!packet.hasData()) { + m.name.clear(); + } else { + m.name = packet.readString(); + } + + // Validate before reading rank/level/class/gender/zone + if (!packet.hasRemaining(1)) { + m.rankIndex = 0; + m.level = 1; + m.classId = 0; + m.gender = 0; + m.zoneId = 0; + } else { + m.rankIndex = packet.readUInt32(); + if (!packet.hasRemaining(3)) { + m.level = 1; + m.classId = 0; + m.gender = 0; + } else { + m.level = packet.readUInt8(); + m.classId = packet.readUInt8(); + m.gender = packet.readUInt8(); + } + if (!packet.hasRemaining(4)) { + m.zoneId = 0; + } else { + m.zoneId = packet.readUInt32(); + } + } + + // Online status affects next fields + if (!m.online) { + if (!packet.hasRemaining(4)) { + m.lastOnline = 0.0f; + } else { + m.lastOnline = packet.readFloat(); + } + } + + // Read notes + if (!packet.hasData()) { + m.publicNote.clear(); + m.officerNote.clear(); + } else { + m.publicNote = packet.readString(); + if (!packet.hasData()) { + m.officerNote.clear(); + } else { + m.officerNote = packet.readString(); + } + } + } + LOG_INFO("Parsed SMSG_GUILD_ROSTER: ", numMembers, " members, motd=", data.motd); + return true; +} + +bool GuildEventParser::parse(network::Packet& packet, GuildEventData& data) { + if (packet.getSize() < 2) { + LOG_ERROR("SMSG_GUILD_EVENT too small: ", packet.getSize()); + return false; + } + data.eventType = packet.readUInt8(); + data.numStrings = packet.readUInt8(); + for (uint8_t i = 0; i < data.numStrings && i < 3; ++i) { + data.strings[i] = packet.readString(); + } + if (packet.hasRemaining(8)) { + data.guid = packet.readUInt64(); + } + LOG_INFO("Parsed SMSG_GUILD_EVENT: type=", static_cast(data.eventType), " strings=", static_cast(data.numStrings)); + return true; +} + +bool GuildInviteResponseParser::parse(network::Packet& packet, GuildInviteResponseData& data) { + if (packet.getSize() < 2) { + LOG_ERROR("SMSG_GUILD_INVITE too small: ", packet.getSize()); + return false; + } + data.inviterName = packet.readString(); + data.guildName = packet.readString(); + LOG_INFO("Parsed SMSG_GUILD_INVITE: from=", data.inviterName, " guild=", data.guildName); + return true; +} + +bool GuildCommandResultParser::parse(network::Packet& packet, GuildCommandResultData& data) { + if (packet.getSize() < 8) { + LOG_ERROR("SMSG_GUILD_COMMAND_RESULT too small: ", packet.getSize()); + return false; + } + data.command = packet.readUInt32(); + data.name = packet.readString(); + data.errorCode = packet.readUInt32(); + LOG_INFO("Parsed SMSG_GUILD_COMMAND_RESULT: cmd=", data.command, " error=", data.errorCode); + return true; +} + +// ============================================================ +// Ready Check +// ============================================================ + +network::Packet ReadyCheckPacket::build() { + network::Packet packet(wireOpcode(Opcode::MSG_RAID_READY_CHECK)); + LOG_DEBUG("Built MSG_RAID_READY_CHECK"); + return packet; +} + +network::Packet ReadyCheckConfirmPacket::build(bool ready) { + network::Packet packet(wireOpcode(Opcode::MSG_RAID_READY_CHECK_CONFIRM)); + packet.writeUInt8(ready ? 1 : 0); + LOG_DEBUG("Built MSG_RAID_READY_CHECK_CONFIRM: ready=", ready); + return packet; +} + +// ============================================================ +// Duel +// ============================================================ + +network::Packet DuelAcceptPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_DUEL_ACCEPTED)); + LOG_DEBUG("Built CMSG_DUEL_ACCEPTED"); + return packet; +} + +network::Packet DuelCancelPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_DUEL_CANCELLED)); + LOG_DEBUG("Built CMSG_DUEL_CANCELLED"); + return packet; +} + +// ============================================================ +// Party/Raid Management +// ============================================================ + +network::Packet GroupUninvitePacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_UNINVITE_GUID)); + packet.writeString(playerName); + LOG_DEBUG("Built CMSG_GROUP_UNINVITE_GUID for player: ", playerName); + return packet; +} + +network::Packet GroupDisbandPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_DISBAND)); + LOG_DEBUG("Built CMSG_GROUP_DISBAND"); + return packet; +} + +network::Packet GroupRaidConvertPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_RAID_CONVERT)); + LOG_DEBUG("Built CMSG_GROUP_RAID_CONVERT"); + return packet; +} + +network::Packet SetLootMethodPacket::build(uint32_t method, uint32_t threshold, uint64_t masterLooterGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_METHOD)); + packet.writeUInt32(method); + packet.writeUInt32(threshold); + packet.writeUInt64(masterLooterGuid); + LOG_DEBUG("Built CMSG_LOOT_METHOD: method=", method, " threshold=", threshold, + " masterLooter=0x", std::hex, masterLooterGuid, std::dec); + return packet; +} + +network::Packet RaidTargetUpdatePacket::build(uint8_t targetIndex, uint64_t targetGuid) { + network::Packet packet(wireOpcode(Opcode::MSG_RAID_TARGET_UPDATE)); + packet.writeUInt8(targetIndex); + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built MSG_RAID_TARGET_UPDATE, index: ", static_cast(targetIndex), ", guid: 0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet RequestRaidInfoPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_REQUEST_RAID_INFO)); + LOG_DEBUG("Built CMSG_REQUEST_RAID_INFO"); + return packet; +} + +// ============================================================ +// Combat and Trade +// ============================================================ + +network::Packet DuelProposedPacket::build(uint64_t targetGuid) { + // Duels are initiated via CMSG_CAST_SPELL with spell 7266 (Duel) targeted at the opponent. + // There is no separate CMSG_DUEL_PROPOSED opcode in WoW. + auto packet = CastSpellPacket::build(7266, targetGuid, 0); + LOG_DEBUG("Built duel request (spell 7266) for target: 0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet BeginTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_BEGIN_TRADE)); + LOG_DEBUG("Built CMSG_BEGIN_TRADE"); + return packet; +} + +network::Packet CancelTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_CANCEL_TRADE)); + LOG_DEBUG("Built CMSG_CANCEL_TRADE"); + return packet; +} + +network::Packet AcceptTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_ACCEPT_TRADE)); + LOG_DEBUG("Built CMSG_ACCEPT_TRADE"); + return packet; +} + +network::Packet SetTradeItemPacket::build(uint8_t tradeSlot, uint8_t bag, uint8_t bagSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_ITEM)); + packet.writeUInt8(tradeSlot); + packet.writeUInt8(bag); + packet.writeUInt8(bagSlot); + LOG_DEBUG("Built CMSG_SET_TRADE_ITEM slot=", static_cast(tradeSlot), " bag=", static_cast(bag), " bagSlot=", static_cast(bagSlot)); + return packet; +} + +network::Packet ClearTradeItemPacket::build(uint8_t tradeSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_CLEAR_TRADE_ITEM)); + packet.writeUInt8(tradeSlot); + LOG_DEBUG("Built CMSG_CLEAR_TRADE_ITEM slot=", static_cast(tradeSlot)); + return packet; +} + +network::Packet SetTradeGoldPacket::build(uint64_t copper) { + network::Packet packet(wireOpcode(Opcode::CMSG_SET_TRADE_GOLD)); + packet.writeUInt64(copper); + LOG_DEBUG("Built CMSG_SET_TRADE_GOLD copper=", copper); + return packet; +} + +network::Packet UnacceptTradePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_UNACCEPT_TRADE)); + LOG_DEBUG("Built CMSG_UNACCEPT_TRADE"); + return packet; +} + +network::Packet InitiateTradePacket::build(uint64_t targetGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_INITIATE_TRADE)); + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_INITIATE_TRADE for target: 0x", std::hex, targetGuid, std::dec); + return packet; +} + +} // namespace game +} // namespace wowee diff --git a/src/game/world_packets_world.cpp b/src/game/world_packets_world.cpp new file mode 100644 index 00000000..83d631c1 --- /dev/null +++ b/src/game/world_packets_world.cpp @@ -0,0 +1,1420 @@ +#include "game/world_packets.hpp" +#include "game/packet_parsers.hpp" +#include "game/opcodes.hpp" +#include "game/character.hpp" +#include "auth/crypto.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +inline uint32_t bswap32(uint32_t v) { + return ((v & 0xFF000000u) >> 24) | ((v & 0x00FF0000u) >> 8) + | ((v & 0x0000FF00u) << 8) | ((v & 0x000000FFu) << 24); +} + +inline uint16_t bswap16(uint16_t v) { + return static_cast(((v & 0xFF00u) >> 8) | ((v & 0x00FFu) << 8)); +} + +} // anonymous namespace + +namespace wowee { +namespace game { + +bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { + // Always reset output to avoid stale targets when callers reuse buffers. + data = SpellGoData{}; + + // Packed GUIDs are variable-length, so only require the smallest possible + // shape up front: 2 GUID masks + fixed fields through hitCount. + if (!packet.hasRemaining(16)) return false; + + size_t startPos = packet.getReadPos(); + if (!packet.hasFullPackedGuid()) { + return false; + } + data.casterGuid = packet.readPackedGuid(); + if (!packet.hasFullPackedGuid()) { + packet.setReadPos(startPos); + return false; + } + data.casterUnit = packet.readPackedGuid(); + + // Validate remaining fixed fields up to hitCount/missCount + if (!packet.hasRemaining(14)) { // castCount(1) + spellId(4) + castFlags(4) + timestamp(4) + hitCount(1) + packet.setReadPos(startPos); + return false; + } + + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt32(); + // Timestamp in 3.3.5a + packet.readUInt32(); + + const uint8_t rawHitCount = packet.readUInt8(); + if (rawHitCount > 128) { + LOG_WARNING("Spell go: hitCount capped (requested=", static_cast(rawHitCount), ")"); + } + const uint8_t storedHitLimit = std::min(rawHitCount, 128); + + bool truncatedTargets = false; + + data.hitTargets.reserve(storedHitLimit); + for (uint16_t i = 0; i < rawHitCount; ++i) { + // WotLK 3.3.5a hit targets are full uint64 GUIDs (not PackedGuid). + if (!packet.hasRemaining(8)) { + LOG_WARNING("Spell go: truncated hit targets at index ", i, "/", static_cast(rawHitCount)); + truncatedTargets = true; + break; + } + const uint64_t targetGuid = packet.readUInt64(); + if (i < storedHitLimit) { + data.hitTargets.push_back(targetGuid); + } + } + if (truncatedTargets) { + packet.setReadPos(startPos); + return false; + } + data.hitCount = static_cast(data.hitTargets.size()); + + // missCount is mandatory in SMSG_SPELL_GO. Missing byte means truncation. + if (!packet.hasRemaining(1)) { + LOG_WARNING("Spell go: missing missCount after hit target list"); + packet.setReadPos(startPos); + return false; + } + + const size_t missCountPos = packet.getReadPos(); + const uint8_t rawMissCount = packet.readUInt8(); + if (rawMissCount > 20) { + // Likely offset error — dump context bytes for diagnostics. + const auto& raw = packet.getData(); + std::string hexCtx; + size_t dumpStart = (missCountPos >= 8) ? missCountPos - 8 : startPos; + size_t dumpEnd = std::min(missCountPos + 16, raw.size()); + for (size_t i = dumpStart; i < dumpEnd; ++i) { + char buf[4]; + std::snprintf(buf, sizeof(buf), "%02x ", raw[i]); + hexCtx += buf; + if (i == missCountPos - 1) hexCtx += "["; + if (i == missCountPos) hexCtx += "] "; + } + LOG_WARNING("Spell go: suspect missCount=", static_cast(rawMissCount), + " spell=", data.spellId, " hits=", static_cast(data.hitCount), + " castFlags=0x", std::hex, data.castFlags, std::dec, + " missCountPos=", missCountPos, " pktSize=", packet.getSize(), + " ctx=", hexCtx); + } + if (rawMissCount > 128) { + LOG_WARNING("Spell go: missCount capped (requested=", static_cast(rawMissCount), + ") spell=", data.spellId, " hits=", static_cast(data.hitCount), + " remaining=", packet.getRemainingSize()); + } + const uint8_t storedMissLimit = std::min(rawMissCount, 128); + + data.missTargets.reserve(storedMissLimit); + for (uint16_t i = 0; i < rawMissCount; ++i) { + // WotLK 3.3.5a miss targets are full uint64 GUIDs + uint8 missType. + // REFLECT additionally appends uint8 reflectResult. + if (!packet.hasRemaining(9)) { // 8 GUID + 1 missType + LOG_WARNING("Spell go: truncated miss targets at index ", i, "/", static_cast(rawMissCount), + " spell=", data.spellId, " hits=", static_cast(data.hitCount)); + truncatedTargets = true; + break; + } + SpellGoMissEntry m; + m.targetGuid = packet.readUInt64(); + m.missType = packet.readUInt8(); + if (m.missType == 11) { // SPELL_MISS_REFLECT + if (!packet.hasRemaining(1)) { + LOG_WARNING("Spell go: truncated reflect payload at miss index ", i, "/", static_cast(rawMissCount)); + truncatedTargets = true; + break; + } + (void)packet.readUInt8(); // reflectResult + } + if (i < storedMissLimit) { + data.missTargets.push_back(m); + } + } + data.missCount = static_cast(data.missTargets.size()); + + // If miss targets were truncated, salvage the successfully-parsed hit data + // rather than discarding the entire spell. The server already applied effects; + // we just need the hit list for UI feedback (combat text, health bars). + if (truncatedTargets) { + LOG_DEBUG("Spell go: salvaging ", static_cast(data.hitCount), " hits despite miss truncation"); + packet.skipAll(); // consume remaining bytes + return true; + } + + // WotLK 3.3.5a SpellCastTargets — consume ALL target payload bytes so that + // any trailing fields after the target section are not misaligned for + // ground-targeted or AoE spells. Same layout as SpellStartParser. + if (packet.hasData()) { + if (packet.hasRemaining(4)) { + uint32_t targetFlags = packet.readUInt32(); + + auto readPackedTarget = [&](uint64_t* out) -> bool { + if (!packet.hasFullPackedGuid()) return false; + uint64_t g = packet.readPackedGuid(); + if (out) *out = g; + return true; + }; + auto skipPackedAndFloats3 = [&]() -> bool { + if (!packet.hasFullPackedGuid()) return false; + packet.readPackedGuid(); // transport GUID + if (!packet.hasRemaining(12)) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + return true; + }; + + // UNIT/UNIT_MINIPET/CORPSE_ALLY/GAMEOBJECT share one object target GUID + if (targetFlags & (0x0002u | 0x0004u | 0x0400u | 0x0800u)) { + readPackedTarget(&data.targetGuid); + } + // ITEM/TRADE_ITEM share one item target GUID + if (targetFlags & (0x0010u | 0x0100u)) { + readPackedTarget(nullptr); + } + // SOURCE_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0020u) { + skipPackedAndFloats3(); + } + // DEST_LOCATION: PackedGuid (transport) + float x,y,z + if (targetFlags & 0x0040u) { + skipPackedAndFloats3(); + } + // STRING: null-terminated + if (targetFlags & 0x0200u) { + while (packet.hasData() && packet.readUInt8() != 0) {} + } + } + } + + LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", static_cast(data.hitCount), + " misses=", static_cast(data.missCount)); + return true; +} + +bool AuraUpdateParser::parse(network::Packet& packet, AuraUpdateData& data, bool isAll) { + // Validation: packed GUID (1-8 bytes minimum for reading) + if (!packet.hasRemaining(1)) return false; + + data.guid = packet.readPackedGuid(); + + // Cap number of aura entries to prevent unbounded loop DoS + uint32_t maxAuras = isAll ? 512 : 1; + uint32_t auraCount = 0; + + while (packet.hasData() && auraCount < maxAuras) { + // Validate we can read slot (1) + spellId (4) = 5 bytes minimum + if (!packet.hasRemaining(5)) { + LOG_DEBUG("Aura update: truncated entry at position ", auraCount); + break; + } + + uint8_t slot = packet.readUInt8(); + uint32_t spellId = packet.readUInt32(); + auraCount++; + + AuraSlot aura; + if (spellId != 0) { + aura.spellId = spellId; + + // Validate flags + level + charges (3 bytes) + if (!packet.hasRemaining(3)) { + LOG_WARNING("Aura update: truncated flags/level/charges at entry ", auraCount); + aura.flags = 0; + aura.level = 0; + aura.charges = 0; + } else { + aura.flags = packet.readUInt8(); + aura.level = packet.readUInt8(); + aura.charges = packet.readUInt8(); + } + + if (!(aura.flags & 0x08)) { // NOT_CASTER flag + // Validate space for packed GUID read (minimum 1 byte) + if (!packet.hasRemaining(1)) { + aura.casterGuid = 0; + } else { + aura.casterGuid = packet.readPackedGuid(); + } + } + + if (aura.flags & 0x20) { // DURATION - need 8 bytes (two uint32s) + if (!packet.hasRemaining(8)) { + LOG_WARNING("Aura update: truncated duration fields at entry ", auraCount); + aura.maxDurationMs = 0; + aura.durationMs = 0; + } else { + aura.maxDurationMs = static_cast(packet.readUInt32()); + aura.durationMs = static_cast(packet.readUInt32()); + } + } + + if (aura.flags & 0x40) { // EFFECT_AMOUNTS + // Only read amounts for active effect indices (flags 0x01, 0x02, 0x04) + for (int i = 0; i < 3; ++i) { + if (aura.flags & (1 << i)) { + if (packet.hasRemaining(4)) { + packet.readUInt32(); + } else { + LOG_WARNING("Aura update: truncated effect amount ", i, " at entry ", auraCount); + break; + } + } + } + } + } + + data.updates.push_back({slot, aura}); + + // For single update, only one entry + if (!isAll) break; + } + + if (auraCount >= maxAuras && packet.hasData()) { + LOG_WARNING("Aura update: capped at ", maxAuras, " entries, remaining data ignored"); + } + + LOG_DEBUG("Aura update for 0x", std::hex, data.guid, std::dec, + ": ", data.updates.size(), " slots"); + return true; +} + +bool SpellCooldownParser::parse(network::Packet& packet, SpellCooldownData& data) { + // Upfront validation: guid(8) + flags(1) = 9 bytes minimum + if (!packet.hasRemaining(9)) return false; + + data.guid = packet.readUInt64(); + data.flags = packet.readUInt8(); + + // Cap cooldown entries to prevent unbounded memory allocation (each entry is 8 bytes) + uint32_t maxCooldowns = 512; + uint32_t cooldownCount = 0; + + while (packet.hasRemaining(8) && cooldownCount < maxCooldowns) { + uint32_t spellId = packet.readUInt32(); + uint32_t cooldownMs = packet.readUInt32(); + data.cooldowns.push_back({spellId, cooldownMs}); + cooldownCount++; + } + + if (cooldownCount >= maxCooldowns && packet.hasRemaining(8)) { + LOG_WARNING("Spell cooldowns: capped at ", maxCooldowns, " entries, remaining data ignored"); + } + + LOG_DEBUG("Spell cooldowns: ", data.cooldowns.size(), " entries"); + return true; +} + +// ============================================================ +// Phase 4: Group/Party System +// ============================================================ + +network::Packet GroupInvitePacket::build(const std::string& playerName) { + network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_INVITE)); + packet.writeString(playerName); + packet.writeUInt32(0); // unused + LOG_DEBUG("Built CMSG_GROUP_INVITE: ", playerName); + return packet; +} + +bool GroupInviteResponseParser::parse(network::Packet& packet, GroupInviteResponseData& data) { + // Validate minimum packet size: canAccept(1) + if (!packet.hasRemaining(1)) { + LOG_WARNING("SMSG_GROUP_INVITE: packet too small (", packet.getSize(), " bytes)"); + return false; + } + + data.canAccept = packet.readUInt8(); + // Note: inviterName is a string, which is always safe to read even if empty + data.inviterName = packet.readString(); + LOG_INFO("Group invite from: ", data.inviterName, " (canAccept=", static_cast(data.canAccept), ")"); + return true; +} + +network::Packet GroupAcceptPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_ACCEPT)); + packet.writeUInt32(0); // unused in 3.3.5a + return packet; +} + +network::Packet GroupDeclinePacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_GROUP_DECLINE)); + return packet; +} + +bool GroupListParser::parse(network::Packet& packet, GroupListData& data, bool hasRoles) { + auto rem = [&]() { return packet.getRemainingSize(); }; + + if (rem() < 3) return false; + data.groupType = packet.readUInt8(); + data.subGroup = packet.readUInt8(); + data.flags = packet.readUInt8(); + + // WotLK 3.3.5a added a roles byte (tank/healer/dps) for the dungeon finder. + // Classic 1.12 and TBC 2.4.3 do not have this byte. + if (hasRoles) { + if (rem() < 1) return false; + data.roles = packet.readUInt8(); + } else { + data.roles = 0; + } + + // WotLK: LFG data gated by groupType bit 0x04 (LFD group type) + if (hasRoles && (data.groupType & 0x04)) { + if (rem() < 5) return false; + packet.readUInt8(); // lfg state + packet.readUInt32(); // lfg entry + // WotLK 3.3.5a may or may not send the lfg flags byte — read it only if present + if (rem() >= 13) { // enough for lfgFlags(1)+groupGuid(8)+counter(4) + packet.readUInt8(); // lfg flags + } + } + + if (rem() < 12) return false; + packet.readUInt64(); // group GUID + packet.readUInt32(); // update counter + + if (rem() < 4) return false; + data.memberCount = packet.readUInt32(); + if (data.memberCount > 40) { + LOG_WARNING("GroupListParser: implausible memberCount=", data.memberCount, ", clamping"); + data.memberCount = 40; + } + data.members.reserve(data.memberCount); + + for (uint32_t i = 0; i < data.memberCount; ++i) { + if (rem() == 0) break; + GroupMember member; + member.name = packet.readString(); + if (rem() < 8) break; + member.guid = packet.readUInt64(); + if (rem() < 3) break; + member.isOnline = packet.readUInt8(); + member.subGroup = packet.readUInt8(); + member.flags = packet.readUInt8(); + // WotLK added per-member roles byte; Classic/TBC do not have it. + if (hasRoles) { + if (rem() < 1) break; + member.roles = packet.readUInt8(); + } else { + member.roles = 0; + } + data.members.push_back(member); + } + + if (rem() < 8) { + LOG_INFO("Group list: ", data.memberCount, " members (no leader GUID in packet)"); + return true; + } + data.leaderGuid = packet.readUInt64(); + + if (data.memberCount > 0 && rem() >= 10) { + data.lootMethod = packet.readUInt8(); + data.looterGuid = packet.readUInt64(); + data.lootThreshold = packet.readUInt8(); + // Dungeon difficulty (heroic/normal) — Classic doesn't send this; TBC/WotLK do + if (rem() >= 1) data.difficultyId = packet.readUInt8(); + // Raid difficulty — WotLK only + if (rem() >= 1) data.raidDifficultyId = packet.readUInt8(); + // Extra byte in some 3.3.5a builds + if (hasRoles && rem() >= 1) packet.readUInt8(); + } + + LOG_INFO("Group list: ", data.memberCount, " members, leader=0x", + std::hex, data.leaderGuid, std::dec); + return true; +} + +bool PartyCommandResultParser::parse(network::Packet& packet, PartyCommandResultData& data) { + // Upfront validation: command(4) + name(var) + result(4) = 8 bytes minimum (plus name string) + if (!packet.hasRemaining(8)) return false; + + data.command = static_cast(packet.readUInt32()); + data.name = packet.readString(); + + // Validate result field exists (4 bytes) + if (!packet.hasRemaining(4)) { + data.result = static_cast(0); + return true; // Partial read is acceptable + } + + data.result = static_cast(packet.readUInt32()); + LOG_DEBUG("Party command result: ", static_cast(data.result)); + return true; +} + +bool GroupDeclineResponseParser::parse(network::Packet& packet, GroupDeclineData& data) { + // Upfront validation: playerName is a CString (minimum 1 null terminator) + if (!packet.hasRemaining(1)) return false; + + data.playerName = packet.readString(); + LOG_INFO("Group decline from: ", data.playerName); + return true; +} + +// ============================================================ +// Phase 5: Loot System +// ============================================================ + +network::Packet LootPacket::build(uint64_t targetGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_LOOT)); + packet.writeUInt64(targetGuid); + LOG_DEBUG("Built CMSG_LOOT: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + +network::Packet AutostoreLootItemPacket::build(uint8_t slotIndex) { + network::Packet packet(wireOpcode(Opcode::CMSG_AUTOSTORE_LOOT_ITEM)); + packet.writeUInt8(slotIndex); + return packet; +} + +network::Packet UseItemPacket::build(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId) { + network::Packet packet(wireOpcode(Opcode::CMSG_USE_ITEM)); + packet.writeUInt8(bagIndex); + packet.writeUInt8(slotIndex); + packet.writeUInt8(0); // cast count + packet.writeUInt32(spellId); // spell id from item data + packet.writeUInt64(itemGuid); // full 8-byte GUID + packet.writeUInt32(0); // glyph index + packet.writeUInt8(0); // cast flags + // SpellCastTargets: self + packet.writeUInt32(0x00); + return packet; +} + +network::Packet OpenItemPacket::build(uint8_t bagIndex, uint8_t slotIndex) { + network::Packet packet(wireOpcode(Opcode::CMSG_OPEN_ITEM)); + packet.writeUInt8(bagIndex); + packet.writeUInt8(slotIndex); + return packet; +} + +network::Packet AutoEquipItemPacket::build(uint8_t srcBag, uint8_t srcSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_AUTOEQUIP_ITEM)); + packet.writeUInt8(srcBag); + packet.writeUInt8(srcSlot); + return packet; +} + +network::Packet SwapItemPacket::build(uint8_t dstBag, uint8_t dstSlot, uint8_t srcBag, uint8_t srcSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_ITEM)); + packet.writeUInt8(dstBag); + packet.writeUInt8(dstSlot); + packet.writeUInt8(srcBag); + packet.writeUInt8(srcSlot); + return packet; +} + +network::Packet SplitItemPacket::build(uint8_t srcBag, uint8_t srcSlot, + uint8_t dstBag, uint8_t dstSlot, uint8_t count) { + network::Packet packet(wireOpcode(Opcode::CMSG_SPLIT_ITEM)); + packet.writeUInt8(srcBag); + packet.writeUInt8(srcSlot); + packet.writeUInt8(dstBag); + packet.writeUInt8(dstSlot); + packet.writeUInt8(count); + return packet; +} + +network::Packet SwapInvItemPacket::build(uint8_t srcSlot, uint8_t dstSlot) { + network::Packet packet(wireOpcode(Opcode::CMSG_SWAP_INV_ITEM)); + packet.writeUInt8(srcSlot); + packet.writeUInt8(dstSlot); + return packet; +} + +network::Packet LootMoneyPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_MONEY)); + return packet; +} + +network::Packet LootReleasePacket::build(uint64_t lootGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_LOOT_RELEASE)); + packet.writeUInt64(lootGuid); + return packet; +} + +bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat) { + data = LootResponseData{}; + size_t avail = packet.getRemainingSize(); + + // Minimum is guid(8)+lootType(1) = 9 bytes. Servers send a short packet with + // lootType=0 (LOOT_NONE) when loot is unavailable (e.g. chest not yet opened, + // needs a key, or another player is looting). We treat this as an empty-loot + // signal and return false so the caller knows not to open the loot window. + if (avail < 9) { + LOG_WARNING("LootResponseParser: packet too short (", avail, " bytes)"); + return false; + } + + data.lootGuid = packet.readUInt64(); + data.lootType = packet.readUInt8(); + + // Short failure packet — no gold/item data follows. + avail = packet.getRemainingSize(); + if (avail < 5) { + LOG_DEBUG("LootResponseParser: lootType=", static_cast(data.lootType), " (empty/failure response)"); + return false; + } + + data.gold = packet.readUInt32(); + uint8_t itemCount = packet.readUInt8(); + + // Per-item wire size is 22 bytes across all expansions: + // slot(1)+itemId(4)+count(4)+displayInfo(4)+randSuffix(4)+randProp(4)+slotType(1) = 22 + constexpr size_t kItemSize = 22u; + + auto parseLootItemList = [&](uint8_t listCount, bool markQuestItems) -> bool { + for (uint8_t i = 0; i < listCount; ++i) { + size_t remaining = packet.getRemainingSize(); + if (remaining < kItemSize) { + return false; + } + + LootItem item; + item.slotIndex = packet.readUInt8(); + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + item.randomSuffix = packet.readUInt32(); + item.randomPropertyId = packet.readUInt32(); + item.lootSlotType = packet.readUInt8(); + item.isQuestItem = markQuestItems; + data.items.push_back(item); + } + return true; + }; + + data.items.reserve(itemCount); + if (!parseLootItemList(itemCount, false)) { + LOG_WARNING("LootResponseParser: truncated regular item list"); + return false; + } + + // Quest item section only present in WotLK 3.3.5a + uint8_t questItemCount = 0; + if (isWotlkFormat && packet.hasRemaining(1)) { + questItemCount = packet.readUInt8(); + data.items.reserve(data.items.size() + questItemCount); + if (!parseLootItemList(questItemCount, true)) { + LOG_WARNING("LootResponseParser: truncated quest item list"); + return false; + } + } + + LOG_DEBUG("Loot response: ", static_cast(itemCount), " regular + ", static_cast(questItemCount), + " quest items, ", data.gold, " copper"); + return true; +} + +// ============================================================ +// Phase 5: NPC Gossip +// ============================================================ + +network::Packet GossipHelloPacket::build(uint64_t npcGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_GOSSIP_HELLO)); + packet.writeUInt64(npcGuid); + return packet; +} + +network::Packet QuestgiverHelloPacket::build(uint64_t npcGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_HELLO)); + packet.writeUInt64(npcGuid); + return packet; +} + +network::Packet GossipSelectOptionPacket::build(uint64_t npcGuid, uint32_t menuId, uint32_t optionId, const std::string& code) { + network::Packet packet(wireOpcode(Opcode::CMSG_GOSSIP_SELECT_OPTION)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(menuId); + packet.writeUInt32(optionId); + if (!code.empty()) { + packet.writeString(code); + } + return packet; +} + +network::Packet QuestgiverQueryQuestPacket::build(uint64_t npcGuid, uint32_t questId) { + network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_QUERY_QUEST)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + packet.writeUInt8(1); // isDialogContinued = 1 (from gossip) + return packet; +} + +network::Packet QuestgiverAcceptQuestPacket::build(uint64_t npcGuid, uint32_t questId) { + network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + packet.writeUInt32(0); // AzerothCore/WotLK expects trailing unk1 + return packet; +} + +bool QuestDetailsParser::parse(network::Packet& packet, QuestDetailsData& data) { + if (packet.getSize() < 20) return false; + data.npcGuid = packet.readUInt64(); + + // WotLK has informUnit(u64) before questId; Vanilla/TBC do not. + // Detect: try WotLK first (read informUnit + questId), then check if title + // string looks valid. If not, rewind and try vanilla (questId directly). + size_t preInform = packet.getReadPos(); + /*informUnit*/ packet.readUInt64(); + data.questId = packet.readUInt32(); + data.title = normalizeWowTextTokens(packet.readString()); + if (data.title.empty() || data.questId > 100000) { + // Likely vanilla format — rewind past informUnit + packet.setReadPos(preInform); + data.questId = packet.readUInt32(); + data.title = normalizeWowTextTokens(packet.readString()); + } + data.details = normalizeWowTextTokens(packet.readString()); + data.objectives = normalizeWowTextTokens(packet.readString()); + + if (!packet.hasRemaining(10)) { + LOG_DEBUG("Quest details (short): id=", data.questId, " title='", data.title, "'"); + return true; + } + + /*activateAccept*/ packet.readUInt8(); + /*flags*/ packet.readUInt32(); + data.suggestedPlayers = packet.readUInt32(); + /*isFinished*/ packet.readUInt8(); + + // Reward choice items: server always writes 6 entries (QUEST_REWARD_CHOICES_COUNT) + if (packet.hasRemaining(4)) { + /*choiceCount*/ packet.readUInt32(); + for (int i = 0; i < 6; i++) { + if (!packet.hasRemaining(12)) break; + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + ri.choiceSlot = static_cast(i); + data.rewardChoiceItems.push_back(ri); + } + } + } + + // Reward items: server always writes 4 entries (QUEST_REWARDS_COUNT) + if (packet.hasRemaining(4)) { + /*rewardCount*/ packet.readUInt32(); + for (int i = 0; i < 4; i++) { + if (!packet.hasRemaining(12)) break; + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + uint32_t dispId = packet.readUInt32(); + if (itemId != 0) { + QuestRewardItem ri; + ri.itemId = itemId; ri.count = count; ri.displayInfoId = dispId; + data.rewardItems.push_back(ri); + } + } + } + + // Money and XP rewards + if (packet.hasRemaining(4)) + data.rewardMoney = packet.readUInt32(); + if (packet.hasRemaining(4)) + data.rewardXp = packet.readUInt32(); + + LOG_DEBUG("Quest details: id=", data.questId, " title='", data.title, "'"); + return true; +} + +bool GossipMessageParser::parse(network::Packet& packet, GossipMessageData& data) { + // Upfront validation: npcGuid(8) + menuId(4) + titleTextId(4) + optionCount(4) = 20 bytes minimum + if (!packet.hasRemaining(20)) return false; + + data.npcGuid = packet.readUInt64(); + data.menuId = packet.readUInt32(); + data.titleTextId = packet.readUInt32(); + uint32_t optionCount = packet.readUInt32(); + + // Cap option count to prevent unbounded memory allocation + const uint32_t MAX_GOSSIP_OPTIONS = 64; + if (optionCount > MAX_GOSSIP_OPTIONS) { + LOG_WARNING("GossipMessageParser: optionCount capped (requested=", optionCount, ")"); + optionCount = MAX_GOSSIP_OPTIONS; + } + + data.options.clear(); + data.options.reserve(optionCount); + for (uint32_t i = 0; i < optionCount; ++i) { + // Each option: id(4) + icon(1) + isCoded(1) + boxMoney(4) + text(var) + boxText(var) + // Minimum: 10 bytes + 2 empty strings (2 null terminators) = 12 bytes + if (!packet.hasRemaining(12)) { + LOG_WARNING("GossipMessageParser: truncated options at index ", i, "/", optionCount); + break; + } + GossipOption opt; + opt.id = packet.readUInt32(); + opt.icon = packet.readUInt8(); + opt.isCoded = (packet.readUInt8() != 0); + opt.boxMoney = packet.readUInt32(); + opt.text = packet.readString(); + opt.boxText = packet.readString(); + data.options.push_back(opt); + } + + // Validate questCount field exists (4 bytes) + if (!packet.hasRemaining(4)) { + LOG_DEBUG("Gossip: ", data.options.size(), " options (no quest data)"); + return true; + } + + uint32_t questCount = packet.readUInt32(); + // Cap quest count to prevent unbounded memory allocation + const uint32_t MAX_GOSSIP_QUESTS = 64; + if (questCount > MAX_GOSSIP_QUESTS) { + LOG_WARNING("GossipMessageParser: questCount capped (requested=", questCount, ")"); + questCount = MAX_GOSSIP_QUESTS; + } + + data.quests.clear(); + data.quests.reserve(questCount); + for (uint32_t i = 0; i < questCount; ++i) { + // Each quest: questId(4) + questIcon(4) + questLevel(4) + questFlags(4) + isRepeatable(1) + title(var) + // Minimum: 17 bytes + empty string (1 null terminator) = 18 bytes + if (!packet.hasRemaining(18)) { + LOG_WARNING("GossipMessageParser: truncated quests at index ", i, "/", questCount); + break; + } + GossipQuestItem quest; + quest.questId = packet.readUInt32(); + quest.questIcon = packet.readUInt32(); + quest.questLevel = static_cast(packet.readUInt32()); + quest.questFlags = packet.readUInt32(); + quest.isRepeatable = packet.readUInt8(); + quest.title = normalizeWowTextTokens(packet.readString()); + data.quests.push_back(quest); + } + + LOG_DEBUG("Gossip: ", data.options.size(), " options, ", data.quests.size(), " quests"); + return true; +} + +// ============================================================ +// Bind Point (Hearthstone) +// ============================================================ + +network::Packet BinderActivatePacket::build(uint64_t npcGuid) { + network::Packet pkt(wireOpcode(Opcode::CMSG_BINDER_ACTIVATE)); + pkt.writeUInt64(npcGuid); + return pkt; +} + +bool BindPointUpdateParser::parse(network::Packet& packet, BindPointUpdateData& data) { + if (packet.getSize() < 20) return false; + data.x = packet.readFloat(); + data.y = packet.readFloat(); + data.z = packet.readFloat(); + data.mapId = packet.readUInt32(); + data.zoneId = packet.readUInt32(); + return true; +} + +bool QuestRequestItemsParser::parse(network::Packet& packet, QuestRequestItemsData& data) { + if (!packet.hasRemaining(20)) return false; + data.npcGuid = packet.readUInt64(); + data.questId = packet.readUInt32(); + data.title = normalizeWowTextTokens(packet.readString()); + data.completionText = normalizeWowTextTokens(packet.readString()); + + if (!packet.hasRemaining(9)) { + LOG_DEBUG("Quest request items (short): id=", data.questId, " title='", data.title, "'"); + return true; + } + + struct ParsedTail { + uint32_t requiredMoney = 0; + uint32_t completableFlags = 0; + std::vector requiredItems; + bool ok = false; + int score = -1; + }; + + auto parseTail = [&](size_t startPos, size_t prefixSkip) -> ParsedTail { + ParsedTail out; + packet.setReadPos(startPos); + + if (!packet.hasRemaining(prefixSkip)) return out; + packet.setReadPos(packet.getReadPos() + prefixSkip); + + if (!packet.hasRemaining(8)) return out; + out.requiredMoney = packet.readUInt32(); + uint32_t requiredItemCount = packet.readUInt32(); + if (requiredItemCount > 64) return out; // sanity guard against misalignment + + out.requiredItems.reserve(requiredItemCount); + for (uint32_t i = 0; i < requiredItemCount; ++i) { + if (!packet.hasRemaining(12)) return out; + QuestRewardItem item; + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + if (item.itemId != 0) out.requiredItems.push_back(item); + } + + if (!packet.hasRemaining(4)) return out; + out.completableFlags = packet.readUInt32(); + out.ok = true; + + // Prefer layouts that produce plausible quest-requirement shapes. + out.score = 0; + if (requiredItemCount <= 6) out.score += 4; + if (out.requiredItems.size() == requiredItemCount) out.score += 3; + if ((out.completableFlags & ~0x3u) == 0) out.score += 5; + if (out.requiredMoney == 0) out.score += 4; + else if (out.requiredMoney <= 100000) out.score += 2; // <=10g is common + else if (out.requiredMoney >= 1000000) out.score -= 3; // implausible for most quests + if (!out.requiredItems.empty()) out.score += 1; + size_t remaining = packet.getRemainingSize(); + if (remaining <= 16) out.score += 3; + else if (remaining <= 32) out.score += 2; + else if (remaining <= 64) out.score += 1; + if (prefixSkip == 0) out.score += 1; + else if (prefixSkip <= 12) out.score += 1; + return out; + }; + + size_t tailStart = packet.getReadPos(); + std::vector candidates; + candidates.reserve(25); + for (size_t skip = 0; skip <= 24; ++skip) { + candidates.push_back(parseTail(tailStart, skip)); + } + + const ParsedTail* chosen = nullptr; + for (const auto& cand : candidates) { + if (!cand.ok) continue; + if (!chosen || cand.score > chosen->score) chosen = &cand; + } + if (!chosen) { + return true; + } + + data.requiredMoney = chosen->requiredMoney; + data.completableFlags = chosen->completableFlags; + data.requiredItems = chosen->requiredItems; + + LOG_DEBUG("Quest request items: id=", data.questId, " title='", data.title, + "' items=", data.requiredItems.size(), " completable=", data.isCompletable()); + return true; +} + +bool QuestOfferRewardParser::parse(network::Packet& packet, QuestOfferRewardData& data) { + if (!packet.hasRemaining(20)) return false; + data.npcGuid = packet.readUInt64(); + data.questId = packet.readUInt32(); + data.title = normalizeWowTextTokens(packet.readString()); + data.rewardText = normalizeWowTextTokens(packet.readString()); + + if (!packet.hasRemaining(8)) { + LOG_DEBUG("Quest offer reward (short): id=", data.questId, " title='", data.title, "'"); + return true; + } + + // After the two strings the packet contains a variable prefix (autoFinish + optional fields) + // before the emoteCount. Different expansions and server emulator versions differ: + // Classic 1.12 : uint8 autoFinish + uint32 suggestedPlayers = 5 bytes + // TBC 2.4.3 : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (variable arrays) + // WotLK 3.3.5a : uint32 autoFinish + uint32 suggestedPlayers = 8 bytes (fixed 6/4 arrays) + // Some vanilla-family servers omit autoFinish entirely (0 bytes of prefix). + // We scan prefix sizes 0..16 bytes with both fixed and variable array layouts, scoring each. + + struct ParsedTail { + uint32_t rewardMoney = 0; + uint32_t rewardXp = 0; + std::vector choiceRewards; + std::vector fixedRewards; + bool ok = false; + int score = -1000; + size_t prefixSkip = 0; + bool fixedArrays = false; + }; + + auto parseTail = [&](size_t startPos, size_t prefixSkip, bool fixedArrays) -> ParsedTail { + ParsedTail out; + out.prefixSkip = prefixSkip; + out.fixedArrays = fixedArrays; + packet.setReadPos(startPos); + + // Skip the prefix bytes (autoFinish + optional suggestedPlayers before emoteCount) + if (!packet.hasRemaining(prefixSkip)) return out; + packet.setReadPos(packet.getReadPos() + prefixSkip); + + if (!packet.hasRemaining(4)) return out; + uint32_t emoteCount = packet.readUInt32(); + if (emoteCount > 32) return out; // guard against misalignment + for (uint32_t i = 0; i < emoteCount; ++i) { + if (!packet.hasRemaining(8)) return out; + packet.readUInt32(); // delay + packet.readUInt32(); // emote type + } + + if (!packet.hasRemaining(4)) return out; + uint32_t choiceCount = packet.readUInt32(); + if (choiceCount > 6) return out; + uint32_t choiceSlots = fixedArrays ? 6u : choiceCount; + out.choiceRewards.reserve(choiceCount); + uint32_t nonZeroChoice = 0; + for (uint32_t i = 0; i < choiceSlots; ++i) { + if (!packet.hasRemaining(12)) return out; + QuestRewardItem item; + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + item.choiceSlot = i; + if (item.itemId > 0) { + out.choiceRewards.push_back(item); + ++nonZeroChoice; + } + } + + if (!packet.hasRemaining(4)) return out; + uint32_t rewardCount = packet.readUInt32(); + if (rewardCount > 4) return out; + uint32_t rewardSlots = fixedArrays ? 4u : rewardCount; + out.fixedRewards.reserve(rewardCount); + uint32_t nonZeroFixed = 0; + for (uint32_t i = 0; i < rewardSlots; ++i) { + if (!packet.hasRemaining(12)) return out; + QuestRewardItem item; + item.itemId = packet.readUInt32(); + item.count = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + if (item.itemId > 0) { + out.fixedRewards.push_back(item); + ++nonZeroFixed; + } + } + + if (packet.hasRemaining(4)) + out.rewardMoney = packet.readUInt32(); + if (packet.hasRemaining(4)) + out.rewardXp = packet.readUInt32(); + + out.ok = true; + out.score = 0; + // Prefer the standard WotLK/TBC 8-byte prefix (uint32 autoFinish + uint32 suggestedPlayers) + if (prefixSkip == 8) out.score += 3; + else if (prefixSkip == 5) out.score += 1; // Classic uint8 autoFinish + uint32 suggestedPlayers + // Prefer fixed arrays (WotLK/TBC servers always send 6+4 slots) + if (fixedArrays) out.score += 2; + // Valid counts + if (choiceCount <= 6) out.score += 3; + if (rewardCount <= 4) out.score += 3; + // All non-zero items are within declared counts + if (nonZeroChoice <= choiceCount) out.score += 2; + if (nonZeroFixed <= rewardCount) out.score += 2; + // No bytes left over (or only a few) + size_t remaining = packet.getRemainingSize(); + if (remaining == 0) out.score += 5; + else if (remaining <= 4) out.score += 3; + else if (remaining <= 8) out.score += 2; + else if (remaining <= 16) out.score += 1; + else out.score -= static_cast(remaining / 4); + // Plausible money/XP values + if (out.rewardMoney < 5000000u) out.score += 1; // < 500g + if (out.rewardXp < 200000u) out.score += 1; // < 200k XP + return out; + }; + + size_t tailStart = packet.getReadPos(); + // Try prefix sizes 0..16 bytes with both fixed and variable array layouts + std::vector candidates; + candidates.reserve(34); + for (size_t skip = 0; skip <= 16; ++skip) { + candidates.push_back(parseTail(tailStart, skip, true)); // fixed arrays + candidates.push_back(parseTail(tailStart, skip, false)); // variable arrays + } + + const ParsedTail* best = nullptr; + for (const auto& cand : candidates) { + if (!cand.ok) continue; + if (!best || cand.score > best->score) best = &cand; + } + + if (best) { + data.choiceRewards = best->choiceRewards; + data.fixedRewards = best->fixedRewards; + data.rewardMoney = best->rewardMoney; + data.rewardXp = best->rewardXp; + } + + LOG_DEBUG("Quest offer reward: id=", data.questId, " title='", data.title, + "' choices=", data.choiceRewards.size(), " fixed=", data.fixedRewards.size(), + " prefix=", (best ? best->prefixSkip : size_t(0)), + (best && best->fixedArrays ? " fixed" : " var")); + return true; +} + +network::Packet QuestgiverCompleteQuestPacket::build(uint64_t npcGuid, uint32_t questId) { + network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_COMPLETE_QUEST)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + return packet; +} + +network::Packet QuestgiverRequestRewardPacket::build(uint64_t npcGuid, uint32_t questId) { + network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_REQUEST_REWARD)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + return packet; +} + +network::Packet QuestgiverChooseRewardPacket::build(uint64_t npcGuid, uint32_t questId, uint32_t rewardIndex) { + network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_CHOOSE_REWARD)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + packet.writeUInt32(rewardIndex); + return packet; +} + +// ============================================================ +// Phase 5: Vendor +// ============================================================ + +network::Packet ListInventoryPacket::build(uint64_t npcGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_LIST_INVENTORY)); + packet.writeUInt64(npcGuid); + return packet; +} + +network::Packet BuyItemPacket::build(uint64_t vendorGuid, uint32_t itemId, uint32_t slot, uint32_t count) { + network::Packet packet(wireOpcode(Opcode::CMSG_BUY_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt32(itemId); // item entry + packet.writeUInt32(slot); // vendor slot index from SMSG_LIST_INVENTORY + packet.writeUInt32(count); + // Note: WotLK/AzerothCore expects a trailing byte; Classic/TBC do not. + // This static helper always adds it (appropriate for CMaNGOS/AzerothCore). + // For Classic/TBC, use the GameHandler::buyItem() path which checks expansion. + packet.writeUInt8(0); + return packet; +} + +network::Packet SellItemPacket::build(uint64_t vendorGuid, uint64_t itemGuid, uint32_t count) { + network::Packet packet(wireOpcode(Opcode::CMSG_SELL_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt64(itemGuid); + packet.writeUInt32(count); + return packet; +} + +network::Packet BuybackItemPacket::build(uint64_t vendorGuid, uint32_t slot) { + network::Packet packet(wireOpcode(Opcode::CMSG_BUYBACK_ITEM)); + packet.writeUInt64(vendorGuid); + packet.writeUInt32(slot); + return packet; +} + +bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data) { + // Preserve canRepair — it was set by the gossip handler before this packet + // arrived and is not part of the wire format. + const bool savedCanRepair = data.canRepair; + data = ListInventoryData{}; + data.canRepair = savedCanRepair; + + if (!packet.hasRemaining(9)) { + LOG_WARNING("ListInventoryParser: packet too short"); + return false; + } + + data.vendorGuid = packet.readUInt64(); + uint8_t itemCount = packet.readUInt8(); + + if (itemCount == 0) { + LOG_INFO("Vendor has nothing for sale"); + return true; + } + + // Auto-detect whether server sends 7 fields (28 bytes/item) or 8 fields (32 bytes/item). + // Some servers omit the extendedCost field entirely; reading 8 fields on a 7-field packet + // misaligns every item after the first and produces garbage prices. + size_t remaining = packet.getRemainingSize(); + const size_t bytesPerItemNoExt = 28; + const size_t bytesPerItemWithExt = 32; + bool hasExtendedCost = false; + if (remaining < static_cast(itemCount) * bytesPerItemNoExt) { + LOG_WARNING("ListInventoryParser: truncated packet (items=", static_cast(itemCount), + ", remaining=", remaining, ")"); + return false; + } + if (remaining >= static_cast(itemCount) * bytesPerItemWithExt) { + hasExtendedCost = true; + } + + data.items.reserve(itemCount); + for (uint8_t i = 0; i < itemCount; ++i) { + const size_t perItemBytes = hasExtendedCost ? bytesPerItemWithExt : bytesPerItemNoExt; + if (!packet.hasRemaining(perItemBytes)) { + LOG_WARNING("ListInventoryParser: item ", static_cast(i), " truncated"); + return false; + } + VendorItem item; + item.slot = packet.readUInt32(); + item.itemId = packet.readUInt32(); + item.displayInfoId = packet.readUInt32(); + item.maxCount = static_cast(packet.readUInt32()); + item.buyPrice = packet.readUInt32(); + item.durability = packet.readUInt32(); + item.stackCount = packet.readUInt32(); + item.extendedCost = hasExtendedCost ? packet.readUInt32() : 0; + data.items.push_back(item); + } + + LOG_DEBUG("Vendor inventory: ", static_cast(itemCount), " items (extendedCost: ", hasExtendedCost ? "yes" : "no", ")"); + return true; +} + +// ============================================================ +// Trainer +// ============================================================ + +bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data, bool isClassic) { + // WotLK per-entry: spellId(4) + state(1) + cost(4) + profDialog(4) + profButton(4) + + // reqLevel(1) + reqSkill(4) + reqSkillValue(4) + chain×3(12) = 38 bytes + // Classic per-entry: spellId(4) + state(1) + cost(4) + reqLevel(1) + + // reqSkill(4) + reqSkillValue(4) + chain×3(12) + unk(4) = 34 bytes + data = TrainerListData{}; + if (!packet.hasRemaining(16)) return false; // guid(8) + type(4) + count(4) + + data.trainerGuid = packet.readUInt64(); + data.trainerType = packet.readUInt32(); + uint32_t spellCount = packet.readUInt32(); + + if (spellCount > 1000) { + LOG_ERROR("TrainerListParser: unreasonable spell count ", spellCount); + return false; + } + + data.spells.reserve(spellCount); + for (uint32_t i = 0; i < spellCount; ++i) { + // Validate minimum entry size before reading + const size_t minEntrySize = isClassic ? 34 : 38; + if (!packet.hasRemaining(minEntrySize)) { + LOG_WARNING("TrainerListParser: truncated at spell ", i); + break; + } + + TrainerSpell spell; + spell.spellId = packet.readUInt32(); + spell.state = packet.readUInt8(); + spell.spellCost = packet.readUInt32(); + if (isClassic) { + // Classic 1.12: reqLevel immediately after cost; no profDialog/profButton + spell.profDialog = 0; + spell.profButton = 0; + spell.reqLevel = packet.readUInt8(); + } else { + // TBC / WotLK: profDialog + profButton before reqLevel + spell.profDialog = packet.readUInt32(); + spell.profButton = packet.readUInt32(); + spell.reqLevel = packet.readUInt8(); + } + spell.reqSkill = packet.readUInt32(); + spell.reqSkillValue = packet.readUInt32(); + spell.chainNode1 = packet.readUInt32(); + spell.chainNode2 = packet.readUInt32(); + spell.chainNode3 = packet.readUInt32(); + if (isClassic) { + packet.readUInt32(); // trailing unk / sort index + } + data.spells.push_back(spell); + } + + if (!packet.hasData()) { + LOG_WARNING("TrainerListParser: truncated before greeting"); + data.greeting.clear(); + } else { + data.greeting = packet.readString(); + } + + LOG_INFO("Trainer list (", isClassic ? "Classic" : "TBC/WotLK", "): ", + spellCount, " spells, type=", data.trainerType, + ", greeting=\"", data.greeting, "\""); + return true; +} + +network::Packet TrainerBuySpellPacket::build(uint64_t trainerGuid, uint32_t spellId) { + network::Packet packet(wireOpcode(Opcode::CMSG_TRAINER_BUY_SPELL)); + packet.writeUInt64(trainerGuid); + packet.writeUInt32(spellId); + return packet; +} + +// ============================================================ +// Talents +// ============================================================ + +bool TalentsInfoParser::parse(network::Packet& packet, TalentsInfoData& data) { + // SMSG_TALENTS_INFO format (AzerothCore variant): + // uint8 activeSpec + // uint8 unspentPoints + // be32 talentCount (metadata, may not match entry count) + // be16 entryCount (actual number of id+rank entries) + // Entry[entryCount]: { le32 id, uint8 rank } + // le32 glyphSlots + // le16 glyphIds[glyphSlots] + + const size_t startPos = packet.getReadPos(); + const size_t remaining = packet.getSize() - startPos; + + if (remaining < 2 + 4 + 2) { + LOG_ERROR("SMSG_TALENTS_INFO: packet too short (remaining=", remaining, ")"); + return false; + } + + data = TalentsInfoData{}; + + // Read header + data.talentSpec = packet.readUInt8(); + data.unspentPoints = packet.readUInt8(); + + // These two counts are big-endian (network byte order) + uint32_t talentCountBE = packet.readUInt32(); + uint32_t talentCount = bswap32(talentCountBE); + + uint16_t entryCountBE = packet.readUInt16(); + uint16_t entryCount = bswap16(entryCountBE); + + // Sanity check: prevent corrupt packets from allocating excessive memory + if (entryCount > 64) { + LOG_ERROR("SMSG_TALENTS_INFO: entryCount too large (", entryCount, "), rejecting packet"); + return false; + } + + LOG_INFO("SMSG_TALENTS_INFO: spec=", static_cast(data.talentSpec), + " unspent=", static_cast(data.unspentPoints), + " talentCount=", talentCount, + " entryCount=", entryCount); + + // Parse learned entries (id + rank pairs) + // These may be talents, glyphs, or other learned abilities + data.talents.clear(); + data.talents.reserve(entryCount); + + for (uint16_t i = 0; i < entryCount; ++i) { + if (!packet.hasRemaining(5)) { + LOG_ERROR("SMSG_TALENTS_INFO: truncated entry list at i=", i); + return false; + } + uint32_t id = packet.readUInt32(); // LE + uint8_t rank = packet.readUInt8(); + data.talents.push_back({id, rank}); + + LOG_INFO(" Entry: id=", id, " rank=", static_cast(rank)); + } + + // Parse glyph tail: glyphSlots + glyphIds[] + if (!packet.hasRemaining(1)) { + LOG_WARNING("SMSG_TALENTS_INFO: no glyph tail data"); + return true; // Not fatal, older formats may not have glyphs + } + + uint8_t glyphSlots = packet.readUInt8(); + + // Sanity check: Wrath has 6 glyph slots, cap at 12 for safety + if (glyphSlots > 12) { + LOG_WARNING("SMSG_TALENTS_INFO: glyphSlots too large (", static_cast(glyphSlots), "), clamping to 12"); + glyphSlots = 12; + } + + LOG_INFO(" GlyphSlots: ", static_cast(glyphSlots)); + + data.glyphs.clear(); + data.glyphs.reserve(glyphSlots); + + for (uint8_t i = 0; i < glyphSlots; ++i) { + if (!packet.hasRemaining(2)) { + LOG_ERROR("SMSG_TALENTS_INFO: truncated glyph list at i=", i); + return false; + } + uint16_t glyphId = packet.readUInt16(); // LE + data.glyphs.push_back(glyphId); + if (glyphId != 0) { + LOG_INFO(" Glyph slot ", i, ": ", glyphId); + } + } + + LOG_INFO("SMSG_TALENTS_INFO: bytesConsumed=", (packet.getReadPos() - startPos), + " bytesRemaining=", (packet.getRemainingSize())); + + return true; +} + +network::Packet LearnTalentPacket::build(uint32_t talentId, uint32_t requestedRank) { + network::Packet packet(wireOpcode(Opcode::CMSG_LEARN_TALENT)); + packet.writeUInt32(talentId); + packet.writeUInt32(requestedRank); + return packet; +} + +network::Packet TalentWipeConfirmPacket::build(bool accept) { + network::Packet packet(wireOpcode(Opcode::MSG_TALENT_WIPE_CONFIRM)); + packet.writeUInt32(accept ? 1 : 0); + return packet; +} + +network::Packet ActivateTalentGroupPacket::build(uint32_t group) { + // CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3 in WotLK 3.3.5a) + // Payload: uint32 group (0 = primary, 1 = secondary) + network::Packet packet(wireOpcode(Opcode::CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE)); + packet.writeUInt32(group); + return packet; +} + +// ============================================================ +// Death/Respawn +// ============================================================ + +network::Packet RepopRequestPacket::build() { + network::Packet packet(wireOpcode(Opcode::CMSG_REPOP_REQUEST)); + packet.writeUInt8(1); // request release (1 = manual) + return packet; +} + +network::Packet ReclaimCorpsePacket::build(uint64_t guid) { + network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); + packet.writeUInt64(guid); + return packet; +} + +network::Packet SpiritHealerActivatePacket::build(uint64_t npcGuid) { + network::Packet packet(wireOpcode(Opcode::CMSG_SPIRIT_HEALER_ACTIVATE)); + packet.writeUInt64(npcGuid); + return packet; +} + +network::Packet ResurrectResponsePacket::build(uint64_t casterGuid, bool accept) { + network::Packet packet(wireOpcode(Opcode::CMSG_RESURRECT_RESPONSE)); + packet.writeUInt64(casterGuid); + packet.writeUInt8(accept ? 1 : 0); + return packet; +} + +// ============================================================ +// Taxi / Flight Paths +// ============================================================ + +} // namespace game +} // namespace wowee diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index fd7c6bd4..8cd5adeb 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1,4 +1,5 @@ #include "rendering/m2_renderer.hpp" +#include "rendering/m2_renderer_internal.h" #include "rendering/m2_model_classifier.hpp" #include "rendering/vk_context.hpp" #include "rendering/vk_buffer.hpp" @@ -33,24 +34,6 @@ namespace rendering { namespace { -// Seeded RNG for animation time offsets and variation timers. Using rand() -// without srand() produces the same sequence every launch, causing all -// doodads (trees, torches, grass) to sway/flicker in sync. -std::mt19937& rng() { - static std::mt19937 gen(std::random_device{}()); - return gen; -} -uint32_t randRange(uint32_t maxExclusive) { - if (maxExclusive == 0) return 0; - return std::uniform_int_distribution(0, maxExclusive - 1)(rng()); -} -float randFloat(float lo, float hi) { - return std::uniform_real_distribution(lo, hi)(rng()); -} - -// Shared lava UV scroll timer — ensures consistent animation across all render passes -const auto kLavaAnimStart = std::chrono::steady_clock::now(); - bool envFlagEnabled(const char* key, bool defaultValue) { const char* raw = std::getenv(key); if (!raw || !*raw) return defaultValue; @@ -61,223 +44,8 @@ bool envFlagEnabled(const char* key, bool defaultValue) { return !(v == "0" || v == "false" || v == "off" || v == "no"); } -static constexpr uint32_t kParticleFlagRandomized = 0x40; -static constexpr uint32_t kParticleFlagTiled = 0x80; -static constexpr float kSmokeEmitInterval = 1.0f / 48.0f; - -float computeGroundDetailDownOffset(const M2ModelGPU& model, float scale) { - // Keep a tiny sink to avoid hovering, but cap pivot compensation so details - // don't get pushed below the terrain on models with large positive boundMin. - const float pivotComp = glm::clamp(std::max(0.0f, model.boundMin.z * scale), 0.0f, 0.10f); - const float terrainSink = 0.03f; - return pivotComp + terrainSink; -} - -void getTightCollisionBounds(const M2ModelGPU& model, glm::vec3& outMin, glm::vec3& outMax) { - glm::vec3 center = (model.boundMin + model.boundMax) * 0.5f; - glm::vec3 half = (model.boundMax - model.boundMin) * 0.5f; - - // Per-shape collision fitting: - // - small solid props (boxes/crates/chests): tighter than full mesh, but - // larger than default to prevent walk-through on narrow objects - // - default: tighter fit (avoid oversized blockers) - // - stepped low platforms (tree curbs/planters): wider XY + lower Z - if (model.collisionTreeTrunk) { - // Tree trunk: proportional cylinder at the base of the tree. - float modelHoriz = std::max(model.boundMax.x - model.boundMin.x, - model.boundMax.y - model.boundMin.y); - float trunkHalf = std::clamp(modelHoriz * 0.05f, 0.5f, 5.0f); - half.x = trunkHalf; - half.y = trunkHalf; - // Height proportional to trunk width, capped at 3.5 units. - half.z = std::min(trunkHalf * 2.5f, 3.5f); - // Shift center down so collision is at the base (trunk), not mid-canopy. - center.z = model.boundMin.z + half.z; - } else if (model.collisionNarrowVerticalProp) { - // Tall thin props (lamps/posts): keep passable gaps near walls. - half.x *= 0.30f; - half.y *= 0.30f; - half.z *= 0.96f; - } else if (model.collisionSmallSolidProp) { - // Keep full tight mesh bounds for small solid props to avoid clip-through. - half.x *= 1.00f; - half.y *= 1.00f; - half.z *= 1.00f; - } else if (model.collisionSteppedLowPlatform) { - half.x *= 0.98f; - half.y *= 0.98f; - half.z *= 0.52f; - } else { - half.x *= 0.66f; - half.y *= 0.66f; - half.z *= 0.76f; - } - - outMin = center - half; - outMax = center + half; -} - -float getEffectiveCollisionTopLocal(const M2ModelGPU& model, - const glm::vec3& localPos, - const glm::vec3& localMin, - const glm::vec3& localMax) { - if (!model.collisionSteppedFountain && !model.collisionSteppedLowPlatform) { - return localMax.z; - } - - glm::vec2 center((localMin.x + localMax.x) * 0.5f, (localMin.y + localMax.y) * 0.5f); - glm::vec2 half((localMax.x - localMin.x) * 0.5f, (localMax.y - localMin.y) * 0.5f); - if (half.x < 1e-4f || half.y < 1e-4f) { - return localMax.z; - } - - float nx = (localPos.x - center.x) / half.x; - float ny = (localPos.y - center.y) / half.y; - float r = std::sqrt(nx * nx + ny * ny); - - float h = localMax.z - localMin.z; - if (model.collisionSteppedFountain) { - if (r > 0.85f) return localMin.z + h * 0.18f; // outer lip - if (r > 0.65f) return localMin.z + h * 0.36f; // mid step - if (r > 0.45f) return localMin.z + h * 0.54f; // inner step - if (r > 0.28f) return localMin.z + h * 0.70f; // center platform / statue base - if (r > 0.14f) return localMin.z + h * 0.84f; // statue body / sword - return localMin.z + h * 0.96f; // statue head / top - } - - // Low square curb/planter profile: - // use edge distance (not radial) so corner blocks don't become too low and - // clip-through at diagonals. - float edge = std::max(std::abs(nx), std::abs(ny)); - if (edge > 0.92f) return localMin.z + h * 0.06f; - if (edge > 0.72f) return localMin.z + h * 0.30f; - return localMin.z + h * 0.62f; -} - -bool segmentIntersectsAABB(const glm::vec3& from, const glm::vec3& to, - const glm::vec3& bmin, const glm::vec3& bmax, - float& outEnterT) { - glm::vec3 d = to - from; - float tEnter = 0.0f; - float tExit = 1.0f; - - for (int axis = 0; axis < 3; axis++) { - if (std::abs(d[axis]) < 1e-6f) { - if (from[axis] < bmin[axis] || from[axis] > bmax[axis]) { - return false; - } - continue; - } - - float inv = 1.0f / d[axis]; - float t0 = (bmin[axis] - from[axis]) * inv; - float t1 = (bmax[axis] - from[axis]) * inv; - if (t0 > t1) std::swap(t0, t1); - - tEnter = std::max(tEnter, t0); - tExit = std::min(tExit, t1); - if (tEnter > tExit) return false; - } - - outEnterT = tEnter; - return tExit >= 0.0f && tEnter <= 1.0f; -} - -void transformAABB(const glm::mat4& modelMatrix, - const glm::vec3& localMin, - const glm::vec3& localMax, - glm::vec3& outMin, - glm::vec3& outMax) { - const glm::vec3 corners[8] = { - {localMin.x, localMin.y, localMin.z}, - {localMin.x, localMin.y, localMax.z}, - {localMin.x, localMax.y, localMin.z}, - {localMin.x, localMax.y, localMax.z}, - {localMax.x, localMin.y, localMin.z}, - {localMax.x, localMin.y, localMax.z}, - {localMax.x, localMax.y, localMin.z}, - {localMax.x, localMax.y, localMax.z} - }; - - outMin = glm::vec3(std::numeric_limits::max()); - outMax = glm::vec3(-std::numeric_limits::max()); - for (const auto& c : corners) { - glm::vec3 wc = glm::vec3(modelMatrix * glm::vec4(c, 1.0f)); - outMin = glm::min(outMin, wc); - outMax = glm::max(outMax, wc); - } -} - -float pointAABBDistanceSq(const glm::vec3& p, const glm::vec3& bmin, const glm::vec3& bmax) { - glm::vec3 q = glm::clamp(p, bmin, bmax); - glm::vec3 d = p - q; - return glm::dot(d, d); -} - -// Möller–Trumbore ray-triangle intersection. -// Returns distance along ray if hit, negative if miss. -float rayTriangleIntersect(const glm::vec3& origin, const glm::vec3& dir, - const glm::vec3& v0, const glm::vec3& v1, const glm::vec3& v2) { - constexpr float EPSILON = 1e-6f; - glm::vec3 e1 = v1 - v0; - glm::vec3 e2 = v2 - v0; - glm::vec3 h = glm::cross(dir, e2); - float a = glm::dot(e1, h); - if (a > -EPSILON && a < EPSILON) return -1.0f; - float f = 1.0f / a; - glm::vec3 s = origin - v0; - float u = f * glm::dot(s, h); - if (u < 0.0f || u > 1.0f) return -1.0f; - glm::vec3 q = glm::cross(s, e1); - float v = f * glm::dot(dir, q); - if (v < 0.0f || u + v > 1.0f) return -1.0f; - float t = f * glm::dot(e2, q); - return t > EPSILON ? t : -1.0f; -} - -// Closest point on triangle to a point (Ericson, Real-Time Collision Detection §5.1.5). -glm::vec3 closestPointOnTriangle(const glm::vec3& p, - const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) { - glm::vec3 ab = b - a, ac = c - a, ap = p - a; - float d1 = glm::dot(ab, ap), d2 = glm::dot(ac, ap); - if (d1 <= 0.0f && d2 <= 0.0f) return a; - glm::vec3 bp = p - b; - float d3 = glm::dot(ab, bp), d4 = glm::dot(ac, bp); - if (d3 >= 0.0f && d4 <= d3) return b; - float vc = d1 * d4 - d3 * d2; - if (vc <= 0.0f && d1 >= 0.0f && d3 <= 0.0f) { - float v = d1 / (d1 - d3); - return a + v * ab; - } - glm::vec3 cp = p - c; - float d5 = glm::dot(ab, cp), d6 = glm::dot(ac, cp); - if (d6 >= 0.0f && d5 <= d6) return c; - float vb = d5 * d2 - d1 * d6; - if (vb <= 0.0f && d2 >= 0.0f && d6 <= 0.0f) { - float w = d2 / (d2 - d6); - return a + w * ac; - } - float va = d3 * d6 - d5 * d4; - if (va <= 0.0f && (d4 - d3) >= 0.0f && (d5 - d6) >= 0.0f) { - float w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); - return b + w * (c - b); - } - float denom = 1.0f / (va + vb + vc); - float v = vb * denom; - float w = vc * denom; - return a + ab * v + ac * w; -} - } // namespace -// Thread-local scratch buffers for collision queries (allows concurrent getFloorHeight calls) -static thread_local std::vector tl_m2_candidateScratch; -static thread_local std::unordered_set tl_m2_candidateIdScratch; -static thread_local std::vector tl_m2_collisionTriScratch; - -// Forward declaration (defined after animation helpers) -static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance); - void M2Instance::updateModelMatrix() { modelMatrix = glm::mat4(1.0f); modelMatrix = glm::translate(modelMatrix, position); @@ -1869,3569 +1637,5 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { return true; } -uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, - const glm::vec3& rotation, float scale) { - auto modelIt = models.find(modelId); - if (modelIt == models.end()) { - LOG_WARNING("Cannot create instance: model ", modelId, " not loaded"); - return 0; - } - const auto& mdlRef = modelIt->second; - modelUnusedSince_.erase(modelId); - - // Deduplicate: skip if same model already at nearly the same position. - // Uses hash map for O(1) lookup instead of O(N) scan. - if (!mdlRef.isGroundDetail) { - DedupKey dk{modelId, - static_cast(std::round(position.x * 10.0f)), - static_cast(std::round(position.y * 10.0f)), - static_cast(std::round(position.z * 10.0f))}; - auto dit = instanceDedupMap_.find(dk); - if (dit != instanceDedupMap_.end()) { - return dit->second; - } - } - - M2Instance instance; - instance.id = nextInstanceId++; - instance.modelId = modelId; - instance.position = position; - if (mdlRef.isGroundDetail) { - instance.position.z -= computeGroundDetailDownOffset(mdlRef, scale); - } - instance.rotation = rotation; - instance.scale = scale; - instance.updateModelMatrix(); - glm::vec3 localMin, localMax; - getTightCollisionBounds(mdlRef, localMin, localMax); - transformAABB(instance.modelMatrix, localMin, localMax, instance.worldBoundsMin, instance.worldBoundsMax); - - // Cache model flags on instance to avoid per-frame hash lookups - instance.cachedHasAnimation = mdlRef.hasAnimation; - instance.cachedDisableAnimation = mdlRef.disableAnimation; - instance.cachedIsSmoke = mdlRef.isSmoke; - instance.cachedHasParticleEmitters = !mdlRef.particleEmitters.empty(); - instance.cachedBoundRadius = mdlRef.boundRadius; - instance.cachedIsGroundDetail = mdlRef.isGroundDetail; - instance.cachedIsInvisibleTrap = mdlRef.isInvisibleTrap; - instance.cachedIsInstancePortal = mdlRef.isInstancePortal; - instance.cachedIsValid = mdlRef.isValid(); - instance.cachedModel = &mdlRef; - - // Initialize animation: play first sequence (usually Stand/Idle) - const auto& mdl = mdlRef; - if (mdl.hasAnimation && !mdl.disableAnimation) { - if (!mdl.sequences.empty()) { - instance.currentSequenceIndex = 0; - instance.idleSequenceIndex = 0; - instance.animDuration = static_cast(mdl.sequences[0].duration); - instance.animTime = static_cast(randRange(std::max(1u, mdl.sequences[0].duration))); - instance.variationTimer = randFloat(3000.0f, 11000.0f); - } - - // Seed bone matrices from an existing instance of the same model so the - // new instance renders immediately instead of being invisible until the - // next update() computes bones (prevents pop-in flash). - for (const auto& existing : instances) { - if (existing.modelId == modelId && !existing.boneMatrices.empty()) { - instance.boneMatrices = existing.boneMatrices; - instance.bonesDirty[0] = instance.bonesDirty[1] = true; - break; - } - } - // If no sibling exists yet, compute bones immediately - if (instance.boneMatrices.empty()) { - computeBoneMatrices(mdlRef, instance); - } - } - - // Register in dedup map before pushing (uses original position, not ground-adjusted) - if (!mdlRef.isGroundDetail) { - DedupKey dk{modelId, - static_cast(std::round(position.x * 10.0f)), - static_cast(std::round(position.y * 10.0f)), - static_cast(std::round(position.z * 10.0f))}; - instanceDedupMap_[dk] = instance.id; - } - - instances.push_back(instance); - size_t idx = instances.size() - 1; - // Track special instances for fast-path iteration - if (mdlRef.isSmoke) { - smokeInstanceIndices_.push_back(idx); - } - if (mdlRef.isInstancePortal) { - portalInstanceIndices_.push_back(idx); - } - if (!mdlRef.particleEmitters.empty()) { - particleInstanceIndices_.push_back(idx); - } - if (mdlRef.hasAnimation && !mdlRef.disableAnimation) { - animatedInstanceIndices_.push_back(idx); - } else if (!mdlRef.particleEmitters.empty()) { - particleOnlyInstanceIndices_.push_back(idx); - } - instanceIndexById[instance.id] = idx; - GridCell minCell = toCell(instance.worldBoundsMin); - GridCell maxCell = toCell(instance.worldBoundsMax); - for (int z = minCell.z; z <= maxCell.z; z++) { - for (int y = minCell.y; y <= maxCell.y; y++) { - for (int x = minCell.x; x <= maxCell.x; x++) { - spatialGrid[GridCell{x, y, z}].push_back(instance.id); - } - } - } - - return instance.id; -} - -uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4& modelMatrix, - const glm::vec3& position) { - if (models.find(modelId) == models.end()) { - LOG_WARNING("Cannot create instance: model ", modelId, " not loaded"); - return 0; - } - modelUnusedSince_.erase(modelId); - - // Deduplicate: O(1) hash lookup - { - DedupKey dk{modelId, - static_cast(std::round(position.x * 10.0f)), - static_cast(std::round(position.y * 10.0f)), - static_cast(std::round(position.z * 10.0f))}; - auto dit = instanceDedupMap_.find(dk); - if (dit != instanceDedupMap_.end()) { - return dit->second; - } - } - - M2Instance instance; - instance.id = nextInstanceId++; - instance.modelId = modelId; - instance.position = position; // Used for frustum culling - instance.rotation = glm::vec3(0.0f); - instance.scale = 1.0f; - instance.modelMatrix = modelMatrix; - instance.invModelMatrix = glm::inverse(modelMatrix); - glm::vec3 localMin, localMax; - getTightCollisionBounds(models[modelId], localMin, localMax); - transformAABB(instance.modelMatrix, localMin, localMax, instance.worldBoundsMin, instance.worldBoundsMax); - // Cache model flags on instance to avoid per-frame hash lookups - const auto& mdl2 = models[modelId]; - instance.cachedHasAnimation = mdl2.hasAnimation; - instance.cachedDisableAnimation = mdl2.disableAnimation; - instance.cachedIsSmoke = mdl2.isSmoke; - instance.cachedHasParticleEmitters = !mdl2.particleEmitters.empty(); - instance.cachedBoundRadius = mdl2.boundRadius; - instance.cachedIsGroundDetail = mdl2.isGroundDetail; - instance.cachedIsInvisibleTrap = mdl2.isInvisibleTrap; - instance.cachedIsValid = mdl2.isValid(); - instance.cachedModel = &mdl2; - - // Initialize animation - if (mdl2.hasAnimation && !mdl2.disableAnimation) { - if (!mdl2.sequences.empty()) { - instance.currentSequenceIndex = 0; - instance.idleSequenceIndex = 0; - instance.animDuration = static_cast(mdl2.sequences[0].duration); - instance.animTime = static_cast(randRange(std::max(1u, mdl2.sequences[0].duration))); - instance.variationTimer = randFloat(3000.0f, 11000.0f); - } - - // Seed bone matrices from an existing sibling so the instance renders immediately - for (const auto& existing : instances) { - if (existing.modelId == modelId && !existing.boneMatrices.empty()) { - instance.boneMatrices = existing.boneMatrices; - instance.bonesDirty[0] = instance.bonesDirty[1] = true; - break; - } - } - if (instance.boneMatrices.empty()) { - computeBoneMatrices(mdl2, instance); - } - } else { - instance.animTime = randFloat(0.0f, 10000.0f); - } - - // Register in dedup map - { - DedupKey dk{modelId, - static_cast(std::round(position.x * 10.0f)), - static_cast(std::round(position.y * 10.0f)), - static_cast(std::round(position.z * 10.0f))}; - instanceDedupMap_[dk] = instance.id; - } - - instances.push_back(instance); - size_t idx = instances.size() - 1; - if (mdl2.isSmoke) { - smokeInstanceIndices_.push_back(idx); - } - if (!mdl2.particleEmitters.empty()) { - particleInstanceIndices_.push_back(idx); - } - if (mdl2.hasAnimation && !mdl2.disableAnimation) { - animatedInstanceIndices_.push_back(idx); - } else if (!mdl2.particleEmitters.empty()) { - particleOnlyInstanceIndices_.push_back(idx); - } - instanceIndexById[instance.id] = idx; - GridCell minCell = toCell(instance.worldBoundsMin); - GridCell maxCell = toCell(instance.worldBoundsMax); - for (int z = minCell.z; z <= maxCell.z; z++) { - for (int y = minCell.y; y <= maxCell.y; y++) { - for (int x = minCell.x; x <= maxCell.x; x++) { - spatialGrid[GridCell{x, y, z}].push_back(instance.id); - } - } - } - - return instance.id; -} - -// --- Bone animation helpers (same logic as CharacterRenderer) --- - -static int findKeyframeIndex(const std::vector& timestamps, float time) { - if (timestamps.empty()) return -1; - if (timestamps.size() == 1) return 0; - // Binary search using float comparison to match original semantics exactly - auto it = std::upper_bound(timestamps.begin(), timestamps.end(), time, - [](float t, uint32_t ts) { return t < static_cast(ts); }); - if (it == timestamps.begin()) return 0; - size_t idx = static_cast(it - timestamps.begin()) - 1; - return static_cast(std::min(idx, timestamps.size() - 2)); -} - -// Resolve sequence index and time for a track, handling global sequences. -static void resolveTrackTime(const pipeline::M2AnimationTrack& track, - int seqIdx, float time, - const std::vector& globalSeqDurations, - int& outSeqIdx, float& outTime) { - if (track.globalSequence >= 0 && - static_cast(track.globalSequence) < globalSeqDurations.size()) { - // Global sequence: always use sub-array 0, wrap time at global duration - outSeqIdx = 0; - float dur = static_cast(globalSeqDurations[track.globalSequence]); - if (dur > 0.0f) { - // Use iterative subtraction instead of fmod() to preserve precision - outTime = time; - while (outTime >= dur) { - outTime -= dur; - } - } else { - outTime = 0.0f; - } - } else { - outSeqIdx = seqIdx; - outTime = time; - } -} - -static glm::vec3 interpVec3(const pipeline::M2AnimationTrack& track, - int seqIdx, float time, const glm::vec3& def, - const std::vector& globalSeqDurations) { - if (!track.hasData()) return def; - int si; float t; - resolveTrackTime(track, seqIdx, time, globalSeqDurations, si, t); - if (si < 0 || si >= static_cast(track.sequences.size())) return def; - const auto& keys = track.sequences[si]; - if (keys.timestamps.empty() || keys.vec3Values.empty()) return def; - auto safe = [&](const glm::vec3& v) -> glm::vec3 { - if (std::isnan(v.x) || std::isnan(v.y) || std::isnan(v.z)) return def; - return v; - }; - if (keys.vec3Values.size() == 1) return safe(keys.vec3Values[0]); - int idx = findKeyframeIndex(keys.timestamps, t); - if (idx < 0) return def; - size_t i0 = static_cast(idx); - size_t i1 = std::min(i0 + 1, keys.vec3Values.size() - 1); - if (i0 == i1) return safe(keys.vec3Values[i0]); - float t0 = static_cast(keys.timestamps[i0]); - float t1 = static_cast(keys.timestamps[i1]); - float dur = t1 - t0; - float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; - return safe(glm::mix(keys.vec3Values[i0], keys.vec3Values[i1], frac)); -} - -static glm::quat interpQuat(const pipeline::M2AnimationTrack& track, - int seqIdx, float time, - const std::vector& globalSeqDurations) { - glm::quat identity(1.0f, 0.0f, 0.0f, 0.0f); - if (!track.hasData()) return identity; - int si; float t; - resolveTrackTime(track, seqIdx, time, globalSeqDurations, si, t); - if (si < 0 || si >= static_cast(track.sequences.size())) return identity; - const auto& keys = track.sequences[si]; - if (keys.timestamps.empty() || keys.quatValues.empty()) return identity; - auto safe = [&](const glm::quat& q) -> glm::quat { - float lenSq = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w; - if (lenSq < 0.000001f || std::isnan(lenSq)) return identity; - return q; - }; - if (keys.quatValues.size() == 1) return safe(keys.quatValues[0]); - int idx = findKeyframeIndex(keys.timestamps, t); - if (idx < 0) return identity; - size_t i0 = static_cast(idx); - size_t i1 = std::min(i0 + 1, keys.quatValues.size() - 1); - if (i0 == i1) return safe(keys.quatValues[i0]); - float t0 = static_cast(keys.timestamps[i0]); - float t1 = static_cast(keys.timestamps[i1]); - float dur = t1 - t0; - float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; - return glm::slerp(safe(keys.quatValues[i0]), safe(keys.quatValues[i1]), frac); -} - -static void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { - ZoneScopedN("M2::computeBoneMatrices"); - size_t numBones = std::min(model.bones.size(), size_t(128)); - if (numBones == 0) return; - instance.boneMatrices.resize(numBones); - const auto& gsd = model.globalSequenceDurations; - - for (size_t i = 0; i < numBones; i++) { - const auto& bone = model.bones[i]; - glm::vec3 trans = interpVec3(bone.translation, instance.currentSequenceIndex, instance.animTime, glm::vec3(0.0f), gsd); - glm::quat rot = interpQuat(bone.rotation, instance.currentSequenceIndex, instance.animTime, gsd); - glm::vec3 scl = interpVec3(bone.scale, instance.currentSequenceIndex, instance.animTime, glm::vec3(1.0f), gsd); - - // Sanity check scale to avoid degenerate matrices - if (scl.x < 0.001f) scl.x = 1.0f; - if (scl.y < 0.001f) scl.y = 1.0f; - if (scl.z < 0.001f) scl.z = 1.0f; - - glm::mat4 local = glm::translate(glm::mat4(1.0f), bone.pivot); - local = glm::translate(local, trans); - local *= glm::toMat4(rot); - local = glm::scale(local, scl); - local = glm::translate(local, -bone.pivot); - - if (bone.parentBone >= 0 && static_cast(bone.parentBone) < numBones) { - instance.boneMatrices[i] = instance.boneMatrices[bone.parentBone] * local; - } else { - instance.boneMatrices[i] = local; - } - } - instance.bonesDirty[0] = instance.bonesDirty[1] = true; -} - -void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) { - ZoneScopedN("M2Renderer::update"); - if (spatialIndexDirty_) { - rebuildSpatialIndex(); - } - - float dtMs = deltaTime * 1000.0f; - - // Cache camera state for frustum-culling bone computation - cachedCamPos_ = cameraPos; - const float maxRenderDistance = (instances.size() > 2000) ? 800.0f : 2800.0f; - cachedMaxRenderDistSq_ = maxRenderDistance * maxRenderDistance; - - // Build frustum for culling bones - Frustum updateFrustum; - updateFrustum.extractFromMatrix(viewProjection); - - // --- Smoke particle spawning (only iterate tracked smoke instances) --- - std::uniform_real_distribution distXY(-0.4f, 0.4f); - std::uniform_real_distribution distVelXY(-0.3f, 0.3f); - std::uniform_real_distribution distVelZ(3.0f, 5.0f); - std::uniform_real_distribution distLife(4.0f, 7.0f); - std::uniform_real_distribution distDrift(-0.2f, 0.2f); - - smokeEmitAccum += deltaTime; - constexpr float emitInterval = kSmokeEmitInterval; // 48 particles per second per emitter - - if (smokeEmitAccum >= emitInterval && - static_cast(smokeParticles.size()) < MAX_SMOKE_PARTICLES) { - for (size_t si : smokeInstanceIndices_) { - if (si >= instances.size()) continue; - auto& instance = instances[si]; - - glm::vec3 emitWorld = glm::vec3(instance.modelMatrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); - bool spark = (smokeRng() % 8 == 0); - - SmokeParticle p; - p.position = emitWorld + glm::vec3(distXY(smokeRng), distXY(smokeRng), 0.0f); - if (spark) { - p.velocity = glm::vec3(distVelXY(smokeRng) * 2.0f, distVelXY(smokeRng) * 2.0f, distVelZ(smokeRng) * 1.5f); - p.maxLife = 0.8f + static_cast(smokeRng() % 100) / 100.0f * 1.2f; - p.size = 0.5f; - p.isSpark = 1.0f; - } else { - p.velocity = glm::vec3(distVelXY(smokeRng), distVelXY(smokeRng), distVelZ(smokeRng)); - p.maxLife = distLife(smokeRng); - p.size = 1.0f; - p.isSpark = 0.0f; - } - p.life = 0.0f; - p.instanceId = instance.id; - smokeParticles.push_back(p); - if (static_cast(smokeParticles.size()) >= MAX_SMOKE_PARTICLES) break; - } - smokeEmitAccum = 0.0f; - } - - // --- Update existing smoke particles (swap-and-pop for O(1) removal) --- - for (size_t i = 0; i < smokeParticles.size(); ) { - auto& p = smokeParticles[i]; - p.life += deltaTime; - if (p.life >= p.maxLife) { - smokeParticles[i] = smokeParticles.back(); - smokeParticles.pop_back(); - continue; - } - p.position += p.velocity * deltaTime; - p.velocity.z *= 0.98f; // Slight deceleration - p.velocity.x += distDrift(smokeRng) * deltaTime; - p.velocity.y += distDrift(smokeRng) * deltaTime; - // Grow from 1.0 to 3.5 over lifetime - float t = p.life / p.maxLife; - p.size = 1.0f + t * 2.5f; - ++i; - } - - // --- Spin instance portals --- - static constexpr float PORTAL_SPIN_SPEED = 1.2f; // radians/sec - static constexpr float kTwoPi = 6.2831853f; - for (size_t idx : portalInstanceIndices_) { - if (idx >= instances.size()) continue; - auto& inst = instances[idx]; - inst.portalSpinAngle += PORTAL_SPIN_SPEED * deltaTime; - if (inst.portalSpinAngle > kTwoPi) - inst.portalSpinAngle -= kTwoPi; - inst.rotation.z = inst.portalSpinAngle; - inst.updateModelMatrix(); - } - - // --- Normal M2 animation update --- - // Advance animTime for ALL instances (needed for texture UV animation on static doodads). - // This is a tight loop touching only one float per instance — no hash lookups. - for (auto& instance : instances) { - instance.animTime += dtMs; - } - // Wrap animTime for particle-only instances so emission rate tracks keep looping. - // 3333ms chosen as a safe wrap period: long enough to cover the longest known M2 - // particle emission cycle (~3s for torch/campfire effects) while preventing float - // precision loss that accumulates over hours of runtime. - static constexpr float kParticleWrapMs = 3333.0f; - for (size_t idx : particleOnlyInstanceIndices_) { - if (idx >= instances.size()) continue; - auto& instance = instances[idx]; - // Use iterative subtraction instead of fmod() to preserve precision - while (instance.animTime > kParticleWrapMs) { - instance.animTime -= kParticleWrapMs; - } - } - - boneWorkIndices_.clear(); - boneWorkIndices_.reserve(animatedInstanceIndices_.size()); - - // Update animated instances (full animation state + bone computation culling) - // Note: animTime was already advanced by dtMs in the global loop above. - // Here we apply the speed factor: subtract the base dtMs and add dtMs*speed. - for (size_t idx : animatedInstanceIndices_) { - if (idx >= instances.size()) continue; - auto& instance = instances[idx]; - - instance.animTime += dtMs * (instance.animSpeed - 1.0f); - - // For animation looping/variation, we need the actual model data. - if (!instance.cachedModel) continue; - const M2ModelGPU& model = *instance.cachedModel; - - // Validate sequence index - if (instance.currentSequenceIndex < 0 || - instance.currentSequenceIndex >= static_cast(model.sequences.size())) { - instance.currentSequenceIndex = 0; - if (!model.sequences.empty()) { - instance.animDuration = static_cast(model.sequences[0].duration); - } - } - - // Handle animation looping / variation transitions - if (instance.animDuration <= 0.0f && instance.cachedHasParticleEmitters) { - instance.animDuration = 3333.0f; - } - if (instance.animDuration > 0.0f && instance.animTime >= instance.animDuration) { - if (instance.playingVariation) { - instance.playingVariation = false; - instance.currentSequenceIndex = instance.idleSequenceIndex; - if (instance.idleSequenceIndex < static_cast(model.sequences.size())) { - instance.animDuration = static_cast(model.sequences[instance.idleSequenceIndex].duration); - } - instance.animTime = 0.0f; - instance.variationTimer = randFloat(4000.0f, 10000.0f); - } else { - // Use iterative subtraction instead of fmod() to preserve precision - float duration = std::max(1.0f, instance.animDuration); - while (instance.animTime >= duration) { - instance.animTime -= duration; - } - } - } - - // Idle variation timer - if (!instance.playingVariation && model.idleVariationIndices.size() > 1) { - instance.variationTimer -= dtMs; - if (instance.variationTimer <= 0.0f) { - int pick = static_cast(randRange(static_cast(model.idleVariationIndices.size()))); - int newSeq = model.idleVariationIndices[pick]; - if (newSeq != instance.currentSequenceIndex && newSeq < static_cast(model.sequences.size())) { - instance.playingVariation = true; - instance.currentSequenceIndex = newSeq; - instance.animDuration = static_cast(model.sequences[newSeq].duration); - instance.animTime = 0.0f; - } else { - instance.variationTimer = randFloat(2000.0f, 6000.0f); - } - } - } - - // Frustum + distance cull: skip expensive bone computation for off-screen instances. - float worldRadius = instance.cachedBoundRadius * instance.scale; - float cullRadius = worldRadius; - glm::vec3 toCam = instance.position - cachedCamPos_; - float distSq = glm::dot(toCam, toCam); - float effectiveMaxDistSq = cachedMaxRenderDistSq_ * std::max(1.0f, cullRadius / 12.0f); - if (distSq > effectiveMaxDistSq) continue; - float paddedRadius = std::max(cullRadius * 1.5f, cullRadius + 3.0f); - if (cullRadius > 0.0f && !updateFrustum.intersectsSphere(instance.position, paddedRadius)) continue; - - // LOD 3 skip: models beyond 150 units use the lowest LOD mesh which has - // no visible skeletal animation. Keep their last-computed bone matrices - // (always valid — seeded on spawn) and avoid the expensive per-bone work. - constexpr float kLOD3DistSq = 150.0f * 150.0f; - if (distSq > kLOD3DistSq) continue; - - // Distance-based frame skipping: update distant bones less frequently - uint32_t boneInterval = 1; - if (distSq > 100.0f * 100.0f) boneInterval = 4; - else if (distSq > 50.0f * 50.0f) boneInterval = 2; - instance.frameSkipCounter++; - if ((instance.frameSkipCounter % boneInterval) != 0) continue; - - boneWorkIndices_.push_back(idx); - } - - // Compute bone matrices (expensive, parallel if enough work) - const size_t animCount = boneWorkIndices_.size(); - if (animCount > 0) { - static const size_t minParallelAnimInstances = std::max( - 8, envSizeOrDefault("WOWEE_M2_ANIM_MT_MIN", 96)); - if (animCount < minParallelAnimInstances || numAnimThreads_ <= 1) { - // Sequential — not enough work to justify thread overhead - for (size_t i : boneWorkIndices_) { - if (i >= instances.size()) continue; - auto& inst = instances[i]; - if (!inst.cachedModel) continue; - computeBoneMatrices(*inst.cachedModel, inst); - } - } else { - // Parallel — dispatch across worker threads - static const size_t minAnimWorkPerThread = std::max( - 16, envSizeOrDefault("WOWEE_M2_ANIM_WORK_PER_THREAD", 64)); - const size_t maxUsefulThreads = std::max( - 1, (animCount + minAnimWorkPerThread - 1) / minAnimWorkPerThread); - const size_t numThreads = std::min(static_cast(numAnimThreads_), maxUsefulThreads); - if (numThreads <= 1) { - for (size_t i : boneWorkIndices_) { - if (i >= instances.size()) continue; - auto& inst = instances[i]; - if (!inst.cachedModel) continue; - computeBoneMatrices(*inst.cachedModel, inst); - } - } else { - const size_t chunkSize = animCount / numThreads; - const size_t remainder = animCount % numThreads; - - // Reuse persistent futures vector to avoid allocation - animFutures_.clear(); - if (animFutures_.capacity() < numThreads) { - animFutures_.reserve(numThreads); - } - - size_t start = 0; - for (size_t t = 0; t < numThreads; ++t) { - size_t end = start + chunkSize + (t < remainder ? 1 : 0); - animFutures_.push_back(std::async(std::launch::async, - [this, start, end]() { - for (size_t j = start; j < end; ++j) { - size_t idx = boneWorkIndices_[j]; - if (idx >= instances.size()) continue; - auto& inst = instances[idx]; - if (!inst.cachedModel) continue; - computeBoneMatrices(*inst.cachedModel, inst); - } - })); - start = end; - } - - for (auto& f : animFutures_) { - f.get(); - } - } - } - } - - // Particle update (sequential — uses RNG, not thread-safe) - // Only iterate instances that have particle emitters (pre-built list). - for (size_t idx : particleInstanceIndices_) { - if (idx >= instances.size()) continue; - auto& instance = instances[idx]; - // Distance cull: only update particles within visible range - glm::vec3 toCam = instance.position - cachedCamPos_; - float distSq = glm::dot(toCam, toCam); - if (distSq > cachedMaxRenderDistSq_) continue; - if (!instance.cachedModel) continue; - emitParticles(instance, *instance.cachedModel, deltaTime); - updateParticles(instance, deltaTime); - if (!instance.cachedModel->ribbonEmitters.empty()) { - updateRibbons(instance, *instance.cachedModel, deltaTime); - } - } - -} - -void M2Renderer::prepareRender(uint32_t frameIndex, const Camera& camera) { - if (!initialized_ || instances.empty()) return; - (void)camera; // reserved for future frustum-based culling - - // --- Mega bone SSBO: assign slots and upload all animated instance bones --- - // Slot 0 = identity (non-animated), slots 1..N = animated instances. - uint32_t nextSlot = 1; - for (size_t idx : animatedInstanceIndices_) { - if (idx >= instances.size()) continue; - auto& instance = instances[idx]; - - if (instance.boneMatrices.empty()) { - instance.megaBoneOffset = 0; // Use identity slot - continue; - } - - if (nextSlot >= MEGA_BONE_MAX_INSTANCES) { - instance.megaBoneOffset = 0; // Overflow — use identity - continue; - } - - instance.megaBoneOffset = nextSlot * MAX_BONES_PER_INSTANCE; - - // Upload bone matrices to mega buffer - if (megaBoneMapped_[frameIndex]) { - int numBones = std::min(static_cast(instance.boneMatrices.size()), - static_cast(MAX_BONES_PER_INSTANCE)); - auto* dst = static_cast(megaBoneMapped_[frameIndex]) + instance.megaBoneOffset; - memcpy(dst, instance.boneMatrices.data(), numBones * sizeof(glm::mat4)); - } - - nextSlot++; - } -} - -// Dispatch GPU frustum culling compute shader. -// Called on the primary command buffer BEFORE the render pass begins so that -// compute dispatch and memory barrier complete before secondary command buffers -// read the visibility output in render(). -void M2Renderer::dispatchCullCompute(VkCommandBuffer cmd, uint32_t frameIndex, const Camera& camera) { - if (!cullPipeline_ || instances.empty()) return; - - const uint32_t numInstances = std::min(static_cast(instances.size()), MAX_CULL_INSTANCES); - - // --- Compute per-instance adaptive distances (same formula as old CPU cull) --- - const float targetRenderDist = (instances.size() > 2000) ? 300.0f - : (instances.size() > 1000) ? 500.0f - : 1000.0f; - const float shrinkRate = 0.005f; - const float growRate = 0.05f; - float blendRate = (targetRenderDist < smoothedRenderDist_) ? shrinkRate : growRate; - smoothedRenderDist_ = glm::mix(smoothedRenderDist_, targetRenderDist, blendRate); - const float maxRenderDistance = smoothedRenderDist_; - const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance; - const float maxPossibleDistSq = maxRenderDistanceSq * 4.0f; // 2x safety margin - - // --- Upload frustum planes + camera (UBO, binding 0) --- - const glm::mat4 vp = camera.getProjectionMatrix() * camera.getViewMatrix(); - Frustum frustum; - frustum.extractFromMatrix(vp); - const glm::vec3 camPos = camera.getPosition(); - - if (cullUniformMapped_[frameIndex]) { - auto* ubo = static_cast(cullUniformMapped_[frameIndex]); - for (int i = 0; i < 6; i++) { - const auto& p = frustum.getPlane(static_cast(i)); - ubo->frustumPlanes[i] = glm::vec4(p.normal, p.distance); - } - ubo->cameraPos = glm::vec4(camPos, maxPossibleDistSq); - ubo->instanceCount = numInstances; - } - - // --- Upload per-instance cull data (SSBO, binding 1) --- - if (cullInputMapped_[frameIndex]) { - auto* input = static_cast(cullInputMapped_[frameIndex]); - for (uint32_t i = 0; i < numInstances; i++) { - const auto& inst = instances[i]; - float worldRadius = inst.cachedBoundRadius * inst.scale; - float cullRadius = worldRadius; - if (inst.cachedDisableAnimation) { - cullRadius = std::max(cullRadius, 3.0f); - } - float effectiveMaxDistSq = maxRenderDistanceSq * std::max(1.0f, cullRadius / 12.0f); - if (inst.cachedDisableAnimation) effectiveMaxDistSq *= 2.6f; - if (inst.cachedIsGroundDetail) effectiveMaxDistSq *= 0.9f; - - float paddedRadius = std::max(cullRadius * 1.5f, cullRadius + 3.0f); - - uint32_t flags = 0; - if (inst.cachedIsValid) flags |= 1u; - if (inst.cachedIsSmoke) flags |= 2u; - if (inst.cachedIsInvisibleTrap) flags |= 4u; - - input[i].sphere = glm::vec4(inst.position, paddedRadius); - input[i].effectiveMaxDistSq = effectiveMaxDistSq; - input[i].flags = flags; - } - } - - // --- Dispatch compute shader --- - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, cullPipeline_); - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, - cullPipelineLayout_, 0, 1, &cullSet_[frameIndex], 0, nullptr); - - const uint32_t groupCount = (numInstances + 63) / 64; - vkCmdDispatch(cmd, groupCount, 1, 1); - - // --- Memory barrier: compute writes → host reads --- - VkMemoryBarrier barrier{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; - barrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; - barrier.dstAccessMask = VK_ACCESS_HOST_READ_BIT; - vkCmdPipelineBarrier(cmd, - VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, - VK_PIPELINE_STAGE_HOST_BIT, - 0, 1, &barrier, 0, nullptr, 0, nullptr); -} - -void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { - if (instances.empty() || !opaquePipeline_) { - return; - } - - // Debug: log once when we start rendering - static bool loggedOnce = false; - if (!loggedOnce) { - loggedOnce = true; - LOG_INFO("M2 render: ", instances.size(), " instances, ", models.size(), " models"); - } - - // Periodic diagnostic: report render pipeline stats every 10 seconds - static int diagCounter = 0; - if (++diagCounter == 600) { // ~10s at 60fps - diagCounter = 0; - uint32_t totalValid = 0, totalAnimated = 0, totalBonesReady = 0, totalMegaBoneOk = 0; - for (const auto& inst : instances) { - if (inst.cachedIsValid) totalValid++; - if (inst.cachedHasAnimation && !inst.cachedDisableAnimation) { - totalAnimated++; - if (!inst.boneMatrices.empty()) totalBonesReady++; - if (inst.megaBoneOffset != 0) totalMegaBoneOk++; - } - } - LOG_INFO("M2 diag: total=", instances.size(), - " valid=", totalValid, - " animated=", totalAnimated, - " bonesReady=", totalBonesReady, - " megaBoneOk=", totalMegaBoneOk, - " visible=", sortedVisible_.size(), - " draws=", lastDrawCallCount); - } - - // Reuse persistent buffers (clear instead of reallocating) - glowSprites_.clear(); - - lastDrawCallCount = 0; - - // GPU cull results — dispatchCullCompute() already updated smoothedRenderDist_. - // Use the cached value (set by dispatchCullCompute or fallback below). - const uint32_t frameIndex = vkCtx_->getCurrentFrame(); - const uint32_t numInstances = std::min(static_cast(instances.size()), MAX_CULL_INSTANCES); - const uint32_t* visibility = static_cast(cullOutputMapped_[frameIndex]); - const bool gpuCullAvailable = (cullPipeline_ != VK_NULL_HANDLE && visibility != nullptr); - - // If GPU culling was not dispatched, fallback: compute distances on CPU - float maxRenderDistanceSq; - if (!gpuCullAvailable) { - const float targetRenderDist = (instances.size() > 2000) ? 300.0f - : (instances.size() > 1000) ? 500.0f - : 1000.0f; - const float shrinkRate = 0.005f; - const float growRate = 0.05f; - float blendRate = (targetRenderDist < smoothedRenderDist_) ? shrinkRate : growRate; - smoothedRenderDist_ = glm::mix(smoothedRenderDist_, targetRenderDist, blendRate); - maxRenderDistanceSq = smoothedRenderDist_ * smoothedRenderDist_; - } else { - maxRenderDistanceSq = smoothedRenderDist_ * smoothedRenderDist_; - } - - const float fadeStartFraction = 0.75f; - const glm::vec3 camPos = camera.getPosition(); - - // Build sorted visible instance list - sortedVisible_.clear(); - const size_t expectedVisible = std::min(instances.size() / 3, size_t(600)); - if (sortedVisible_.capacity() < expectedVisible) { - sortedVisible_.reserve(expectedVisible); - } - - // GPU frustum culling — build frustum for CPU fallback path and overflow instances - Frustum frustum; - { - const glm::mat4 vp = camera.getProjectionMatrix() * camera.getViewMatrix(); - frustum.extractFromMatrix(vp); - } - const float maxPossibleDistSq = maxRenderDistanceSq * 4.0f; - - const uint32_t totalInstances = static_cast(instances.size()); - for (uint32_t i = 0; i < totalInstances; ++i) { - const auto& instance = instances[i]; - - if (gpuCullAvailable && i < numInstances) { - // GPU already tested flags + distance + frustum - if (!visibility[i]) continue; - } else { - // CPU fallback: for non-GPU path or instances beyond cull buffer - if (!instance.cachedIsValid || instance.cachedIsSmoke || instance.cachedIsInvisibleTrap) continue; - - glm::vec3 toCam = instance.position - camPos; - float distSqTest = glm::dot(toCam, toCam); - if (distSqTest > maxPossibleDistSq) continue; - - float worldRadius = instance.cachedBoundRadius * instance.scale; - float cullRadius = worldRadius; - if (instance.cachedDisableAnimation) cullRadius = std::max(cullRadius, 3.0f); - float effDistSq = maxRenderDistanceSq * std::max(1.0f, cullRadius / 12.0f); - if (instance.cachedDisableAnimation) effDistSq *= 2.6f; - if (instance.cachedIsGroundDetail) effDistSq *= 0.9f; - if (distSqTest > effDistSq) continue; - - float paddedRadius = std::max(cullRadius * 1.5f, cullRadius + 3.0f); - if (cullRadius > 0.0f && !frustum.intersectsSphere(instance.position, paddedRadius)) continue; - } - - // Compute distSq + effectiveMaxDistSq for sorting and fade alpha (cheap for visible-only) - glm::vec3 toCam = instance.position - camPos; - float distSq = glm::dot(toCam, toCam); - float worldRadius = instance.cachedBoundRadius * instance.scale; - float cullRadius = worldRadius; - if (instance.cachedDisableAnimation) cullRadius = std::max(cullRadius, 3.0f); - float effectiveMaxDistSq = maxRenderDistanceSq * std::max(1.0f, cullRadius / 12.0f); - if (instance.cachedDisableAnimation) effectiveMaxDistSq *= 2.6f; - if (instance.cachedIsGroundDetail) effectiveMaxDistSq *= 0.9f; - - sortedVisible_.push_back({i, instance.modelId, distSq, effectiveMaxDistSq}); - } - - // Two-pass rendering: opaque/alpha-test first (depth write ON), then transparent/additive - // (depth write OFF, sorted back-to-front) so transparent geometry composites correctly - // against all opaque geometry rather than only against what was rendered before it. - - // Pass 1: sort by modelId for minimum buffer rebinds (opaque batches) - std::sort(sortedVisible_.begin(), sortedVisible_.end(), - [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); - - uint32_t currentModelId = UINT32_MAX; - const M2ModelGPU* currentModel = nullptr; - bool currentModelValid = false; - - // State tracking - VkPipeline currentPipeline = VK_NULL_HANDLE; - VkDescriptorSet currentMaterialSet = VK_NULL_HANDLE; - - // Push constants now carry per-batch data only; per-instance data is in instance SSBO. - struct M2PushConstants { - int32_t texCoordSet; // UV set index (0 or 1) - int32_t isFoliage; // Foliage wind animation flag - int32_t instanceDataOffset; // Base index into instance SSBO for this draw group - }; - - // Validate per-frame descriptor set before any Vulkan commands - if (!perFrameSet) { - LOG_ERROR("M2Renderer::render: perFrameSet is VK_NULL_HANDLE — skipping M2 render"); - return; - } - - // Bind per-frame descriptor set (set 0) — shared across all draws - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); - - // Start with opaque pipeline - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, opaquePipeline_); - currentPipeline = opaquePipeline_; - - // Bind dummy bone set (set 2) so non-animated draws have a valid binding. - // Bind mega bone SSBO instead — all instances index into one buffer via boneBase. - if (megaBoneSet_[frameIndex]) { - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipelineLayout_, 2, 1, &megaBoneSet_[frameIndex], 0, nullptr); - } else if (dummyBoneSet_) { - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipelineLayout_, 2, 1, &dummyBoneSet_, 0, nullptr); - } - - // Bind instance data SSBO (set 3) — per-instance transforms, fade, bones - if (instanceSet_[frameIndex]) { - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipelineLayout_, 3, 1, &instanceSet_[frameIndex], 0, nullptr); - } - - // Reset instance SSBO write cursor for this frame - instanceDataCount_ = 0; - auto* instSSBO = static_cast(instanceMapped_[frameIndex]); - - // ===================================================================== - // Opaque pass — instanced draws grouped by (modelId, LOD) - // ===================================================================== - // sortedVisible_ is already sorted by modelId so consecutive entries share - // the same vertex/index buffer. Within each model group we sub-group by - // targetLOD to guarantee all instances in one vkCmdDrawIndexed use the - // same batch set. Per-instance data (model matrix, fade, bones) is - // written to the instance SSBO; the shader reads it via gl_InstanceIndex. - { - struct PendingInstance { - uint32_t instanceIdx; - float fadeAlpha; - bool useBones; - uint16_t targetLOD; - }; - std::vector pending; - pending.reserve(128); - - size_t visStart = 0; - while (visStart < sortedVisible_.size()) { - // Find group of consecutive entries with same modelId - uint32_t groupModelId = sortedVisible_[visStart].modelId; - size_t groupEnd = visStart; - while (groupEnd < sortedVisible_.size() && sortedVisible_[groupEnd].modelId == groupModelId) - groupEnd++; - - auto mdlIt = models.find(groupModelId); - if (mdlIt == models.end() || !mdlIt->second.vertexBuffer || !mdlIt->second.indexBuffer) { - visStart = groupEnd; - continue; - } - const M2ModelGPU& model = mdlIt->second; - - bool modelNeedsAnimation = model.hasAnimation && !model.disableAnimation; - const bool foliageLikeModel = model.isFoliageLike; - const bool particleDominantEffect = model.isSpellEffect && - !model.particleEmitters.empty() && model.batches.size() <= 2; - - // Collect per-instance data for this model group - pending.clear(); - for (size_t vi = visStart; vi < groupEnd; vi++) { - const auto& entry = sortedVisible_[vi]; - if (entry.index >= instances.size()) continue; - auto& instance = instances[entry.index]; - - // Distance-based fade alpha - float fadeFrac = model.disableAnimation ? 0.55f : fadeStartFraction; - float fadeStartDistSq = entry.effectiveMaxDistSq * fadeFrac * fadeFrac; - float fadeAlpha = 1.0f; - if (entry.distSq > fadeStartDistSq) { - fadeAlpha = std::clamp((entry.effectiveMaxDistSq - entry.distSq) / - (entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f); - } - float instanceFadeAlpha = fadeAlpha; - if (model.isGroundDetail) instanceFadeAlpha *= 0.82f; - if (model.isInstancePortal) { - instanceFadeAlpha *= 0.12f; - if (entry.distSq < 400.0f * 400.0f) { - glm::vec3 center = glm::vec3(instance.modelMatrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); - GlowSprite gs; - gs.worldPos = center; - gs.color = glm::vec4(0.35f, 0.5f, 1.0f, 1.1f); - gs.size = instance.scale * 5.0f; - glowSprites_.push_back(gs); - GlowSprite halo = gs; - halo.color.a *= 0.3f; - halo.size *= 2.2f; - glowSprites_.push_back(halo); - } - } - - // Bone readiness check - if (modelNeedsAnimation && instance.boneMatrices.empty()) continue; - bool needsBones = modelNeedsAnimation && !instance.boneMatrices.empty(); - if (needsBones && instance.megaBoneOffset == 0) continue; - - // LOD selection - uint16_t desiredLOD = 0; - if (entry.distSq > 150.0f * 150.0f) desiredLOD = 3; - else if (entry.distSq > 80.0f * 80.0f) desiredLOD = 2; - else if (entry.distSq > 40.0f * 40.0f) desiredLOD = 1; - uint16_t targetLOD = desiredLOD; - if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) targetLOD = 0; - - pending.push_back({entry.index, instanceFadeAlpha, needsBones, targetLOD}); - } - - if (pending.empty()) { visStart = groupEnd; continue; } - - // Sort by targetLOD so each sub-group occupies a contiguous SSBO range - std::sort(pending.begin(), pending.end(), - [](const PendingInstance& a, const PendingInstance& b) { return a.targetLOD < b.targetLOD; }); - - // Bind vertex/index buffers once per model group - VkDeviceSize vbOffset = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, &model.vertexBuffer, &vbOffset); - vkCmdBindIndexBuffer(cmd, model.indexBuffer, 0, VK_INDEX_TYPE_UINT16); - - // Write base instance data to SSBO (uvOffset=0 — overridden for tex-anim batches) - uint32_t baseSSBOOffset = instanceDataCount_; - for (const auto& p : pending) { - if (instanceDataCount_ >= MAX_INSTANCE_DATA) break; - auto& inst = instances[p.instanceIdx]; - auto& e = instSSBO[instanceDataCount_]; - e.model = inst.modelMatrix; - e.uvOffset = glm::vec2(0.0f); - e.fadeAlpha = p.fadeAlpha; - e.useBones = p.useBones ? 1 : 0; - e.boneBase = p.useBones ? static_cast(inst.megaBoneOffset) : 0; - std::memset(e._pad, 0, sizeof(e._pad)); - instanceDataCount_++; - } - - // Process LOD sub-groups within this model group - size_t lodIdx = 0; - while (lodIdx < pending.size()) { - uint16_t lod = pending[lodIdx].targetLOD; - size_t lodEnd = lodIdx + 1; - while (lodEnd < pending.size() && pending[lodEnd].targetLOD == lod) lodEnd++; - uint32_t groupSize = static_cast(lodEnd - lodIdx); - uint32_t groupSSBOOffset = baseSSBOOffset + static_cast(lodIdx); - - for (size_t bi = 0; bi < model.batches.size(); bi++) { - const auto& batch = model.batches[bi]; - if (batch.indexCount == 0) continue; - if (!model.isGroundDetail && batch.submeshLevel != lod) continue; - if (batch.batchOpacity < 0.01f) continue; - - // Opaque gate — skip transparent batches - const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect; - if (rawTransparent) continue; - - // Particle-dominant effects: emission geometry — skip opaque - if (particleDominantEffect && batch.blendMode <= 1) continue; - - // Glow sprite check (per model+batch, sprites generated per instance) - const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; - const bool smallCardLikeBatch = - (batch.glowSize <= 1.35f) || - (batch.lanternGlowHint && batch.glowSize <= 6.0f); - const bool batchUnlit = (batch.materialFlags & 0x01) != 0; - const bool shouldUseGlowSprite = - !koboldFlameCard && - (model.isElvenLike || (model.isLanternLike && batch.lanternGlowHint)) && - !model.isSpellEffect && - smallCardLikeBatch && - (batch.lanternGlowHint || - (batch.blendMode >= 3) || - (batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1)); - if (shouldUseGlowSprite) { - // Generate glow sprites for each instance in the group - for (size_t j = lodIdx; j < lodEnd; j++) { - auto& inst = instances[pending[j].instanceIdx]; - float distSq = sortedVisible_[visStart].distSq; // approximate with group - if (distSq < 180.0f * 180.0f) { - glm::vec3 worldPos = glm::vec3(inst.modelMatrix * glm::vec4(batch.center, 1.0f)); - GlowSprite gs; - gs.worldPos = worldPos; - if (batch.glowTint == 1 || model.isElvenLike) - gs.color = glm::vec4(0.48f, 0.72f, 1.0f, 1.05f); - else if (batch.glowTint == 2) - gs.color = glm::vec4(1.0f, 0.28f, 0.22f, 1.10f); - else - gs.color = glm::vec4(1.0f, 0.82f, 0.46f, 1.15f); - gs.size = batch.glowSize * inst.scale * 1.45f; - glowSprites_.push_back(gs); - GlowSprite halo = gs; - halo.color.a *= 0.42f; - halo.size *= 1.8f; - glowSprites_.push_back(halo); - } - } - const bool cardLikeSkipMesh = - (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit; - const bool lanternGlowCardSkip = - model.isLanternLike && batch.lanternGlowHint && - smallCardLikeBatch && cardLikeSkipMesh; - if (lanternGlowCardSkip || (cardLikeSkipMesh && !model.isLanternLike)) - continue; - } - - // Handle texture animation: if this batch has per-instance uvOffset, - // write a separate SSBO range with the correct offsets. - bool hasBatchTexAnim = (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) - || model.isLavaModel; - uint32_t drawOffset = groupSSBOOffset; - if (hasBatchTexAnim && instanceDataCount_ + groupSize <= MAX_INSTANCE_DATA) { - drawOffset = instanceDataCount_; - for (size_t j = lodIdx; j < lodEnd; j++) { - auto& inst = instances[pending[j].instanceIdx]; - glm::vec2 uvOffset(0.0f); - if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) { - uint16_t lookupIdx = batch.textureAnimIndex; - if (lookupIdx < model.textureTransformLookup.size()) { - uint16_t transformIdx = model.textureTransformLookup[lookupIdx]; - if (transformIdx < model.textureTransforms.size()) { - const auto& tt = model.textureTransforms[transformIdx]; - glm::vec3 trans = interpVec3(tt.translation, - inst.currentSequenceIndex, inst.animTime, - glm::vec3(0.0f), model.globalSequenceDurations); - uvOffset = glm::vec2(trans.x, trans.y); - } - } - } - if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) { - float t = std::chrono::duration( - std::chrono::steady_clock::now() - kLavaAnimStart).count(); - uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); - } - // Copy base entry and override uvOffset - instSSBO[instanceDataCount_] = instSSBO[groupSSBOOffset + (j - lodIdx)]; - instSSBO[instanceDataCount_].uvOffset = uvOffset; - instanceDataCount_++; - } - } - - // Pipeline selection (per-model/batch, not per-instance) - const bool foliageCutout = foliageLikeModel && !model.isSpellEffect && batch.blendMode <= 3; - const bool forceCutout = - !model.isSpellEffect && - (model.isGroundDetail || foliageCutout || - batch.blendMode == 1 || - (batch.blendMode >= 2 && !batch.hasAlpha) || - batch.colorKeyBlack); - - uint8_t effectiveBlendMode = batch.blendMode; - if (model.isSpellEffect) { - if (effectiveBlendMode <= 1) effectiveBlendMode = 3; - else if (effectiveBlendMode == 4 || effectiveBlendMode == 5) effectiveBlendMode = 3; - } - if (forceCutout) effectiveBlendMode = 1; - - VkPipeline desiredPipeline; - if (forceCutout) { - desiredPipeline = opaquePipeline_; - } else { - switch (effectiveBlendMode) { - case 0: desiredPipeline = opaquePipeline_; break; - case 1: desiredPipeline = alphaTestPipeline_; break; - case 2: desiredPipeline = alphaPipeline_; break; - default: desiredPipeline = additivePipeline_; break; - } - } - if (desiredPipeline != currentPipeline) { - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); - currentPipeline = desiredPipeline; - } - - // Update material UBO - if (batch.materialUBOMapped) { - auto* mat = static_cast(batch.materialUBOMapped); - mat->interiorDarken = insideInterior ? 1.0f : 0.0f; - if (batch.colorKeyBlack) - mat->colorKeyThreshold = (effectiveBlendMode == 4 || effectiveBlendMode == 5) ? 0.7f : 0.08f; - if (forceCutout) { - mat->alphaTest = model.isGroundDetail ? 3 : (foliageCutout ? 2 : 1); - if (model.isGroundDetail) mat->unlit = 0; - } - } - - // Bind material descriptor set (set 1) - if (!batch.materialSet) continue; - if (batch.materialSet != currentMaterialSet) { - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); - currentMaterialSet = batch.materialSet; - } - - // Push constants + instanced draw - M2PushConstants pc; - pc.texCoordSet = static_cast(batch.textureUnit); - pc.isFoliage = model.shadowWindFoliage ? 1 : 0; - pc.instanceDataOffset = static_cast(drawOffset); - vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc); - vkCmdDrawIndexed(cmd, batch.indexCount, groupSize, batch.indexStart, 0, 0); - lastDrawCallCount++; - } - - lodIdx = lodEnd; - } - - visStart = groupEnd; - } - } - - // ===================================================================== - // Pass 2: Transparent/additive batches — back-to-front per instance - // ===================================================================== - // Transparent geometry must be drawn individually per instance in back-to- - // front order for correct alpha compositing. Each draw writes one - // M2InstanceGPU entry and issues a single-instance indexed draw. - std::sort(sortedVisible_.begin(), sortedVisible_.end(), - [](const VisibleEntry& a, const VisibleEntry& b) { return a.distSq > b.distSq; }); - - currentModelId = UINT32_MAX; - currentModel = nullptr; - currentModelValid = false; - currentPipeline = opaquePipeline_; - currentMaterialSet = VK_NULL_HANDLE; - - for (const auto& entry : sortedVisible_) { - if (entry.index >= instances.size()) continue; - auto& instance = instances[entry.index]; - - // Quick skip: if model has no transparent batches at all - if (entry.modelId != currentModelId) { - auto mdlIt = models.find(entry.modelId); - if (mdlIt == models.end()) continue; - if (!mdlIt->second.hasTransparentBatches && !mdlIt->second.isSpellEffect) continue; - } - - if (entry.modelId != currentModelId) { - currentModelId = entry.modelId; - currentModelValid = false; - auto mdlIt = models.find(currentModelId); - if (mdlIt == models.end()) continue; - currentModel = &mdlIt->second; - if (!currentModel->vertexBuffer || !currentModel->indexBuffer) continue; - currentModelValid = true; - VkDeviceSize vbOff = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &vbOff); - vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); - } - if (!currentModelValid) continue; - - const M2ModelGPU& model = *currentModel; - - // Fade alpha - float fadeAlpha = 1.0f; - float fadeFrac = model.disableAnimation ? 0.55f : fadeStartFraction; - float fadeStartDistSq = entry.effectiveMaxDistSq * fadeFrac * fadeFrac; - if (entry.distSq > fadeStartDistSq) { - fadeAlpha = std::clamp((entry.effectiveMaxDistSq - entry.distSq) / - (entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f); - } - float instanceFadeAlpha = fadeAlpha; - if (model.isGroundDetail) instanceFadeAlpha *= 0.82f; - if (model.isInstancePortal) instanceFadeAlpha *= 0.12f; - - bool modelNeedsAnimation = model.hasAnimation && !model.disableAnimation; - if (modelNeedsAnimation && instance.boneMatrices.empty()) continue; - bool needsBones = modelNeedsAnimation && !instance.boneMatrices.empty(); - if (needsBones && instance.megaBoneOffset == 0) continue; - - uint16_t desiredLOD = 0; - if (entry.distSq > 150.0f * 150.0f) desiredLOD = 3; - else if (entry.distSq > 80.0f * 80.0f) desiredLOD = 2; - else if (entry.distSq > 40.0f * 40.0f) desiredLOD = 1; - uint16_t targetLOD = desiredLOD; - if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) targetLOD = 0; - - const bool particleDominantEffect = model.isSpellEffect && - !model.particleEmitters.empty() && model.batches.size() <= 2; - - for (const auto& batch : model.batches) { - if (batch.indexCount == 0) continue; - if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue; - if (batch.batchOpacity < 0.01f) continue; - - // Pass 2 gate: only transparent/additive batches - { - const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect; - if (!rawTransparent) continue; - } - - // Skip glow sprites (handled in opaque pass) - const bool batchUnlit = (batch.materialFlags & 0x01) != 0; - const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; - const bool smallCardLikeBatch = - (batch.glowSize <= 1.35f) || - (batch.lanternGlowHint && batch.glowSize <= 6.0f); - const bool shouldUseGlowSprite = - !koboldFlameCard && - (model.isElvenLike || model.isLanternLike) && - !model.isSpellEffect && - smallCardLikeBatch && - (batch.lanternGlowHint || (batch.blendMode >= 3) || - (batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1)); - if (shouldUseGlowSprite) { - const bool cardLikeSkipMesh = (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit; - const bool lanternGlowCardSkip = - model.isLanternLike && - batch.lanternGlowHint && - smallCardLikeBatch && - cardLikeSkipMesh; - if (lanternGlowCardSkip || (cardLikeSkipMesh && !model.isLanternLike)) - continue; - } - - if (particleDominantEffect) continue; // emission-only mesh - - // Compute UV offset for this instance + batch - glm::vec2 uvOffset(0.0f); - if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) { - uint16_t lookupIdx = batch.textureAnimIndex; - if (lookupIdx < model.textureTransformLookup.size()) { - uint16_t transformIdx = model.textureTransformLookup[lookupIdx]; - if (transformIdx < model.textureTransforms.size()) { - const auto& tt = model.textureTransforms[transformIdx]; - glm::vec3 trans = interpVec3(tt.translation, - instance.currentSequenceIndex, instance.animTime, - glm::vec3(0.0f), model.globalSequenceDurations); - uvOffset = glm::vec2(trans.x, trans.y); - } - } - } - if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) { - float t = std::chrono::duration(std::chrono::steady_clock::now() - kLavaAnimStart).count(); - uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); - } - - // Write single instance entry to SSBO - if (instanceDataCount_ >= MAX_INSTANCE_DATA) continue; - uint32_t drawOffset = instanceDataCount_; - auto& e = instSSBO[instanceDataCount_]; - e.model = instance.modelMatrix; - e.uvOffset = uvOffset; - e.fadeAlpha = instanceFadeAlpha; - e.useBones = needsBones ? 1 : 0; - e.boneBase = needsBones ? static_cast(instance.megaBoneOffset) : 0; - std::memset(e._pad, 0, sizeof(e._pad)); - instanceDataCount_++; - - // Pipeline selection - uint8_t effectiveBlendMode = batch.blendMode; - if (model.isSpellEffect) { - if (effectiveBlendMode <= 1) effectiveBlendMode = 3; - else if (effectiveBlendMode == 4 || effectiveBlendMode == 5) effectiveBlendMode = 3; - } - - VkPipeline desiredPipeline; - switch (effectiveBlendMode) { - case 2: desiredPipeline = alphaPipeline_; break; - default: desiredPipeline = additivePipeline_; break; - } - if (desiredPipeline != currentPipeline) { - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); - currentPipeline = desiredPipeline; - } - - if (batch.materialUBOMapped) { - auto* mat = static_cast(batch.materialUBOMapped); - mat->interiorDarken = insideInterior ? 1.0f : 0.0f; - if (batch.colorKeyBlack) - mat->colorKeyThreshold = (effectiveBlendMode == 4 || effectiveBlendMode == 5) ? 0.7f : 0.08f; - } - - if (!batch.materialSet) continue; - if (batch.materialSet != currentMaterialSet) { - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); - currentMaterialSet = batch.materialSet; - } - - // Push constants + single-instance draw - M2PushConstants pc; - pc.texCoordSet = static_cast(batch.textureUnit); - pc.isFoliage = model.shadowWindFoliage ? 1 : 0; - pc.instanceDataOffset = static_cast(drawOffset); - vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc); - vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); - lastDrawCallCount++; - } - } - - // Render glow sprites as billboarded additive point lights - if (!glowSprites_.empty() && particleAdditivePipeline_ && glowVB_ && glowTexDescSet_) { - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particleAdditivePipeline_); - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - particlePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - particlePipelineLayout_, 1, 1, &glowTexDescSet_, 0, nullptr); - - // Push constants for particle: tileCount(vec2) + alphaKey(int) - struct { float tileX, tileY; int alphaKey; } particlePush = {1.0f, 1.0f, 0}; - vkCmdPushConstants(cmd, particlePipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0, - sizeof(particlePush), &particlePush); - - // Write glow vertex data directly to mapped buffer (no temp vector) - size_t uploadCount = std::min(glowSprites_.size(), MAX_GLOW_SPRITES); - float* dst = static_cast(glowVBMapped_); - for (size_t gi = 0; gi < uploadCount; gi++) { - const auto& gs = glowSprites_[gi]; - *dst++ = gs.worldPos.x; - *dst++ = gs.worldPos.y; - *dst++ = gs.worldPos.z; - *dst++ = gs.color.r; - *dst++ = gs.color.g; - *dst++ = gs.color.b; - *dst++ = gs.color.a; - *dst++ = gs.size; - *dst++ = 0.0f; - } - - VkDeviceSize offset = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, &glowVB_, &offset); - vkCmdDraw(cmd, static_cast(uploadCount), 1, 0, 0); - } - -} - -bool M2Renderer::initializeShadow(VkRenderPass shadowRenderPass) { - if (!vkCtx_ || shadowRenderPass == VK_NULL_HANDLE) return false; - VkDevice device = vkCtx_->getDevice(); - - // Create ShadowParams UBO - VkBufferCreateInfo bufCI{}; - bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; - bufCI.size = sizeof(ShadowParamsUBO); - bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; - VmaAllocationCreateInfo allocCI{}; - allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; - allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; - VmaAllocationInfo allocInfo{}; - if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufCI, &allocCI, - &shadowParamsUBO_, &shadowParamsAlloc_, &allocInfo) != VK_SUCCESS) { - LOG_ERROR("M2Renderer: failed to create shadow params UBO"); - return false; - } - ShadowParamsUBO defaultParams{}; - std::memcpy(allocInfo.pMappedData, &defaultParams, sizeof(defaultParams)); - - // Create descriptor set layout: binding 0 = sampler2D, binding 1 = ShadowParams UBO - VkDescriptorSetLayoutBinding layoutBindings[2]{}; - layoutBindings[0].binding = 0; - layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - layoutBindings[0].descriptorCount = 1; - layoutBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; - layoutBindings[1].binding = 1; - layoutBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; - layoutBindings[1].descriptorCount = 1; - layoutBindings[1].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; - VkDescriptorSetLayoutCreateInfo layoutCI{}; - layoutCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; - layoutCI.bindingCount = 2; - layoutCI.pBindings = layoutBindings; - if (vkCreateDescriptorSetLayout(device, &layoutCI, nullptr, &shadowParamsLayout_) != VK_SUCCESS) { - LOG_ERROR("M2Renderer: failed to create shadow params layout"); - return false; - } - - // Create descriptor pool - VkDescriptorPoolSize poolSizes[2]{}; - poolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - poolSizes[0].descriptorCount = 1; - poolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; - poolSizes[1].descriptorCount = 1; - VkDescriptorPoolCreateInfo poolCI{}; - poolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; - poolCI.maxSets = 1; - poolCI.poolSizeCount = 2; - poolCI.pPoolSizes = poolSizes; - if (vkCreateDescriptorPool(device, &poolCI, nullptr, &shadowParamsPool_) != VK_SUCCESS) { - LOG_ERROR("M2Renderer: failed to create shadow params pool"); - return false; - } - - // Allocate descriptor set - VkDescriptorSetAllocateInfo setAlloc{}; - setAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; - setAlloc.descriptorPool = shadowParamsPool_; - setAlloc.descriptorSetCount = 1; - setAlloc.pSetLayouts = &shadowParamsLayout_; - if (vkAllocateDescriptorSets(device, &setAlloc, &shadowParamsSet_) != VK_SUCCESS) { - LOG_ERROR("M2Renderer: failed to allocate shadow params set"); - return false; - } - - // Write descriptors (use white fallback for binding 0) - VkDescriptorBufferInfo bufInfo{}; - bufInfo.buffer = shadowParamsUBO_; - bufInfo.offset = 0; - bufInfo.range = sizeof(ShadowParamsUBO); - - VkDescriptorImageInfo imgInfo{}; - imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - imgInfo.imageView = whiteTexture_->getImageView(); - imgInfo.sampler = whiteTexture_->getSampler(); - - VkWriteDescriptorSet writes[2]{}; - writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[0].dstSet = shadowParamsSet_; - writes[0].dstBinding = 0; - writes[0].descriptorCount = 1; - writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - writes[0].pImageInfo = &imgInfo; - writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[1].dstSet = shadowParamsSet_; - writes[1].dstBinding = 1; - writes[1].descriptorCount = 1; - writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; - writes[1].pBufferInfo = &bufInfo; - vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); - - // Per-frame pools for foliage shadow texture sets (one per frame-in-flight, reset each frame) - { - VkDescriptorPoolSize texPoolSizes[2]{}; - texPoolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - texPoolSizes[0].descriptorCount = 256; - texPoolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; - texPoolSizes[1].descriptorCount = 256; - VkDescriptorPoolCreateInfo texPoolCI{}; - texPoolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; - texPoolCI.maxSets = 256; - texPoolCI.poolSizeCount = 2; - texPoolCI.pPoolSizes = texPoolSizes; - for (uint32_t f = 0; f < kShadowTexPoolFrames; ++f) { - if (vkCreateDescriptorPool(device, &texPoolCI, nullptr, &shadowTexPool_[f]) != VK_SUCCESS) { - LOG_ERROR("M2Renderer: failed to create shadow texture pool ", f); - return false; - } - } - } - - // Create shadow pipeline layout: set 1 = shadowParamsLayout_, push constants = 128 bytes - VkPushConstantRange pc{}; - pc.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; - pc.offset = 0; - pc.size = 128; // lightSpaceMatrix (64) + model (64) - shadowPipelineLayout_ = createPipelineLayout(device, {shadowParamsLayout_}, {pc}); - if (!shadowPipelineLayout_) { - LOG_ERROR("M2Renderer: failed to create shadow pipeline layout"); - return false; - } - - // Load shadow shaders - VkShaderModule vertShader, fragShader; - if (!vertShader.loadFromFile(device, "assets/shaders/shadow.vert.spv")) { - LOG_ERROR("M2Renderer: failed to load shadow vertex shader"); - return false; - } - if (!fragShader.loadFromFile(device, "assets/shaders/shadow.frag.spv")) { - LOG_ERROR("M2Renderer: failed to load shadow fragment shader"); - return false; - } - - // M2 vertex layout: 18 floats = 72 bytes stride - // loc0=pos(off0), loc1=normal(off12), loc2=texCoord0(off24), loc5=texCoord1(off32), - // loc3=boneWeights(off40), loc4=boneIndices(off56) - // Shadow shader locations: 0=aPos, 1=aTexCoord, 2=aBoneWeights, 3=aBoneIndicesF - // useBones=0 so locations 2,3 are never used - VkVertexInputBindingDescription vertBind{}; - vertBind.binding = 0; - vertBind.stride = 18 * sizeof(float); - vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - std::vector vertAttrs = { - {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // aPos -> position - {1, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float)}, // aTexCoord -> texCoord0 - {2, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 10 * sizeof(float)}, // aBoneWeights - {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 14 * sizeof(float)}, // aBoneIndicesF - }; - - shadowPipeline_ = PipelineBuilder() - .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), - fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) - .setVertexInput({vertBind}, vertAttrs) - .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) - // Foliage/leaf cards are effectively two-sided; front-face culling can - // drop them from the shadow map depending on light/view orientation. - .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) - .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) - .setDepthBias(0.05f, 0.20f) - .setNoColorAttachment() - .setLayout(shadowPipelineLayout_) - .setRenderPass(shadowRenderPass) - .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) - .build(device, vkCtx_->getPipelineCache()); - - vertShader.destroy(); - fragShader.destroy(); - - if (!shadowPipeline_) { - LOG_ERROR("M2Renderer: failed to create shadow pipeline"); - return false; - } - LOG_INFO("M2Renderer shadow pipeline initialized"); - return true; -} - -void M2Renderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, float globalTime, - const glm::vec3& shadowCenter, float shadowRadius) { - if (!shadowPipeline_ || !shadowParamsSet_) return; - if (instances.empty() || models.empty()) return; - - const float shadowRadiusSq = shadowRadius * shadowRadius; - - // Reset this frame slot's texture descriptor pool (safe: fence was waited on in beginFrame) - const uint32_t frameIdx = vkCtx_->getCurrentFrame(); - VkDescriptorPool curShadowTexPool = shadowTexPool_[frameIdx]; - if (curShadowTexPool) { - vkResetDescriptorPool(vkCtx_->getDevice(), curShadowTexPool, 0); - } - // Cache: texture imageView -> allocated descriptor set (avoids duplicates within frame) - // Reuse persistent map — pool reset already invalidated the sets. - shadowTexSetCache_.clear(); - auto& texSetCache = shadowTexSetCache_; - - auto getTexDescSet = [&](VkTexture* tex) -> VkDescriptorSet { - VkImageView iv = tex->getImageView(); - auto cacheIt = texSetCache.find(iv); - if (cacheIt != texSetCache.end()) return cacheIt->second; - - VkDescriptorSet set = VK_NULL_HANDLE; - VkDescriptorSetAllocateInfo ai{}; - ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; - ai.descriptorPool = curShadowTexPool; - ai.descriptorSetCount = 1; - ai.pSetLayouts = &shadowParamsLayout_; - if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set) != VK_SUCCESS) { - return shadowParamsSet_; // fallback to white texture - } - VkDescriptorImageInfo imgInfo{}; - imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; - imgInfo.imageView = iv; - imgInfo.sampler = tex->getSampler(); - VkDescriptorBufferInfo bufInfo{}; - bufInfo.buffer = shadowParamsUBO_; - bufInfo.offset = 0; - bufInfo.range = sizeof(ShadowParamsUBO); - VkWriteDescriptorSet writes[2]{}; - writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[0].dstSet = set; - writes[0].dstBinding = 0; - writes[0].descriptorCount = 1; - writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - writes[0].pImageInfo = &imgInfo; - writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; - writes[1].dstSet = set; - writes[1].dstBinding = 1; - writes[1].descriptorCount = 1; - writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; - writes[1].pBufferInfo = &bufInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 2, writes, 0, nullptr); - texSetCache[iv] = set; - return set; - }; - - // Helper lambda to draw instances with a given foliageSway setting - auto drawPass = [&](bool foliagePass) { - ShadowParamsUBO params{}; - params.foliageSway = foliagePass ? 1 : 0; - params.windTime = globalTime; - params.foliageMotionDamp = 1.0f; - // For foliage pass: enable texture+alphaTest in UBO (per-batch textures bound below) - if (foliagePass) { - params.useTexture = 1; - params.alphaTest = 1; - } - - VmaAllocationInfo allocInfo{}; - vmaGetAllocationInfo(vkCtx_->getAllocator(), shadowParamsAlloc_, &allocInfo); - std::memcpy(allocInfo.pMappedData, ¶ms, sizeof(params)); - - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipeline_); - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, - 0, 1, &shadowParamsSet_, 0, nullptr); - - uint32_t currentModelId = UINT32_MAX; - const M2ModelGPU* currentModel = nullptr; - - for (const auto& instance : instances) { - // Use cached flags to skip early without hash lookup - if (!instance.cachedIsValid || instance.cachedIsSmoke || instance.cachedIsInvisibleTrap) continue; - - // Distance cull against shadow frustum - glm::vec3 diff = instance.position - shadowCenter; - if (glm::dot(diff, diff) > shadowRadiusSq) continue; - - if (!instance.cachedModel) continue; - const M2ModelGPU& model = *instance.cachedModel; - - // Filter: only draw foliage models in foliage pass, non-foliage in non-foliage pass - if (model.shadowWindFoliage != foliagePass) continue; - - // Bind vertex/index buffers when model changes - if (instance.modelId != currentModelId) { - currentModelId = instance.modelId; - currentModel = &model; - VkDeviceSize offset = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &offset); - vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); - } - - ShadowPush push{lightSpaceMatrix, instance.modelMatrix}; - vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, - 0, 128, &push); - - for (const auto& batch : model.batches) { - if (batch.submeshLevel > 0) continue; - // For foliage: bind per-batch texture for alpha-tested shadows - if (foliagePass && batch.hasAlpha && batch.texture) { - VkDescriptorSet texSet = getTexDescSet(batch.texture); - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, - 0, 1, &texSet, 0, nullptr); - } else if (foliagePass) { - // Non-alpha batch: rebind default set (white texture, alpha test passes) - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, - 0, 1, &shadowParamsSet_, 0, nullptr); - } - vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); - } - } - }; - - // Pass 1: non-foliage (no wind displacement) - drawPass(false); - // Pass 2: foliage (wind displacement enabled, per-batch alpha-tested textures) - drawPass(true); -} - -// --- M2 Particle Emitter Helpers --- - -float M2Renderer::interpFloat(const pipeline::M2AnimationTrack& track, float animTime, - int seqIdx, const std::vector& /*seqs*/, - const std::vector& globalSeqDurations) { - if (!track.hasData()) return 0.0f; - int si; float t; - resolveTrackTime(track, seqIdx, animTime, globalSeqDurations, si, t); - if (si < 0 || si >= static_cast(track.sequences.size())) return 0.0f; - const auto& keys = track.sequences[si]; - if (keys.timestamps.empty() || keys.floatValues.empty()) return 0.0f; - if (keys.floatValues.size() == 1) return keys.floatValues[0]; - int idx = findKeyframeIndex(keys.timestamps, t); - if (idx < 0) return 0.0f; - size_t i0 = static_cast(idx); - size_t i1 = std::min(i0 + 1, keys.floatValues.size() - 1); - if (i0 == i1) return keys.floatValues[i0]; - float t0 = static_cast(keys.timestamps[i0]); - float t1 = static_cast(keys.timestamps[i1]); - float dur = t1 - t0; - float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; - return glm::mix(keys.floatValues[i0], keys.floatValues[i1], frac); -} - -// Interpolate an M2 FBlock (particle lifetime curve) at a given life ratio [0..1]. -// FBlocks store per-lifetime keyframes for particle color, alpha, and scale. -// NOTE: interpFBlockFloat and interpFBlockVec3 share identical interpolation logic — -// if you fix a bug in one, update the other to match. -float M2Renderer::interpFBlockFloat(const pipeline::M2FBlock& fb, float lifeRatio) { - if (fb.floatValues.empty()) return 1.0f; - if (fb.floatValues.size() == 1 || fb.timestamps.empty()) return fb.floatValues[0]; - lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f); - for (size_t i = 0; i < fb.timestamps.size() - 1; i++) { - if (lifeRatio <= fb.timestamps[i + 1]) { - float t0 = fb.timestamps[i]; - float t1 = fb.timestamps[i + 1]; - float dur = t1 - t0; - float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f; - size_t v0 = std::min(i, fb.floatValues.size() - 1); - size_t v1 = std::min(i + 1, fb.floatValues.size() - 1); - return glm::mix(fb.floatValues[v0], fb.floatValues[v1], frac); - } - } - return fb.floatValues.back(); -} - -glm::vec3 M2Renderer::interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio) { - if (fb.vec3Values.empty()) return glm::vec3(1.0f); - if (fb.vec3Values.size() == 1 || fb.timestamps.empty()) return fb.vec3Values[0]; - lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f); - for (size_t i = 0; i < fb.timestamps.size() - 1; i++) { - if (lifeRatio <= fb.timestamps[i + 1]) { - float t0 = fb.timestamps[i]; - float t1 = fb.timestamps[i + 1]; - float dur = t1 - t0; - float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f; - size_t v0 = std::min(i, fb.vec3Values.size() - 1); - size_t v1 = std::min(i + 1, fb.vec3Values.size() - 1); - return glm::mix(fb.vec3Values[v0], fb.vec3Values[v1], frac); - } - } - return fb.vec3Values.back(); -} - -std::vector M2Renderer::getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const { - std::vector result; - float maxDistSq = maxDist * maxDist; - for (const auto& inst : instances) { - if (!inst.cachedModel || !inst.cachedModel->isWaterVegetation) continue; - glm::vec3 diff = inst.position - camPos; - if (glm::dot(diff, diff) <= maxDistSq) { - result.push_back(inst.position); - } - } - return result; -} - -void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) { - if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) { - inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f); - } - - std::uniform_real_distribution dist01(0.0f, 1.0f); - std::uniform_real_distribution distN(-1.0f, 1.0f); - std::uniform_int_distribution distTile; - - for (size_t ei = 0; ei < gpu.particleEmitters.size(); ei++) { - const auto& em = gpu.particleEmitters[ei]; - if (!em.enabled) continue; - - float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - float life = interpFloat(em.lifespan, inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - if (rate <= 0.0f || life <= 0.0f) continue; - - inst.emitterAccumulators[ei] += rate * dt; - - while (inst.emitterAccumulators[ei] >= 1.0f && inst.particles.size() < MAX_M2_PARTICLES) { - inst.emitterAccumulators[ei] -= 1.0f; - - M2Particle p; - p.emitterIndex = static_cast(ei); - p.life = 0.0f; - p.maxLife = life; - p.tileIndex = 0.0f; - - // Position: emitter position transformed by bone matrix - glm::vec3 localPos = em.position; - glm::mat4 boneXform = glm::mat4(1.0f); - if (em.bone < inst.boneMatrices.size()) { - boneXform = inst.boneMatrices[em.bone]; - } - glm::vec3 worldPos = glm::vec3(inst.modelMatrix * boneXform * glm::vec4(localPos, 1.0f)); - p.position = worldPos; - - // Velocity: emission speed in upward direction + random spread - float speed = interpFloat(em.emissionSpeed, inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - float vRange = interpFloat(em.verticalRange, inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - float hRange = interpFloat(em.horizontalRange, inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - - // Base direction: up in model space, transformed to world - glm::vec3 dir(0.0f, 0.0f, 1.0f); - // Add random spread - dir.x += distN(particleRng_) * hRange; - dir.y += distN(particleRng_) * hRange; - dir.z += distN(particleRng_) * vRange; - float lenSq = glm::dot(dir, dir); - if (lenSq > 0.001f * 0.001f) dir *= glm::inversesqrt(lenSq); - - // Transform direction by bone + model orientation (rotation only) - glm::mat3 rotMat = glm::mat3(inst.modelMatrix * boneXform); - p.velocity = rotMat * dir * speed; - - // When emission speed is ~0 and bone animation isn't loaded (.anim files), - // particles pile up at the same position. Give them a drift so they - // spread outward like a mist/spray effect instead of clustering. - if (std::abs(speed) < 0.01f) { - if (gpu.isFireflyEffect) { - // Fireflies: gentle random drift in all directions - p.velocity = rotMat * glm::vec3( - distN(particleRng_) * 0.6f, - distN(particleRng_) * 0.6f, - distN(particleRng_) * 0.3f - ); - } else { - p.velocity = rotMat * glm::vec3( - distN(particleRng_) * 1.0f, - distN(particleRng_) * 1.0f, - -dist01(particleRng_) * 0.5f - ); - } - } - - const uint32_t tilesX = std::max(em.textureCols, 1); - const uint32_t tilesY = std::max(em.textureRows, 1); - const uint32_t totalTiles = tilesX * tilesY; - if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { - if (em.flags & kParticleFlagRandomized) { - distTile = std::uniform_int_distribution(0, static_cast(totalTiles - 1)); - p.tileIndex = static_cast(distTile(particleRng_)); - } else { - p.tileIndex = 0.0f; - } - } - - inst.particles.push_back(p); - } - // Cap accumulator to avoid bursts after lag - if (inst.emitterAccumulators[ei] > 2.0f) { - inst.emitterAccumulators[ei] = 0.0f; - } - } -} - -void M2Renderer::updateParticles(M2Instance& inst, float dt) { - if (!inst.cachedModel) return; - const auto& gpu = *inst.cachedModel; - - for (size_t i = 0; i < inst.particles.size(); ) { - auto& p = inst.particles[i]; - p.life += dt; - if (p.life >= p.maxLife) { - // Swap-and-pop removal - inst.particles[i] = inst.particles.back(); - inst.particles.pop_back(); - continue; - } - // Apply gravity - if (p.emitterIndex >= 0 && p.emitterIndex < static_cast(gpu.particleEmitters.size())) { - const auto& pem = gpu.particleEmitters[p.emitterIndex]; - float grav = interpFloat(pem.gravity, - inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - // When M2 gravity is 0, apply default gravity so particles arc downward. - // Many fountain M2s rely on bone animation (.anim files) we don't load yet. - // Firefly/ambient glow particles intentionally have zero gravity — skip fallback. - if (grav == 0.0f && !gpu.isFireflyEffect) { - float emSpeed = interpFloat(pem.emissionSpeed, - inst.animTime, inst.currentSequenceIndex, - gpu.sequences, gpu.globalSequenceDurations); - if (std::abs(emSpeed) > 0.1f) { - grav = 4.0f; // spray particles - } else { - grav = 1.5f; // mist/drift particles - gentler fall - } - } - p.velocity.z -= grav * dt; - } - p.position += p.velocity * dt; - i++; - } -} - -// --------------------------------------------------------------------------- -// Ribbon emitter simulation -// --------------------------------------------------------------------------- -void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) { - const auto& emitters = gpu.ribbonEmitters; - if (emitters.empty()) return; - - // Grow per-instance state arrays if needed - if (inst.ribbonEdges.size() != emitters.size()) { - inst.ribbonEdges.resize(emitters.size()); - } - if (inst.ribbonEdgeAccumulators.size() != emitters.size()) { - inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f); - } - - for (size_t ri = 0; ri < emitters.size(); ri++) { - const auto& em = emitters[ri]; - auto& edges = inst.ribbonEdges[ri]; - auto& accum = inst.ribbonEdgeAccumulators[ri]; - - // Determine bone world position for spine - glm::vec3 spineWorld = inst.position; - if (em.bone < inst.boneMatrices.size()) { - glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); - spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local); - } else { - glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); - spineWorld = glm::vec3(inst.modelMatrix * local); - } - - // Evaluate animated tracks (use first available sequence key, or fallback value) - auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float { - for (const auto& seq : track.sequences) { - if (!seq.floatValues.empty()) return seq.floatValues[0]; - } - return fallback; - }; - auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 { - for (const auto& seq : track.sequences) { - if (!seq.vec3Values.empty()) return seq.vec3Values[0]; - } - return fallback; - }; - - float visibility = getFloatVal(em.visibilityTrack, 1.0f); - float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f); - float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f); - glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f)); - float alpha = getFloatVal(em.alphaTrack, 1.0f); - - // Age existing edges and remove expired ones - for (auto& e : edges) { - e.age += dt; - // Apply gravity - if (em.gravity != 0.0f) { - e.worldPos.z -= em.gravity * dt * dt * 0.5f; - } - } - while (!edges.empty() && edges.front().age >= em.edgeLifetime) { - edges.pop_front(); - } - - // Emit new edges based on edgesPerSecond - if (visibility > 0.5f) { - accum += em.edgesPerSecond * dt; - while (accum >= 1.0f) { - accum -= 1.0f; - M2Instance::RibbonEdge e; - e.worldPos = spineWorld; - e.color = color; - e.alpha = alpha; - e.heightAbove = heightAbove; - e.heightBelow = heightBelow; - e.age = 0.0f; - edges.push_back(e); - // Cap trail length - if (edges.size() > 128) edges.pop_front(); - } - } else { - accum = 0.0f; - } - } -} - -// --------------------------------------------------------------------------- -// Ribbon rendering -// --------------------------------------------------------------------------- -void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { - if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return; - - // Build camera right vector for billboard orientation - // For ribbons we orient the quad strip along the spine with screen-space up. - // Simple approach: use world-space Z=up for the ribbon cross direction. - const glm::vec3 upWorld(0.0f, 0.0f, 1.0f); - - float* dst = static_cast(ribbonVBMapped_); - size_t written = 0; - - ribbonDraws_.clear(); - auto& draws = ribbonDraws_; - - for (const auto& inst : instances) { - if (!inst.cachedModel) continue; - const auto& gpu = *inst.cachedModel; - if (gpu.ribbonEmitters.empty()) continue; - - for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) { - if (ri >= inst.ribbonEdges.size()) continue; - const auto& edges = inst.ribbonEdges[ri]; - if (edges.size() < 2) continue; - - const auto& em = gpu.ribbonEmitters[ri]; - - // Select blend pipeline based on material blend mode - bool additive = false; - if (em.materialIndex < gpu.batches.size()) { - additive = (gpu.batches[em.materialIndex].blendMode >= 3); - } - VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_; - - // Descriptor set for texture - VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size()) - ? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE; - if (!texSet) continue; - - uint32_t firstVert = static_cast(written); - - // Emit triangle strip: 2 verts per edge (top + bottom) - for (size_t ei = 0; ei < edges.size(); ei++) { - if (written + 2 > MAX_RIBBON_VERTS) break; - const auto& e = edges[ei]; - float t = (em.edgeLifetime > 0.0f) - ? 1.0f - (e.age / em.edgeLifetime) : 1.0f; - float a = e.alpha * t; - float u = static_cast(ei) / static_cast(edges.size() - 1); - - // Top vertex (above spine along upWorld) - glm::vec3 top = e.worldPos + upWorld * e.heightAbove; - dst[written * 9 + 0] = top.x; - dst[written * 9 + 1] = top.y; - dst[written * 9 + 2] = top.z; - dst[written * 9 + 3] = e.color.r; - dst[written * 9 + 4] = e.color.g; - dst[written * 9 + 5] = e.color.b; - dst[written * 9 + 6] = a; - dst[written * 9 + 7] = u; - dst[written * 9 + 8] = 0.0f; // v = top - written++; - - // Bottom vertex (below spine) - glm::vec3 bot = e.worldPos - upWorld * e.heightBelow; - dst[written * 9 + 0] = bot.x; - dst[written * 9 + 1] = bot.y; - dst[written * 9 + 2] = bot.z; - dst[written * 9 + 3] = e.color.r; - dst[written * 9 + 4] = e.color.g; - dst[written * 9 + 5] = e.color.b; - dst[written * 9 + 6] = a; - dst[written * 9 + 7] = u; - dst[written * 9 + 8] = 1.0f; // v = bottom - written++; - } - - uint32_t vertCount = static_cast(written) - firstVert; - if (vertCount >= 4) { - draws.push_back({texSet, pipe, firstVert, vertCount}); - } else { - // Rollback if too few verts - written = firstVert; - } - } - } - - if (draws.empty() || written == 0) return; - - VkExtent2D ext = vkCtx_->getSwapchainExtent(); - VkViewport vp{}; - vp.x = 0; vp.y = 0; - vp.width = static_cast(ext.width); - vp.height = static_cast(ext.height); - vp.minDepth = 0.0f; vp.maxDepth = 1.0f; - VkRect2D sc{}; - sc.offset = {0, 0}; - sc.extent = ext; - vkCmdSetViewport(cmd, 0, 1, &vp); - vkCmdSetScissor(cmd, 0, 1, &sc); - - VkPipeline lastPipe = VK_NULL_HANDLE; - for (const auto& dc : draws) { - if (dc.pipeline != lastPipe) { - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline); - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); - lastPipe = dc.pipeline; - } - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr); - VkDeviceSize offset = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset); - vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0); - } -} - -void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { - if (!particlePipeline_ || !m2ParticleVB_) return; - - // Collect all particles from all instances, grouped by texture+blend - // Reuse persistent map — clear each group's vertex data but keep bucket structure. - for (auto& [k, g] : particleGroups_) { - g.vertexData.clear(); - g.preAllocSet = VK_NULL_HANDLE; - } - auto& groups = particleGroups_; - - size_t totalParticles = 0; - - for (auto& inst : instances) { - if (inst.particles.empty()) continue; - if (!inst.cachedModel) continue; - const auto& gpu = *inst.cachedModel; - - for (const auto& p : inst.particles) { - if (p.emitterIndex < 0 || p.emitterIndex >= static_cast(gpu.particleEmitters.size())) continue; - const auto& em = gpu.particleEmitters[p.emitterIndex]; - - float lifeRatio = p.life / std::max(p.maxLife, 0.001f); - glm::vec3 color = interpFBlockVec3(em.particleColor, lifeRatio); - float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f); - float rawScale = interpFBlockFloat(em.particleScale, lifeRatio); - - if (!gpu.isSpellEffect && !gpu.isFireflyEffect) { - color = glm::mix(color, glm::vec3(1.0f), 0.7f); - if (rawScale > 2.0f) alpha *= 0.02f; - if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f; - } - float scale = (gpu.isSpellEffect || gpu.isFireflyEffect) ? rawScale : std::min(rawScale, 1.5f); - - VkTexture* tex = whiteTexture_.get(); - if (p.emitterIndex < static_cast(gpu.particleTextures.size())) { - tex = gpu.particleTextures[p.emitterIndex]; - } - - uint16_t tilesX = std::max(em.textureCols, 1); - uint16_t tilesY = std::max(em.textureRows, 1); - uint32_t totalTiles = static_cast(tilesX) * static_cast(tilesY); - ParticleGroupKey key{tex, em.blendingType, tilesX, tilesY}; - auto& group = groups[key]; - group.texture = tex; - group.blendType = em.blendingType; - group.tilesX = tilesX; - group.tilesY = tilesY; - // Capture pre-allocated descriptor set on first insertion for this key - if (group.preAllocSet == VK_NULL_HANDLE && - p.emitterIndex < static_cast(gpu.particleTexSets.size())) { - group.preAllocSet = gpu.particleTexSets[p.emitterIndex]; - } - - group.vertexData.push_back(p.position.x); - group.vertexData.push_back(p.position.y); - group.vertexData.push_back(p.position.z); - group.vertexData.push_back(color.r); - group.vertexData.push_back(color.g); - group.vertexData.push_back(color.b); - group.vertexData.push_back(alpha); - group.vertexData.push_back(scale); - float tileIndex = p.tileIndex; - if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { - float animSeconds = inst.animTime / 1000.0f; - uint32_t animFrame = static_cast(std::floor(animSeconds * totalTiles)) % totalTiles; - tileIndex = p.tileIndex + static_cast(animFrame); - float tilesFloat = static_cast(totalTiles); - // Wrap tile index within totalTiles range - while (tileIndex >= tilesFloat) { - tileIndex -= tilesFloat; - } - } - group.vertexData.push_back(tileIndex); - totalParticles++; - } - } - - if (totalParticles == 0) return; - - // Bind per-frame set (set 0) for particle pipeline - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - particlePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); - - VkDeviceSize vbOffset = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, &m2ParticleVB_, &vbOffset); - - VkPipeline currentPipeline = VK_NULL_HANDLE; - - for (auto& [key, group] : groups) { - if (group.vertexData.empty()) continue; - - uint8_t blendType = group.blendType; - VkPipeline desiredPipeline = (blendType == 3 || blendType == 4) - ? particleAdditivePipeline_ : particlePipeline_; - if (desiredPipeline != currentPipeline) { - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); - currentPipeline = desiredPipeline; - } - - // Use pre-allocated stable descriptor set; fall back to per-frame alloc only if unavailable - VkDescriptorSet texSet = group.preAllocSet; - if (texSet == VK_NULL_HANDLE) { - // Fallback: allocate per-frame (pool exhaustion risk — should not happen in practice) - VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; - ai.descriptorPool = materialDescPool_; - ai.descriptorSetCount = 1; - ai.pSetLayouts = &particleTexLayout_; - if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { - VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); - VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); - VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; - write.dstSet = texSet; - write.dstBinding = 0; - write.descriptorCount = 1; - write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - write.pImageInfo = &imgInfo; - vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); - } - } - if (texSet != VK_NULL_HANDLE) { - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - particlePipelineLayout_, 1, 1, &texSet, 0, nullptr); - } - - // Push constants: tileCount + alphaKey - struct { float tileX, tileY; int alphaKey; } pc = { - static_cast(group.tilesX), static_cast(group.tilesY), - (blendType == 1) ? 1 : 0 - }; - vkCmdPushConstants(cmd, particlePipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0, - sizeof(pc), &pc); - - // Upload and draw in chunks - size_t count = group.vertexData.size() / 9; - size_t offset = 0; - while (offset < count) { - size_t batch = std::min(count - offset, MAX_M2_PARTICLES); - memcpy(m2ParticleVBMapped_, &group.vertexData[offset * 9], batch * 9 * sizeof(float)); - vkCmdDraw(cmd, static_cast(batch), 1, 0, 0); - offset += batch; - } - } -} - -void M2Renderer::renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { - if (smokeParticles.empty() || !smokePipeline_ || !smokeVB_) return; - - // Build vertex data: pos(3) + lifeRatio(1) + size(1) + isSpark(1) per particle - size_t count = std::min(smokeParticles.size(), static_cast(MAX_SMOKE_PARTICLES)); - float* dst = static_cast(smokeVBMapped_); - for (size_t i = 0; i < count; i++) { - const auto& p = smokeParticles[i]; - *dst++ = p.position.x; - *dst++ = p.position.y; - *dst++ = p.position.z; - *dst++ = p.life / p.maxLife; - *dst++ = p.size; - *dst++ = p.isSpark; - } - - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, smokePipeline_); - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - smokePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); - - // Push constant: screenHeight - float screenHeight = static_cast(vkCtx_->getSwapchainExtent().height); - vkCmdPushConstants(cmd, smokePipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, - sizeof(float), &screenHeight); - - VkDeviceSize offset = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, &smokeVB_, &offset); - vkCmdDraw(cmd, static_cast(count), 1, 0, 0); -} - -void M2Renderer::setInstancePosition(uint32_t instanceId, const glm::vec3& position) { - auto idxIt = instanceIndexById.find(instanceId); - if (idxIt == instanceIndexById.end()) return; - auto& inst = instances[idxIt->second]; - - // Save old grid cells - GridCell oldMinCell = toCell(inst.worldBoundsMin); - GridCell oldMaxCell = toCell(inst.worldBoundsMax); - - inst.position = position; - inst.updateModelMatrix(); - auto modelIt = models.find(inst.modelId); - if (modelIt != models.end()) { - glm::vec3 localMin, localMax; - getTightCollisionBounds(modelIt->second, localMin, localMax); - transformAABB(inst.modelMatrix, localMin, localMax, inst.worldBoundsMin, inst.worldBoundsMax); - } - - // Incrementally update spatial grid - GridCell newMinCell = toCell(inst.worldBoundsMin); - GridCell newMaxCell = toCell(inst.worldBoundsMax); - if (oldMinCell.x != newMinCell.x || oldMinCell.y != newMinCell.y || oldMinCell.z != newMinCell.z || - oldMaxCell.x != newMaxCell.x || oldMaxCell.y != newMaxCell.y || oldMaxCell.z != newMaxCell.z) { - for (int z = oldMinCell.z; z <= oldMaxCell.z; z++) { - for (int y = oldMinCell.y; y <= oldMaxCell.y; y++) { - for (int x = oldMinCell.x; x <= oldMaxCell.x; x++) { - auto it = spatialGrid.find(GridCell{x, y, z}); - if (it != spatialGrid.end()) { - auto& vec = it->second; - vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); - } - } - } - } - for (int z = newMinCell.z; z <= newMaxCell.z; z++) { - for (int y = newMinCell.y; y <= newMaxCell.y; y++) { - for (int x = newMinCell.x; x <= newMaxCell.x; x++) { - spatialGrid[GridCell{x, y, z}].push_back(instanceId); - } - } - } - } -} - -void M2Renderer::setInstanceAnimationFrozen(uint32_t instanceId, bool frozen) { - auto idxIt = instanceIndexById.find(instanceId); - if (idxIt == instanceIndexById.end()) return; - auto& inst = instances[idxIt->second]; - inst.animSpeed = frozen ? 0.0f : 1.0f; - if (frozen) { - inst.animTime = 0.0f; // Reset to bind pose - } -} - -void M2Renderer::setInstanceAnimation(uint32_t instanceId, uint32_t animationId, bool loop) { - auto idxIt = instanceIndexById.find(instanceId); - if (idxIt == instanceIndexById.end()) return; - auto& inst = instances[idxIt->second]; - if (!inst.cachedModel) return; - const auto& seqs = inst.cachedModel->sequences; - // Find the first sequence matching the requested animation ID - for (int i = 0; i < static_cast(seqs.size()); ++i) { - if (seqs[i].id == animationId) { - inst.currentSequenceIndex = i; - inst.animDuration = static_cast(seqs[i].duration); - inst.animTime = 0.0f; - inst.animSpeed = 1.0f; - // Use playingVariation=true for one-shot (returns to idle when done) - inst.playingVariation = !loop; - return; - } - } -} - -bool M2Renderer::hasAnimation(uint32_t instanceId, uint32_t animationId) const { - auto idxIt = instanceIndexById.find(instanceId); - if (idxIt == instanceIndexById.end()) return false; - const auto& inst = instances[idxIt->second]; - if (!inst.cachedModel) return false; - for (const auto& seq : inst.cachedModel->sequences) { - if (seq.id == animationId) return true; - } - return false; -} - -float M2Renderer::getInstanceAnimDuration(uint32_t instanceId) const { - auto idxIt = instanceIndexById.find(instanceId); - if (idxIt == instanceIndexById.end()) return 0.0f; - const auto& inst = instances[idxIt->second]; - if (!inst.cachedModel) return 0.0f; - const auto& seqs = inst.cachedModel->sequences; - if (seqs.empty()) return 0.0f; - int seqIdx = inst.currentSequenceIndex; - if (seqIdx < 0 || seqIdx >= static_cast(seqs.size())) seqIdx = 0; - return seqs[seqIdx].duration; // in milliseconds -} - -void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) { - auto idxIt = instanceIndexById.find(instanceId); - if (idxIt == instanceIndexById.end()) return; - auto& inst = instances[idxIt->second]; - - // Remove old grid cells before updating bounds - GridCell oldMinCell = toCell(inst.worldBoundsMin); - GridCell oldMaxCell = toCell(inst.worldBoundsMax); - - // Update model matrix directly - inst.modelMatrix = transform; - inst.invModelMatrix = glm::inverse(transform); - - // Extract position from transform for bounds - inst.position = glm::vec3(transform[3]); - - // Update bounds - auto modelIt = models.find(inst.modelId); - if (modelIt != models.end()) { - glm::vec3 localMin, localMax; - getTightCollisionBounds(modelIt->second, localMin, localMax); - transformAABB(inst.modelMatrix, localMin, localMax, inst.worldBoundsMin, inst.worldBoundsMax); - } - - // Incrementally update spatial grid (remove old cells, add new cells) - GridCell newMinCell = toCell(inst.worldBoundsMin); - GridCell newMaxCell = toCell(inst.worldBoundsMax); - if (oldMinCell.x != newMinCell.x || oldMinCell.y != newMinCell.y || oldMinCell.z != newMinCell.z || - oldMaxCell.x != newMaxCell.x || oldMaxCell.y != newMaxCell.y || oldMaxCell.z != newMaxCell.z) { - // Remove from old cells - for (int z = oldMinCell.z; z <= oldMaxCell.z; z++) { - for (int y = oldMinCell.y; y <= oldMaxCell.y; y++) { - for (int x = oldMinCell.x; x <= oldMaxCell.x; x++) { - auto it = spatialGrid.find(GridCell{x, y, z}); - if (it != spatialGrid.end()) { - auto& vec = it->second; - vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); - } - } - } - } - // Add to new cells - for (int z = newMinCell.z; z <= newMaxCell.z; z++) { - for (int y = newMinCell.y; y <= newMaxCell.y; y++) { - for (int x = newMinCell.x; x <= newMaxCell.x; x++) { - spatialGrid[GridCell{x, y, z}].push_back(instanceId); - } - } - } - } - // No spatialIndexDirty_ = true — handled incrementally -} - -void M2Renderer::removeInstance(uint32_t instanceId) { - auto idxIt = instanceIndexById.find(instanceId); - if (idxIt == instanceIndexById.end()) return; - size_t idx = idxIt->second; - if (idx >= instances.size()) return; - - auto& inst = instances[idx]; - - // Remove from spatial grid incrementally (same pattern as the move-update path) - GridCell minCell = toCell(inst.worldBoundsMin); - GridCell maxCell = toCell(inst.worldBoundsMax); - for (int z = minCell.z; z <= maxCell.z; z++) { - for (int y = minCell.y; y <= maxCell.y; y++) { - for (int x = minCell.x; x <= maxCell.x; x++) { - auto gIt = spatialGrid.find(GridCell{x, y, z}); - if (gIt != spatialGrid.end()) { - auto& vec = gIt->second; - vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); - } - } - } - } - - // Remove from dedup map - if (!inst.cachedIsGroundDetail) { - DedupKey dk{inst.modelId, - static_cast(std::round(inst.position.x * 10.0f)), - static_cast(std::round(inst.position.y * 10.0f)), - static_cast(std::round(inst.position.z * 10.0f))}; - instanceDedupMap_.erase(dk); - } - - destroyInstanceBones(inst, /*defer=*/true); - - // Swap-remove: move last element to the hole and pop_back to avoid O(n) shift - instanceIndexById.erase(instanceId); - if (idx < instances.size() - 1) { - uint32_t movedId = instances.back().id; - instances[idx] = std::move(instances.back()); - instances.pop_back(); - instanceIndexById[movedId] = idx; - } else { - instances.pop_back(); - } - - // Rebuild the lightweight auxiliary index vectors (smoke, portal, etc.) - // These are small vectors of indices that are rebuilt cheaply. - smokeInstanceIndices_.clear(); - portalInstanceIndices_.clear(); - animatedInstanceIndices_.clear(); - particleOnlyInstanceIndices_.clear(); - particleInstanceIndices_.clear(); - for (size_t i = 0; i < instances.size(); i++) { - auto& ri = instances[i]; - if (ri.cachedIsSmoke) smokeInstanceIndices_.push_back(i); - if (ri.cachedIsInstancePortal) portalInstanceIndices_.push_back(i); - if (ri.cachedHasParticleEmitters) particleInstanceIndices_.push_back(i); - if (ri.cachedHasAnimation && !ri.cachedDisableAnimation) - animatedInstanceIndices_.push_back(i); - else if (ri.cachedHasParticleEmitters) - particleOnlyInstanceIndices_.push_back(i); - } -} - -void M2Renderer::setSkipCollision(uint32_t instanceId, bool skip) { - for (auto& inst : instances) { - if (inst.id == instanceId) { - inst.skipCollision = skip; - return; - } - } -} - -void M2Renderer::removeInstances(const std::vector& instanceIds) { - if (instanceIds.empty() || instances.empty()) { - return; - } - - std::unordered_set toRemove(instanceIds.begin(), instanceIds.end()); - const size_t oldSize = instances.size(); - for (auto& inst : instances) { - if (toRemove.count(inst.id)) { - destroyInstanceBones(inst, /*defer=*/true); - } - } - instances.erase(std::remove_if(instances.begin(), instances.end(), - [&toRemove](const M2Instance& inst) { - return toRemove.find(inst.id) != toRemove.end(); - }), - instances.end()); - - if (instances.size() != oldSize) { - rebuildSpatialIndex(); - } -} - -void M2Renderer::clear() { - if (vkCtx_) { - vkDeviceWaitIdle(vkCtx_->getDevice()); - for (auto& [id, model] : models) { - destroyModelGPU(model); - } - for (auto& inst : instances) { - destroyInstanceBones(inst); - } - // Reset descriptor pools so new allocations succeed after reload. - // destroyModelGPU/destroyInstanceBones don't free individual sets, - // so the pools fill up across map changes without this reset. - VkDevice device = vkCtx_->getDevice(); - if (materialDescPool_) { - vkResetDescriptorPool(device, materialDescPool_, 0); - // Re-allocate the glow texture descriptor set (pre-allocated during init, - // invalidated by pool reset). - if (glowTexture_ && particleTexLayout_) { - VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; - ai.descriptorPool = materialDescPool_; - ai.descriptorSetCount = 1; - ai.pSetLayouts = &particleTexLayout_; - glowTexDescSet_ = VK_NULL_HANDLE; - if (vkAllocateDescriptorSets(device, &ai, &glowTexDescSet_) == VK_SUCCESS) { - VkDescriptorImageInfo imgInfo = glowTexture_->descriptorInfo(); - VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; - write.dstSet = glowTexDescSet_; - write.dstBinding = 0; - write.descriptorCount = 1; - write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; - write.pImageInfo = &imgInfo; - vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); - } - } - } - if (boneDescPool_) { - vkResetDescriptorPool(device, boneDescPool_, 0); - // Re-allocate the dummy bone set (invalidated by pool reset) - dummyBoneSet_ = allocateBoneSet(); - if (dummyBoneSet_ && dummyBoneBuffer_) { - VkDescriptorBufferInfo bufInfo{}; - bufInfo.buffer = dummyBoneBuffer_; - bufInfo.offset = 0; - bufInfo.range = sizeof(glm::mat4); - VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; - write.dstSet = dummyBoneSet_; - write.dstBinding = 0; - write.descriptorCount = 1; - write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; - write.pBufferInfo = &bufInfo; - vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); - } - } - } - models.clear(); - instances.clear(); - spatialGrid.clear(); - instanceIndexById.clear(); - instanceDedupMap_.clear(); - smokeParticles.clear(); - smokeInstanceIndices_.clear(); - portalInstanceIndices_.clear(); - animatedInstanceIndices_.clear(); - particleOnlyInstanceIndices_.clear(); - particleInstanceIndices_.clear(); - smokeEmitAccum = 0.0f; -} - -void M2Renderer::setCollisionFocus(const glm::vec3& worldPos, float radius) { - collisionFocusEnabled = (radius > 0.0f); - collisionFocusPos = worldPos; - collisionFocusRadius = std::max(0.0f, radius); - collisionFocusRadiusSq = collisionFocusRadius * collisionFocusRadius; -} - -void M2Renderer::clearCollisionFocus() { - collisionFocusEnabled = false; -} - -void M2Renderer::resetQueryStats() { - queryTimeMs = 0.0; - queryCallCount = 0; -} - -M2Renderer::GridCell M2Renderer::toCell(const glm::vec3& p) const { - return GridCell{ - static_cast(std::floor(p.x / SPATIAL_CELL_SIZE)), - static_cast(std::floor(p.y / SPATIAL_CELL_SIZE)), - static_cast(std::floor(p.z / SPATIAL_CELL_SIZE)) - }; -} - -void M2Renderer::rebuildSpatialIndex() { - spatialGrid.clear(); - instanceIndexById.clear(); - instanceDedupMap_.clear(); - instanceIndexById.reserve(instances.size()); - smokeInstanceIndices_.clear(); - portalInstanceIndices_.clear(); - animatedInstanceIndices_.clear(); - particleOnlyInstanceIndices_.clear(); - particleInstanceIndices_.clear(); - - for (size_t i = 0; i < instances.size(); i++) { - auto& inst = instances[i]; - instanceIndexById[inst.id] = i; - - // Re-cache model pointer (may have changed after model map modifications) - auto mdlIt = models.find(inst.modelId); - inst.cachedModel = (mdlIt != models.end()) ? &mdlIt->second : nullptr; - - // Rebuild dedup map (skip ground detail) - if (!inst.cachedIsGroundDetail) { - DedupKey dk{inst.modelId, - static_cast(std::round(inst.position.x * 10.0f)), - static_cast(std::round(inst.position.y * 10.0f)), - static_cast(std::round(inst.position.z * 10.0f))}; - instanceDedupMap_[dk] = inst.id; - } - - if (inst.cachedIsSmoke) { - smokeInstanceIndices_.push_back(i); - } - if (inst.cachedIsInstancePortal) { - portalInstanceIndices_.push_back(i); - } - if (inst.cachedHasParticleEmitters) { - particleInstanceIndices_.push_back(i); - } - if (inst.cachedHasAnimation && !inst.cachedDisableAnimation) { - animatedInstanceIndices_.push_back(i); - } else if (inst.cachedHasParticleEmitters) { - particleOnlyInstanceIndices_.push_back(i); - } - - GridCell minCell = toCell(inst.worldBoundsMin); - GridCell maxCell = toCell(inst.worldBoundsMax); - for (int z = minCell.z; z <= maxCell.z; z++) { - for (int y = minCell.y; y <= maxCell.y; y++) { - for (int x = minCell.x; x <= maxCell.x; x++) { - spatialGrid[GridCell{x, y, z}].push_back(inst.id); - } - } - } - } - spatialIndexDirty_ = false; -} - -void M2Renderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& queryMax, - std::vector& outIndices) const { - outIndices.clear(); - tl_m2_candidateIdScratch.clear(); - - GridCell minCell = toCell(queryMin); - GridCell maxCell = toCell(queryMax); - for (int z = minCell.z; z <= maxCell.z; z++) { - for (int y = minCell.y; y <= maxCell.y; y++) { - for (int x = minCell.x; x <= maxCell.x; x++) { - auto it = spatialGrid.find(GridCell{x, y, z}); - if (it == spatialGrid.end()) continue; - for (uint32_t id : it->second) { - if (!tl_m2_candidateIdScratch.insert(id).second) continue; - auto idxIt = instanceIndexById.find(id); - if (idxIt != instanceIndexById.end()) { - outIndices.push_back(idxIt->second); - } - } - } - } - } - - // Safety fallback to preserve collision correctness if the spatial index - // misses candidates (e.g. during streaming churn). - if (outIndices.empty() && !instances.empty()) { - outIndices.reserve(instances.size()); - for (size_t i = 0; i < instances.size(); i++) { - outIndices.push_back(i); - } - } -} - -void M2Renderer::cleanupUnusedModels() { - // Build set of model IDs that are still referenced by instances - std::unordered_set usedModelIds; - for (const auto& instance : instances) { - usedModelIds.insert(instance.modelId); - } - - const auto now = std::chrono::steady_clock::now(); - constexpr auto kGracePeriod = std::chrono::seconds(60); - - // Find models with no instances that have exceeded the grace period. - // Models that just lost their last instance get tracked but not evicted - // immediately — this prevents thrashing when GO models are briefly - // instance-free between despawn and respawn cycles. - std::vector toRemove; - for (const auto& [id, model] : models) { - if (usedModelIds.find(id) != usedModelIds.end()) { - // Model still in use — clear any pending unused timestamp - modelUnusedSince_.erase(id); - continue; - } - auto unusedIt = modelUnusedSince_.find(id); - if (unusedIt == modelUnusedSince_.end()) { - // First cycle with no instances — start the grace timer - modelUnusedSince_[id] = now; - } else if (now - unusedIt->second >= kGracePeriod) { - // Grace period expired — mark for removal - toRemove.push_back(id); - modelUnusedSince_.erase(unusedIt); - } - } - - // Delete GPU resources and remove from map. - // Wait for the GPU to finish all in-flight frames before destroying any - // buffers — the previous frame's command buffer may still be referencing - // vertex/index buffers that are about to be freed. Without this wait, - // the GPU reads freed memory, which can cause VK_ERROR_DEVICE_LOST. - if (!toRemove.empty() && vkCtx_) { - vkDeviceWaitIdle(vkCtx_->getDevice()); - } - for (uint32_t id : toRemove) { - auto it = models.find(id); - if (it != models.end()) { - destroyModelGPU(it->second); - models.erase(it); - } - } - - if (!toRemove.empty()) { - LOG_INFO("M2 cleanup: removed ", toRemove.size(), " unused models, ", models.size(), " remaining"); - } -} - -VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { - constexpr uint64_t kFailedTextureRetryLookups = 512; - auto normalizeKey = [](std::string key) { - std::replace(key.begin(), key.end(), '/', '\\'); - std::transform(key.begin(), key.end(), key.begin(), - [](unsigned char c) { return static_cast(std::tolower(c)); }); - return key; - }; - std::string key = normalizeKey(path); - const uint64_t lookupSerial = ++textureLookupSerial_; - - // Check cache - auto it = textureCache.find(key); - if (it != textureCache.end()) { - it->second.lastUse = ++textureCacheCounter_; - return it->second.texture.get(); - } - auto failIt = failedTextureRetryAt_.find(key); - if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) { - return whiteTexture_.get(); - } - - auto containsToken = [](const std::string& haystack, const char* token) { - return haystack.find(token) != std::string::npos; - }; - const bool colorKeyBlackHint = - containsToken(key, "candle") || - containsToken(key, "flame") || - containsToken(key, "fire") || - containsToken(key, "torch") || - containsToken(key, "lamp") || - containsToken(key, "lantern") || - containsToken(key, "glow") || - containsToken(key, "flare") || - containsToken(key, "brazier") || - containsToken(key, "campfire") || - containsToken(key, "bonfire"); - - // Check pre-decoded BLP cache first (populated by background worker threads) - pipeline::BLPImage blp; - if (predecodedBLPCache_) { - auto pit = predecodedBLPCache_->find(key); - if (pit != predecodedBLPCache_->end()) { - blp = std::move(pit->second); - predecodedBLPCache_->erase(pit); - } - } - if (!blp.isValid()) { - blp = assetManager->loadTexture(key); - } - if (!blp.isValid()) { - // Cache misses briefly to avoid repeated expensive MPQ/disk probes. - failedTextureCache_.insert(key); - failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; - if (loggedTextureLoadFails_.insert(key).second) { - LOG_WARNING("M2: Failed to load texture: ", path); - } - return whiteTexture_.get(); - } - - size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; - size_t approxBytes = base + (base / 3); - if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { - static constexpr size_t kMaxFailedTextureCache = 200000; - if (failedTextureCache_.size() < kMaxFailedTextureCache) { - // Cache budget-rejected keys too; without this we repeatedly decode/load - // the same textures every frame once budget is saturated. - failedTextureCache_.insert(key); - failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; - } - if (textureBudgetRejectWarnings_ < 3) { - LOG_WARNING("M2 texture cache full (", textureCacheBytes_ / (1024 * 1024), - " MB / ", textureCacheBudgetBytes_ / (1024 * 1024), - " MB), rejecting texture: ", path); - } - ++textureBudgetRejectWarnings_; - return whiteTexture_.get(); - } - - // Track whether the texture actually uses alpha (any pixel with alpha < 255). - bool hasAlpha = false; - for (size_t i = 3; i < blp.data.size(); i += 4) { - if (blp.data[i] != 255) { - hasAlpha = true; - break; - } - } - - // Create Vulkan texture - auto tex = std::make_unique(); - tex->upload(*vkCtx_, blp.data.data(), blp.width, blp.height, VK_FORMAT_R8G8B8A8_UNORM); - - // M2Texture flags: bit 0 = WrapS (1=repeat, 0=clamp), bit 1 = WrapT - VkSamplerAddressMode wrapS = (texFlags & 0x1) ? VK_SAMPLER_ADDRESS_MODE_REPEAT : VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; - VkSamplerAddressMode wrapT = (texFlags & 0x2) ? VK_SAMPLER_ADDRESS_MODE_REPEAT : VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; - tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, wrapS, wrapT); - - VkTexture* texPtr = tex.get(); - - TextureCacheEntry e; - e.texture = std::move(tex); - e.approxBytes = approxBytes; - e.hasAlpha = hasAlpha; - e.colorKeyBlack = colorKeyBlackHint; - e.lastUse = ++textureCacheCounter_; - textureCacheBytes_ += e.approxBytes; - textureCache[key] = std::move(e); - failedTextureCache_.erase(key); - failedTextureRetryAt_.erase(key); - texturePropsByPtr_[texPtr] = {hasAlpha, colorKeyBlackHint}; - LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); - - return texPtr; -} - -uint32_t M2Renderer::getTotalTriangleCount() const { - uint32_t total = 0; - for (const auto& instance : instances) { - if (instance.cachedModel) { - total += instance.cachedModel->indexCount / 3; - } - } - return total; -} - -std::optional M2Renderer::getFloorHeight(float glX, float glY, float glZ, float* outNormalZ) const { - QueryTimer timer(&queryTimeMs, &queryCallCount); - std::optional bestFloor; - float bestNormalZ = 1.0f; // Default to flat - - glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 6.0f); - glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 8.0f); - gatherCandidates(queryMin, queryMax, tl_m2_candidateScratch); - - for (size_t idx : tl_m2_candidateScratch) { - const auto& instance = instances[idx]; - if (collisionFocusEnabled && - pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { - continue; - } - - if (!instance.cachedModel) continue; - if (instance.scale <= 0.001f) continue; - - const M2ModelGPU& model = *instance.cachedModel; - if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; - if (instance.skipCollision) continue; - - // --- Mesh-based floor: vertical ray vs collision triangles --- - // Does NOT skip the AABB path — both contribute and highest wins. - if (model.collision.valid()) { - glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); - - model.collision.getFloorTrisInRange( - localPos.x - 1.0f, localPos.y - 1.0f, - localPos.x + 1.0f, localPos.y + 1.0f, - tl_m2_collisionTriScratch); - - glm::vec3 rayOrigin(localPos.x, localPos.y, localPos.z + 5.0f); - glm::vec3 rayDir(0.0f, 0.0f, -1.0f); - float bestHitZ = -std::numeric_limits::max(); - bool hitAny = false; - - for (uint32_t ti : tl_m2_collisionTriScratch) { - if (ti >= model.collision.triCount) continue; - if (model.collision.triBounds[ti].maxZ < localPos.z - 10.0f || - model.collision.triBounds[ti].minZ > localPos.z + 5.0f) continue; - - const auto& verts = model.collision.vertices; - const auto& idx = model.collision.indices; - const auto& v0 = verts[idx[ti * 3]]; - const auto& v1 = verts[idx[ti * 3 + 1]]; - const auto& v2 = verts[idx[ti * 3 + 2]]; - - // Two-sided: try both windings - float tHit = rayTriangleIntersect(rayOrigin, rayDir, v0, v1, v2); - if (tHit < 0.0f) - tHit = rayTriangleIntersect(rayOrigin, rayDir, v0, v2, v1); - if (tHit < 0.0f) continue; - - float hitZ = rayOrigin.z - tHit; - - // Walkable normal check (world space) - glm::vec3 worldN(0.0f, 0.0f, 1.0f); // Default to flat - glm::vec3 localN = glm::cross(v1 - v0, v2 - v0); - float nLen = glm::length(localN); - if (nLen > 0.001f) { - localN /= nLen; - if (localN.z < 0.0f) localN = -localN; - worldN = glm::normalize( - glm::vec3(instance.modelMatrix * glm::vec4(localN, 0.0f))); - if (std::abs(worldN.z) < 0.35f) continue; // too steep (~70° max slope) - } - - if (hitZ <= localPos.z + 3.0f && hitZ > bestHitZ) { - bestHitZ = hitZ; - hitAny = true; - bestNormalZ = std::abs(worldN.z); // Store normal for output - } - } - - if (hitAny) { - glm::vec3 localHit(localPos.x, localPos.y, bestHitZ); - glm::vec3 worldHit = glm::vec3(instance.modelMatrix * glm::vec4(localHit, 1.0f)); - if (worldHit.z <= glZ + 3.0f && (!bestFloor || worldHit.z > *bestFloor)) { - bestFloor = worldHit.z; - } - } - // Fall through to AABB floor — both contribute, highest wins - } - - float zMargin = model.collisionBridge ? 25.0f : 2.0f; - if (glX < instance.worldBoundsMin.x || glX > instance.worldBoundsMax.x || - glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y || - glZ < instance.worldBoundsMin.z - zMargin || glZ > instance.worldBoundsMax.z + zMargin) { - continue; - } - glm::vec3 localMin, localMax; - getTightCollisionBounds(model, localMin, localMax); - - glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); - - // Must be within doodad footprint in local XY. - // Stepped low platforms get a small pad so walk-up snapping catches edges. - float footprintPad = 0.0f; - if (model.collisionSteppedLowPlatform) { - footprintPad = model.collisionPlanter ? 0.22f : 0.16f; - if (model.collisionBridge) { - footprintPad = 0.35f; - } - } - if (localPos.x < localMin.x - footprintPad || localPos.x > localMax.x + footprintPad || - localPos.y < localMin.y - footprintPad || localPos.y > localMax.y + footprintPad) { - continue; - } - - // Construct "top" point at queried XY in local space, then transform back. - float localTopZ = getEffectiveCollisionTopLocal(model, localPos, localMin, localMax); - glm::vec3 localTop(localPos.x, localPos.y, localTopZ); - glm::vec3 worldTop = glm::vec3(instance.modelMatrix * glm::vec4(localTop, 1.0f)); - - // Reachability filter: allow a bit more climb for stepped low platforms. - float maxStepUp = 1.0f; - if (model.collisionStatue) { - maxStepUp = 2.5f; - } else if (model.collisionSmallSolidProp) { - maxStepUp = 2.0f; - } else if (model.collisionSteppedFountain) { - maxStepUp = 2.5f; - } else if (model.collisionSteppedLowPlatform) { - maxStepUp = model.collisionPlanter ? 3.0f : 2.4f; - if (model.collisionBridge) { - maxStepUp = 25.0f; - } - } - if (worldTop.z > glZ + maxStepUp) continue; - - if (!bestFloor || worldTop.z > *bestFloor) { - bestFloor = worldTop.z; - } - } - - // Output surface normal if requested - if (outNormalZ) { - *outNormalZ = bestNormalZ; - } - - return bestFloor; -} - -bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to, - glm::vec3& adjustedPos, float playerRadius) const { - QueryTimer timer(&queryTimeMs, &queryCallCount); - adjustedPos = to; - bool collided = false; - - glm::vec3 queryMin = glm::min(from, to) - glm::vec3(7.0f, 7.0f, 5.0f); - glm::vec3 queryMax = glm::max(from, to) + glm::vec3(7.0f, 7.0f, 5.0f); - gatherCandidates(queryMin, queryMax, tl_m2_candidateScratch); - - // Check against all M2 instances in local space (rotation-aware). - for (size_t idx : tl_m2_candidateScratch) { - const auto& instance = instances[idx]; - if (collisionFocusEnabled && - pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { - continue; - } - - const float broadMargin = playerRadius + 1.0f; - if (from.x < instance.worldBoundsMin.x - broadMargin && adjustedPos.x < instance.worldBoundsMin.x - broadMargin) continue; - if (from.x > instance.worldBoundsMax.x + broadMargin && adjustedPos.x > instance.worldBoundsMax.x + broadMargin) continue; - if (from.y < instance.worldBoundsMin.y - broadMargin && adjustedPos.y < instance.worldBoundsMin.y - broadMargin) continue; - if (from.y > instance.worldBoundsMax.y + broadMargin && adjustedPos.y > instance.worldBoundsMax.y + broadMargin) continue; - if (from.z > instance.worldBoundsMax.z + 2.5f && adjustedPos.z > instance.worldBoundsMax.z + 2.5f) continue; - if (from.z + 2.5f < instance.worldBoundsMin.z && adjustedPos.z + 2.5f < instance.worldBoundsMin.z) continue; - - if (!instance.cachedModel) continue; - - const M2ModelGPU& model = *instance.cachedModel; - if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; - if (instance.skipCollision) continue; - if (instance.scale <= 0.001f) continue; - - // --- Mesh-based wall collision: closest-point push --- - if (model.collision.valid()) { - glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); - glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); - float localRadius = playerRadius / instance.scale; - - model.collision.getWallTrisInRange( - std::min(localFrom.x, localPos.x) - localRadius - 1.0f, - std::min(localFrom.y, localPos.y) - localRadius - 1.0f, - std::max(localFrom.x, localPos.x) + localRadius + 1.0f, - std::max(localFrom.y, localPos.y) + localRadius + 1.0f, - tl_m2_collisionTriScratch); - - constexpr float PLAYER_HEIGHT = 2.0f; - constexpr float MAX_TOTAL_PUSH = 0.02f; // Cap total push per instance - bool pushed = false; - float totalPushX = 0.0f, totalPushY = 0.0f; - - for (uint32_t ti : tl_m2_collisionTriScratch) { - if (ti >= model.collision.triCount) continue; - if (localPos.z + PLAYER_HEIGHT < model.collision.triBounds[ti].minZ || - localPos.z > model.collision.triBounds[ti].maxZ) continue; - - // Step-up: only skip wall when player is rising (jumping over it) - constexpr float MAX_STEP_UP = 1.2f; - bool rising = (localPos.z > localFrom.z + 0.05f); - if (rising && localPos.z + MAX_STEP_UP >= model.collision.triBounds[ti].maxZ) continue; - - // Early out if we already pushed enough this instance - float totalPushSoFar = std::sqrt(totalPushX * totalPushX + totalPushY * totalPushY); - if (totalPushSoFar >= MAX_TOTAL_PUSH) break; - - const auto& verts = model.collision.vertices; - const auto& idx = model.collision.indices; - const auto& v0 = verts[idx[ti * 3]]; - const auto& v1 = verts[idx[ti * 3 + 1]]; - const auto& v2 = verts[idx[ti * 3 + 2]]; - - glm::vec3 closest = closestPointOnTriangle(localPos, v0, v1, v2); - glm::vec3 diff = localPos - closest; - float distXY = std::sqrt(diff.x * diff.x + diff.y * diff.y); - - if (distXY < localRadius && distXY > 1e-4f) { - // Gentle push — very small fraction of penetration - float penetration = localRadius - distXY; - float pushDist = std::clamp(penetration * 0.08f, 0.001f, 0.015f); - float dx = (diff.x / distXY) * pushDist; - float dy = (diff.y / distXY) * pushDist; - localPos.x += dx; - localPos.y += dy; - totalPushX += dx; - totalPushY += dy; - pushed = true; - } else if (distXY < 1e-4f) { - // On the plane — soft push along triangle normal XY - glm::vec3 n = glm::cross(v1 - v0, v2 - v0); - float nxyLen = std::sqrt(n.x * n.x + n.y * n.y); - if (nxyLen > 1e-4f) { - float pushDist = std::min(localRadius, 0.015f); - float dx = (n.x / nxyLen) * pushDist; - float dy = (n.y / nxyLen) * pushDist; - localPos.x += dx; - localPos.y += dy; - totalPushX += dx; - totalPushY += dy; - pushed = true; - } - } - } - - if (pushed) { - glm::vec3 worldPos = glm::vec3(instance.modelMatrix * glm::vec4(localPos, 1.0f)); - adjustedPos.x = worldPos.x; - adjustedPos.y = worldPos.y; - collided = true; - } - continue; - } - - glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); - glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); - float radiusScale = model.collisionNarrowVerticalProp ? 0.45f : 1.0f; - float localRadius = (playerRadius * radiusScale) / instance.scale; - - glm::vec3 rawMin, rawMax; - getTightCollisionBounds(model, rawMin, rawMax); - glm::vec3 localMin = rawMin - glm::vec3(localRadius); - glm::vec3 localMax = rawMax + glm::vec3(localRadius); - float effectiveTop = getEffectiveCollisionTopLocal(model, localPos, rawMin, rawMax) + localRadius; - glm::vec2 localCenter((localMin.x + localMax.x) * 0.5f, (localMin.y + localMax.y) * 0.5f); - float fromR = glm::length(glm::vec2(localFrom.x, localFrom.y) - localCenter); - float toR = glm::length(glm::vec2(localPos.x, localPos.y) - localCenter); - - // Feet-based vertical overlap test: ignore objects fully above/below us. - constexpr float PLAYER_HEIGHT = 2.0f; - if (localPos.z + PLAYER_HEIGHT < localMin.z || localPos.z > effectiveTop) { - continue; - } - - bool fromInsideXY = - (localFrom.x >= localMin.x && localFrom.x <= localMax.x && - localFrom.y >= localMin.y && localFrom.y <= localMax.y); - bool fromInsideZ = (localFrom.z + PLAYER_HEIGHT >= localMin.z && localFrom.z <= effectiveTop); - bool escapingOverlap = (fromInsideXY && fromInsideZ && (toR > fromR + 1e-4f)); - bool allowEscapeRelax = escapingOverlap && !model.collisionSmallSolidProp; - - // Swept hard clamp for taller blockers only. - // Low/stepable objects should be climbable and not "shove" the player off. - float maxStepUp = 1.20f; - if (model.collisionStatue) { - maxStepUp = 2.5f; - } else if (model.collisionSmallSolidProp) { - // Keep box/crate-class props hard-solid to prevent phase-through. - maxStepUp = 0.75f; - } else if (model.collisionSteppedFountain) { - maxStepUp = 2.5f; - } else if (model.collisionSteppedLowPlatform) { - maxStepUp = model.collisionPlanter ? 2.8f : 2.4f; - if (model.collisionBridge) { - maxStepUp = 25.0f; - } - } - bool stepableLowObject = (effectiveTop <= localFrom.z + maxStepUp); - bool climbingAttempt = (localPos.z > localFrom.z + 0.18f); - bool nearTop = (localFrom.z >= effectiveTop - 0.30f); - float climbAllowance = model.collisionPlanter ? 0.95f : 0.60f; - if (model.collisionSteppedLowPlatform && !model.collisionPlanter) { - // Let low curb/planter blocks be stepable without sticky side shoves. - climbAllowance = 1.00f; - } - if (model.collisionBridge) { - climbAllowance = 3.0f; - } - if (model.collisionSmallSolidProp) { - climbAllowance = 1.05f; - } - bool climbingTowardTop = climbingAttempt && (localFrom.z + climbAllowance >= effectiveTop); - bool forceHardLateral = - model.collisionSmallSolidProp && - !nearTop && !climbingTowardTop; - if ((!stepableLowObject || forceHardLateral) && !allowEscapeRelax) { - float tEnter = 0.0f; - glm::vec3 sweepMax = localMax; - sweepMax.z = std::min(sweepMax.z, effectiveTop); - if (segmentIntersectsAABB(localFrom, localPos, localMin, sweepMax, tEnter)) { - float tSafe = std::clamp(tEnter - 0.03f, 0.0f, 1.0f); - glm::vec3 localSafe = localFrom + (localPos - localFrom) * tSafe; - glm::vec3 worldSafe = glm::vec3(instance.modelMatrix * glm::vec4(localSafe, 1.0f)); - adjustedPos.x = worldSafe.x; - adjustedPos.y = worldSafe.y; - collided = true; - continue; - } - } - - if (localPos.x < localMin.x || localPos.x > localMax.x || - localPos.y < localMin.y || localPos.y > localMax.y) { - continue; - } - - float pushLeft = localPos.x - localMin.x; - float pushRight = localMax.x - localPos.x; - float pushBack = localPos.y - localMin.y; - float pushFront = localMax.y - localPos.y; - - float minPush = std::min({pushLeft, pushRight, pushBack, pushFront}); - if (allowEscapeRelax) { - continue; - } - if (stepableLowObject && localFrom.z >= effectiveTop - 0.35f) { - // Already on/near top surface: don't apply lateral push that ejects - // the player from the object (carpets, platforms, etc). - continue; - } - // Gentle fallback push for overlapping cases. - float pushAmount; - if (model.collisionNarrowVerticalProp) { - pushAmount = std::clamp(minPush * 0.10f, 0.001f, 0.010f); - } else if (model.collisionSteppedLowPlatform) { - if (model.collisionPlanter && stepableLowObject) { - pushAmount = std::clamp(minPush * 0.06f, 0.001f, 0.006f); - } else { - pushAmount = std::clamp(minPush * 0.12f, 0.003f, 0.012f); - } - } else if (stepableLowObject) { - pushAmount = std::clamp(minPush * 0.12f, 0.002f, 0.015f); - } else { - pushAmount = std::clamp(minPush * 0.28f, 0.010f, 0.045f); - } - glm::vec3 localPush(0.0f); - if (minPush == pushLeft) { - localPush.x = -pushAmount; - } else if (minPush == pushRight) { - localPush.x = pushAmount; - } else if (minPush == pushBack) { - localPush.y = -pushAmount; - } else { - localPush.y = pushAmount; - } - - glm::vec3 worldPush = glm::vec3(instance.modelMatrix * glm::vec4(localPush, 0.0f)); - adjustedPos.x += worldPush.x; - adjustedPos.y += worldPush.y; - collided = true; - } - - return collided; -} - -float M2Renderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3& direction, float maxDistance) const { - QueryTimer timer(&queryTimeMs, &queryCallCount); - float closestHit = maxDistance; - - glm::vec3 rayEnd = origin + direction * maxDistance; - glm::vec3 queryMin = glm::min(origin, rayEnd) - glm::vec3(1.0f); - glm::vec3 queryMax = glm::max(origin, rayEnd) + glm::vec3(1.0f); - gatherCandidates(queryMin, queryMax, tl_m2_candidateScratch); - - for (size_t idx : tl_m2_candidateScratch) { - const auto& instance = instances[idx]; - if (collisionFocusEnabled && - pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { - continue; - } - - // Cheap world-space broad-phase. - float tEnter = 0.0f; - glm::vec3 worldMin = instance.worldBoundsMin - glm::vec3(0.35f); - glm::vec3 worldMax = instance.worldBoundsMax + glm::vec3(0.35f); - if (!segmentIntersectsAABB(origin, origin + direction * maxDistance, worldMin, worldMax, tEnter)) { - continue; - } - - if (!instance.cachedModel) continue; - - const M2ModelGPU& model = *instance.cachedModel; - if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; - glm::vec3 localMin, localMax; - getTightCollisionBounds(model, localMin, localMax); - // Skip tiny doodads for camera occlusion; they cause jitter and false hits. - glm::vec3 extents = (localMax - localMin) * instance.scale; - if (glm::dot(extents, extents) < 0.5625f) continue; - - glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(origin, 1.0f)); - glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(direction, 0.0f))); - if (!std::isfinite(localDir.x) || !std::isfinite(localDir.y) || !std::isfinite(localDir.z)) { - continue; - } - - // Local-space AABB slab intersection. - glm::vec3 invDir = 1.0f / localDir; - glm::vec3 tMin = (localMin - localOrigin) * invDir; - glm::vec3 tMax = (localMax - localOrigin) * invDir; - glm::vec3 t1 = glm::min(tMin, tMax); - glm::vec3 t2 = glm::max(tMin, tMax); - - float tNear = std::max({t1.x, t1.y, t1.z}); - float tFar = std::min({t2.x, t2.y, t2.z}); - if (tNear > tFar || tFar <= 0.0f) continue; - - float tHit = tNear > 0.0f ? tNear : tFar; - glm::vec3 localHit = localOrigin + localDir * tHit; - glm::vec3 worldHit = glm::vec3(instance.modelMatrix * glm::vec4(localHit, 1.0f)); - float worldDist = glm::length(worldHit - origin); - if (worldDist > 0.0f && worldDist < closestHit) { - closestHit = worldDist; - } - } - - return closestHit; -} - -void M2Renderer::recreatePipelines() { - if (!vkCtx_) return; - VkDevice device = vkCtx_->getDevice(); - - // Destroy old main-pass pipelines (NOT shadow, NOT pipeline layouts) - if (opaquePipeline_) { vkDestroyPipeline(device, opaquePipeline_, nullptr); opaquePipeline_ = VK_NULL_HANDLE; } - if (alphaTestPipeline_) { vkDestroyPipeline(device, alphaTestPipeline_, nullptr); alphaTestPipeline_ = VK_NULL_HANDLE; } - if (alphaPipeline_) { vkDestroyPipeline(device, alphaPipeline_, nullptr); alphaPipeline_ = VK_NULL_HANDLE; } - if (additivePipeline_) { vkDestroyPipeline(device, additivePipeline_, nullptr); additivePipeline_ = VK_NULL_HANDLE; } - if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; } - if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; } - if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; } - if (ribbonPipeline_) { vkDestroyPipeline(device, ribbonPipeline_, nullptr); ribbonPipeline_ = VK_NULL_HANDLE; } - if (ribbonAdditivePipeline_) { vkDestroyPipeline(device, ribbonAdditivePipeline_, nullptr); ribbonAdditivePipeline_ = VK_NULL_HANDLE; } - - // --- Load shaders --- - rendering::VkShaderModule m2Vert, m2Frag; - rendering::VkShaderModule particleVert, particleFrag; - rendering::VkShaderModule smokeVert, smokeFrag; - - (void)m2Vert.loadFromFile(device, "assets/shaders/m2.vert.spv"); - (void)m2Frag.loadFromFile(device, "assets/shaders/m2.frag.spv"); - (void)particleVert.loadFromFile(device, "assets/shaders/m2_particle.vert.spv"); - (void)particleFrag.loadFromFile(device, "assets/shaders/m2_particle.frag.spv"); - (void)smokeVert.loadFromFile(device, "assets/shaders/m2_smoke.vert.spv"); - (void)smokeFrag.loadFromFile(device, "assets/shaders/m2_smoke.frag.spv"); - - if (!m2Vert.isValid() || !m2Frag.isValid()) { - LOG_ERROR("M2Renderer::recreatePipelines: missing required shaders"); - return; - } - - VkRenderPass mainPass = vkCtx_->getImGuiRenderPass(); - - // --- M2 model vertex input --- - VkVertexInputBindingDescription m2Binding{}; - m2Binding.binding = 0; - m2Binding.stride = 18 * sizeof(float); - m2Binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - - std::vector m2Attrs = { - {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position - {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // normal - {2, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float)}, // texCoord0 - {5, 0, VK_FORMAT_R32G32_SFLOAT, 8 * sizeof(float)}, // texCoord1 - {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 10 * sizeof(float)}, // boneWeights - {4, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 14 * sizeof(float)}, // boneIndices (float) - }; - - // Pipeline derivatives — opaque is the base, others derive from it for shared state optimization - auto buildM2Pipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite, - VkPipelineCreateFlags flags = 0, VkPipeline basePipeline = VK_NULL_HANDLE) -> VkPipeline { - return PipelineBuilder() - .setShaders(m2Vert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), - m2Frag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) - .setVertexInput({m2Binding}, m2Attrs) - .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) - .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) - .setDepthTest(true, depthWrite, VK_COMPARE_OP_LESS_OR_EQUAL) - .setColorBlendAttachment(blendState) - .setMultisample(vkCtx_->getMsaaSamples()) - .setLayout(pipelineLayout_) - .setRenderPass(mainPass) - .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) - .setFlags(flags) - .setBasePipeline(basePipeline) - .build(device, vkCtx_->getPipelineCache()); - }; - - opaquePipeline_ = buildM2Pipeline(PipelineBuilder::blendDisabled(), true, - VK_PIPELINE_CREATE_ALLOW_DERIVATIVES_BIT); - alphaTestPipeline_ = buildM2Pipeline(PipelineBuilder::blendAlpha(), true, - VK_PIPELINE_CREATE_DERIVATIVE_BIT, opaquePipeline_); - alphaPipeline_ = buildM2Pipeline(PipelineBuilder::blendAlpha(), false, - VK_PIPELINE_CREATE_DERIVATIVE_BIT, opaquePipeline_); - additivePipeline_ = buildM2Pipeline(PipelineBuilder::blendAdditive(), false, - VK_PIPELINE_CREATE_DERIVATIVE_BIT, opaquePipeline_); - - // --- Particle pipelines --- - if (particleVert.isValid() && particleFrag.isValid()) { - VkVertexInputBindingDescription pBind{}; - pBind.binding = 0; - pBind.stride = 9 * sizeof(float); // pos3 + color4 + size1 + tile1 - pBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - - std::vector pAttrs = { - {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position - {1, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 3 * sizeof(float)}, // color - {2, 0, VK_FORMAT_R32_SFLOAT, 7 * sizeof(float)}, // size - {3, 0, VK_FORMAT_R32_SFLOAT, 8 * sizeof(float)}, // tile - }; - - auto buildParticlePipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { - return PipelineBuilder() - .setShaders(particleVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), - particleFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) - .setVertexInput({pBind}, pAttrs) - .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) - .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) - .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) - .setColorBlendAttachment(blend) - .setMultisample(vkCtx_->getMsaaSamples()) - .setLayout(particlePipelineLayout_) - .setRenderPass(mainPass) - .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) - .build(device, vkCtx_->getPipelineCache()); - }; - - particlePipeline_ = buildParticlePipeline(PipelineBuilder::blendAlpha()); - particleAdditivePipeline_ = buildParticlePipeline(PipelineBuilder::blendAdditive()); - } - - // --- Smoke pipeline --- - if (smokeVert.isValid() && smokeFrag.isValid()) { - VkVertexInputBindingDescription sBind{}; - sBind.binding = 0; - sBind.stride = 6 * sizeof(float); // pos3 + lifeRatio1 + size1 + isSpark1 - sBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - - std::vector sAttrs = { - {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position - {1, 0, VK_FORMAT_R32_SFLOAT, 3 * sizeof(float)}, // lifeRatio - {2, 0, VK_FORMAT_R32_SFLOAT, 4 * sizeof(float)}, // size - {3, 0, VK_FORMAT_R32_SFLOAT, 5 * sizeof(float)}, // isSpark - }; - - smokePipeline_ = PipelineBuilder() - .setShaders(smokeVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), - smokeFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) - .setVertexInput({sBind}, sAttrs) - .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) - .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) - .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) - .setColorBlendAttachment(PipelineBuilder::blendAlpha()) - .setMultisample(vkCtx_->getMsaaSamples()) - .setLayout(smokePipelineLayout_) - .setRenderPass(mainPass) - .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) - .build(device, vkCtx_->getPipelineCache()); - } - - // --- Ribbon pipelines --- - { - rendering::VkShaderModule ribVert, ribFrag; - (void)ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); - (void)ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); - if (ribVert.isValid() && ribFrag.isValid()) { - VkVertexInputBindingDescription rBind{}; - rBind.binding = 0; - rBind.stride = 9 * sizeof(float); - rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; - - std::vector rAttrs = { - {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, - {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, - {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, - {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, - }; - - auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { - return PipelineBuilder() - .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), - ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) - .setVertexInput({rBind}, rAttrs) - .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) - .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) - .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) - .setColorBlendAttachment(blend) - .setMultisample(vkCtx_->getMsaaSamples()) - .setLayout(ribbonPipelineLayout_) - .setRenderPass(mainPass) - .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) - .build(device, vkCtx_->getPipelineCache()); - }; - - ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); - ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); - } - ribVert.destroy(); ribFrag.destroy(); - } - - m2Vert.destroy(); m2Frag.destroy(); - particleVert.destroy(); particleFrag.destroy(); - smokeVert.destroy(); smokeFrag.destroy(); - - core::Logger::getInstance().info("M2Renderer: pipelines recreated"); -} - } // namespace rendering } // namespace wowee diff --git a/src/rendering/m2_renderer_instance.cpp b/src/rendering/m2_renderer_instance.cpp new file mode 100644 index 00000000..0a31fbd5 --- /dev/null +++ b/src/rendering/m2_renderer_instance.cpp @@ -0,0 +1,1271 @@ +#include "rendering/m2_renderer.hpp" +#include "rendering/m2_renderer_internal.h" +#include "rendering/m2_model_classifier.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_buffer.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/camera.hpp" +#include "rendering/frustum.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/blp_loader.hpp" +#include "core/logger.hpp" +#include "core/profiler.hpp" +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +void M2Renderer::setInstancePosition(uint32_t instanceId, const glm::vec3& position) { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + auto& inst = instances[idxIt->second]; + + // Save old grid cells + GridCell oldMinCell = toCell(inst.worldBoundsMin); + GridCell oldMaxCell = toCell(inst.worldBoundsMax); + + inst.position = position; + inst.updateModelMatrix(); + auto modelIt = models.find(inst.modelId); + if (modelIt != models.end()) { + glm::vec3 localMin, localMax; + getTightCollisionBounds(modelIt->second, localMin, localMax); + transformAABB(inst.modelMatrix, localMin, localMax, inst.worldBoundsMin, inst.worldBoundsMax); + } + + // Incrementally update spatial grid + GridCell newMinCell = toCell(inst.worldBoundsMin); + GridCell newMaxCell = toCell(inst.worldBoundsMax); + if (oldMinCell.x != newMinCell.x || oldMinCell.y != newMinCell.y || oldMinCell.z != newMinCell.z || + oldMaxCell.x != newMaxCell.x || oldMaxCell.y != newMaxCell.y || oldMaxCell.z != newMaxCell.z) { + for (int z = oldMinCell.z; z <= oldMaxCell.z; z++) { + for (int y = oldMinCell.y; y <= oldMaxCell.y; y++) { + for (int x = oldMinCell.x; x <= oldMaxCell.x; x++) { + auto it = spatialGrid.find(GridCell{x, y, z}); + if (it != spatialGrid.end()) { + auto& vec = it->second; + vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); + } + } + } + } + for (int z = newMinCell.z; z <= newMaxCell.z; z++) { + for (int y = newMinCell.y; y <= newMaxCell.y; y++) { + for (int x = newMinCell.x; x <= newMaxCell.x; x++) { + spatialGrid[GridCell{x, y, z}].push_back(instanceId); + } + } + } + } +} + +void M2Renderer::setInstanceAnimationFrozen(uint32_t instanceId, bool frozen) { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + auto& inst = instances[idxIt->second]; + inst.animSpeed = frozen ? 0.0f : 1.0f; + if (frozen) { + inst.animTime = 0.0f; // Reset to bind pose + } +} + +void M2Renderer::setInstanceAnimation(uint32_t instanceId, uint32_t animationId, bool loop) { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + auto& inst = instances[idxIt->second]; + if (!inst.cachedModel) return; + const auto& seqs = inst.cachedModel->sequences; + // Find the first sequence matching the requested animation ID + for (int i = 0; i < static_cast(seqs.size()); ++i) { + if (seqs[i].id == animationId) { + inst.currentSequenceIndex = i; + inst.animDuration = static_cast(seqs[i].duration); + inst.animTime = 0.0f; + inst.animSpeed = 1.0f; + // Use playingVariation=true for one-shot (returns to idle when done) + inst.playingVariation = !loop; + return; + } + } +} + +bool M2Renderer::hasAnimation(uint32_t instanceId, uint32_t animationId) const { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return false; + const auto& inst = instances[idxIt->second]; + if (!inst.cachedModel) return false; + for (const auto& seq : inst.cachedModel->sequences) { + if (seq.id == animationId) return true; + } + return false; +} + +float M2Renderer::getInstanceAnimDuration(uint32_t instanceId) const { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return 0.0f; + const auto& inst = instances[idxIt->second]; + if (!inst.cachedModel) return 0.0f; + const auto& seqs = inst.cachedModel->sequences; + if (seqs.empty()) return 0.0f; + int seqIdx = inst.currentSequenceIndex; + if (seqIdx < 0 || seqIdx >= static_cast(seqs.size())) seqIdx = 0; + return seqs[seqIdx].duration; // in milliseconds +} + +void M2Renderer::setInstanceTransform(uint32_t instanceId, const glm::mat4& transform) { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + auto& inst = instances[idxIt->second]; + + // Remove old grid cells before updating bounds + GridCell oldMinCell = toCell(inst.worldBoundsMin); + GridCell oldMaxCell = toCell(inst.worldBoundsMax); + + // Update model matrix directly + inst.modelMatrix = transform; + inst.invModelMatrix = glm::inverse(transform); + + // Extract position from transform for bounds + inst.position = glm::vec3(transform[3]); + + // Update bounds + auto modelIt = models.find(inst.modelId); + if (modelIt != models.end()) { + glm::vec3 localMin, localMax; + getTightCollisionBounds(modelIt->second, localMin, localMax); + transformAABB(inst.modelMatrix, localMin, localMax, inst.worldBoundsMin, inst.worldBoundsMax); + } + + // Incrementally update spatial grid (remove old cells, add new cells) + GridCell newMinCell = toCell(inst.worldBoundsMin); + GridCell newMaxCell = toCell(inst.worldBoundsMax); + if (oldMinCell.x != newMinCell.x || oldMinCell.y != newMinCell.y || oldMinCell.z != newMinCell.z || + oldMaxCell.x != newMaxCell.x || oldMaxCell.y != newMaxCell.y || oldMaxCell.z != newMaxCell.z) { + // Remove from old cells + for (int z = oldMinCell.z; z <= oldMaxCell.z; z++) { + for (int y = oldMinCell.y; y <= oldMaxCell.y; y++) { + for (int x = oldMinCell.x; x <= oldMaxCell.x; x++) { + auto it = spatialGrid.find(GridCell{x, y, z}); + if (it != spatialGrid.end()) { + auto& vec = it->second; + vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); + } + } + } + } + // Add to new cells + for (int z = newMinCell.z; z <= newMaxCell.z; z++) { + for (int y = newMinCell.y; y <= newMaxCell.y; y++) { + for (int x = newMinCell.x; x <= newMaxCell.x; x++) { + spatialGrid[GridCell{x, y, z}].push_back(instanceId); + } + } + } + } + // No spatialIndexDirty_ = true — handled incrementally +} + +void M2Renderer::removeInstance(uint32_t instanceId) { + auto idxIt = instanceIndexById.find(instanceId); + if (idxIt == instanceIndexById.end()) return; + size_t idx = idxIt->second; + if (idx >= instances.size()) return; + + auto& inst = instances[idx]; + + // Remove from spatial grid incrementally (same pattern as the move-update path) + GridCell minCell = toCell(inst.worldBoundsMin); + GridCell maxCell = toCell(inst.worldBoundsMax); + for (int z = minCell.z; z <= maxCell.z; z++) { + for (int y = minCell.y; y <= maxCell.y; y++) { + for (int x = minCell.x; x <= maxCell.x; x++) { + auto gIt = spatialGrid.find(GridCell{x, y, z}); + if (gIt != spatialGrid.end()) { + auto& vec = gIt->second; + vec.erase(std::remove(vec.begin(), vec.end(), instanceId), vec.end()); + } + } + } + } + + // Remove from dedup map + if (!inst.cachedIsGroundDetail) { + DedupKey dk{inst.modelId, + static_cast(std::round(inst.position.x * 10.0f)), + static_cast(std::round(inst.position.y * 10.0f)), + static_cast(std::round(inst.position.z * 10.0f))}; + instanceDedupMap_.erase(dk); + } + + destroyInstanceBones(inst, /*defer=*/true); + + // Swap-remove: move last element to the hole and pop_back to avoid O(n) shift + instanceIndexById.erase(instanceId); + if (idx < instances.size() - 1) { + uint32_t movedId = instances.back().id; + instances[idx] = std::move(instances.back()); + instances.pop_back(); + instanceIndexById[movedId] = idx; + } else { + instances.pop_back(); + } + + // Rebuild the lightweight auxiliary index vectors (smoke, portal, etc.) + // These are small vectors of indices that are rebuilt cheaply. + smokeInstanceIndices_.clear(); + portalInstanceIndices_.clear(); + animatedInstanceIndices_.clear(); + particleOnlyInstanceIndices_.clear(); + particleInstanceIndices_.clear(); + for (size_t i = 0; i < instances.size(); i++) { + auto& ri = instances[i]; + if (ri.cachedIsSmoke) smokeInstanceIndices_.push_back(i); + if (ri.cachedIsInstancePortal) portalInstanceIndices_.push_back(i); + if (ri.cachedHasParticleEmitters) particleInstanceIndices_.push_back(i); + if (ri.cachedHasAnimation && !ri.cachedDisableAnimation) + animatedInstanceIndices_.push_back(i); + else if (ri.cachedHasParticleEmitters) + particleOnlyInstanceIndices_.push_back(i); + } +} + +void M2Renderer::setSkipCollision(uint32_t instanceId, bool skip) { + for (auto& inst : instances) { + if (inst.id == instanceId) { + inst.skipCollision = skip; + return; + } + } +} + +void M2Renderer::removeInstances(const std::vector& instanceIds) { + if (instanceIds.empty() || instances.empty()) { + return; + } + + std::unordered_set toRemove(instanceIds.begin(), instanceIds.end()); + const size_t oldSize = instances.size(); + for (auto& inst : instances) { + if (toRemove.count(inst.id)) { + destroyInstanceBones(inst, /*defer=*/true); + } + } + instances.erase(std::remove_if(instances.begin(), instances.end(), + [&toRemove](const M2Instance& inst) { + return toRemove.find(inst.id) != toRemove.end(); + }), + instances.end()); + + if (instances.size() != oldSize) { + rebuildSpatialIndex(); + } +} + +void M2Renderer::clear() { + if (vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + for (auto& [id, model] : models) { + destroyModelGPU(model); + } + for (auto& inst : instances) { + destroyInstanceBones(inst); + } + // Reset descriptor pools so new allocations succeed after reload. + // destroyModelGPU/destroyInstanceBones don't free individual sets, + // so the pools fill up across map changes without this reset. + VkDevice device = vkCtx_->getDevice(); + if (materialDescPool_) { + vkResetDescriptorPool(device, materialDescPool_, 0); + // Re-allocate the glow texture descriptor set (pre-allocated during init, + // invalidated by pool reset). + if (glowTexture_ && particleTexLayout_) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + glowTexDescSet_ = VK_NULL_HANDLE; + if (vkAllocateDescriptorSets(device, &ai, &glowTexDescSet_) == VK_SUCCESS) { + VkDescriptorImageInfo imgInfo = glowTexture_->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = glowTexDescSet_; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + if (boneDescPool_) { + vkResetDescriptorPool(device, boneDescPool_, 0); + // Re-allocate the dummy bone set (invalidated by pool reset) + dummyBoneSet_ = allocateBoneSet(); + if (dummyBoneSet_ && dummyBoneBuffer_) { + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = dummyBoneBuffer_; + bufInfo.offset = 0; + bufInfo.range = sizeof(glm::mat4); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = dummyBoneSet_; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_STORAGE_BUFFER; + write.pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + models.clear(); + instances.clear(); + spatialGrid.clear(); + instanceIndexById.clear(); + instanceDedupMap_.clear(); + smokeParticles.clear(); + smokeInstanceIndices_.clear(); + portalInstanceIndices_.clear(); + animatedInstanceIndices_.clear(); + particleOnlyInstanceIndices_.clear(); + particleInstanceIndices_.clear(); + smokeEmitAccum = 0.0f; +} + +void M2Renderer::setCollisionFocus(const glm::vec3& worldPos, float radius) { + collisionFocusEnabled = (radius > 0.0f); + collisionFocusPos = worldPos; + collisionFocusRadius = std::max(0.0f, radius); + collisionFocusRadiusSq = collisionFocusRadius * collisionFocusRadius; +} + +void M2Renderer::clearCollisionFocus() { + collisionFocusEnabled = false; +} + +void M2Renderer::resetQueryStats() { + queryTimeMs = 0.0; + queryCallCount = 0; +} + +M2Renderer::GridCell M2Renderer::toCell(const glm::vec3& p) const { + return GridCell{ + static_cast(std::floor(p.x / SPATIAL_CELL_SIZE)), + static_cast(std::floor(p.y / SPATIAL_CELL_SIZE)), + static_cast(std::floor(p.z / SPATIAL_CELL_SIZE)) + }; +} + +void M2Renderer::rebuildSpatialIndex() { + spatialGrid.clear(); + instanceIndexById.clear(); + instanceDedupMap_.clear(); + instanceIndexById.reserve(instances.size()); + smokeInstanceIndices_.clear(); + portalInstanceIndices_.clear(); + animatedInstanceIndices_.clear(); + particleOnlyInstanceIndices_.clear(); + particleInstanceIndices_.clear(); + + for (size_t i = 0; i < instances.size(); i++) { + auto& inst = instances[i]; + instanceIndexById[inst.id] = i; + + // Re-cache model pointer (may have changed after model map modifications) + auto mdlIt = models.find(inst.modelId); + inst.cachedModel = (mdlIt != models.end()) ? &mdlIt->second : nullptr; + + // Rebuild dedup map (skip ground detail) + if (!inst.cachedIsGroundDetail) { + DedupKey dk{inst.modelId, + static_cast(std::round(inst.position.x * 10.0f)), + static_cast(std::round(inst.position.y * 10.0f)), + static_cast(std::round(inst.position.z * 10.0f))}; + instanceDedupMap_[dk] = inst.id; + } + + if (inst.cachedIsSmoke) { + smokeInstanceIndices_.push_back(i); + } + if (inst.cachedIsInstancePortal) { + portalInstanceIndices_.push_back(i); + } + if (inst.cachedHasParticleEmitters) { + particleInstanceIndices_.push_back(i); + } + if (inst.cachedHasAnimation && !inst.cachedDisableAnimation) { + animatedInstanceIndices_.push_back(i); + } else if (inst.cachedHasParticleEmitters) { + particleOnlyInstanceIndices_.push_back(i); + } + + GridCell minCell = toCell(inst.worldBoundsMin); + GridCell maxCell = toCell(inst.worldBoundsMax); + for (int z = minCell.z; z <= maxCell.z; z++) { + for (int y = minCell.y; y <= maxCell.y; y++) { + for (int x = minCell.x; x <= maxCell.x; x++) { + spatialGrid[GridCell{x, y, z}].push_back(inst.id); + } + } + } + } + spatialIndexDirty_ = false; +} + +void M2Renderer::gatherCandidates(const glm::vec3& queryMin, const glm::vec3& queryMax, + std::vector& outIndices) const { + outIndices.clear(); + tl_m2_candidateIdScratch.clear(); + + GridCell minCell = toCell(queryMin); + GridCell maxCell = toCell(queryMax); + for (int z = minCell.z; z <= maxCell.z; z++) { + for (int y = minCell.y; y <= maxCell.y; y++) { + for (int x = minCell.x; x <= maxCell.x; x++) { + auto it = spatialGrid.find(GridCell{x, y, z}); + if (it == spatialGrid.end()) continue; + for (uint32_t id : it->second) { + if (!tl_m2_candidateIdScratch.insert(id).second) continue; + auto idxIt = instanceIndexById.find(id); + if (idxIt != instanceIndexById.end()) { + outIndices.push_back(idxIt->second); + } + } + } + } + } + + // Safety fallback to preserve collision correctness if the spatial index + // misses candidates (e.g. during streaming churn). + if (outIndices.empty() && !instances.empty()) { + outIndices.reserve(instances.size()); + for (size_t i = 0; i < instances.size(); i++) { + outIndices.push_back(i); + } + } +} + +void M2Renderer::cleanupUnusedModels() { + // Build set of model IDs that are still referenced by instances + std::unordered_set usedModelIds; + for (const auto& instance : instances) { + usedModelIds.insert(instance.modelId); + } + + const auto now = std::chrono::steady_clock::now(); + constexpr auto kGracePeriod = std::chrono::seconds(60); + + // Find models with no instances that have exceeded the grace period. + // Models that just lost their last instance get tracked but not evicted + // immediately — this prevents thrashing when GO models are briefly + // instance-free between despawn and respawn cycles. + std::vector toRemove; + for (const auto& [id, model] : models) { + if (usedModelIds.find(id) != usedModelIds.end()) { + // Model still in use — clear any pending unused timestamp + modelUnusedSince_.erase(id); + continue; + } + auto unusedIt = modelUnusedSince_.find(id); + if (unusedIt == modelUnusedSince_.end()) { + // First cycle with no instances — start the grace timer + modelUnusedSince_[id] = now; + } else if (now - unusedIt->second >= kGracePeriod) { + // Grace period expired — mark for removal + toRemove.push_back(id); + modelUnusedSince_.erase(unusedIt); + } + } + + // Delete GPU resources and remove from map. + // Wait for the GPU to finish all in-flight frames before destroying any + // buffers — the previous frame's command buffer may still be referencing + // vertex/index buffers that are about to be freed. Without this wait, + // the GPU reads freed memory, which can cause VK_ERROR_DEVICE_LOST. + if (!toRemove.empty() && vkCtx_) { + vkDeviceWaitIdle(vkCtx_->getDevice()); + } + for (uint32_t id : toRemove) { + auto it = models.find(id); + if (it != models.end()) { + destroyModelGPU(it->second); + models.erase(it); + } + } + + if (!toRemove.empty()) { + LOG_INFO("M2 cleanup: removed ", toRemove.size(), " unused models, ", models.size(), " remaining"); + } +} + +VkTexture* M2Renderer::loadTexture(const std::string& path, uint32_t texFlags) { + constexpr uint64_t kFailedTextureRetryLookups = 512; + auto normalizeKey = [](std::string key) { + std::replace(key.begin(), key.end(), '/', '\\'); + std::transform(key.begin(), key.end(), key.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return key; + }; + std::string key = normalizeKey(path); + const uint64_t lookupSerial = ++textureLookupSerial_; + + // Check cache + auto it = textureCache.find(key); + if (it != textureCache.end()) { + it->second.lastUse = ++textureCacheCounter_; + return it->second.texture.get(); + } + auto failIt = failedTextureRetryAt_.find(key); + if (failIt != failedTextureRetryAt_.end() && lookupSerial < failIt->second) { + return whiteTexture_.get(); + } + + auto containsToken = [](const std::string& haystack, const char* token) { + return haystack.find(token) != std::string::npos; + }; + const bool colorKeyBlackHint = + containsToken(key, "candle") || + containsToken(key, "flame") || + containsToken(key, "fire") || + containsToken(key, "torch") || + containsToken(key, "lamp") || + containsToken(key, "lantern") || + containsToken(key, "glow") || + containsToken(key, "flare") || + containsToken(key, "brazier") || + containsToken(key, "campfire") || + containsToken(key, "bonfire"); + + // Check pre-decoded BLP cache first (populated by background worker threads) + pipeline::BLPImage blp; + if (predecodedBLPCache_) { + auto pit = predecodedBLPCache_->find(key); + if (pit != predecodedBLPCache_->end()) { + blp = std::move(pit->second); + predecodedBLPCache_->erase(pit); + } + } + if (!blp.isValid()) { + blp = assetManager->loadTexture(key); + } + if (!blp.isValid()) { + // Cache misses briefly to avoid repeated expensive MPQ/disk probes. + failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; + if (loggedTextureLoadFails_.insert(key).second) { + LOG_WARNING("M2: Failed to load texture: ", path); + } + return whiteTexture_.get(); + } + + size_t base = static_cast(blp.width) * static_cast(blp.height) * 4ull; + size_t approxBytes = base + (base / 3); + if (textureCacheBytes_ + approxBytes > textureCacheBudgetBytes_) { + static constexpr size_t kMaxFailedTextureCache = 200000; + if (failedTextureCache_.size() < kMaxFailedTextureCache) { + // Cache budget-rejected keys too; without this we repeatedly decode/load + // the same textures every frame once budget is saturated. + failedTextureCache_.insert(key); + failedTextureRetryAt_[key] = lookupSerial + kFailedTextureRetryLookups; + } + if (textureBudgetRejectWarnings_ < 3) { + LOG_WARNING("M2 texture cache full (", textureCacheBytes_ / (1024 * 1024), + " MB / ", textureCacheBudgetBytes_ / (1024 * 1024), + " MB), rejecting texture: ", path); + } + ++textureBudgetRejectWarnings_; + return whiteTexture_.get(); + } + + // Track whether the texture actually uses alpha (any pixel with alpha < 255). + bool hasAlpha = false; + for (size_t i = 3; i < blp.data.size(); i += 4) { + if (blp.data[i] != 255) { + hasAlpha = true; + break; + } + } + + // Create Vulkan texture + auto tex = std::make_unique(); + tex->upload(*vkCtx_, blp.data.data(), blp.width, blp.height, VK_FORMAT_R8G8B8A8_UNORM); + + // M2Texture flags: bit 0 = WrapS (1=repeat, 0=clamp), bit 1 = WrapT + VkSamplerAddressMode wrapS = (texFlags & 0x1) ? VK_SAMPLER_ADDRESS_MODE_REPEAT : VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + VkSamplerAddressMode wrapT = (texFlags & 0x2) ? VK_SAMPLER_ADDRESS_MODE_REPEAT : VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + tex->createSampler(vkCtx_->getDevice(), VK_FILTER_LINEAR, wrapS, wrapT); + + VkTexture* texPtr = tex.get(); + + TextureCacheEntry e; + e.texture = std::move(tex); + e.approxBytes = approxBytes; + e.hasAlpha = hasAlpha; + e.colorKeyBlack = colorKeyBlackHint; + e.lastUse = ++textureCacheCounter_; + textureCacheBytes_ += e.approxBytes; + textureCache[key] = std::move(e); + failedTextureCache_.erase(key); + failedTextureRetryAt_.erase(key); + texturePropsByPtr_[texPtr] = {hasAlpha, colorKeyBlackHint}; + LOG_DEBUG("M2: Loaded texture: ", path, " (", blp.width, "x", blp.height, ")"); + + return texPtr; +} + +uint32_t M2Renderer::getTotalTriangleCount() const { + uint32_t total = 0; + for (const auto& instance : instances) { + if (instance.cachedModel) { + total += instance.cachedModel->indexCount / 3; + } + } + return total; +} + +std::optional M2Renderer::getFloorHeight(float glX, float glY, float glZ, float* outNormalZ) const { + QueryTimer timer(&queryTimeMs, &queryCallCount); + std::optional bestFloor; + float bestNormalZ = 1.0f; // Default to flat + + glm::vec3 queryMin(glX - 2.0f, glY - 2.0f, glZ - 6.0f); + glm::vec3 queryMax(glX + 2.0f, glY + 2.0f, glZ + 8.0f); + gatherCandidates(queryMin, queryMax, tl_m2_candidateScratch); + + for (size_t idx : tl_m2_candidateScratch) { + const auto& instance = instances[idx]; + if (collisionFocusEnabled && + pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { + continue; + } + + if (!instance.cachedModel) continue; + if (instance.scale <= 0.001f) continue; + + const M2ModelGPU& model = *instance.cachedModel; + if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; + if (instance.skipCollision) continue; + + // --- Mesh-based floor: vertical ray vs collision triangles --- + // Does NOT skip the AABB path — both contribute and highest wins. + if (model.collision.valid()) { + glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); + + model.collision.getFloorTrisInRange( + localPos.x - 1.0f, localPos.y - 1.0f, + localPos.x + 1.0f, localPos.y + 1.0f, + tl_m2_collisionTriScratch); + + glm::vec3 rayOrigin(localPos.x, localPos.y, localPos.z + 5.0f); + glm::vec3 rayDir(0.0f, 0.0f, -1.0f); + float bestHitZ = -std::numeric_limits::max(); + bool hitAny = false; + + for (uint32_t ti : tl_m2_collisionTriScratch) { + if (ti >= model.collision.triCount) continue; + if (model.collision.triBounds[ti].maxZ < localPos.z - 10.0f || + model.collision.triBounds[ti].minZ > localPos.z + 5.0f) continue; + + const auto& verts = model.collision.vertices; + const auto& idx = model.collision.indices; + const auto& v0 = verts[idx[ti * 3]]; + const auto& v1 = verts[idx[ti * 3 + 1]]; + const auto& v2 = verts[idx[ti * 3 + 2]]; + + // Two-sided: try both windings + float tHit = rayTriangleIntersect(rayOrigin, rayDir, v0, v1, v2); + if (tHit < 0.0f) + tHit = rayTriangleIntersect(rayOrigin, rayDir, v0, v2, v1); + if (tHit < 0.0f) continue; + + float hitZ = rayOrigin.z - tHit; + + // Walkable normal check (world space) + glm::vec3 worldN(0.0f, 0.0f, 1.0f); // Default to flat + glm::vec3 localN = glm::cross(v1 - v0, v2 - v0); + float nLen = glm::length(localN); + if (nLen > 0.001f) { + localN /= nLen; + if (localN.z < 0.0f) localN = -localN; + worldN = glm::normalize( + glm::vec3(instance.modelMatrix * glm::vec4(localN, 0.0f))); + if (std::abs(worldN.z) < 0.35f) continue; // too steep (~70° max slope) + } + + if (hitZ <= localPos.z + 3.0f && hitZ > bestHitZ) { + bestHitZ = hitZ; + hitAny = true; + bestNormalZ = std::abs(worldN.z); // Store normal for output + } + } + + if (hitAny) { + glm::vec3 localHit(localPos.x, localPos.y, bestHitZ); + glm::vec3 worldHit = glm::vec3(instance.modelMatrix * glm::vec4(localHit, 1.0f)); + if (worldHit.z <= glZ + 3.0f && (!bestFloor || worldHit.z > *bestFloor)) { + bestFloor = worldHit.z; + } + } + // Fall through to AABB floor — both contribute, highest wins + } + + float zMargin = model.collisionBridge ? 25.0f : 2.0f; + if (glX < instance.worldBoundsMin.x || glX > instance.worldBoundsMax.x || + glY < instance.worldBoundsMin.y || glY > instance.worldBoundsMax.y || + glZ < instance.worldBoundsMin.z - zMargin || glZ > instance.worldBoundsMax.z + zMargin) { + continue; + } + glm::vec3 localMin, localMax; + getTightCollisionBounds(model, localMin, localMax); + + glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(glX, glY, glZ, 1.0f)); + + // Must be within doodad footprint in local XY. + // Stepped low platforms get a small pad so walk-up snapping catches edges. + float footprintPad = 0.0f; + if (model.collisionSteppedLowPlatform) { + footprintPad = model.collisionPlanter ? 0.22f : 0.16f; + if (model.collisionBridge) { + footprintPad = 0.35f; + } + } + if (localPos.x < localMin.x - footprintPad || localPos.x > localMax.x + footprintPad || + localPos.y < localMin.y - footprintPad || localPos.y > localMax.y + footprintPad) { + continue; + } + + // Construct "top" point at queried XY in local space, then transform back. + float localTopZ = getEffectiveCollisionTopLocal(model, localPos, localMin, localMax); + glm::vec3 localTop(localPos.x, localPos.y, localTopZ); + glm::vec3 worldTop = glm::vec3(instance.modelMatrix * glm::vec4(localTop, 1.0f)); + + // Reachability filter: allow a bit more climb for stepped low platforms. + float maxStepUp = 1.0f; + if (model.collisionStatue) { + maxStepUp = 2.5f; + } else if (model.collisionSmallSolidProp) { + maxStepUp = 2.0f; + } else if (model.collisionSteppedFountain) { + maxStepUp = 2.5f; + } else if (model.collisionSteppedLowPlatform) { + maxStepUp = model.collisionPlanter ? 3.0f : 2.4f; + if (model.collisionBridge) { + maxStepUp = 25.0f; + } + } + if (worldTop.z > glZ + maxStepUp) continue; + + if (!bestFloor || worldTop.z > *bestFloor) { + bestFloor = worldTop.z; + } + } + + // Output surface normal if requested + if (outNormalZ) { + *outNormalZ = bestNormalZ; + } + + return bestFloor; +} + +bool M2Renderer::checkCollision(const glm::vec3& from, const glm::vec3& to, + glm::vec3& adjustedPos, float playerRadius) const { + QueryTimer timer(&queryTimeMs, &queryCallCount); + adjustedPos = to; + bool collided = false; + + glm::vec3 queryMin = glm::min(from, to) - glm::vec3(7.0f, 7.0f, 5.0f); + glm::vec3 queryMax = glm::max(from, to) + glm::vec3(7.0f, 7.0f, 5.0f); + gatherCandidates(queryMin, queryMax, tl_m2_candidateScratch); + + // Check against all M2 instances in local space (rotation-aware). + for (size_t idx : tl_m2_candidateScratch) { + const auto& instance = instances[idx]; + if (collisionFocusEnabled && + pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { + continue; + } + + const float broadMargin = playerRadius + 1.0f; + if (from.x < instance.worldBoundsMin.x - broadMargin && adjustedPos.x < instance.worldBoundsMin.x - broadMargin) continue; + if (from.x > instance.worldBoundsMax.x + broadMargin && adjustedPos.x > instance.worldBoundsMax.x + broadMargin) continue; + if (from.y < instance.worldBoundsMin.y - broadMargin && adjustedPos.y < instance.worldBoundsMin.y - broadMargin) continue; + if (from.y > instance.worldBoundsMax.y + broadMargin && adjustedPos.y > instance.worldBoundsMax.y + broadMargin) continue; + if (from.z > instance.worldBoundsMax.z + 2.5f && adjustedPos.z > instance.worldBoundsMax.z + 2.5f) continue; + if (from.z + 2.5f < instance.worldBoundsMin.z && adjustedPos.z + 2.5f < instance.worldBoundsMin.z) continue; + + if (!instance.cachedModel) continue; + + const M2ModelGPU& model = *instance.cachedModel; + if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; + if (instance.skipCollision) continue; + if (instance.scale <= 0.001f) continue; + + // --- Mesh-based wall collision: closest-point push --- + if (model.collision.valid()) { + glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); + glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); + float localRadius = playerRadius / instance.scale; + + model.collision.getWallTrisInRange( + std::min(localFrom.x, localPos.x) - localRadius - 1.0f, + std::min(localFrom.y, localPos.y) - localRadius - 1.0f, + std::max(localFrom.x, localPos.x) + localRadius + 1.0f, + std::max(localFrom.y, localPos.y) + localRadius + 1.0f, + tl_m2_collisionTriScratch); + + constexpr float PLAYER_HEIGHT = 2.0f; + constexpr float MAX_TOTAL_PUSH = 0.02f; // Cap total push per instance + bool pushed = false; + float totalPushX = 0.0f, totalPushY = 0.0f; + + for (uint32_t ti : tl_m2_collisionTriScratch) { + if (ti >= model.collision.triCount) continue; + if (localPos.z + PLAYER_HEIGHT < model.collision.triBounds[ti].minZ || + localPos.z > model.collision.triBounds[ti].maxZ) continue; + + // Step-up: only skip wall when player is rising (jumping over it) + constexpr float MAX_STEP_UP = 1.2f; + bool rising = (localPos.z > localFrom.z + 0.05f); + if (rising && localPos.z + MAX_STEP_UP >= model.collision.triBounds[ti].maxZ) continue; + + // Early out if we already pushed enough this instance + float totalPushSoFar = std::sqrt(totalPushX * totalPushX + totalPushY * totalPushY); + if (totalPushSoFar >= MAX_TOTAL_PUSH) break; + + const auto& verts = model.collision.vertices; + const auto& idx = model.collision.indices; + const auto& v0 = verts[idx[ti * 3]]; + const auto& v1 = verts[idx[ti * 3 + 1]]; + const auto& v2 = verts[idx[ti * 3 + 2]]; + + glm::vec3 closest = closestPointOnTriangle(localPos, v0, v1, v2); + glm::vec3 diff = localPos - closest; + float distXY = std::sqrt(diff.x * diff.x + diff.y * diff.y); + + if (distXY < localRadius && distXY > 1e-4f) { + // Gentle push — very small fraction of penetration + float penetration = localRadius - distXY; + float pushDist = std::clamp(penetration * 0.08f, 0.001f, 0.015f); + float dx = (diff.x / distXY) * pushDist; + float dy = (diff.y / distXY) * pushDist; + localPos.x += dx; + localPos.y += dy; + totalPushX += dx; + totalPushY += dy; + pushed = true; + } else if (distXY < 1e-4f) { + // On the plane — soft push along triangle normal XY + glm::vec3 n = glm::cross(v1 - v0, v2 - v0); + float nxyLen = std::sqrt(n.x * n.x + n.y * n.y); + if (nxyLen > 1e-4f) { + float pushDist = std::min(localRadius, 0.015f); + float dx = (n.x / nxyLen) * pushDist; + float dy = (n.y / nxyLen) * pushDist; + localPos.x += dx; + localPos.y += dy; + totalPushX += dx; + totalPushY += dy; + pushed = true; + } + } + } + + if (pushed) { + glm::vec3 worldPos = glm::vec3(instance.modelMatrix * glm::vec4(localPos, 1.0f)); + adjustedPos.x = worldPos.x; + adjustedPos.y = worldPos.y; + collided = true; + } + continue; + } + + glm::vec3 localFrom = glm::vec3(instance.invModelMatrix * glm::vec4(from, 1.0f)); + glm::vec3 localPos = glm::vec3(instance.invModelMatrix * glm::vec4(adjustedPos, 1.0f)); + float radiusScale = model.collisionNarrowVerticalProp ? 0.45f : 1.0f; + float localRadius = (playerRadius * radiusScale) / instance.scale; + + glm::vec3 rawMin, rawMax; + getTightCollisionBounds(model, rawMin, rawMax); + glm::vec3 localMin = rawMin - glm::vec3(localRadius); + glm::vec3 localMax = rawMax + glm::vec3(localRadius); + float effectiveTop = getEffectiveCollisionTopLocal(model, localPos, rawMin, rawMax) + localRadius; + glm::vec2 localCenter((localMin.x + localMax.x) * 0.5f, (localMin.y + localMax.y) * 0.5f); + float fromR = glm::length(glm::vec2(localFrom.x, localFrom.y) - localCenter); + float toR = glm::length(glm::vec2(localPos.x, localPos.y) - localCenter); + + // Feet-based vertical overlap test: ignore objects fully above/below us. + constexpr float PLAYER_HEIGHT = 2.0f; + if (localPos.z + PLAYER_HEIGHT < localMin.z || localPos.z > effectiveTop) { + continue; + } + + bool fromInsideXY = + (localFrom.x >= localMin.x && localFrom.x <= localMax.x && + localFrom.y >= localMin.y && localFrom.y <= localMax.y); + bool fromInsideZ = (localFrom.z + PLAYER_HEIGHT >= localMin.z && localFrom.z <= effectiveTop); + bool escapingOverlap = (fromInsideXY && fromInsideZ && (toR > fromR + 1e-4f)); + bool allowEscapeRelax = escapingOverlap && !model.collisionSmallSolidProp; + + // Swept hard clamp for taller blockers only. + // Low/stepable objects should be climbable and not "shove" the player off. + float maxStepUp = 1.20f; + if (model.collisionStatue) { + maxStepUp = 2.5f; + } else if (model.collisionSmallSolidProp) { + // Keep box/crate-class props hard-solid to prevent phase-through. + maxStepUp = 0.75f; + } else if (model.collisionSteppedFountain) { + maxStepUp = 2.5f; + } else if (model.collisionSteppedLowPlatform) { + maxStepUp = model.collisionPlanter ? 2.8f : 2.4f; + if (model.collisionBridge) { + maxStepUp = 25.0f; + } + } + bool stepableLowObject = (effectiveTop <= localFrom.z + maxStepUp); + bool climbingAttempt = (localPos.z > localFrom.z + 0.18f); + bool nearTop = (localFrom.z >= effectiveTop - 0.30f); + float climbAllowance = model.collisionPlanter ? 0.95f : 0.60f; + if (model.collisionSteppedLowPlatform && !model.collisionPlanter) { + // Let low curb/planter blocks be stepable without sticky side shoves. + climbAllowance = 1.00f; + } + if (model.collisionBridge) { + climbAllowance = 3.0f; + } + if (model.collisionSmallSolidProp) { + climbAllowance = 1.05f; + } + bool climbingTowardTop = climbingAttempt && (localFrom.z + climbAllowance >= effectiveTop); + bool forceHardLateral = + model.collisionSmallSolidProp && + !nearTop && !climbingTowardTop; + if ((!stepableLowObject || forceHardLateral) && !allowEscapeRelax) { + float tEnter = 0.0f; + glm::vec3 sweepMax = localMax; + sweepMax.z = std::min(sweepMax.z, effectiveTop); + if (segmentIntersectsAABB(localFrom, localPos, localMin, sweepMax, tEnter)) { + float tSafe = std::clamp(tEnter - 0.03f, 0.0f, 1.0f); + glm::vec3 localSafe = localFrom + (localPos - localFrom) * tSafe; + glm::vec3 worldSafe = glm::vec3(instance.modelMatrix * glm::vec4(localSafe, 1.0f)); + adjustedPos.x = worldSafe.x; + adjustedPos.y = worldSafe.y; + collided = true; + continue; + } + } + + if (localPos.x < localMin.x || localPos.x > localMax.x || + localPos.y < localMin.y || localPos.y > localMax.y) { + continue; + } + + float pushLeft = localPos.x - localMin.x; + float pushRight = localMax.x - localPos.x; + float pushBack = localPos.y - localMin.y; + float pushFront = localMax.y - localPos.y; + + float minPush = std::min({pushLeft, pushRight, pushBack, pushFront}); + if (allowEscapeRelax) { + continue; + } + if (stepableLowObject && localFrom.z >= effectiveTop - 0.35f) { + // Already on/near top surface: don't apply lateral push that ejects + // the player from the object (carpets, platforms, etc). + continue; + } + // Gentle fallback push for overlapping cases. + float pushAmount; + if (model.collisionNarrowVerticalProp) { + pushAmount = std::clamp(minPush * 0.10f, 0.001f, 0.010f); + } else if (model.collisionSteppedLowPlatform) { + if (model.collisionPlanter && stepableLowObject) { + pushAmount = std::clamp(minPush * 0.06f, 0.001f, 0.006f); + } else { + pushAmount = std::clamp(minPush * 0.12f, 0.003f, 0.012f); + } + } else if (stepableLowObject) { + pushAmount = std::clamp(minPush * 0.12f, 0.002f, 0.015f); + } else { + pushAmount = std::clamp(minPush * 0.28f, 0.010f, 0.045f); + } + glm::vec3 localPush(0.0f); + if (minPush == pushLeft) { + localPush.x = -pushAmount; + } else if (minPush == pushRight) { + localPush.x = pushAmount; + } else if (minPush == pushBack) { + localPush.y = -pushAmount; + } else { + localPush.y = pushAmount; + } + + glm::vec3 worldPush = glm::vec3(instance.modelMatrix * glm::vec4(localPush, 0.0f)); + adjustedPos.x += worldPush.x; + adjustedPos.y += worldPush.y; + collided = true; + } + + return collided; +} + +float M2Renderer::raycastBoundingBoxes(const glm::vec3& origin, const glm::vec3& direction, float maxDistance) const { + QueryTimer timer(&queryTimeMs, &queryCallCount); + float closestHit = maxDistance; + + glm::vec3 rayEnd = origin + direction * maxDistance; + glm::vec3 queryMin = glm::min(origin, rayEnd) - glm::vec3(1.0f); + glm::vec3 queryMax = glm::max(origin, rayEnd) + glm::vec3(1.0f); + gatherCandidates(queryMin, queryMax, tl_m2_candidateScratch); + + for (size_t idx : tl_m2_candidateScratch) { + const auto& instance = instances[idx]; + if (collisionFocusEnabled && + pointAABBDistanceSq(collisionFocusPos, instance.worldBoundsMin, instance.worldBoundsMax) > collisionFocusRadiusSq) { + continue; + } + + // Cheap world-space broad-phase. + float tEnter = 0.0f; + glm::vec3 worldMin = instance.worldBoundsMin - glm::vec3(0.35f); + glm::vec3 worldMax = instance.worldBoundsMax + glm::vec3(0.35f); + if (!segmentIntersectsAABB(origin, origin + direction * maxDistance, worldMin, worldMax, tEnter)) { + continue; + } + + if (!instance.cachedModel) continue; + + const M2ModelGPU& model = *instance.cachedModel; + if (model.collisionNoBlock || model.isInvisibleTrap || model.isSpellEffect) continue; + glm::vec3 localMin, localMax; + getTightCollisionBounds(model, localMin, localMax); + // Skip tiny doodads for camera occlusion; they cause jitter and false hits. + glm::vec3 extents = (localMax - localMin) * instance.scale; + if (glm::dot(extents, extents) < 0.5625f) continue; + + glm::vec3 localOrigin = glm::vec3(instance.invModelMatrix * glm::vec4(origin, 1.0f)); + glm::vec3 localDir = glm::normalize(glm::vec3(instance.invModelMatrix * glm::vec4(direction, 0.0f))); + if (!std::isfinite(localDir.x) || !std::isfinite(localDir.y) || !std::isfinite(localDir.z)) { + continue; + } + + // Local-space AABB slab intersection. + glm::vec3 invDir = 1.0f / localDir; + glm::vec3 tMin = (localMin - localOrigin) * invDir; + glm::vec3 tMax = (localMax - localOrigin) * invDir; + glm::vec3 t1 = glm::min(tMin, tMax); + glm::vec3 t2 = glm::max(tMin, tMax); + + float tNear = std::max({t1.x, t1.y, t1.z}); + float tFar = std::min({t2.x, t2.y, t2.z}); + if (tNear > tFar || tFar <= 0.0f) continue; + + float tHit = tNear > 0.0f ? tNear : tFar; + glm::vec3 localHit = localOrigin + localDir * tHit; + glm::vec3 worldHit = glm::vec3(instance.modelMatrix * glm::vec4(localHit, 1.0f)); + float worldDist = glm::length(worldHit - origin); + if (worldDist > 0.0f && worldDist < closestHit) { + closestHit = worldDist; + } + } + + return closestHit; +} + +void M2Renderer::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + + // Destroy old main-pass pipelines (NOT shadow, NOT pipeline layouts) + if (opaquePipeline_) { vkDestroyPipeline(device, opaquePipeline_, nullptr); opaquePipeline_ = VK_NULL_HANDLE; } + if (alphaTestPipeline_) { vkDestroyPipeline(device, alphaTestPipeline_, nullptr); alphaTestPipeline_ = VK_NULL_HANDLE; } + if (alphaPipeline_) { vkDestroyPipeline(device, alphaPipeline_, nullptr); alphaPipeline_ = VK_NULL_HANDLE; } + if (additivePipeline_) { vkDestroyPipeline(device, additivePipeline_, nullptr); additivePipeline_ = VK_NULL_HANDLE; } + if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; } + if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; } + if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; } + if (ribbonPipeline_) { vkDestroyPipeline(device, ribbonPipeline_, nullptr); ribbonPipeline_ = VK_NULL_HANDLE; } + if (ribbonAdditivePipeline_) { vkDestroyPipeline(device, ribbonAdditivePipeline_, nullptr); ribbonAdditivePipeline_ = VK_NULL_HANDLE; } + + // --- Load shaders --- + rendering::VkShaderModule m2Vert, m2Frag; + rendering::VkShaderModule particleVert, particleFrag; + rendering::VkShaderModule smokeVert, smokeFrag; + + (void)m2Vert.loadFromFile(device, "assets/shaders/m2.vert.spv"); + (void)m2Frag.loadFromFile(device, "assets/shaders/m2.frag.spv"); + (void)particleVert.loadFromFile(device, "assets/shaders/m2_particle.vert.spv"); + (void)particleFrag.loadFromFile(device, "assets/shaders/m2_particle.frag.spv"); + (void)smokeVert.loadFromFile(device, "assets/shaders/m2_smoke.vert.spv"); + (void)smokeFrag.loadFromFile(device, "assets/shaders/m2_smoke.frag.spv"); + + if (!m2Vert.isValid() || !m2Frag.isValid()) { + LOG_ERROR("M2Renderer::recreatePipelines: missing required shaders"); + return; + } + + VkRenderPass mainPass = vkCtx_->getImGuiRenderPass(); + + // --- M2 model vertex input --- + VkVertexInputBindingDescription m2Binding{}; + m2Binding.binding = 0; + m2Binding.stride = 18 * sizeof(float); + m2Binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector m2Attrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // normal + {2, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float)}, // texCoord0 + {5, 0, VK_FORMAT_R32G32_SFLOAT, 8 * sizeof(float)}, // texCoord1 + {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 10 * sizeof(float)}, // boneWeights + {4, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 14 * sizeof(float)}, // boneIndices (float) + }; + + // Pipeline derivatives — opaque is the base, others derive from it for shared state optimization + auto buildM2Pipeline = [&](VkPipelineColorBlendAttachmentState blendState, bool depthWrite, + VkPipelineCreateFlags flags = 0, VkPipeline basePipeline = VK_NULL_HANDLE) -> VkPipeline { + return PipelineBuilder() + .setShaders(m2Vert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + m2Frag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({m2Binding}, m2Attrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, depthWrite, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blendState) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(pipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .setFlags(flags) + .setBasePipeline(basePipeline) + .build(device, vkCtx_->getPipelineCache()); + }; + + opaquePipeline_ = buildM2Pipeline(PipelineBuilder::blendDisabled(), true, + VK_PIPELINE_CREATE_ALLOW_DERIVATIVES_BIT); + alphaTestPipeline_ = buildM2Pipeline(PipelineBuilder::blendAlpha(), true, + VK_PIPELINE_CREATE_DERIVATIVE_BIT, opaquePipeline_); + alphaPipeline_ = buildM2Pipeline(PipelineBuilder::blendAlpha(), false, + VK_PIPELINE_CREATE_DERIVATIVE_BIT, opaquePipeline_); + additivePipeline_ = buildM2Pipeline(PipelineBuilder::blendAdditive(), false, + VK_PIPELINE_CREATE_DERIVATIVE_BIT, opaquePipeline_); + + // --- Particle pipelines --- + if (particleVert.isValid() && particleFrag.isValid()) { + VkVertexInputBindingDescription pBind{}; + pBind.binding = 0; + pBind.stride = 9 * sizeof(float); // pos3 + color4 + size1 + tile1 + pBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector pAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 3 * sizeof(float)}, // color + {2, 0, VK_FORMAT_R32_SFLOAT, 7 * sizeof(float)}, // size + {3, 0, VK_FORMAT_R32_SFLOAT, 8 * sizeof(float)}, // tile + }; + + auto buildParticlePipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(particleVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + particleFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({pBind}, pAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(particlePipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device, vkCtx_->getPipelineCache()); + }; + + particlePipeline_ = buildParticlePipeline(PipelineBuilder::blendAlpha()); + particleAdditivePipeline_ = buildParticlePipeline(PipelineBuilder::blendAdditive()); + } + + // --- Smoke pipeline --- + if (smokeVert.isValid() && smokeFrag.isValid()) { + VkVertexInputBindingDescription sBind{}; + sBind.binding = 0; + sBind.stride = 6 * sizeof(float); // pos3 + lifeRatio1 + size1 + isSpark1 + sBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector sAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // position + {1, 0, VK_FORMAT_R32_SFLOAT, 3 * sizeof(float)}, // lifeRatio + {2, 0, VK_FORMAT_R32_SFLOAT, 4 * sizeof(float)}, // size + {3, 0, VK_FORMAT_R32_SFLOAT, 5 * sizeof(float)}, // isSpark + }; + + smokePipeline_ = PipelineBuilder() + .setShaders(smokeVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + smokeFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({sBind}, sAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_POINT_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(smokePipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device, vkCtx_->getPipelineCache()); + } + + // --- Ribbon pipelines --- + { + rendering::VkShaderModule ribVert, ribFrag; + (void)ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + (void)ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device, vkCtx_->getPipelineCache()); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + + m2Vert.destroy(); m2Frag.destroy(); + particleVert.destroy(); particleFrag.destroy(); + smokeVert.destroy(); smokeFrag.destroy(); + + core::Logger::getInstance().info("M2Renderer: pipelines recreated"); +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/m2_renderer_internal.h b/src/rendering/m2_renderer_internal.h new file mode 100644 index 00000000..6271fc26 --- /dev/null +++ b/src/rendering/m2_renderer_internal.h @@ -0,0 +1,364 @@ +// m2_renderer_internal.h — shared helpers for the m2_renderer split files. +// All functions are inline to allow inclusion in multiple translation units. +#pragma once + +#include "rendering/m2_renderer.hpp" +#include "pipeline/m2_loader.hpp" +#include "core/profiler.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace rendering { +namespace m2_internal { + +// ---- RNG helpers ---- +inline std::mt19937& rng() { + static std::mt19937 gen(std::random_device{}()); + return gen; +} +inline uint32_t randRange(uint32_t maxExclusive) { + if (maxExclusive == 0) return 0; + return std::uniform_int_distribution(0, maxExclusive - 1)(rng()); +} +inline float randFloat(float lo, float hi) { + return std::uniform_real_distribution(lo, hi)(rng()); +} + +// ---- Constants ---- +inline const auto kLavaAnimStart = std::chrono::steady_clock::now(); +inline constexpr uint32_t kParticleFlagRandomized = 0x40; +inline constexpr uint32_t kParticleFlagTiled = 0x80; +inline constexpr float kSmokeEmitInterval = 1.0f / 48.0f; + +// ---- Geometry / collision helpers ---- + +inline float computeGroundDetailDownOffset(const M2ModelGPU& model, float scale) { + const float pivotComp = glm::clamp(std::max(0.0f, model.boundMin.z * scale), 0.0f, 0.10f); + const float terrainSink = 0.03f; + return pivotComp + terrainSink; +} + +inline void getTightCollisionBounds(const M2ModelGPU& model, glm::vec3& outMin, glm::vec3& outMax) { + glm::vec3 center = (model.boundMin + model.boundMax) * 0.5f; + glm::vec3 half = (model.boundMax - model.boundMin) * 0.5f; + + if (model.collisionTreeTrunk) { + float modelHoriz = std::max(model.boundMax.x - model.boundMin.x, + model.boundMax.y - model.boundMin.y); + float trunkHalf = std::clamp(modelHoriz * 0.05f, 0.5f, 5.0f); + half.x = trunkHalf; + half.y = trunkHalf; + half.z = std::min(trunkHalf * 2.5f, 3.5f); + center.z = model.boundMin.z + half.z; + } else if (model.collisionNarrowVerticalProp) { + half.x *= 0.30f; + half.y *= 0.30f; + half.z *= 0.96f; + } else if (model.collisionSmallSolidProp) { + half.x *= 1.00f; + half.y *= 1.00f; + half.z *= 1.00f; + } else if (model.collisionSteppedLowPlatform) { + half.x *= 0.98f; + half.y *= 0.98f; + half.z *= 0.52f; + } else { + half.x *= 0.66f; + half.y *= 0.66f; + half.z *= 0.76f; + } + + outMin = center - half; + outMax = center + half; +} + +inline float getEffectiveCollisionTopLocal(const M2ModelGPU& model, + const glm::vec3& localPos, + const glm::vec3& localMin, + const glm::vec3& localMax) { + if (!model.collisionSteppedFountain && !model.collisionSteppedLowPlatform) { + return localMax.z; + } + + glm::vec2 center((localMin.x + localMax.x) * 0.5f, (localMin.y + localMax.y) * 0.5f); + glm::vec2 half((localMax.x - localMin.x) * 0.5f, (localMax.y - localMin.y) * 0.5f); + if (half.x < 1e-4f || half.y < 1e-4f) { + return localMax.z; + } + + float nx = (localPos.x - center.x) / half.x; + float ny = (localPos.y - center.y) / half.y; + float r = std::sqrt(nx * nx + ny * ny); + + float h = localMax.z - localMin.z; + if (model.collisionSteppedFountain) { + if (r > 0.85f) return localMin.z + h * 0.18f; + if (r > 0.65f) return localMin.z + h * 0.36f; + if (r > 0.45f) return localMin.z + h * 0.54f; + if (r > 0.28f) return localMin.z + h * 0.70f; + if (r > 0.14f) return localMin.z + h * 0.84f; + return localMin.z + h * 0.96f; + } + + float edge = std::max(std::abs(nx), std::abs(ny)); + if (edge > 0.92f) return localMin.z + h * 0.06f; + if (edge > 0.72f) return localMin.z + h * 0.30f; + return localMin.z + h * 0.62f; +} + +inline bool segmentIntersectsAABB(const glm::vec3& from, const glm::vec3& to, + const glm::vec3& bmin, const glm::vec3& bmax, + float& outEnterT) { + glm::vec3 d = to - from; + float tEnter = 0.0f; + float tExit = 1.0f; + + for (int axis = 0; axis < 3; axis++) { + if (std::abs(d[axis]) < 1e-6f) { + if (from[axis] < bmin[axis] || from[axis] > bmax[axis]) { + return false; + } + continue; + } + + float inv = 1.0f / d[axis]; + float t0 = (bmin[axis] - from[axis]) * inv; + float t1 = (bmax[axis] - from[axis]) * inv; + if (t0 > t1) std::swap(t0, t1); + + tEnter = std::max(tEnter, t0); + tExit = std::min(tExit, t1); + if (tEnter > tExit) return false; + } + + outEnterT = tEnter; + return tExit >= 0.0f && tEnter <= 1.0f; +} + +inline void transformAABB(const glm::mat4& modelMatrix, + const glm::vec3& localMin, + const glm::vec3& localMax, + glm::vec3& outMin, + glm::vec3& outMax) { + const glm::vec3 corners[8] = { + {localMin.x, localMin.y, localMin.z}, + {localMin.x, localMin.y, localMax.z}, + {localMin.x, localMax.y, localMin.z}, + {localMin.x, localMax.y, localMax.z}, + {localMax.x, localMin.y, localMin.z}, + {localMax.x, localMin.y, localMax.z}, + {localMax.x, localMax.y, localMin.z}, + {localMax.x, localMax.y, localMax.z} + }; + + outMin = glm::vec3(std::numeric_limits::max()); + outMax = glm::vec3(-std::numeric_limits::max()); + for (const auto& c : corners) { + glm::vec3 wc = glm::vec3(modelMatrix * glm::vec4(c, 1.0f)); + outMin = glm::min(outMin, wc); + outMax = glm::max(outMax, wc); + } +} + +inline float pointAABBDistanceSq(const glm::vec3& p, const glm::vec3& bmin, const glm::vec3& bmax) { + glm::vec3 q = glm::clamp(p, bmin, bmax); + glm::vec3 d = p - q; + return glm::dot(d, d); +} + +// Möller–Trumbore ray-triangle intersection. +// Returns distance along ray if hit, negative if miss. +inline float rayTriangleIntersect(const glm::vec3& origin, const glm::vec3& dir, + const glm::vec3& v0, const glm::vec3& v1, const glm::vec3& v2) { + constexpr float EPSILON = 1e-6f; + glm::vec3 e1 = v1 - v0; + glm::vec3 e2 = v2 - v0; + glm::vec3 h = glm::cross(dir, e2); + float a = glm::dot(e1, h); + if (a > -EPSILON && a < EPSILON) return -1.0f; + float f = 1.0f / a; + glm::vec3 s = origin - v0; + float u = f * glm::dot(s, h); + if (u < 0.0f || u > 1.0f) return -1.0f; + glm::vec3 q = glm::cross(s, e1); + float v = f * glm::dot(dir, q); + if (v < 0.0f || u + v > 1.0f) return -1.0f; + float t = f * glm::dot(e2, q); + return t > EPSILON ? t : -1.0f; +} + +// Closest point on triangle to a point (Ericson, Real-Time Collision Detection §5.1.5). +inline glm::vec3 closestPointOnTriangle(const glm::vec3& p, + const glm::vec3& a, const glm::vec3& b, const glm::vec3& c) { + glm::vec3 ab = b - a, ac = c - a, ap = p - a; + float d1 = glm::dot(ab, ap), d2 = glm::dot(ac, ap); + if (d1 <= 0.0f && d2 <= 0.0f) return a; + glm::vec3 bp = p - b; + float d3 = glm::dot(ab, bp), d4 = glm::dot(ac, bp); + if (d3 >= 0.0f && d4 <= d3) return b; + float vc = d1 * d4 - d3 * d2; + if (vc <= 0.0f && d1 >= 0.0f && d3 <= 0.0f) { + float v = d1 / (d1 - d3); + return a + v * ab; + } + glm::vec3 cp = p - c; + float d5 = glm::dot(ab, cp), d6 = glm::dot(ac, cp); + if (d6 >= 0.0f && d5 <= d6) return c; + float vb = d5 * d2 - d1 * d6; + if (vb <= 0.0f && d2 >= 0.0f && d6 <= 0.0f) { + float w = d2 / (d2 - d6); + return a + w * ac; + } + float va = d3 * d6 - d5 * d4; + if (va <= 0.0f && (d4 - d3) >= 0.0f && (d5 - d6) >= 0.0f) { + float w = (d4 - d3) / ((d4 - d3) + (d5 - d6)); + return b + w * (c - b); + } + float denom = 1.0f / (va + vb + vc); + float v = vb * denom; + float w = vc * denom; + return a + ab * v + ac * w; +} + +// ---- Thread-local scratch buffers for collision queries ---- +inline thread_local std::vector tl_m2_candidateScratch; +inline thread_local std::unordered_set tl_m2_candidateIdScratch; +inline thread_local std::vector tl_m2_collisionTriScratch; + +// ---- Bone animation helpers ---- + +inline int findKeyframeIndex(const std::vector& timestamps, float time) { + if (timestamps.empty()) return -1; + if (timestamps.size() == 1) return 0; + auto it = std::upper_bound(timestamps.begin(), timestamps.end(), time, + [](float t, uint32_t ts) { return t < static_cast(ts); }); + if (it == timestamps.begin()) return 0; + size_t idx = static_cast(it - timestamps.begin()) - 1; + return static_cast(std::min(idx, timestamps.size() - 2)); +} + +inline void resolveTrackTime(const pipeline::M2AnimationTrack& track, + int seqIdx, float time, + const std::vector& globalSeqDurations, + int& outSeqIdx, float& outTime) { + if (track.globalSequence >= 0 && + static_cast(track.globalSequence) < globalSeqDurations.size()) { + outSeqIdx = 0; + float dur = static_cast(globalSeqDurations[track.globalSequence]); + if (dur > 0.0f) { + outTime = time; + while (outTime >= dur) { + outTime -= dur; + } + } else { + outTime = 0.0f; + } + } else { + outSeqIdx = seqIdx; + outTime = time; + } +} + +inline glm::vec3 interpVec3(const pipeline::M2AnimationTrack& track, + int seqIdx, float time, const glm::vec3& def, + const std::vector& globalSeqDurations) { + if (!track.hasData()) return def; + int si; float t; + resolveTrackTime(track, seqIdx, time, globalSeqDurations, si, t); + if (si < 0 || si >= static_cast(track.sequences.size())) return def; + const auto& keys = track.sequences[si]; + if (keys.timestamps.empty() || keys.vec3Values.empty()) return def; + auto safe = [&](const glm::vec3& v) -> glm::vec3 { + if (std::isnan(v.x) || std::isnan(v.y) || std::isnan(v.z)) return def; + return v; + }; + if (keys.vec3Values.size() == 1) return safe(keys.vec3Values[0]); + int idx = findKeyframeIndex(keys.timestamps, t); + if (idx < 0) return def; + size_t i0 = static_cast(idx); + size_t i1 = std::min(i0 + 1, keys.vec3Values.size() - 1); + if (i0 == i1) return safe(keys.vec3Values[i0]); + float t0 = static_cast(keys.timestamps[i0]); + float t1 = static_cast(keys.timestamps[i1]); + float dur = t1 - t0; + float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; + return safe(glm::mix(keys.vec3Values[i0], keys.vec3Values[i1], frac)); +} + +inline glm::quat interpQuat(const pipeline::M2AnimationTrack& track, + int seqIdx, float time, + const std::vector& globalSeqDurations) { + glm::quat identity(1.0f, 0.0f, 0.0f, 0.0f); + if (!track.hasData()) return identity; + int si; float t; + resolveTrackTime(track, seqIdx, time, globalSeqDurations, si, t); + if (si < 0 || si >= static_cast(track.sequences.size())) return identity; + const auto& keys = track.sequences[si]; + if (keys.timestamps.empty() || keys.quatValues.empty()) return identity; + auto safe = [&](const glm::quat& q) -> glm::quat { + float lenSq = q.x*q.x + q.y*q.y + q.z*q.z + q.w*q.w; + if (lenSq < 0.000001f || std::isnan(lenSq)) return identity; + return q; + }; + if (keys.quatValues.size() == 1) return safe(keys.quatValues[0]); + int idx = findKeyframeIndex(keys.timestamps, t); + if (idx < 0) return identity; + size_t i0 = static_cast(idx); + size_t i1 = std::min(i0 + 1, keys.quatValues.size() - 1); + if (i0 == i1) return safe(keys.quatValues[i0]); + float t0 = static_cast(keys.timestamps[i0]); + float t1 = static_cast(keys.timestamps[i1]); + float dur = t1 - t0; + float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; + return glm::slerp(safe(keys.quatValues[i0]), safe(keys.quatValues[i1]), frac); +} + +inline void computeBoneMatrices(const M2ModelGPU& model, M2Instance& instance) { + ZoneScopedN("M2::computeBoneMatrices"); + size_t numBones = std::min(model.bones.size(), size_t(128)); + if (numBones == 0) return; + instance.boneMatrices.resize(numBones); + const auto& gsd = model.globalSequenceDurations; + + for (size_t i = 0; i < numBones; i++) { + const auto& bone = model.bones[i]; + glm::vec3 trans = interpVec3(bone.translation, instance.currentSequenceIndex, instance.animTime, glm::vec3(0.0f), gsd); + glm::quat rot = interpQuat(bone.rotation, instance.currentSequenceIndex, instance.animTime, gsd); + glm::vec3 scl = interpVec3(bone.scale, instance.currentSequenceIndex, instance.animTime, glm::vec3(1.0f), gsd); + + if (scl.x < 0.001f) scl.x = 1.0f; + if (scl.y < 0.001f) scl.y = 1.0f; + if (scl.z < 0.001f) scl.z = 1.0f; + + glm::mat4 local = glm::translate(glm::mat4(1.0f), bone.pivot); + local = glm::translate(local, trans); + local *= glm::toMat4(rot); + local = glm::scale(local, scl); + local = glm::translate(local, -bone.pivot); + + if (bone.parentBone >= 0 && static_cast(bone.parentBone) < numBones) { + instance.boneMatrices[i] = instance.boneMatrices[bone.parentBone] * local; + } else { + instance.boneMatrices[i] = local; + } + } + instance.bonesDirty[0] = instance.bonesDirty[1] = true; +} + +} // namespace m2_internal + +// Pull all symbols into the rendering namespace so existing code compiles unchanged +using namespace m2_internal; + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/m2_renderer_particles.cpp b/src/rendering/m2_renderer_particles.cpp new file mode 100644 index 00000000..65c71424 --- /dev/null +++ b/src/rendering/m2_renderer_particles.cpp @@ -0,0 +1,618 @@ +#include "rendering/m2_renderer.hpp" +#include "rendering/m2_renderer_internal.h" +#include "rendering/vk_context.hpp" +#include "rendering/vk_buffer.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" +#include "rendering/camera.hpp" +#include "pipeline/asset_manager.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +// --- M2 Particle Emitter Helpers --- + +float M2Renderer::interpFloat(const pipeline::M2AnimationTrack& track, float animTime, + int seqIdx, const std::vector& /*seqs*/, + const std::vector& globalSeqDurations) { + if (!track.hasData()) return 0.0f; + int si; float t; + resolveTrackTime(track, seqIdx, animTime, globalSeqDurations, si, t); + if (si < 0 || si >= static_cast(track.sequences.size())) return 0.0f; + const auto& keys = track.sequences[si]; + if (keys.timestamps.empty() || keys.floatValues.empty()) return 0.0f; + if (keys.floatValues.size() == 1) return keys.floatValues[0]; + int idx = findKeyframeIndex(keys.timestamps, t); + if (idx < 0) return 0.0f; + size_t i0 = static_cast(idx); + size_t i1 = std::min(i0 + 1, keys.floatValues.size() - 1); + if (i0 == i1) return keys.floatValues[i0]; + float t0 = static_cast(keys.timestamps[i0]); + float t1 = static_cast(keys.timestamps[i1]); + float dur = t1 - t0; + float frac = (dur > 0.0f) ? glm::clamp((t - t0) / dur, 0.0f, 1.0f) : 0.0f; + return glm::mix(keys.floatValues[i0], keys.floatValues[i1], frac); +} + +// Interpolate an M2 FBlock (particle lifetime curve) at a given life ratio [0..1]. +// FBlocks store per-lifetime keyframes for particle color, alpha, and scale. +// NOTE: interpFBlockFloat and interpFBlockVec3 share identical interpolation logic — +// if you fix a bug in one, update the other to match. +float M2Renderer::interpFBlockFloat(const pipeline::M2FBlock& fb, float lifeRatio) { + if (fb.floatValues.empty()) return 1.0f; + if (fb.floatValues.size() == 1 || fb.timestamps.empty()) return fb.floatValues[0]; + lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f); + for (size_t i = 0; i < fb.timestamps.size() - 1; i++) { + if (lifeRatio <= fb.timestamps[i + 1]) { + float t0 = fb.timestamps[i]; + float t1 = fb.timestamps[i + 1]; + float dur = t1 - t0; + float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f; + size_t v0 = std::min(i, fb.floatValues.size() - 1); + size_t v1 = std::min(i + 1, fb.floatValues.size() - 1); + return glm::mix(fb.floatValues[v0], fb.floatValues[v1], frac); + } + } + return fb.floatValues.back(); +} + +glm::vec3 M2Renderer::interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio) { + if (fb.vec3Values.empty()) return glm::vec3(1.0f); + if (fb.vec3Values.size() == 1 || fb.timestamps.empty()) return fb.vec3Values[0]; + lifeRatio = glm::clamp(lifeRatio, 0.0f, 1.0f); + for (size_t i = 0; i < fb.timestamps.size() - 1; i++) { + if (lifeRatio <= fb.timestamps[i + 1]) { + float t0 = fb.timestamps[i]; + float t1 = fb.timestamps[i + 1]; + float dur = t1 - t0; + float frac = (dur > 0.0f) ? (lifeRatio - t0) / dur : 0.0f; + size_t v0 = std::min(i, fb.vec3Values.size() - 1); + size_t v1 = std::min(i + 1, fb.vec3Values.size() - 1); + return glm::mix(fb.vec3Values[v0], fb.vec3Values[v1], frac); + } + } + return fb.vec3Values.back(); +} + +std::vector M2Renderer::getWaterVegetationPositions(const glm::vec3& camPos, float maxDist) const { + std::vector result; + float maxDistSq = maxDist * maxDist; + for (const auto& inst : instances) { + if (!inst.cachedModel || !inst.cachedModel->isWaterVegetation) continue; + glm::vec3 diff = inst.position - camPos; + if (glm::dot(diff, diff) <= maxDistSq) { + result.push_back(inst.position); + } + } + return result; +} + +void M2Renderer::emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt) { + if (inst.emitterAccumulators.size() != gpu.particleEmitters.size()) { + inst.emitterAccumulators.resize(gpu.particleEmitters.size(), 0.0f); + } + + std::uniform_real_distribution dist01(0.0f, 1.0f); + std::uniform_real_distribution distN(-1.0f, 1.0f); + std::uniform_int_distribution distTile; + + for (size_t ei = 0; ei < gpu.particleEmitters.size(); ei++) { + const auto& em = gpu.particleEmitters[ei]; + if (!em.enabled) continue; + + float rate = interpFloat(em.emissionRate, inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + float life = interpFloat(em.lifespan, inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + if (rate <= 0.0f || life <= 0.0f) continue; + + inst.emitterAccumulators[ei] += rate * dt; + + while (inst.emitterAccumulators[ei] >= 1.0f && inst.particles.size() < MAX_M2_PARTICLES) { + inst.emitterAccumulators[ei] -= 1.0f; + + M2Particle p; + p.emitterIndex = static_cast(ei); + p.life = 0.0f; + p.maxLife = life; + p.tileIndex = 0.0f; + + // Position: emitter position transformed by bone matrix + glm::vec3 localPos = em.position; + glm::mat4 boneXform = glm::mat4(1.0f); + if (em.bone < inst.boneMatrices.size()) { + boneXform = inst.boneMatrices[em.bone]; + } + glm::vec3 worldPos = glm::vec3(inst.modelMatrix * boneXform * glm::vec4(localPos, 1.0f)); + p.position = worldPos; + + // Velocity: emission speed in upward direction + random spread + float speed = interpFloat(em.emissionSpeed, inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + float vRange = interpFloat(em.verticalRange, inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + float hRange = interpFloat(em.horizontalRange, inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + + // Base direction: up in model space, transformed to world + glm::vec3 dir(0.0f, 0.0f, 1.0f); + // Add random spread + dir.x += distN(particleRng_) * hRange; + dir.y += distN(particleRng_) * hRange; + dir.z += distN(particleRng_) * vRange; + float lenSq = glm::dot(dir, dir); + if (lenSq > 0.001f * 0.001f) dir *= glm::inversesqrt(lenSq); + + // Transform direction by bone + model orientation (rotation only) + glm::mat3 rotMat = glm::mat3(inst.modelMatrix * boneXform); + p.velocity = rotMat * dir * speed; + + // When emission speed is ~0 and bone animation isn't loaded (.anim files), + // particles pile up at the same position. Give them a drift so they + // spread outward like a mist/spray effect instead of clustering. + if (std::abs(speed) < 0.01f) { + if (gpu.isFireflyEffect) { + // Fireflies: gentle random drift in all directions + p.velocity = rotMat * glm::vec3( + distN(particleRng_) * 0.6f, + distN(particleRng_) * 0.6f, + distN(particleRng_) * 0.3f + ); + } else { + p.velocity = rotMat * glm::vec3( + distN(particleRng_) * 1.0f, + distN(particleRng_) * 1.0f, + -dist01(particleRng_) * 0.5f + ); + } + } + + const uint32_t tilesX = std::max(em.textureCols, 1); + const uint32_t tilesY = std::max(em.textureRows, 1); + const uint32_t totalTiles = tilesX * tilesY; + if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { + if (em.flags & kParticleFlagRandomized) { + distTile = std::uniform_int_distribution(0, static_cast(totalTiles - 1)); + p.tileIndex = static_cast(distTile(particleRng_)); + } else { + p.tileIndex = 0.0f; + } + } + + inst.particles.push_back(p); + } + // Cap accumulator to avoid bursts after lag + if (inst.emitterAccumulators[ei] > 2.0f) { + inst.emitterAccumulators[ei] = 0.0f; + } + } +} + +void M2Renderer::updateParticles(M2Instance& inst, float dt) { + if (!inst.cachedModel) return; + const auto& gpu = *inst.cachedModel; + + for (size_t i = 0; i < inst.particles.size(); ) { + auto& p = inst.particles[i]; + p.life += dt; + if (p.life >= p.maxLife) { + // Swap-and-pop removal + inst.particles[i] = inst.particles.back(); + inst.particles.pop_back(); + continue; + } + // Apply gravity + if (p.emitterIndex >= 0 && p.emitterIndex < static_cast(gpu.particleEmitters.size())) { + const auto& pem = gpu.particleEmitters[p.emitterIndex]; + float grav = interpFloat(pem.gravity, + inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + // When M2 gravity is 0, apply default gravity so particles arc downward. + // Many fountain M2s rely on bone animation (.anim files) we don't load yet. + // Firefly/ambient glow particles intentionally have zero gravity — skip fallback. + if (grav == 0.0f && !gpu.isFireflyEffect) { + float emSpeed = interpFloat(pem.emissionSpeed, + inst.animTime, inst.currentSequenceIndex, + gpu.sequences, gpu.globalSequenceDurations); + if (std::abs(emSpeed) > 0.1f) { + grav = 4.0f; // spray particles + } else { + grav = 1.5f; // mist/drift particles - gentler fall + } + } + p.velocity.z -= grav * dt; + } + p.position += p.velocity * dt; + i++; + } +} + +// --------------------------------------------------------------------------- +// Ribbon emitter simulation +// --------------------------------------------------------------------------- +void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) { + const auto& emitters = gpu.ribbonEmitters; + if (emitters.empty()) return; + + // Grow per-instance state arrays if needed + if (inst.ribbonEdges.size() != emitters.size()) { + inst.ribbonEdges.resize(emitters.size()); + } + if (inst.ribbonEdgeAccumulators.size() != emitters.size()) { + inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f); + } + + for (size_t ri = 0; ri < emitters.size(); ri++) { + const auto& em = emitters[ri]; + auto& edges = inst.ribbonEdges[ri]; + auto& accum = inst.ribbonEdgeAccumulators[ri]; + + // Determine bone world position for spine + glm::vec3 spineWorld = inst.position; + if (em.bone < inst.boneMatrices.size()) { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local); + } else { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * local); + } + + // Evaluate animated tracks (use first available sequence key, or fallback value) + auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float { + for (const auto& seq : track.sequences) { + if (!seq.floatValues.empty()) return seq.floatValues[0]; + } + return fallback; + }; + auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 { + for (const auto& seq : track.sequences) { + if (!seq.vec3Values.empty()) return seq.vec3Values[0]; + } + return fallback; + }; + + float visibility = getFloatVal(em.visibilityTrack, 1.0f); + float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f); + float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f); + glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f)); + float alpha = getFloatVal(em.alphaTrack, 1.0f); + + // Age existing edges and remove expired ones + for (auto& e : edges) { + e.age += dt; + // Apply gravity + if (em.gravity != 0.0f) { + e.worldPos.z -= em.gravity * dt * dt * 0.5f; + } + } + while (!edges.empty() && edges.front().age >= em.edgeLifetime) { + edges.pop_front(); + } + + // Emit new edges based on edgesPerSecond + if (visibility > 0.5f) { + accum += em.edgesPerSecond * dt; + while (accum >= 1.0f) { + accum -= 1.0f; + M2Instance::RibbonEdge e; + e.worldPos = spineWorld; + e.color = color; + e.alpha = alpha; + e.heightAbove = heightAbove; + e.heightBelow = heightBelow; + e.age = 0.0f; + edges.push_back(e); + // Cap trail length + if (edges.size() > 128) edges.pop_front(); + } + } else { + accum = 0.0f; + } + } +} + +// --------------------------------------------------------------------------- +// Ribbon rendering +// --------------------------------------------------------------------------- +void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return; + + // Build camera right vector for billboard orientation + // For ribbons we orient the quad strip along the spine with screen-space up. + // Simple approach: use world-space Z=up for the ribbon cross direction. + const glm::vec3 upWorld(0.0f, 0.0f, 1.0f); + + float* dst = static_cast(ribbonVBMapped_); + size_t written = 0; + + ribbonDraws_.clear(); + auto& draws = ribbonDraws_; + + for (const auto& inst : instances) { + if (!inst.cachedModel) continue; + const auto& gpu = *inst.cachedModel; + if (gpu.ribbonEmitters.empty()) continue; + + for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) { + if (ri >= inst.ribbonEdges.size()) continue; + const auto& edges = inst.ribbonEdges[ri]; + if (edges.size() < 2) continue; + + const auto& em = gpu.ribbonEmitters[ri]; + + // Select blend pipeline based on material blend mode + bool additive = false; + if (em.materialIndex < gpu.batches.size()) { + additive = (gpu.batches[em.materialIndex].blendMode >= 3); + } + VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_; + + // Descriptor set for texture + VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size()) + ? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE; + if (!texSet) continue; + + uint32_t firstVert = static_cast(written); + + // Emit triangle strip: 2 verts per edge (top + bottom) + for (size_t ei = 0; ei < edges.size(); ei++) { + if (written + 2 > MAX_RIBBON_VERTS) break; + const auto& e = edges[ei]; + float t = (em.edgeLifetime > 0.0f) + ? 1.0f - (e.age / em.edgeLifetime) : 1.0f; + float a = e.alpha * t; + float u = static_cast(ei) / static_cast(edges.size() - 1); + + // Top vertex (above spine along upWorld) + glm::vec3 top = e.worldPos + upWorld * e.heightAbove; + dst[written * 9 + 0] = top.x; + dst[written * 9 + 1] = top.y; + dst[written * 9 + 2] = top.z; + dst[written * 9 + 3] = e.color.r; + dst[written * 9 + 4] = e.color.g; + dst[written * 9 + 5] = e.color.b; + dst[written * 9 + 6] = a; + dst[written * 9 + 7] = u; + dst[written * 9 + 8] = 0.0f; // v = top + written++; + + // Bottom vertex (below spine) + glm::vec3 bot = e.worldPos - upWorld * e.heightBelow; + dst[written * 9 + 0] = bot.x; + dst[written * 9 + 1] = bot.y; + dst[written * 9 + 2] = bot.z; + dst[written * 9 + 3] = e.color.r; + dst[written * 9 + 4] = e.color.g; + dst[written * 9 + 5] = e.color.b; + dst[written * 9 + 6] = a; + dst[written * 9 + 7] = u; + dst[written * 9 + 8] = 1.0f; // v = bottom + written++; + } + + uint32_t vertCount = static_cast(written) - firstVert; + if (vertCount >= 4) { + draws.push_back({texSet, pipe, firstVert, vertCount}); + } else { + // Rollback if too few verts + written = firstVert; + } + } + } + + if (draws.empty() || written == 0) return; + + VkExtent2D ext = vkCtx_->getSwapchainExtent(); + VkViewport vp{}; + vp.x = 0; vp.y = 0; + vp.width = static_cast(ext.width); + vp.height = static_cast(ext.height); + vp.minDepth = 0.0f; vp.maxDepth = 1.0f; + VkRect2D sc{}; + sc.offset = {0, 0}; + sc.extent = ext; + vkCmdSetViewport(cmd, 0, 1, &vp); + vkCmdSetScissor(cmd, 0, 1, &sc); + + VkPipeline lastPipe = VK_NULL_HANDLE; + for (const auto& dc : draws) { + if (dc.pipeline != lastPipe) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + lastPipe = dc.pipeline; + } + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset); + vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0); + } +} + +void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!particlePipeline_ || !m2ParticleVB_) return; + + // Collect all particles from all instances, grouped by texture+blend + // Reuse persistent map — clear each group's vertex data but keep bucket structure. + for (auto& [k, g] : particleGroups_) { + g.vertexData.clear(); + g.preAllocSet = VK_NULL_HANDLE; + } + auto& groups = particleGroups_; + + size_t totalParticles = 0; + + for (auto& inst : instances) { + if (inst.particles.empty()) continue; + if (!inst.cachedModel) continue; + const auto& gpu = *inst.cachedModel; + + for (const auto& p : inst.particles) { + if (p.emitterIndex < 0 || p.emitterIndex >= static_cast(gpu.particleEmitters.size())) continue; + const auto& em = gpu.particleEmitters[p.emitterIndex]; + + float lifeRatio = p.life / std::max(p.maxLife, 0.001f); + glm::vec3 color = interpFBlockVec3(em.particleColor, lifeRatio); + float alpha = std::min(interpFBlockFloat(em.particleAlpha, lifeRatio), 1.0f); + float rawScale = interpFBlockFloat(em.particleScale, lifeRatio); + + if (!gpu.isSpellEffect && !gpu.isFireflyEffect) { + color = glm::mix(color, glm::vec3(1.0f), 0.7f); + if (rawScale > 2.0f) alpha *= 0.02f; + if (em.blendingType == 3 || em.blendingType == 4) alpha *= 0.05f; + } + float scale = (gpu.isSpellEffect || gpu.isFireflyEffect) ? rawScale : std::min(rawScale, 1.5f); + + VkTexture* tex = whiteTexture_.get(); + if (p.emitterIndex < static_cast(gpu.particleTextures.size())) { + tex = gpu.particleTextures[p.emitterIndex]; + } + + uint16_t tilesX = std::max(em.textureCols, 1); + uint16_t tilesY = std::max(em.textureRows, 1); + uint32_t totalTiles = static_cast(tilesX) * static_cast(tilesY); + ParticleGroupKey key{tex, em.blendingType, tilesX, tilesY}; + auto& group = groups[key]; + group.texture = tex; + group.blendType = em.blendingType; + group.tilesX = tilesX; + group.tilesY = tilesY; + // Capture pre-allocated descriptor set on first insertion for this key + if (group.preAllocSet == VK_NULL_HANDLE && + p.emitterIndex < static_cast(gpu.particleTexSets.size())) { + group.preAllocSet = gpu.particleTexSets[p.emitterIndex]; + } + + group.vertexData.push_back(p.position.x); + group.vertexData.push_back(p.position.y); + group.vertexData.push_back(p.position.z); + group.vertexData.push_back(color.r); + group.vertexData.push_back(color.g); + group.vertexData.push_back(color.b); + group.vertexData.push_back(alpha); + group.vertexData.push_back(scale); + float tileIndex = p.tileIndex; + if ((em.flags & kParticleFlagTiled) && totalTiles > 1) { + float animSeconds = inst.animTime / 1000.0f; + uint32_t animFrame = static_cast(std::floor(animSeconds * totalTiles)) % totalTiles; + tileIndex = p.tileIndex + static_cast(animFrame); + float tilesFloat = static_cast(totalTiles); + // Wrap tile index within totalTiles range + while (tileIndex >= tilesFloat) { + tileIndex -= tilesFloat; + } + } + group.vertexData.push_back(tileIndex); + totalParticles++; + } + } + + if (totalParticles == 0) return; + + // Bind per-frame set (set 0) for particle pipeline + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + + VkDeviceSize vbOffset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &m2ParticleVB_, &vbOffset); + + VkPipeline currentPipeline = VK_NULL_HANDLE; + + for (auto& [key, group] : groups) { + if (group.vertexData.empty()) continue; + + uint8_t blendType = group.blendType; + VkPipeline desiredPipeline = (blendType == 3 || blendType == 4) + ? particleAdditivePipeline_ : particlePipeline_; + if (desiredPipeline != currentPipeline) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); + currentPipeline = desiredPipeline; + } + + // Use pre-allocated stable descriptor set; fall back to per-frame alloc only if unavailable + VkDescriptorSet texSet = group.preAllocSet; + if (texSet == VK_NULL_HANDLE) { + // Fallback: allocate per-frame (pool exhaustion risk — should not happen in practice) + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &texSet) == VK_SUCCESS) { + VkTexture* tex = group.texture ? group.texture : whiteTexture_.get(); + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.dstSet = texSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 1, &write, 0, nullptr); + } + } + if (texSet != VK_NULL_HANDLE) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 1, 1, &texSet, 0, nullptr); + } + + // Push constants: tileCount + alphaKey + struct { float tileX, tileY; int alphaKey; } pc = { + static_cast(group.tilesX), static_cast(group.tilesY), + (blendType == 1) ? 1 : 0 + }; + vkCmdPushConstants(cmd, particlePipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(pc), &pc); + + // Upload and draw in chunks + size_t count = group.vertexData.size() / 9; + size_t offset = 0; + while (offset < count) { + size_t batch = std::min(count - offset, MAX_M2_PARTICLES); + memcpy(m2ParticleVBMapped_, &group.vertexData[offset * 9], batch * 9 * sizeof(float)); + vkCmdDraw(cmd, static_cast(batch), 1, 0, 0); + offset += batch; + } + } +} + +void M2Renderer::renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (smokeParticles.empty() || !smokePipeline_ || !smokeVB_) return; + + // Build vertex data: pos(3) + lifeRatio(1) + size(1) + isSpark(1) per particle + size_t count = std::min(smokeParticles.size(), static_cast(MAX_SMOKE_PARTICLES)); + float* dst = static_cast(smokeVBMapped_); + for (size_t i = 0; i < count; i++) { + const auto& p = smokeParticles[i]; + *dst++ = p.position.x; + *dst++ = p.position.y; + *dst++ = p.position.z; + *dst++ = p.life / p.maxLife; + *dst++ = p.size; + *dst++ = p.isSpark; + } + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, smokePipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + smokePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + + // Push constant: screenHeight + float screenHeight = static_cast(vkCtx_->getSwapchainExtent().height); + vkCmdPushConstants(cmd, smokePipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, + sizeof(float), &screenHeight); + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &smokeVB_, &offset); + vkCmdDraw(cmd, static_cast(count), 1, 0, 0); +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/m2_renderer_render.cpp b/src/rendering/m2_renderer_render.cpp new file mode 100644 index 00000000..6b2e33f4 --- /dev/null +++ b/src/rendering/m2_renderer_render.cpp @@ -0,0 +1,1637 @@ +#include "rendering/m2_renderer.hpp" +#include "rendering/m2_renderer_internal.h" +#include "rendering/m2_model_classifier.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_buffer.hpp" +#include "rendering/vk_texture.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_utils.hpp" +#include "rendering/vk_frame_data.hpp" +#include "rendering/camera.hpp" +#include "rendering/frustum.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/blp_loader.hpp" +#include "core/logger.hpp" +#include "core/profiler.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wowee { +namespace rendering { + +uint32_t M2Renderer::createInstance(uint32_t modelId, const glm::vec3& position, + const glm::vec3& rotation, float scale) { + auto modelIt = models.find(modelId); + if (modelIt == models.end()) { + LOG_WARNING("Cannot create instance: model ", modelId, " not loaded"); + return 0; + } + const auto& mdlRef = modelIt->second; + modelUnusedSince_.erase(modelId); + + // Deduplicate: skip if same model already at nearly the same position. + // Uses hash map for O(1) lookup instead of O(N) scan. + if (!mdlRef.isGroundDetail) { + DedupKey dk{modelId, + static_cast(std::round(position.x * 10.0f)), + static_cast(std::round(position.y * 10.0f)), + static_cast(std::round(position.z * 10.0f))}; + auto dit = instanceDedupMap_.find(dk); + if (dit != instanceDedupMap_.end()) { + return dit->second; + } + } + + M2Instance instance; + instance.id = nextInstanceId++; + instance.modelId = modelId; + instance.position = position; + if (mdlRef.isGroundDetail) { + instance.position.z -= computeGroundDetailDownOffset(mdlRef, scale); + } + instance.rotation = rotation; + instance.scale = scale; + instance.updateModelMatrix(); + glm::vec3 localMin, localMax; + getTightCollisionBounds(mdlRef, localMin, localMax); + transformAABB(instance.modelMatrix, localMin, localMax, instance.worldBoundsMin, instance.worldBoundsMax); + + // Cache model flags on instance to avoid per-frame hash lookups + instance.cachedHasAnimation = mdlRef.hasAnimation; + instance.cachedDisableAnimation = mdlRef.disableAnimation; + instance.cachedIsSmoke = mdlRef.isSmoke; + instance.cachedHasParticleEmitters = !mdlRef.particleEmitters.empty(); + instance.cachedBoundRadius = mdlRef.boundRadius; + instance.cachedIsGroundDetail = mdlRef.isGroundDetail; + instance.cachedIsInvisibleTrap = mdlRef.isInvisibleTrap; + instance.cachedIsInstancePortal = mdlRef.isInstancePortal; + instance.cachedIsValid = mdlRef.isValid(); + instance.cachedModel = &mdlRef; + + // Initialize animation: play first sequence (usually Stand/Idle) + const auto& mdl = mdlRef; + if (mdl.hasAnimation && !mdl.disableAnimation) { + if (!mdl.sequences.empty()) { + instance.currentSequenceIndex = 0; + instance.idleSequenceIndex = 0; + instance.animDuration = static_cast(mdl.sequences[0].duration); + instance.animTime = static_cast(randRange(std::max(1u, mdl.sequences[0].duration))); + instance.variationTimer = randFloat(3000.0f, 11000.0f); + } + + // Seed bone matrices from an existing instance of the same model so the + // new instance renders immediately instead of being invisible until the + // next update() computes bones (prevents pop-in flash). + for (const auto& existing : instances) { + if (existing.modelId == modelId && !existing.boneMatrices.empty()) { + instance.boneMatrices = existing.boneMatrices; + instance.bonesDirty[0] = instance.bonesDirty[1] = true; + break; + } + } + // If no sibling exists yet, compute bones immediately + if (instance.boneMatrices.empty()) { + computeBoneMatrices(mdlRef, instance); + } + } + + // Register in dedup map before pushing (uses original position, not ground-adjusted) + if (!mdlRef.isGroundDetail) { + DedupKey dk{modelId, + static_cast(std::round(position.x * 10.0f)), + static_cast(std::round(position.y * 10.0f)), + static_cast(std::round(position.z * 10.0f))}; + instanceDedupMap_[dk] = instance.id; + } + + instances.push_back(instance); + size_t idx = instances.size() - 1; + // Track special instances for fast-path iteration + if (mdlRef.isSmoke) { + smokeInstanceIndices_.push_back(idx); + } + if (mdlRef.isInstancePortal) { + portalInstanceIndices_.push_back(idx); + } + if (!mdlRef.particleEmitters.empty()) { + particleInstanceIndices_.push_back(idx); + } + if (mdlRef.hasAnimation && !mdlRef.disableAnimation) { + animatedInstanceIndices_.push_back(idx); + } else if (!mdlRef.particleEmitters.empty()) { + particleOnlyInstanceIndices_.push_back(idx); + } + instanceIndexById[instance.id] = idx; + GridCell minCell = toCell(instance.worldBoundsMin); + GridCell maxCell = toCell(instance.worldBoundsMax); + for (int z = minCell.z; z <= maxCell.z; z++) { + for (int y = minCell.y; y <= maxCell.y; y++) { + for (int x = minCell.x; x <= maxCell.x; x++) { + spatialGrid[GridCell{x, y, z}].push_back(instance.id); + } + } + } + + return instance.id; +} + +uint32_t M2Renderer::createInstanceWithMatrix(uint32_t modelId, const glm::mat4& modelMatrix, + const glm::vec3& position) { + if (models.find(modelId) == models.end()) { + LOG_WARNING("Cannot create instance: model ", modelId, " not loaded"); + return 0; + } + modelUnusedSince_.erase(modelId); + + // Deduplicate: O(1) hash lookup + { + DedupKey dk{modelId, + static_cast(std::round(position.x * 10.0f)), + static_cast(std::round(position.y * 10.0f)), + static_cast(std::round(position.z * 10.0f))}; + auto dit = instanceDedupMap_.find(dk); + if (dit != instanceDedupMap_.end()) { + return dit->second; + } + } + + M2Instance instance; + instance.id = nextInstanceId++; + instance.modelId = modelId; + instance.position = position; // Used for frustum culling + instance.rotation = glm::vec3(0.0f); + instance.scale = 1.0f; + instance.modelMatrix = modelMatrix; + instance.invModelMatrix = glm::inverse(modelMatrix); + glm::vec3 localMin, localMax; + getTightCollisionBounds(models[modelId], localMin, localMax); + transformAABB(instance.modelMatrix, localMin, localMax, instance.worldBoundsMin, instance.worldBoundsMax); + // Cache model flags on instance to avoid per-frame hash lookups + const auto& mdl2 = models[modelId]; + instance.cachedHasAnimation = mdl2.hasAnimation; + instance.cachedDisableAnimation = mdl2.disableAnimation; + instance.cachedIsSmoke = mdl2.isSmoke; + instance.cachedHasParticleEmitters = !mdl2.particleEmitters.empty(); + instance.cachedBoundRadius = mdl2.boundRadius; + instance.cachedIsGroundDetail = mdl2.isGroundDetail; + instance.cachedIsInvisibleTrap = mdl2.isInvisibleTrap; + instance.cachedIsValid = mdl2.isValid(); + instance.cachedModel = &mdl2; + + // Initialize animation + if (mdl2.hasAnimation && !mdl2.disableAnimation) { + if (!mdl2.sequences.empty()) { + instance.currentSequenceIndex = 0; + instance.idleSequenceIndex = 0; + instance.animDuration = static_cast(mdl2.sequences[0].duration); + instance.animTime = static_cast(randRange(std::max(1u, mdl2.sequences[0].duration))); + instance.variationTimer = randFloat(3000.0f, 11000.0f); + } + + // Seed bone matrices from an existing sibling so the instance renders immediately + for (const auto& existing : instances) { + if (existing.modelId == modelId && !existing.boneMatrices.empty()) { + instance.boneMatrices = existing.boneMatrices; + instance.bonesDirty[0] = instance.bonesDirty[1] = true; + break; + } + } + if (instance.boneMatrices.empty()) { + computeBoneMatrices(mdl2, instance); + } + } else { + instance.animTime = randFloat(0.0f, 10000.0f); + } + + // Register in dedup map + { + DedupKey dk{modelId, + static_cast(std::round(position.x * 10.0f)), + static_cast(std::round(position.y * 10.0f)), + static_cast(std::round(position.z * 10.0f))}; + instanceDedupMap_[dk] = instance.id; + } + + instances.push_back(instance); + size_t idx = instances.size() - 1; + if (mdl2.isSmoke) { + smokeInstanceIndices_.push_back(idx); + } + if (!mdl2.particleEmitters.empty()) { + particleInstanceIndices_.push_back(idx); + } + if (mdl2.hasAnimation && !mdl2.disableAnimation) { + animatedInstanceIndices_.push_back(idx); + } else if (!mdl2.particleEmitters.empty()) { + particleOnlyInstanceIndices_.push_back(idx); + } + instanceIndexById[instance.id] = idx; + GridCell minCell = toCell(instance.worldBoundsMin); + GridCell maxCell = toCell(instance.worldBoundsMax); + for (int z = minCell.z; z <= maxCell.z; z++) { + for (int y = minCell.y; y <= maxCell.y; y++) { + for (int x = minCell.x; x <= maxCell.x; x++) { + spatialGrid[GridCell{x, y, z}].push_back(instance.id); + } + } + } + + return instance.id; +} + +void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm::mat4& viewProjection) { + ZoneScopedN("M2Renderer::update"); + if (spatialIndexDirty_) { + rebuildSpatialIndex(); + } + + float dtMs = deltaTime * 1000.0f; + + // Cache camera state for frustum-culling bone computation + cachedCamPos_ = cameraPos; + const float maxRenderDistance = (instances.size() > 2000) ? 800.0f : 2800.0f; + cachedMaxRenderDistSq_ = maxRenderDistance * maxRenderDistance; + + // Build frustum for culling bones + Frustum updateFrustum; + updateFrustum.extractFromMatrix(viewProjection); + + // --- Smoke particle spawning (only iterate tracked smoke instances) --- + std::uniform_real_distribution distXY(-0.4f, 0.4f); + std::uniform_real_distribution distVelXY(-0.3f, 0.3f); + std::uniform_real_distribution distVelZ(3.0f, 5.0f); + std::uniform_real_distribution distLife(4.0f, 7.0f); + std::uniform_real_distribution distDrift(-0.2f, 0.2f); + + smokeEmitAccum += deltaTime; + constexpr float emitInterval = kSmokeEmitInterval; // 48 particles per second per emitter + + if (smokeEmitAccum >= emitInterval && + static_cast(smokeParticles.size()) < MAX_SMOKE_PARTICLES) { + for (size_t si : smokeInstanceIndices_) { + if (si >= instances.size()) continue; + auto& instance = instances[si]; + + glm::vec3 emitWorld = glm::vec3(instance.modelMatrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); + bool spark = (smokeRng() % 8 == 0); + + SmokeParticle p; + p.position = emitWorld + glm::vec3(distXY(smokeRng), distXY(smokeRng), 0.0f); + if (spark) { + p.velocity = glm::vec3(distVelXY(smokeRng) * 2.0f, distVelXY(smokeRng) * 2.0f, distVelZ(smokeRng) * 1.5f); + p.maxLife = 0.8f + static_cast(smokeRng() % 100) / 100.0f * 1.2f; + p.size = 0.5f; + p.isSpark = 1.0f; + } else { + p.velocity = glm::vec3(distVelXY(smokeRng), distVelXY(smokeRng), distVelZ(smokeRng)); + p.maxLife = distLife(smokeRng); + p.size = 1.0f; + p.isSpark = 0.0f; + } + p.life = 0.0f; + p.instanceId = instance.id; + smokeParticles.push_back(p); + if (static_cast(smokeParticles.size()) >= MAX_SMOKE_PARTICLES) break; + } + smokeEmitAccum = 0.0f; + } + + // --- Update existing smoke particles (swap-and-pop for O(1) removal) --- + for (size_t i = 0; i < smokeParticles.size(); ) { + auto& p = smokeParticles[i]; + p.life += deltaTime; + if (p.life >= p.maxLife) { + smokeParticles[i] = smokeParticles.back(); + smokeParticles.pop_back(); + continue; + } + p.position += p.velocity * deltaTime; + p.velocity.z *= 0.98f; // Slight deceleration + p.velocity.x += distDrift(smokeRng) * deltaTime; + p.velocity.y += distDrift(smokeRng) * deltaTime; + // Grow from 1.0 to 3.5 over lifetime + float t = p.life / p.maxLife; + p.size = 1.0f + t * 2.5f; + ++i; + } + + // --- Spin instance portals --- + static constexpr float PORTAL_SPIN_SPEED = 1.2f; // radians/sec + static constexpr float kTwoPi = 6.2831853f; + for (size_t idx : portalInstanceIndices_) { + if (idx >= instances.size()) continue; + auto& inst = instances[idx]; + inst.portalSpinAngle += PORTAL_SPIN_SPEED * deltaTime; + if (inst.portalSpinAngle > kTwoPi) + inst.portalSpinAngle -= kTwoPi; + inst.rotation.z = inst.portalSpinAngle; + inst.updateModelMatrix(); + } + + // --- Normal M2 animation update --- + // Advance animTime for ALL instances (needed for texture UV animation on static doodads). + // This is a tight loop touching only one float per instance — no hash lookups. + for (auto& instance : instances) { + instance.animTime += dtMs; + } + // Wrap animTime for particle-only instances so emission rate tracks keep looping. + // 3333ms chosen as a safe wrap period: long enough to cover the longest known M2 + // particle emission cycle (~3s for torch/campfire effects) while preventing float + // precision loss that accumulates over hours of runtime. + static constexpr float kParticleWrapMs = 3333.0f; + for (size_t idx : particleOnlyInstanceIndices_) { + if (idx >= instances.size()) continue; + auto& instance = instances[idx]; + // Use iterative subtraction instead of fmod() to preserve precision + while (instance.animTime > kParticleWrapMs) { + instance.animTime -= kParticleWrapMs; + } + } + + boneWorkIndices_.clear(); + boneWorkIndices_.reserve(animatedInstanceIndices_.size()); + + // Update animated instances (full animation state + bone computation culling) + // Note: animTime was already advanced by dtMs in the global loop above. + // Here we apply the speed factor: subtract the base dtMs and add dtMs*speed. + for (size_t idx : animatedInstanceIndices_) { + if (idx >= instances.size()) continue; + auto& instance = instances[idx]; + + instance.animTime += dtMs * (instance.animSpeed - 1.0f); + + // For animation looping/variation, we need the actual model data. + if (!instance.cachedModel) continue; + const M2ModelGPU& model = *instance.cachedModel; + + // Validate sequence index + if (instance.currentSequenceIndex < 0 || + instance.currentSequenceIndex >= static_cast(model.sequences.size())) { + instance.currentSequenceIndex = 0; + if (!model.sequences.empty()) { + instance.animDuration = static_cast(model.sequences[0].duration); + } + } + + // Handle animation looping / variation transitions + if (instance.animDuration <= 0.0f && instance.cachedHasParticleEmitters) { + instance.animDuration = 3333.0f; + } + if (instance.animDuration > 0.0f && instance.animTime >= instance.animDuration) { + if (instance.playingVariation) { + instance.playingVariation = false; + instance.currentSequenceIndex = instance.idleSequenceIndex; + if (instance.idleSequenceIndex < static_cast(model.sequences.size())) { + instance.animDuration = static_cast(model.sequences[instance.idleSequenceIndex].duration); + } + instance.animTime = 0.0f; + instance.variationTimer = randFloat(4000.0f, 10000.0f); + } else { + // Use iterative subtraction instead of fmod() to preserve precision + float duration = std::max(1.0f, instance.animDuration); + while (instance.animTime >= duration) { + instance.animTime -= duration; + } + } + } + + // Idle variation timer + if (!instance.playingVariation && model.idleVariationIndices.size() > 1) { + instance.variationTimer -= dtMs; + if (instance.variationTimer <= 0.0f) { + int pick = static_cast(randRange(static_cast(model.idleVariationIndices.size()))); + int newSeq = model.idleVariationIndices[pick]; + if (newSeq != instance.currentSequenceIndex && newSeq < static_cast(model.sequences.size())) { + instance.playingVariation = true; + instance.currentSequenceIndex = newSeq; + instance.animDuration = static_cast(model.sequences[newSeq].duration); + instance.animTime = 0.0f; + } else { + instance.variationTimer = randFloat(2000.0f, 6000.0f); + } + } + } + + // Frustum + distance cull: skip expensive bone computation for off-screen instances. + float worldRadius = instance.cachedBoundRadius * instance.scale; + float cullRadius = worldRadius; + glm::vec3 toCam = instance.position - cachedCamPos_; + float distSq = glm::dot(toCam, toCam); + float effectiveMaxDistSq = cachedMaxRenderDistSq_ * std::max(1.0f, cullRadius / 12.0f); + if (distSq > effectiveMaxDistSq) continue; + float paddedRadius = std::max(cullRadius * 1.5f, cullRadius + 3.0f); + if (cullRadius > 0.0f && !updateFrustum.intersectsSphere(instance.position, paddedRadius)) continue; + + // LOD 3 skip: models beyond 150 units use the lowest LOD mesh which has + // no visible skeletal animation. Keep their last-computed bone matrices + // (always valid — seeded on spawn) and avoid the expensive per-bone work. + constexpr float kLOD3DistSq = 150.0f * 150.0f; + if (distSq > kLOD3DistSq) continue; + + // Distance-based frame skipping: update distant bones less frequently + uint32_t boneInterval = 1; + if (distSq > 100.0f * 100.0f) boneInterval = 4; + else if (distSq > 50.0f * 50.0f) boneInterval = 2; + instance.frameSkipCounter++; + if ((instance.frameSkipCounter % boneInterval) != 0) continue; + + boneWorkIndices_.push_back(idx); + } + + // Compute bone matrices (expensive, parallel if enough work) + const size_t animCount = boneWorkIndices_.size(); + if (animCount > 0) { + static const size_t minParallelAnimInstances = std::max( + 8, envSizeOrDefault("WOWEE_M2_ANIM_MT_MIN", 96)); + if (animCount < minParallelAnimInstances || numAnimThreads_ <= 1) { + // Sequential — not enough work to justify thread overhead + for (size_t i : boneWorkIndices_) { + if (i >= instances.size()) continue; + auto& inst = instances[i]; + if (!inst.cachedModel) continue; + computeBoneMatrices(*inst.cachedModel, inst); + } + } else { + // Parallel — dispatch across worker threads + static const size_t minAnimWorkPerThread = std::max( + 16, envSizeOrDefault("WOWEE_M2_ANIM_WORK_PER_THREAD", 64)); + const size_t maxUsefulThreads = std::max( + 1, (animCount + minAnimWorkPerThread - 1) / minAnimWorkPerThread); + const size_t numThreads = std::min(static_cast(numAnimThreads_), maxUsefulThreads); + if (numThreads <= 1) { + for (size_t i : boneWorkIndices_) { + if (i >= instances.size()) continue; + auto& inst = instances[i]; + if (!inst.cachedModel) continue; + computeBoneMatrices(*inst.cachedModel, inst); + } + } else { + const size_t chunkSize = animCount / numThreads; + const size_t remainder = animCount % numThreads; + + // Reuse persistent futures vector to avoid allocation + animFutures_.clear(); + if (animFutures_.capacity() < numThreads) { + animFutures_.reserve(numThreads); + } + + size_t start = 0; + for (size_t t = 0; t < numThreads; ++t) { + size_t end = start + chunkSize + (t < remainder ? 1 : 0); + animFutures_.push_back(std::async(std::launch::async, + [this, start, end]() { + for (size_t j = start; j < end; ++j) { + size_t idx = boneWorkIndices_[j]; + if (idx >= instances.size()) continue; + auto& inst = instances[idx]; + if (!inst.cachedModel) continue; + computeBoneMatrices(*inst.cachedModel, inst); + } + })); + start = end; + } + + for (auto& f : animFutures_) { + f.get(); + } + } + } + } + + // Particle update (sequential — uses RNG, not thread-safe) + // Only iterate instances that have particle emitters (pre-built list). + for (size_t idx : particleInstanceIndices_) { + if (idx >= instances.size()) continue; + auto& instance = instances[idx]; + // Distance cull: only update particles within visible range + glm::vec3 toCam = instance.position - cachedCamPos_; + float distSq = glm::dot(toCam, toCam); + if (distSq > cachedMaxRenderDistSq_) continue; + if (!instance.cachedModel) continue; + emitParticles(instance, *instance.cachedModel, deltaTime); + updateParticles(instance, deltaTime); + if (!instance.cachedModel->ribbonEmitters.empty()) { + updateRibbons(instance, *instance.cachedModel, deltaTime); + } + } + +} + +void M2Renderer::prepareRender(uint32_t frameIndex, const Camera& camera) { + if (!initialized_ || instances.empty()) return; + (void)camera; // reserved for future frustum-based culling + + // --- Mega bone SSBO: assign slots and upload all animated instance bones --- + // Slot 0 = identity (non-animated), slots 1..N = animated instances. + uint32_t nextSlot = 1; + for (size_t idx : animatedInstanceIndices_) { + if (idx >= instances.size()) continue; + auto& instance = instances[idx]; + + if (instance.boneMatrices.empty()) { + instance.megaBoneOffset = 0; // Use identity slot + continue; + } + + if (nextSlot >= MEGA_BONE_MAX_INSTANCES) { + instance.megaBoneOffset = 0; // Overflow — use identity + continue; + } + + instance.megaBoneOffset = nextSlot * MAX_BONES_PER_INSTANCE; + + // Upload bone matrices to mega buffer + if (megaBoneMapped_[frameIndex]) { + int numBones = std::min(static_cast(instance.boneMatrices.size()), + static_cast(MAX_BONES_PER_INSTANCE)); + auto* dst = static_cast(megaBoneMapped_[frameIndex]) + instance.megaBoneOffset; + memcpy(dst, instance.boneMatrices.data(), numBones * sizeof(glm::mat4)); + } + + nextSlot++; + } +} + +// Dispatch GPU frustum culling compute shader. +// Called on the primary command buffer BEFORE the render pass begins so that +// compute dispatch and memory barrier complete before secondary command buffers +// read the visibility output in render(). +void M2Renderer::dispatchCullCompute(VkCommandBuffer cmd, uint32_t frameIndex, const Camera& camera) { + if (!cullPipeline_ || instances.empty()) return; + + const uint32_t numInstances = std::min(static_cast(instances.size()), MAX_CULL_INSTANCES); + + // --- Compute per-instance adaptive distances (same formula as old CPU cull) --- + const float targetRenderDist = (instances.size() > 2000) ? 300.0f + : (instances.size() > 1000) ? 500.0f + : 1000.0f; + const float shrinkRate = 0.005f; + const float growRate = 0.05f; + float blendRate = (targetRenderDist < smoothedRenderDist_) ? shrinkRate : growRate; + smoothedRenderDist_ = glm::mix(smoothedRenderDist_, targetRenderDist, blendRate); + const float maxRenderDistance = smoothedRenderDist_; + const float maxRenderDistanceSq = maxRenderDistance * maxRenderDistance; + const float maxPossibleDistSq = maxRenderDistanceSq * 4.0f; // 2x safety margin + + // --- Upload frustum planes + camera (UBO, binding 0) --- + const glm::mat4 vp = camera.getProjectionMatrix() * camera.getViewMatrix(); + Frustum frustum; + frustum.extractFromMatrix(vp); + const glm::vec3 camPos = camera.getPosition(); + + if (cullUniformMapped_[frameIndex]) { + auto* ubo = static_cast(cullUniformMapped_[frameIndex]); + for (int i = 0; i < 6; i++) { + const auto& p = frustum.getPlane(static_cast(i)); + ubo->frustumPlanes[i] = glm::vec4(p.normal, p.distance); + } + ubo->cameraPos = glm::vec4(camPos, maxPossibleDistSq); + ubo->instanceCount = numInstances; + } + + // --- Upload per-instance cull data (SSBO, binding 1) --- + if (cullInputMapped_[frameIndex]) { + auto* input = static_cast(cullInputMapped_[frameIndex]); + for (uint32_t i = 0; i < numInstances; i++) { + const auto& inst = instances[i]; + float worldRadius = inst.cachedBoundRadius * inst.scale; + float cullRadius = worldRadius; + if (inst.cachedDisableAnimation) { + cullRadius = std::max(cullRadius, 3.0f); + } + float effectiveMaxDistSq = maxRenderDistanceSq * std::max(1.0f, cullRadius / 12.0f); + if (inst.cachedDisableAnimation) effectiveMaxDistSq *= 2.6f; + if (inst.cachedIsGroundDetail) effectiveMaxDistSq *= 0.9f; + + float paddedRadius = std::max(cullRadius * 1.5f, cullRadius + 3.0f); + + uint32_t flags = 0; + if (inst.cachedIsValid) flags |= 1u; + if (inst.cachedIsSmoke) flags |= 2u; + if (inst.cachedIsInvisibleTrap) flags |= 4u; + + input[i].sphere = glm::vec4(inst.position, paddedRadius); + input[i].effectiveMaxDistSq = effectiveMaxDistSq; + input[i].flags = flags; + } + } + + // --- Dispatch compute shader --- + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, cullPipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_COMPUTE, + cullPipelineLayout_, 0, 1, &cullSet_[frameIndex], 0, nullptr); + + const uint32_t groupCount = (numInstances + 63) / 64; + vkCmdDispatch(cmd, groupCount, 1, 1); + + // --- Memory barrier: compute writes → host reads --- + VkMemoryBarrier barrier{VK_STRUCTURE_TYPE_MEMORY_BARRIER}; + barrier.srcAccessMask = VK_ACCESS_SHADER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + vkCmdPipelineBarrier(cmd, + VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT, + VK_PIPELINE_STAGE_HOST_BIT, + 0, 1, &barrier, 0, nullptr, 0, nullptr); +} + +void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { + if (instances.empty() || !opaquePipeline_) { + return; + } + + // Debug: log once when we start rendering + static bool loggedOnce = false; + if (!loggedOnce) { + loggedOnce = true; + LOG_INFO("M2 render: ", instances.size(), " instances, ", models.size(), " models"); + } + + // Periodic diagnostic: report render pipeline stats every 10 seconds + static int diagCounter = 0; + if (++diagCounter == 600) { // ~10s at 60fps + diagCounter = 0; + uint32_t totalValid = 0, totalAnimated = 0, totalBonesReady = 0, totalMegaBoneOk = 0; + for (const auto& inst : instances) { + if (inst.cachedIsValid) totalValid++; + if (inst.cachedHasAnimation && !inst.cachedDisableAnimation) { + totalAnimated++; + if (!inst.boneMatrices.empty()) totalBonesReady++; + if (inst.megaBoneOffset != 0) totalMegaBoneOk++; + } + } + LOG_INFO("M2 diag: total=", instances.size(), + " valid=", totalValid, + " animated=", totalAnimated, + " bonesReady=", totalBonesReady, + " megaBoneOk=", totalMegaBoneOk, + " visible=", sortedVisible_.size(), + " draws=", lastDrawCallCount); + } + + // Reuse persistent buffers (clear instead of reallocating) + glowSprites_.clear(); + + lastDrawCallCount = 0; + + // GPU cull results — dispatchCullCompute() already updated smoothedRenderDist_. + // Use the cached value (set by dispatchCullCompute or fallback below). + const uint32_t frameIndex = vkCtx_->getCurrentFrame(); + const uint32_t numInstances = std::min(static_cast(instances.size()), MAX_CULL_INSTANCES); + const uint32_t* visibility = static_cast(cullOutputMapped_[frameIndex]); + const bool gpuCullAvailable = (cullPipeline_ != VK_NULL_HANDLE && visibility != nullptr); + + // If GPU culling was not dispatched, fallback: compute distances on CPU + float maxRenderDistanceSq; + if (!gpuCullAvailable) { + const float targetRenderDist = (instances.size() > 2000) ? 300.0f + : (instances.size() > 1000) ? 500.0f + : 1000.0f; + const float shrinkRate = 0.005f; + const float growRate = 0.05f; + float blendRate = (targetRenderDist < smoothedRenderDist_) ? shrinkRate : growRate; + smoothedRenderDist_ = glm::mix(smoothedRenderDist_, targetRenderDist, blendRate); + maxRenderDistanceSq = smoothedRenderDist_ * smoothedRenderDist_; + } else { + maxRenderDistanceSq = smoothedRenderDist_ * smoothedRenderDist_; + } + + const float fadeStartFraction = 0.75f; + const glm::vec3 camPos = camera.getPosition(); + + // Build sorted visible instance list + sortedVisible_.clear(); + const size_t expectedVisible = std::min(instances.size() / 3, size_t(600)); + if (sortedVisible_.capacity() < expectedVisible) { + sortedVisible_.reserve(expectedVisible); + } + + // GPU frustum culling — build frustum for CPU fallback path and overflow instances + Frustum frustum; + { + const glm::mat4 vp = camera.getProjectionMatrix() * camera.getViewMatrix(); + frustum.extractFromMatrix(vp); + } + const float maxPossibleDistSq = maxRenderDistanceSq * 4.0f; + + const uint32_t totalInstances = static_cast(instances.size()); + for (uint32_t i = 0; i < totalInstances; ++i) { + const auto& instance = instances[i]; + + if (gpuCullAvailable && i < numInstances) { + // GPU already tested flags + distance + frustum + if (!visibility[i]) continue; + } else { + // CPU fallback: for non-GPU path or instances beyond cull buffer + if (!instance.cachedIsValid || instance.cachedIsSmoke || instance.cachedIsInvisibleTrap) continue; + + glm::vec3 toCam = instance.position - camPos; + float distSqTest = glm::dot(toCam, toCam); + if (distSqTest > maxPossibleDistSq) continue; + + float worldRadius = instance.cachedBoundRadius * instance.scale; + float cullRadius = worldRadius; + if (instance.cachedDisableAnimation) cullRadius = std::max(cullRadius, 3.0f); + float effDistSq = maxRenderDistanceSq * std::max(1.0f, cullRadius / 12.0f); + if (instance.cachedDisableAnimation) effDistSq *= 2.6f; + if (instance.cachedIsGroundDetail) effDistSq *= 0.9f; + if (distSqTest > effDistSq) continue; + + float paddedRadius = std::max(cullRadius * 1.5f, cullRadius + 3.0f); + if (cullRadius > 0.0f && !frustum.intersectsSphere(instance.position, paddedRadius)) continue; + } + + // Compute distSq + effectiveMaxDistSq for sorting and fade alpha (cheap for visible-only) + glm::vec3 toCam = instance.position - camPos; + float distSq = glm::dot(toCam, toCam); + float worldRadius = instance.cachedBoundRadius * instance.scale; + float cullRadius = worldRadius; + if (instance.cachedDisableAnimation) cullRadius = std::max(cullRadius, 3.0f); + float effectiveMaxDistSq = maxRenderDistanceSq * std::max(1.0f, cullRadius / 12.0f); + if (instance.cachedDisableAnimation) effectiveMaxDistSq *= 2.6f; + if (instance.cachedIsGroundDetail) effectiveMaxDistSq *= 0.9f; + + sortedVisible_.push_back({i, instance.modelId, distSq, effectiveMaxDistSq}); + } + + // Two-pass rendering: opaque/alpha-test first (depth write ON), then transparent/additive + // (depth write OFF, sorted back-to-front) so transparent geometry composites correctly + // against all opaque geometry rather than only against what was rendered before it. + + // Pass 1: sort by modelId for minimum buffer rebinds (opaque batches) + std::sort(sortedVisible_.begin(), sortedVisible_.end(), + [](const VisibleEntry& a, const VisibleEntry& b) { return a.modelId < b.modelId; }); + + uint32_t currentModelId = UINT32_MAX; + const M2ModelGPU* currentModel = nullptr; + bool currentModelValid = false; + + // State tracking + VkPipeline currentPipeline = VK_NULL_HANDLE; + VkDescriptorSet currentMaterialSet = VK_NULL_HANDLE; + + // Push constants now carry per-batch data only; per-instance data is in instance SSBO. + struct M2PushConstants { + int32_t texCoordSet; // UV set index (0 or 1) + int32_t isFoliage; // Foliage wind animation flag + int32_t instanceDataOffset; // Base index into instance SSBO for this draw group + }; + + // Validate per-frame descriptor set before any Vulkan commands + if (!perFrameSet) { + LOG_ERROR("M2Renderer::render: perFrameSet is VK_NULL_HANDLE — skipping M2 render"); + return; + } + + // Bind per-frame descriptor set (set 0) — shared across all draws + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + + // Start with opaque pipeline + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, opaquePipeline_); + currentPipeline = opaquePipeline_; + + // Bind dummy bone set (set 2) so non-animated draws have a valid binding. + // Bind mega bone SSBO instead — all instances index into one buffer via boneBase. + if (megaBoneSet_[frameIndex]) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 2, 1, &megaBoneSet_[frameIndex], 0, nullptr); + } else if (dummyBoneSet_) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 2, 1, &dummyBoneSet_, 0, nullptr); + } + + // Bind instance data SSBO (set 3) — per-instance transforms, fade, bones + if (instanceSet_[frameIndex]) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 3, 1, &instanceSet_[frameIndex], 0, nullptr); + } + + // Reset instance SSBO write cursor for this frame + instanceDataCount_ = 0; + auto* instSSBO = static_cast(instanceMapped_[frameIndex]); + + // ===================================================================== + // Opaque pass — instanced draws grouped by (modelId, LOD) + // ===================================================================== + // sortedVisible_ is already sorted by modelId so consecutive entries share + // the same vertex/index buffer. Within each model group we sub-group by + // targetLOD to guarantee all instances in one vkCmdDrawIndexed use the + // same batch set. Per-instance data (model matrix, fade, bones) is + // written to the instance SSBO; the shader reads it via gl_InstanceIndex. + { + struct PendingInstance { + uint32_t instanceIdx; + float fadeAlpha; + bool useBones; + uint16_t targetLOD; + }; + std::vector pending; + pending.reserve(128); + + size_t visStart = 0; + while (visStart < sortedVisible_.size()) { + // Find group of consecutive entries with same modelId + uint32_t groupModelId = sortedVisible_[visStart].modelId; + size_t groupEnd = visStart; + while (groupEnd < sortedVisible_.size() && sortedVisible_[groupEnd].modelId == groupModelId) + groupEnd++; + + auto mdlIt = models.find(groupModelId); + if (mdlIt == models.end() || !mdlIt->second.vertexBuffer || !mdlIt->second.indexBuffer) { + visStart = groupEnd; + continue; + } + const M2ModelGPU& model = mdlIt->second; + + bool modelNeedsAnimation = model.hasAnimation && !model.disableAnimation; + const bool foliageLikeModel = model.isFoliageLike; + const bool particleDominantEffect = model.isSpellEffect && + !model.particleEmitters.empty() && model.batches.size() <= 2; + + // Collect per-instance data for this model group + pending.clear(); + for (size_t vi = visStart; vi < groupEnd; vi++) { + const auto& entry = sortedVisible_[vi]; + if (entry.index >= instances.size()) continue; + auto& instance = instances[entry.index]; + + // Distance-based fade alpha + float fadeFrac = model.disableAnimation ? 0.55f : fadeStartFraction; + float fadeStartDistSq = entry.effectiveMaxDistSq * fadeFrac * fadeFrac; + float fadeAlpha = 1.0f; + if (entry.distSq > fadeStartDistSq) { + fadeAlpha = std::clamp((entry.effectiveMaxDistSq - entry.distSq) / + (entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f); + } + float instanceFadeAlpha = fadeAlpha; + if (model.isGroundDetail) instanceFadeAlpha *= 0.82f; + if (model.isInstancePortal) { + instanceFadeAlpha *= 0.12f; + if (entry.distSq < 400.0f * 400.0f) { + glm::vec3 center = glm::vec3(instance.modelMatrix * glm::vec4(0.0f, 0.0f, 0.0f, 1.0f)); + GlowSprite gs; + gs.worldPos = center; + gs.color = glm::vec4(0.35f, 0.5f, 1.0f, 1.1f); + gs.size = instance.scale * 5.0f; + glowSprites_.push_back(gs); + GlowSprite halo = gs; + halo.color.a *= 0.3f; + halo.size *= 2.2f; + glowSprites_.push_back(halo); + } + } + + // Bone readiness check + if (modelNeedsAnimation && instance.boneMatrices.empty()) continue; + bool needsBones = modelNeedsAnimation && !instance.boneMatrices.empty(); + if (needsBones && instance.megaBoneOffset == 0) continue; + + // LOD selection + uint16_t desiredLOD = 0; + if (entry.distSq > 150.0f * 150.0f) desiredLOD = 3; + else if (entry.distSq > 80.0f * 80.0f) desiredLOD = 2; + else if (entry.distSq > 40.0f * 40.0f) desiredLOD = 1; + uint16_t targetLOD = desiredLOD; + if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) targetLOD = 0; + + pending.push_back({entry.index, instanceFadeAlpha, needsBones, targetLOD}); + } + + if (pending.empty()) { visStart = groupEnd; continue; } + + // Sort by targetLOD so each sub-group occupies a contiguous SSBO range + std::sort(pending.begin(), pending.end(), + [](const PendingInstance& a, const PendingInstance& b) { return a.targetLOD < b.targetLOD; }); + + // Bind vertex/index buffers once per model group + VkDeviceSize vbOffset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &model.vertexBuffer, &vbOffset); + vkCmdBindIndexBuffer(cmd, model.indexBuffer, 0, VK_INDEX_TYPE_UINT16); + + // Write base instance data to SSBO (uvOffset=0 — overridden for tex-anim batches) + uint32_t baseSSBOOffset = instanceDataCount_; + for (const auto& p : pending) { + if (instanceDataCount_ >= MAX_INSTANCE_DATA) break; + auto& inst = instances[p.instanceIdx]; + auto& e = instSSBO[instanceDataCount_]; + e.model = inst.modelMatrix; + e.uvOffset = glm::vec2(0.0f); + e.fadeAlpha = p.fadeAlpha; + e.useBones = p.useBones ? 1 : 0; + e.boneBase = p.useBones ? static_cast(inst.megaBoneOffset) : 0; + std::memset(e._pad, 0, sizeof(e._pad)); + instanceDataCount_++; + } + + // Process LOD sub-groups within this model group + size_t lodIdx = 0; + while (lodIdx < pending.size()) { + uint16_t lod = pending[lodIdx].targetLOD; + size_t lodEnd = lodIdx + 1; + while (lodEnd < pending.size() && pending[lodEnd].targetLOD == lod) lodEnd++; + uint32_t groupSize = static_cast(lodEnd - lodIdx); + uint32_t groupSSBOOffset = baseSSBOOffset + static_cast(lodIdx); + + for (size_t bi = 0; bi < model.batches.size(); bi++) { + const auto& batch = model.batches[bi]; + if (batch.indexCount == 0) continue; + if (!model.isGroundDetail && batch.submeshLevel != lod) continue; + if (batch.batchOpacity < 0.01f) continue; + + // Opaque gate — skip transparent batches + const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect; + if (rawTransparent) continue; + + // Particle-dominant effects: emission geometry — skip opaque + if (particleDominantEffect && batch.blendMode <= 1) continue; + + // Glow sprite check (per model+batch, sprites generated per instance) + const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; + const bool smallCardLikeBatch = + (batch.glowSize <= 1.35f) || + (batch.lanternGlowHint && batch.glowSize <= 6.0f); + const bool batchUnlit = (batch.materialFlags & 0x01) != 0; + const bool shouldUseGlowSprite = + !koboldFlameCard && + (model.isElvenLike || (model.isLanternLike && batch.lanternGlowHint)) && + !model.isSpellEffect && + smallCardLikeBatch && + (batch.lanternGlowHint || + (batch.blendMode >= 3) || + (batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1)); + if (shouldUseGlowSprite) { + // Generate glow sprites for each instance in the group + for (size_t j = lodIdx; j < lodEnd; j++) { + auto& inst = instances[pending[j].instanceIdx]; + float distSq = sortedVisible_[visStart].distSq; // approximate with group + if (distSq < 180.0f * 180.0f) { + glm::vec3 worldPos = glm::vec3(inst.modelMatrix * glm::vec4(batch.center, 1.0f)); + GlowSprite gs; + gs.worldPos = worldPos; + if (batch.glowTint == 1 || model.isElvenLike) + gs.color = glm::vec4(0.48f, 0.72f, 1.0f, 1.05f); + else if (batch.glowTint == 2) + gs.color = glm::vec4(1.0f, 0.28f, 0.22f, 1.10f); + else + gs.color = glm::vec4(1.0f, 0.82f, 0.46f, 1.15f); + gs.size = batch.glowSize * inst.scale * 1.45f; + glowSprites_.push_back(gs); + GlowSprite halo = gs; + halo.color.a *= 0.42f; + halo.size *= 1.8f; + glowSprites_.push_back(halo); + } + } + const bool cardLikeSkipMesh = + (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit; + const bool lanternGlowCardSkip = + model.isLanternLike && batch.lanternGlowHint && + smallCardLikeBatch && cardLikeSkipMesh; + if (lanternGlowCardSkip || (cardLikeSkipMesh && !model.isLanternLike)) + continue; + } + + // Handle texture animation: if this batch has per-instance uvOffset, + // write a separate SSBO range with the correct offsets. + bool hasBatchTexAnim = (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) + || model.isLavaModel; + uint32_t drawOffset = groupSSBOOffset; + if (hasBatchTexAnim && instanceDataCount_ + groupSize <= MAX_INSTANCE_DATA) { + drawOffset = instanceDataCount_; + for (size_t j = lodIdx; j < lodEnd; j++) { + auto& inst = instances[pending[j].instanceIdx]; + glm::vec2 uvOffset(0.0f); + if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) { + uint16_t lookupIdx = batch.textureAnimIndex; + if (lookupIdx < model.textureTransformLookup.size()) { + uint16_t transformIdx = model.textureTransformLookup[lookupIdx]; + if (transformIdx < model.textureTransforms.size()) { + const auto& tt = model.textureTransforms[transformIdx]; + glm::vec3 trans = interpVec3(tt.translation, + inst.currentSequenceIndex, inst.animTime, + glm::vec3(0.0f), model.globalSequenceDurations); + uvOffset = glm::vec2(trans.x, trans.y); + } + } + } + if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) { + float t = std::chrono::duration( + std::chrono::steady_clock::now() - kLavaAnimStart).count(); + uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); + } + // Copy base entry and override uvOffset + instSSBO[instanceDataCount_] = instSSBO[groupSSBOOffset + (j - lodIdx)]; + instSSBO[instanceDataCount_].uvOffset = uvOffset; + instanceDataCount_++; + } + } + + // Pipeline selection (per-model/batch, not per-instance) + const bool foliageCutout = foliageLikeModel && !model.isSpellEffect && batch.blendMode <= 3; + const bool forceCutout = + !model.isSpellEffect && + (model.isGroundDetail || foliageCutout || + batch.blendMode == 1 || + (batch.blendMode >= 2 && !batch.hasAlpha) || + batch.colorKeyBlack); + + uint8_t effectiveBlendMode = batch.blendMode; + if (model.isSpellEffect) { + if (effectiveBlendMode <= 1) effectiveBlendMode = 3; + else if (effectiveBlendMode == 4 || effectiveBlendMode == 5) effectiveBlendMode = 3; + } + if (forceCutout) effectiveBlendMode = 1; + + VkPipeline desiredPipeline; + if (forceCutout) { + desiredPipeline = opaquePipeline_; + } else { + switch (effectiveBlendMode) { + case 0: desiredPipeline = opaquePipeline_; break; + case 1: desiredPipeline = alphaTestPipeline_; break; + case 2: desiredPipeline = alphaPipeline_; break; + default: desiredPipeline = additivePipeline_; break; + } + } + if (desiredPipeline != currentPipeline) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); + currentPipeline = desiredPipeline; + } + + // Update material UBO + if (batch.materialUBOMapped) { + auto* mat = static_cast(batch.materialUBOMapped); + mat->interiorDarken = insideInterior ? 1.0f : 0.0f; + if (batch.colorKeyBlack) + mat->colorKeyThreshold = (effectiveBlendMode == 4 || effectiveBlendMode == 5) ? 0.7f : 0.08f; + if (forceCutout) { + mat->alphaTest = model.isGroundDetail ? 3 : (foliageCutout ? 2 : 1); + if (model.isGroundDetail) mat->unlit = 0; + } + } + + // Bind material descriptor set (set 1) + if (!batch.materialSet) continue; + if (batch.materialSet != currentMaterialSet) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); + currentMaterialSet = batch.materialSet; + } + + // Push constants + instanced draw + M2PushConstants pc; + pc.texCoordSet = static_cast(batch.textureUnit); + pc.isFoliage = model.shadowWindFoliage ? 1 : 0; + pc.instanceDataOffset = static_cast(drawOffset); + vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc); + vkCmdDrawIndexed(cmd, batch.indexCount, groupSize, batch.indexStart, 0, 0); + lastDrawCallCount++; + } + + lodIdx = lodEnd; + } + + visStart = groupEnd; + } + } + + // ===================================================================== + // Pass 2: Transparent/additive batches — back-to-front per instance + // ===================================================================== + // Transparent geometry must be drawn individually per instance in back-to- + // front order for correct alpha compositing. Each draw writes one + // M2InstanceGPU entry and issues a single-instance indexed draw. + std::sort(sortedVisible_.begin(), sortedVisible_.end(), + [](const VisibleEntry& a, const VisibleEntry& b) { return a.distSq > b.distSq; }); + + currentModelId = UINT32_MAX; + currentModel = nullptr; + currentModelValid = false; + currentPipeline = opaquePipeline_; + currentMaterialSet = VK_NULL_HANDLE; + + for (const auto& entry : sortedVisible_) { + if (entry.index >= instances.size()) continue; + auto& instance = instances[entry.index]; + + // Quick skip: if model has no transparent batches at all + if (entry.modelId != currentModelId) { + auto mdlIt = models.find(entry.modelId); + if (mdlIt == models.end()) continue; + if (!mdlIt->second.hasTransparentBatches && !mdlIt->second.isSpellEffect) continue; + } + + if (entry.modelId != currentModelId) { + currentModelId = entry.modelId; + currentModelValid = false; + auto mdlIt = models.find(currentModelId); + if (mdlIt == models.end()) continue; + currentModel = &mdlIt->second; + if (!currentModel->vertexBuffer || !currentModel->indexBuffer) continue; + currentModelValid = true; + VkDeviceSize vbOff = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &vbOff); + vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); + } + if (!currentModelValid) continue; + + const M2ModelGPU& model = *currentModel; + + // Fade alpha + float fadeAlpha = 1.0f; + float fadeFrac = model.disableAnimation ? 0.55f : fadeStartFraction; + float fadeStartDistSq = entry.effectiveMaxDistSq * fadeFrac * fadeFrac; + if (entry.distSq > fadeStartDistSq) { + fadeAlpha = std::clamp((entry.effectiveMaxDistSq - entry.distSq) / + (entry.effectiveMaxDistSq - fadeStartDistSq), 0.0f, 1.0f); + } + float instanceFadeAlpha = fadeAlpha; + if (model.isGroundDetail) instanceFadeAlpha *= 0.82f; + if (model.isInstancePortal) instanceFadeAlpha *= 0.12f; + + bool modelNeedsAnimation = model.hasAnimation && !model.disableAnimation; + if (modelNeedsAnimation && instance.boneMatrices.empty()) continue; + bool needsBones = modelNeedsAnimation && !instance.boneMatrices.empty(); + if (needsBones && instance.megaBoneOffset == 0) continue; + + uint16_t desiredLOD = 0; + if (entry.distSq > 150.0f * 150.0f) desiredLOD = 3; + else if (entry.distSq > 80.0f * 80.0f) desiredLOD = 2; + else if (entry.distSq > 40.0f * 40.0f) desiredLOD = 1; + uint16_t targetLOD = desiredLOD; + if (desiredLOD > 0 && !(model.availableLODs & (1u << desiredLOD))) targetLOD = 0; + + const bool particleDominantEffect = model.isSpellEffect && + !model.particleEmitters.empty() && model.batches.size() <= 2; + + for (const auto& batch : model.batches) { + if (batch.indexCount == 0) continue; + if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue; + if (batch.batchOpacity < 0.01f) continue; + + // Pass 2 gate: only transparent/additive batches + { + const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect; + if (!rawTransparent) continue; + } + + // Skip glow sprites (handled in opaque pass) + const bool batchUnlit = (batch.materialFlags & 0x01) != 0; + const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; + const bool smallCardLikeBatch = + (batch.glowSize <= 1.35f) || + (batch.lanternGlowHint && batch.glowSize <= 6.0f); + const bool shouldUseGlowSprite = + !koboldFlameCard && + (model.isElvenLike || model.isLanternLike) && + !model.isSpellEffect && + smallCardLikeBatch && + (batch.lanternGlowHint || (batch.blendMode >= 3) || + (batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1)); + if (shouldUseGlowSprite) { + const bool cardLikeSkipMesh = (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit; + const bool lanternGlowCardSkip = + model.isLanternLike && + batch.lanternGlowHint && + smallCardLikeBatch && + cardLikeSkipMesh; + if (lanternGlowCardSkip || (cardLikeSkipMesh && !model.isLanternLike)) + continue; + } + + if (particleDominantEffect) continue; // emission-only mesh + + // Compute UV offset for this instance + batch + glm::vec2 uvOffset(0.0f); + if (batch.textureAnimIndex != 0xFFFF && model.hasTextureAnimation) { + uint16_t lookupIdx = batch.textureAnimIndex; + if (lookupIdx < model.textureTransformLookup.size()) { + uint16_t transformIdx = model.textureTransformLookup[lookupIdx]; + if (transformIdx < model.textureTransforms.size()) { + const auto& tt = model.textureTransforms[transformIdx]; + glm::vec3 trans = interpVec3(tt.translation, + instance.currentSequenceIndex, instance.animTime, + glm::vec3(0.0f), model.globalSequenceDurations); + uvOffset = glm::vec2(trans.x, trans.y); + } + } + } + if (model.isLavaModel && uvOffset == glm::vec2(0.0f)) { + float t = std::chrono::duration(std::chrono::steady_clock::now() - kLavaAnimStart).count(); + uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); + } + + // Write single instance entry to SSBO + if (instanceDataCount_ >= MAX_INSTANCE_DATA) continue; + uint32_t drawOffset = instanceDataCount_; + auto& e = instSSBO[instanceDataCount_]; + e.model = instance.modelMatrix; + e.uvOffset = uvOffset; + e.fadeAlpha = instanceFadeAlpha; + e.useBones = needsBones ? 1 : 0; + e.boneBase = needsBones ? static_cast(instance.megaBoneOffset) : 0; + std::memset(e._pad, 0, sizeof(e._pad)); + instanceDataCount_++; + + // Pipeline selection + uint8_t effectiveBlendMode = batch.blendMode; + if (model.isSpellEffect) { + if (effectiveBlendMode <= 1) effectiveBlendMode = 3; + else if (effectiveBlendMode == 4 || effectiveBlendMode == 5) effectiveBlendMode = 3; + } + + VkPipeline desiredPipeline; + switch (effectiveBlendMode) { + case 2: desiredPipeline = alphaPipeline_; break; + default: desiredPipeline = additivePipeline_; break; + } + if (desiredPipeline != currentPipeline) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, desiredPipeline); + currentPipeline = desiredPipeline; + } + + if (batch.materialUBOMapped) { + auto* mat = static_cast(batch.materialUBOMapped); + mat->interiorDarken = insideInterior ? 1.0f : 0.0f; + if (batch.colorKeyBlack) + mat->colorKeyThreshold = (effectiveBlendMode == 4 || effectiveBlendMode == 5) ? 0.7f : 0.08f; + } + + if (!batch.materialSet) continue; + if (batch.materialSet != currentMaterialSet) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); + currentMaterialSet = batch.materialSet; + } + + // Push constants + single-instance draw + M2PushConstants pc; + pc.texCoordSet = static_cast(batch.textureUnit); + pc.isFoliage = model.shadowWindFoliage ? 1 : 0; + pc.instanceDataOffset = static_cast(drawOffset); + vkCmdPushConstants(cmd, pipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, 0, sizeof(pc), &pc); + vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); + lastDrawCallCount++; + } + } + + // Render glow sprites as billboarded additive point lights + if (!glowSprites_.empty() && particleAdditivePipeline_ && glowVB_ && glowTexDescSet_) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, particleAdditivePipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + particlePipelineLayout_, 1, 1, &glowTexDescSet_, 0, nullptr); + + // Push constants for particle: tileCount(vec2) + alphaKey(int) + struct { float tileX, tileY; int alphaKey; } particlePush = {1.0f, 1.0f, 0}; + vkCmdPushConstants(cmd, particlePipelineLayout_, VK_SHADER_STAGE_FRAGMENT_BIT, 0, + sizeof(particlePush), &particlePush); + + // Write glow vertex data directly to mapped buffer (no temp vector) + size_t uploadCount = std::min(glowSprites_.size(), MAX_GLOW_SPRITES); + float* dst = static_cast(glowVBMapped_); + for (size_t gi = 0; gi < uploadCount; gi++) { + const auto& gs = glowSprites_[gi]; + *dst++ = gs.worldPos.x; + *dst++ = gs.worldPos.y; + *dst++ = gs.worldPos.z; + *dst++ = gs.color.r; + *dst++ = gs.color.g; + *dst++ = gs.color.b; + *dst++ = gs.color.a; + *dst++ = gs.size; + *dst++ = 0.0f; + } + + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &glowVB_, &offset); + vkCmdDraw(cmd, static_cast(uploadCount), 1, 0, 0); + } + +} + +bool M2Renderer::initializeShadow(VkRenderPass shadowRenderPass) { + if (!vkCtx_ || shadowRenderPass == VK_NULL_HANDLE) return false; + VkDevice device = vkCtx_->getDevice(); + + // Create ShadowParams UBO + VkBufferCreateInfo bufCI{}; + bufCI.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + bufCI.size = sizeof(ShadowParamsUBO); + bufCI.usage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + VmaAllocationCreateInfo allocCI{}; + allocCI.usage = VMA_MEMORY_USAGE_CPU_TO_GPU; + allocCI.flags = VMA_ALLOCATION_CREATE_MAPPED_BIT; + VmaAllocationInfo allocInfo{}; + if (vmaCreateBuffer(vkCtx_->getAllocator(), &bufCI, &allocCI, + &shadowParamsUBO_, &shadowParamsAlloc_, &allocInfo) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to create shadow params UBO"); + return false; + } + ShadowParamsUBO defaultParams{}; + std::memcpy(allocInfo.pMappedData, &defaultParams, sizeof(defaultParams)); + + // Create descriptor set layout: binding 0 = sampler2D, binding 1 = ShadowParams UBO + VkDescriptorSetLayoutBinding layoutBindings[2]{}; + layoutBindings[0].binding = 0; + layoutBindings[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + layoutBindings[0].descriptorCount = 1; + layoutBindings[0].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + layoutBindings[1].binding = 1; + layoutBindings[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + layoutBindings[1].descriptorCount = 1; + layoutBindings[1].stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo layoutCI{}; + layoutCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + layoutCI.bindingCount = 2; + layoutCI.pBindings = layoutBindings; + if (vkCreateDescriptorSetLayout(device, &layoutCI, nullptr, &shadowParamsLayout_) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to create shadow params layout"); + return false; + } + + // Create descriptor pool + VkDescriptorPoolSize poolSizes[2]{}; + poolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + poolSizes[0].descriptorCount = 1; + poolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + poolSizes[1].descriptorCount = 1; + VkDescriptorPoolCreateInfo poolCI{}; + poolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + poolCI.maxSets = 1; + poolCI.poolSizeCount = 2; + poolCI.pPoolSizes = poolSizes; + if (vkCreateDescriptorPool(device, &poolCI, nullptr, &shadowParamsPool_) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to create shadow params pool"); + return false; + } + + // Allocate descriptor set + VkDescriptorSetAllocateInfo setAlloc{}; + setAlloc.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + setAlloc.descriptorPool = shadowParamsPool_; + setAlloc.descriptorSetCount = 1; + setAlloc.pSetLayouts = &shadowParamsLayout_; + if (vkAllocateDescriptorSets(device, &setAlloc, &shadowParamsSet_) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to allocate shadow params set"); + return false; + } + + // Write descriptors (use white fallback for binding 0) + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = shadowParamsUBO_; + bufInfo.offset = 0; + bufInfo.range = sizeof(ShadowParamsUBO); + + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = whiteTexture_->getImageView(); + imgInfo.sampler = whiteTexture_->getSampler(); + + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = shadowParamsSet_; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = shadowParamsSet_; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(device, 2, writes, 0, nullptr); + + // Per-frame pools for foliage shadow texture sets (one per frame-in-flight, reset each frame) + { + VkDescriptorPoolSize texPoolSizes[2]{}; + texPoolSizes[0].type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + texPoolSizes[0].descriptorCount = 256; + texPoolSizes[1].type = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + texPoolSizes[1].descriptorCount = 256; + VkDescriptorPoolCreateInfo texPoolCI{}; + texPoolCI.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + texPoolCI.maxSets = 256; + texPoolCI.poolSizeCount = 2; + texPoolCI.pPoolSizes = texPoolSizes; + for (uint32_t f = 0; f < kShadowTexPoolFrames; ++f) { + if (vkCreateDescriptorPool(device, &texPoolCI, nullptr, &shadowTexPool_[f]) != VK_SUCCESS) { + LOG_ERROR("M2Renderer: failed to create shadow texture pool ", f); + return false; + } + } + } + + // Create shadow pipeline layout: set 1 = shadowParamsLayout_, push constants = 128 bytes + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_VERTEX_BIT; + pc.offset = 0; + pc.size = 128; // lightSpaceMatrix (64) + model (64) + shadowPipelineLayout_ = createPipelineLayout(device, {shadowParamsLayout_}, {pc}); + if (!shadowPipelineLayout_) { + LOG_ERROR("M2Renderer: failed to create shadow pipeline layout"); + return false; + } + + // Load shadow shaders + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/shadow.vert.spv")) { + LOG_ERROR("M2Renderer: failed to load shadow vertex shader"); + return false; + } + if (!fragShader.loadFromFile(device, "assets/shaders/shadow.frag.spv")) { + LOG_ERROR("M2Renderer: failed to load shadow fragment shader"); + return false; + } + + // M2 vertex layout: 18 floats = 72 bytes stride + // loc0=pos(off0), loc1=normal(off12), loc2=texCoord0(off24), loc5=texCoord1(off32), + // loc3=boneWeights(off40), loc4=boneIndices(off56) + // Shadow shader locations: 0=aPos, 1=aTexCoord, 2=aBoneWeights, 3=aBoneIndicesF + // useBones=0 so locations 2,3 are never used + VkVertexInputBindingDescription vertBind{}; + vertBind.binding = 0; + vertBind.stride = 18 * sizeof(float); + vertBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + std::vector vertAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // aPos -> position + {1, 0, VK_FORMAT_R32G32_SFLOAT, 6 * sizeof(float)}, // aTexCoord -> texCoord0 + {2, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 10 * sizeof(float)}, // aBoneWeights + {3, 0, VK_FORMAT_R32G32B32A32_SFLOAT, 14 * sizeof(float)}, // aBoneIndicesF + }; + + shadowPipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({vertBind}, vertAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + // Foliage/leaf cards are effectively two-sided; front-face culling can + // drop them from the shadow map depending on light/view orientation. + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, true, VK_COMPARE_OP_LESS_OR_EQUAL) + .setDepthBias(0.05f, 0.20f) + .setNoColorAttachment() + .setLayout(shadowPipelineLayout_) + .setRenderPass(shadowRenderPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device, vkCtx_->getPipelineCache()); + + vertShader.destroy(); + fragShader.destroy(); + + if (!shadowPipeline_) { + LOG_ERROR("M2Renderer: failed to create shadow pipeline"); + return false; + } + LOG_INFO("M2Renderer shadow pipeline initialized"); + return true; +} + +void M2Renderer::renderShadow(VkCommandBuffer cmd, const glm::mat4& lightSpaceMatrix, float globalTime, + const glm::vec3& shadowCenter, float shadowRadius) { + if (!shadowPipeline_ || !shadowParamsSet_) return; + if (instances.empty() || models.empty()) return; + + const float shadowRadiusSq = shadowRadius * shadowRadius; + + // Reset this frame slot's texture descriptor pool (safe: fence was waited on in beginFrame) + const uint32_t frameIdx = vkCtx_->getCurrentFrame(); + VkDescriptorPool curShadowTexPool = shadowTexPool_[frameIdx]; + if (curShadowTexPool) { + vkResetDescriptorPool(vkCtx_->getDevice(), curShadowTexPool, 0); + } + // Cache: texture imageView -> allocated descriptor set (avoids duplicates within frame) + // Reuse persistent map — pool reset already invalidated the sets. + shadowTexSetCache_.clear(); + auto& texSetCache = shadowTexSetCache_; + + auto getTexDescSet = [&](VkTexture* tex) -> VkDescriptorSet { + VkImageView iv = tex->getImageView(); + auto cacheIt = texSetCache.find(iv); + if (cacheIt != texSetCache.end()) return cacheIt->second; + + VkDescriptorSet set = VK_NULL_HANDLE; + VkDescriptorSetAllocateInfo ai{}; + ai.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + ai.descriptorPool = curShadowTexPool; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &shadowParamsLayout_; + if (vkAllocateDescriptorSets(vkCtx_->getDevice(), &ai, &set) != VK_SUCCESS) { + return shadowParamsSet_; // fallback to white texture + } + VkDescriptorImageInfo imgInfo{}; + imgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + imgInfo.imageView = iv; + imgInfo.sampler = tex->getSampler(); + VkDescriptorBufferInfo bufInfo{}; + bufInfo.buffer = shadowParamsUBO_; + bufInfo.offset = 0; + bufInfo.range = sizeof(ShadowParamsUBO); + VkWriteDescriptorSet writes[2]{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = set; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[0].pImageInfo = &imgInfo; + writes[1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[1].dstSet = set; + writes[1].dstBinding = 1; + writes[1].descriptorCount = 1; + writes[1].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[1].pBufferInfo = &bufInfo; + vkUpdateDescriptorSets(vkCtx_->getDevice(), 2, writes, 0, nullptr); + texSetCache[iv] = set; + return set; + }; + + // Helper lambda to draw instances with a given foliageSway setting + auto drawPass = [&](bool foliagePass) { + ShadowParamsUBO params{}; + params.foliageSway = foliagePass ? 1 : 0; + params.windTime = globalTime; + params.foliageMotionDamp = 1.0f; + // For foliage pass: enable texture+alphaTest in UBO (per-batch textures bound below) + if (foliagePass) { + params.useTexture = 1; + params.alphaTest = 1; + } + + VmaAllocationInfo allocInfo{}; + vmaGetAllocationInfo(vkCtx_->getAllocator(), shadowParamsAlloc_, &allocInfo); + std::memcpy(allocInfo.pMappedData, ¶ms, sizeof(params)); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipeline_); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 0, 1, &shadowParamsSet_, 0, nullptr); + + uint32_t currentModelId = UINT32_MAX; + const M2ModelGPU* currentModel = nullptr; + + for (const auto& instance : instances) { + // Use cached flags to skip early without hash lookup + if (!instance.cachedIsValid || instance.cachedIsSmoke || instance.cachedIsInvisibleTrap) continue; + + // Distance cull against shadow frustum + glm::vec3 diff = instance.position - shadowCenter; + if (glm::dot(diff, diff) > shadowRadiusSq) continue; + + if (!instance.cachedModel) continue; + const M2ModelGPU& model = *instance.cachedModel; + + // Filter: only draw foliage models in foliage pass, non-foliage in non-foliage pass + if (model.shadowWindFoliage != foliagePass) continue; + + // Bind vertex/index buffers when model changes + if (instance.modelId != currentModelId) { + currentModelId = instance.modelId; + currentModel = &model; + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); + } + + ShadowPush push{lightSpaceMatrix, instance.modelMatrix}; + vkCmdPushConstants(cmd, shadowPipelineLayout_, VK_SHADER_STAGE_VERTEX_BIT, + 0, 128, &push); + + for (const auto& batch : model.batches) { + if (batch.submeshLevel > 0) continue; + // For foliage: bind per-batch texture for alpha-tested shadows + if (foliagePass && batch.hasAlpha && batch.texture) { + VkDescriptorSet texSet = getTexDescSet(batch.texture); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 0, 1, &texSet, 0, nullptr); + } else if (foliagePass) { + // Non-alpha batch: rebind default set (white texture, alpha test passes) + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, shadowPipelineLayout_, + 0, 1, &shadowParamsSet_, 0, nullptr); + } + vkCmdDrawIndexed(cmd, batch.indexCount, 1, batch.indexStart, 0, 0); + } + } + }; + + // Pass 1: non-foliage (no wind displacement) + drawPass(false); + // Pass 2: foliage (wind displacement enabled, per-batch alpha-tested textures) + drawPass(true); +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/overlay_system.cpp b/src/rendering/overlay_system.cpp new file mode 100644 index 00000000..964e31da --- /dev/null +++ b/src/rendering/overlay_system.cpp @@ -0,0 +1,235 @@ +#include "rendering/overlay_system.hpp" +#include "rendering/vk_context.hpp" +#include "rendering/vk_shader.hpp" +#include "rendering/vk_pipeline.hpp" +#include "rendering/vk_utils.hpp" +#include "core/logger.hpp" +#include +#include +#include + +namespace wowee { +namespace rendering { + +OverlaySystem::OverlaySystem(VkContext* ctx) + : vkCtx_(ctx) {} + +OverlaySystem::~OverlaySystem() { + cleanup(); +} + +void OverlaySystem::cleanup() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + if (selCirclePipeline_) { vkDestroyPipeline(device, selCirclePipeline_, nullptr); selCirclePipeline_ = VK_NULL_HANDLE; } + if (selCirclePipelineLayout_) { vkDestroyPipelineLayout(device, selCirclePipelineLayout_, nullptr); selCirclePipelineLayout_ = VK_NULL_HANDLE; } + if (selCircleVertBuf_) { vmaDestroyBuffer(vkCtx_->getAllocator(), selCircleVertBuf_, selCircleVertAlloc_); selCircleVertBuf_ = VK_NULL_HANDLE; selCircleVertAlloc_ = VK_NULL_HANDLE; } + if (selCircleIdxBuf_) { vmaDestroyBuffer(vkCtx_->getAllocator(), selCircleIdxBuf_, selCircleIdxAlloc_); selCircleIdxBuf_ = VK_NULL_HANDLE; selCircleIdxAlloc_ = VK_NULL_HANDLE; } + if (overlayPipeline_) { vkDestroyPipeline(device, overlayPipeline_, nullptr); overlayPipeline_ = VK_NULL_HANDLE; } + if (overlayPipelineLayout_) { vkDestroyPipelineLayout(device, overlayPipelineLayout_, nullptr); overlayPipelineLayout_ = VK_NULL_HANDLE; } +} + +void OverlaySystem::recreatePipelines() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + // Destroy only pipelines (keep geometry buffers) + if (selCirclePipeline_) { vkDestroyPipeline(device, selCirclePipeline_, nullptr); selCirclePipeline_ = VK_NULL_HANDLE; } + if (overlayPipeline_) { vkDestroyPipeline(device, overlayPipeline_, nullptr); overlayPipeline_ = VK_NULL_HANDLE; } +} + +void OverlaySystem::setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color) { + selCirclePos_ = pos; + selCircleRadius_ = radius; + selCircleColor_ = color; + selCircleVisible_ = true; +} + +void OverlaySystem::clearSelectionCircle() { + selCircleVisible_ = false; +} + +void OverlaySystem::initSelectionCircle() { + if (selCirclePipeline_ != VK_NULL_HANDLE) return; + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + + // Load shaders + VkShaderModule vertShader, fragShader; + if (!vertShader.loadFromFile(device, "assets/shaders/selection_circle.vert.spv")) { + LOG_ERROR("OverlaySystem: failed to load selection circle vertex shader"); + return; + } + if (!fragShader.loadFromFile(device, "assets/shaders/selection_circle.frag.spv")) { + LOG_ERROR("OverlaySystem: failed to load selection circle fragment shader"); + vertShader.destroy(); + return; + } + + // Pipeline layout: push constants only (mat4 mvp=64 + vec4 color=16), VERTEX|FRAGMENT + VkPushConstantRange pcRange{}; + pcRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + pcRange.offset = 0; + pcRange.size = 80; + selCirclePipelineLayout_ = createPipelineLayout(device, {}, {pcRange}); + + // Vertex input: binding 0, stride 12, vec3 at location 0 + VkVertexInputBindingDescription vertBind{0, 12, VK_VERTEX_INPUT_RATE_VERTEX}; + VkVertexInputAttributeDescription vertAttr{0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}; + + // Build disc geometry as TRIANGLE_LIST (N=48 segments) + constexpr int SEGMENTS = 48; + std::vector verts; + verts.reserve((SEGMENTS + 1) * 3); + // Center vertex + verts.insert(verts.end(), {0.0f, 0.0f, 0.0f}); + // Ring vertices + for (int i = 0; i <= SEGMENTS; ++i) { + float angle = 2.0f * 3.14159265f * static_cast(i) / static_cast(SEGMENTS); + verts.push_back(std::cos(angle)); + verts.push_back(std::sin(angle)); + verts.push_back(0.0f); + } + + // Build TRIANGLE_LIST indices + std::vector indices; + indices.reserve(SEGMENTS * 3); + for (int i = 0; i < SEGMENTS; ++i) { + indices.push_back(0); + indices.push_back(static_cast(i + 1)); + indices.push_back(static_cast(i + 2)); + } + selCircleVertCount_ = SEGMENTS * 3; + + // Upload vertex buffer + if (selCircleVertBuf_ == VK_NULL_HANDLE) { + AllocatedBuffer vbuf = uploadBuffer(*vkCtx_, verts.data(), + verts.size() * sizeof(float), VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + selCircleVertBuf_ = vbuf.buffer; + selCircleVertAlloc_ = vbuf.allocation; + + AllocatedBuffer ibuf = uploadBuffer(*vkCtx_, indices.data(), + indices.size() * sizeof(uint16_t), VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + selCircleIdxBuf_ = ibuf.buffer; + selCircleIdxAlloc_ = ibuf.allocation; + } + + // Build pipeline + selCirclePipeline_ = PipelineBuilder() + .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({vertBind}, {vertAttr}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(selCirclePipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device, vkCtx_->getPipelineCache()); + + vertShader.destroy(); + fragShader.destroy(); + + if (!selCirclePipeline_) { + LOG_ERROR("OverlaySystem: failed to build selection circle pipeline"); + } +} + +void OverlaySystem::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection, + VkCommandBuffer cmd, + HeightQuery2D terrainHeight, + HeightQuery3D wmoHeight, + HeightQuery3D m2Height) { + if (!selCircleVisible_) return; + initSelectionCircle(); + if (selCirclePipeline_ == VK_NULL_HANDLE || cmd == VK_NULL_HANDLE) return; + + // Keep circle anchored near target foot Z. + const float baseZ = selCirclePos_.z; + float floorZ = baseZ; + auto considerFloor = [&](std::optional sample) { + if (!sample) return; + const float h = *sample; + if (h < baseZ - 1.25f || h > baseZ + 0.85f) return; + floorZ = std::max(floorZ, h); + }; + + if (terrainHeight) considerFloor(terrainHeight(selCirclePos_.x, selCirclePos_.y)); + if (wmoHeight) considerFloor(wmoHeight(selCirclePos_.x, selCirclePos_.y, selCirclePos_.z + 3.0f)); + if (m2Height) considerFloor(m2Height(selCirclePos_.x, selCirclePos_.y, selCirclePos_.z + 2.0f)); + + glm::vec3 raisedPos = selCirclePos_; + raisedPos.z = floorZ + 0.17f; + glm::mat4 model = glm::translate(glm::mat4(1.0f), raisedPos); + model = glm::scale(model, glm::vec3(selCircleRadius_)); + + glm::mat4 mvp = projection * view * model; + glm::vec4 color4(selCircleColor_, 1.0f); + + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, selCirclePipeline_); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &selCircleVertBuf_, &offset); + vkCmdBindIndexBuffer(cmd, selCircleIdxBuf_, 0, VK_INDEX_TYPE_UINT16); + vkCmdPushConstants(cmd, selCirclePipelineLayout_, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 0, 64, &mvp[0][0]); + vkCmdPushConstants(cmd, selCirclePipelineLayout_, + VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, + 64, 16, &color4[0]); + vkCmdDrawIndexed(cmd, static_cast(selCircleVertCount_), 1, 0, 0, 0); +} + +void OverlaySystem::initOverlayPipeline() { + if (!vkCtx_) return; + VkDevice device = vkCtx_->getDevice(); + + VkPushConstantRange pc{}; + pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + pc.offset = 0; + pc.size = 16; + + VkPipelineLayoutCreateInfo plCI{}; + plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + plCI.pushConstantRangeCount = 1; + plCI.pPushConstantRanges = &pc; + vkCreatePipelineLayout(device, &plCI, nullptr, &overlayPipelineLayout_); + + VkShaderModule vertMod, fragMod; + if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") || + !fragMod.loadFromFile(device, "assets/shaders/overlay.frag.spv")) { + LOG_ERROR("OverlaySystem: failed to load overlay shaders"); + vertMod.destroy(); fragMod.destroy(); + return; + } + + overlayPipeline_ = PipelineBuilder() + .setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({}, {}) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setNoDepthTest() + .setColorBlendAttachment(PipelineBuilder::blendAlpha()) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(overlayPipelineLayout_) + .setRenderPass(vkCtx_->getImGuiRenderPass()) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device, vkCtx_->getPipelineCache()); + + vertMod.destroy(); fragMod.destroy(); + + if (overlayPipeline_) LOG_INFO("OverlaySystem: overlay pipeline initialized"); +} + +void OverlaySystem::renderOverlay(const glm::vec4& color, VkCommandBuffer cmd) { + if (!overlayPipeline_) initOverlayPipeline(); + if (!overlayPipeline_ || cmd == VK_NULL_HANDLE) return; + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline_); + vkCmdPushConstants(cmd, overlayPipelineLayout_, + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &color[0]); + vkCmdDraw(cmd, 3, 1, 0, 0); +} + +} // namespace rendering +} // namespace wowee diff --git a/src/rendering/performance_hud.cpp b/src/rendering/performance_hud.cpp index 7fd5ff4b..4c86e8b0 100644 --- a/src/rendering/performance_hud.cpp +++ b/src/rendering/performance_hud.cpp @@ -1,5 +1,6 @@ #include "rendering/performance_hud.hpp" #include "rendering/renderer.hpp" +#include "rendering/post_process_pipeline.hpp" #include "rendering/vk_context.hpp" #include "rendering/terrain_renderer.hpp" #include "rendering/terrain_manager.hpp" @@ -198,38 +199,38 @@ void PerformanceHUD::render(const Renderer* renderer, const Camera* camera) { } // FSR info - if (renderer->isFSREnabled()) { + if (renderer->getPostProcessPipeline()->isFSREnabled()) { ImGui::TextColored(colors::kGreen, "FSR 1.0: ON"); auto* ctx = renderer->getVkContext(); if (ctx) { auto ext = ctx->getSwapchainExtent(); - float sf = renderer->getFSRScaleFactor(); + float sf = renderer->getPostProcessPipeline()->getFSRScaleFactor(); uint32_t iw = static_cast(ext.width * sf) & ~1u; uint32_t ih = static_cast(ext.height * sf) & ~1u; ImGui::Text(" %ux%u -> %ux%u (%.0f%%)", iw, ih, ext.width, ext.height, sf * 100.0f); } } - if (renderer->isFSR2Enabled()) { + if (renderer->getPostProcessPipeline()->isFSR2Enabled()) { ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "FSR 3 Upscale: ON"); - ImGui::Text(" JitterSign=%.2f", renderer->getFSR2JitterSign()); - const bool fgEnabled = renderer->isAmdFsr3FramegenEnabled(); - const bool fgReady = renderer->isAmdFsr3FramegenRuntimeReady(); - const bool fgActive = renderer->isAmdFsr3FramegenRuntimeActive(); + ImGui::Text(" JitterSign=%.2f", renderer->getPostProcessPipeline()->getFSR2JitterSign()); + const bool fgEnabled = renderer->getPostProcessPipeline()->isAmdFsr3FramegenEnabled(); + const bool fgReady = renderer->getPostProcessPipeline()->isAmdFsr3FramegenRuntimeReady(); + const bool fgActive = renderer->getPostProcessPipeline()->isAmdFsr3FramegenRuntimeActive(); const char* fgStatus = "Disabled"; if (fgEnabled) { fgStatus = fgActive ? "Active" : (fgReady ? "Ready (waiting/fallback)" : "Unavailable"); } - ImGui::Text(" FSR3 FG: %s (%s)", fgStatus, renderer->getAmdFsr3FramegenRuntimePath()); - const std::string& fgErr = renderer->getAmdFsr3FramegenRuntimeError(); + ImGui::Text(" FSR3 FG: %s (%s)", fgStatus, renderer->getPostProcessPipeline()->getAmdFsr3FramegenRuntimePath()); + const std::string& fgErr = renderer->getPostProcessPipeline()->getAmdFsr3FramegenRuntimeError(); if (!fgErr.empty()) { ImGui::TextWrapped(" FG Last Error: %s", fgErr.c_str()); } - ImGui::Text(" FG Dispatches: %zu", renderer->getAmdFsr3FramegenDispatchCount()); - ImGui::Text(" Upscale Dispatches: %zu", renderer->getAmdFsr3UpscaleDispatchCount()); - ImGui::Text(" FG Fallbacks: %zu", renderer->getAmdFsr3FallbackCount()); + ImGui::Text(" FG Dispatches: %zu", renderer->getPostProcessPipeline()->getAmdFsr3FramegenDispatchCount()); + ImGui::Text(" Upscale Dispatches: %zu", renderer->getPostProcessPipeline()->getAmdFsr3UpscaleDispatchCount()); + ImGui::Text(" FG Fallbacks: %zu", renderer->getPostProcessPipeline()->getAmdFsr3FallbackCount()); } - if (renderer->isFXAAEnabled()) { - if (renderer->isFSR2Enabled()) { + if (renderer->getPostProcessPipeline()->isFXAAEnabled()) { + if (renderer->getPostProcessPipeline()->isFSR2Enabled()) { ImGui::TextColored(ImVec4(0.6f, 1.0f, 0.8f, 1.0f), "FXAA: ON (FSR3+FXAA combined)"); } else { ImGui::TextColored(ImVec4(0.8f, 1.0f, 0.6f, 1.0f), "FXAA: ON"); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 4fb0af07..48d3fa85 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -62,6 +62,7 @@ #include "rendering/post_process_pipeline.hpp" #include "rendering/animation_controller.hpp" #include "rendering/render_graph.hpp" +#include "rendering/overlay_system.hpp" #include #include #include @@ -574,6 +575,9 @@ bool Renderer::initialize(core::Window* win) { // Create render graph and register virtual resources renderGraph_ = std::make_unique(); + + // Create overlay system (selection circle + fullscreen overlay) + overlaySystem_ = std::make_unique(vkCtx); renderGraph_->registerResource("shadow_depth"); renderGraph_->registerResource("reflection_texture"); renderGraph_->registerResource("cull_visibility"); @@ -676,15 +680,10 @@ void Renderer::shutdown() { // Audio shutdown is handled by AudioCoordinator (owned by Application). audioCoordinator_ = nullptr; - // Cleanup Vulkan selection circle resources - if (vkCtx) { - VkDevice device = vkCtx->getDevice(); - if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; } - if (selCirclePipelineLayout) { vkDestroyPipelineLayout(device, selCirclePipelineLayout, nullptr); selCirclePipelineLayout = VK_NULL_HANDLE; } - if (selCircleVertBuf) { vmaDestroyBuffer(vkCtx->getAllocator(), selCircleVertBuf, selCircleVertAlloc); selCircleVertBuf = VK_NULL_HANDLE; selCircleVertAlloc = VK_NULL_HANDLE; } - if (selCircleIdxBuf) { vmaDestroyBuffer(vkCtx->getAllocator(), selCircleIdxBuf, selCircleIdxAlloc); selCircleIdxBuf = VK_NULL_HANDLE; selCircleIdxAlloc = VK_NULL_HANDLE; } - if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; } - if (overlayPipelineLayout) { vkDestroyPipelineLayout(device, overlayPipelineLayout, nullptr); overlayPipelineLayout = VK_NULL_HANDLE; } + // Cleanup selection circle + overlay resources + if (overlaySystem_) { + overlaySystem_->cleanup(); + overlaySystem_.reset(); } // Shutdown post-process pipeline (FSR/FXAA/FSR2 resources) (§4.3) @@ -800,9 +799,7 @@ void Renderer::applyMsaaChange() { if (minimap) minimap->recreatePipelines(); // Selection circle + overlay + FSR use lazy init, just destroy them - VkDevice device = vkCtx->getDevice(); - if (selCirclePipeline) { vkDestroyPipeline(device, selCirclePipeline, nullptr); selCirclePipeline = VK_NULL_HANDLE; } - if (overlayPipeline) { vkDestroyPipeline(device, overlayPipeline, nullptr); overlayPipeline = VK_NULL_HANDLE; } + if (overlaySystem_) overlaySystem_->recreatePipelines(); if (postProcessPipeline_) postProcessPipeline_->destroyAllResources(); // Will be lazily recreated in beginFrame() // Reinitialize ImGui Vulkan backend with new MSAA sample count @@ -998,74 +995,6 @@ void Renderer::setCharacterFollow(uint32_t instanceId) { if (animationController_) animationController_->onCharacterFollow(instanceId); } -void Renderer::setMounted(uint32_t mountInstId, uint32_t mountDisplayId, float heightOffset, const std::string& modelPath) { - if (animationController_) animationController_->setMounted(mountInstId, mountDisplayId, heightOffset, modelPath); -} - -void Renderer::clearMount() { - if (animationController_) animationController_->clearMount(); -} - - - -void Renderer::playEmote(const std::string& emoteName) { - if (animationController_) animationController_->playEmote(emoteName); -} - -void Renderer::cancelEmote() { - if (animationController_) animationController_->cancelEmote(); -} - -bool Renderer::isEmoteActive() const { - return animationController_ && animationController_->isEmoteActive(); -} - -void Renderer::setInCombat(bool combat) { - if (animationController_) animationController_->setInCombat(combat); -} - -void Renderer::setEquippedWeaponType(uint32_t inventoryType, bool is2HLoose, bool isFist, - bool isDagger, bool hasOffHand, bool hasShield) { - if (animationController_) animationController_->setEquippedWeaponType(inventoryType, is2HLoose, isFist, isDagger, hasOffHand, hasShield); -} - -void Renderer::triggerSpecialAttack(uint32_t spellId) { - if (animationController_) animationController_->triggerSpecialAttack(spellId); -} - -void Renderer::setEquippedRangedType(RangedWeaponType type) { - if (animationController_) animationController_->setEquippedRangedType(type); -} - -void Renderer::triggerRangedShot() { - if (animationController_) animationController_->triggerRangedShot(); -} - -RangedWeaponType Renderer::getEquippedRangedType() const { - return animationController_ ? animationController_->getEquippedRangedType() - : RangedWeaponType::NONE; -} - -void Renderer::setCharging(bool c) { - if (animationController_) animationController_->setCharging(c); -} - -bool Renderer::isCharging() const { - return animationController_ && animationController_->isCharging(); -} - -void Renderer::setTaxiFlight(bool taxi) { - if (animationController_) animationController_->setTaxiFlight(taxi); -} - -void Renderer::setMountPitchRoll(float pitch, float roll) { - if (animationController_) animationController_->setMountPitchRoll(pitch, roll); -} - -bool Renderer::isMounted() const { - return animationController_ && animationController_->isMounted(); -} - bool Renderer::captureScreenshot(const std::string& outputPath) { if (!vkCtx) return false; @@ -1161,69 +1090,23 @@ bool Renderer::captureScreenshot(const std::string& outputPath) { return ok != 0; } -void Renderer::triggerLevelUpEffect(const glm::vec3& position) { - if (animationController_) animationController_->triggerLevelUpEffect(position); -} - -void Renderer::startChargeEffect(const glm::vec3& position, const glm::vec3& direction) { - if (animationController_) animationController_->startChargeEffect(position, direction); -} - -void Renderer::emitChargeEffect(const glm::vec3& position, const glm::vec3& direction) { - if (animationController_) animationController_->emitChargeEffect(position, direction); -} - -void Renderer::stopChargeEffect() { - if (animationController_) animationController_->stopChargeEffect(); -} - -// ─── Spell Visual Effects — delegated to SpellVisualSystem (§4.4) ──────────── - -void Renderer::playSpellVisual(uint32_t visualId, const glm::vec3& worldPosition, - bool useImpactKit) { - if (spellVisualSystem_) spellVisualSystem_->playSpellVisual(visualId, worldPosition, useImpactKit); -} - -void Renderer::triggerMeleeSwing() { - if (animationController_) animationController_->triggerMeleeSwing(); -} - -std::string Renderer::getEmoteText(const std::string& emoteName, const std::string* targetName) { - return AnimationController::getEmoteText(emoteName, targetName); -} - -uint32_t Renderer::getEmoteDbcId(const std::string& emoteName) { - return AnimationController::getEmoteDbcId(emoteName); -} - -std::string Renderer::getEmoteTextByDbcId(uint32_t dbcId, const std::string& senderName, - const std::string* targetName) { - return AnimationController::getEmoteTextByDbcId(dbcId, senderName, targetName); -} - -uint32_t Renderer::getEmoteAnimByDbcId(uint32_t dbcId) { - return AnimationController::getEmoteAnimByDbcId(dbcId); -} - -void Renderer::setTargetPosition(const glm::vec3* pos) { - if (animationController_) animationController_->setTargetPosition(pos); -} - void Renderer::resetCombatVisualState() { if (animationController_) animationController_->resetCombatVisualState(); if (spellVisualSystem_) spellVisualSystem_->reset(); } -bool Renderer::isMoving() const { - return cameraController && cameraController->isMoving(); +const std::string& Renderer::getCurrentZoneName() const { + static const std::string empty; + return audioCoordinator_ ? audioCoordinator_->getCurrentZoneName() : empty; +} + +uint32_t Renderer::getCurrentZoneId() const { + return audioCoordinator_ ? audioCoordinator_->getCurrentZoneId() : 0; } void Renderer::update(float deltaTime) { ZoneScopedN("Renderer::update"); globalTime += deltaTime; - if (musicSwitchCooldown_ > 0.0f) { - musicSwitchCooldown_ = std::max(0.0f, musicSwitchCooldown_ - deltaTime); - } runDeferredWorldInitStep(deltaTime); auto updateStart = std::chrono::steady_clock::now(); @@ -1281,7 +1164,7 @@ void Renderer::update(float deltaTime) { weather->setIntensity(wInt); } else { // No server weather — use zone-based weather configuration - weather->updateZoneWeather(currentZoneId, deltaTime); + weather->updateZoneWeather(getCurrentZoneId(), deltaTime); } weather->setEnabled(true); @@ -1291,7 +1174,7 @@ void Renderer::update(float deltaTime) { } } else if (weather) { // No game handler (single-player without network) — zone weather only - weather->updateZoneWeather(currentZoneId, deltaTime); + weather->updateZoneWeather(getCurrentZoneId(), deltaTime); weather->setEnabled(true); } } @@ -1307,7 +1190,7 @@ void Renderer::update(float deltaTime) { } else if (cameraController->isMoving() || cameraController->isRightMouseHeld()) { characterYaw = cameraController->getFacingYaw(); } else if (animationController_ && animationController_->isInCombat() && - animationController_->getTargetPosition() && !animationController_->isEmoteActive() && !isMounted()) { + animationController_->getTargetPosition() && !animationController_->isEmoteActive() && !(animationController_ && animationController_->isMounted())) { glm::vec3 toTarget = *animationController_->getTargetPosition() - characterPosition; if (toTarget.x * toTarget.x + toTarget.y * toTarget.y > 0.01f) { float targetYaw = glm::degrees(std::atan2(toTarget.y, toTarget.x)); @@ -1369,7 +1252,7 @@ void Renderer::update(float deltaTime) { mountDust->update(deltaTime); // Spawn dust when mounted and moving on ground - if (isMounted() && camera && cameraController && !(animationController_ && animationController_->isTaxiFlight())) { + if ((animationController_ && animationController_->isMounted()) && camera && cameraController && !(animationController_ && animationController_->isTaxiFlight())) { bool isMoving = cameraController->isMoving(); bool onGround = cameraController->isGrounded(); @@ -1434,45 +1317,31 @@ void Renderer::update(float deltaTime) { wmoRenderer->isInsideWMO(camPos.x, camPos.y, camPos.z, &insideWmoId); playerIndoors_ = insideWmo; - // Ambient environmental sounds: fireplaces, water, birds, etc. - if (audioCoordinator_->getAmbientSoundManager() && camera && wmoRenderer && cameraController) { - bool isIndoor = insideWmo; - bool isSwimming = cameraController->isSwimming(); - - // Detect blacksmith buildings to play ambient forge/anvil sounds. - // 96048 is the WMO group ID for the Goldshire blacksmith interior. - // TODO: extend to other smithy WMO IDs (Ironforge, Orgrimmar, etc.) - bool isBlacksmith = (insideWmoId == 96048); - - // Sync weather audio with visual weather system + // Ambient environmental sounds + zone/music transitions (delegated to AudioCoordinator) + if (audioCoordinator_) { + audio::ZoneAudioContext zctx; + zctx.deltaTime = deltaTime; + zctx.cameraPosition = camPos; + zctx.isSwimming = cameraController ? cameraController->isSwimming() : false; + zctx.insideWmo = insideWmo; + zctx.insideWmoId = insideWmoId; if (weather) { - auto weatherType = weather->getWeatherType(); - float intensity = weather->getIntensity(); - - audio::AmbientSoundManager::WeatherType audioWeatherType = audio::AmbientSoundManager::WeatherType::NONE; - - if (weatherType == Weather::Type::RAIN) { - if (intensity < 0.33f) { - audioWeatherType = audio::AmbientSoundManager::WeatherType::RAIN_LIGHT; - } else if (intensity < 0.66f) { - audioWeatherType = audio::AmbientSoundManager::WeatherType::RAIN_MEDIUM; - } else { - audioWeatherType = audio::AmbientSoundManager::WeatherType::RAIN_HEAVY; - } - } else if (weatherType == Weather::Type::SNOW) { - if (intensity < 0.33f) { - audioWeatherType = audio::AmbientSoundManager::WeatherType::SNOW_LIGHT; - } else if (intensity < 0.66f) { - audioWeatherType = audio::AmbientSoundManager::WeatherType::SNOW_MEDIUM; - } else { - audioWeatherType = audio::AmbientSoundManager::WeatherType::SNOW_HEAVY; - } - } - - audioCoordinator_->getAmbientSoundManager()->setWeather(audioWeatherType); + auto wt = weather->getWeatherType(); + if (wt == Weather::Type::RAIN) zctx.weatherType = 1; + else if (wt == Weather::Type::SNOW) zctx.weatherType = 2; + else if (wt == Weather::Type::STORM) zctx.weatherType = 3; + zctx.weatherIntensity = weather->getIntensity(); } - - audioCoordinator_->getAmbientSoundManager()->update(deltaTime, camPos, isIndoor, isSwimming, isBlacksmith); + if (terrainManager) { + auto tile = terrainManager->getCurrentTile(); + zctx.tileX = tile.x; + zctx.tileY = tile.y; + zctx.hasTile = true; + } + const auto* gh2 = core::Application::getInstance().getGameHandler(); + zctx.serverZoneId = gh2 ? gh2->getWorldStateZoneId() : 0; + zctx.zoneManager = zoneManager.get(); + audioCoordinator_->updateZoneAudio(zctx); } // Wait for M2 doodad animation to finish (was launched earlier in parallel with character anim) @@ -1481,154 +1350,6 @@ void Renderer::update(float deltaTime) { catch (const std::exception& e) { LOG_ERROR("M2 animation worker: ", e.what()); } } - // Helper: play zone music, dispatching local files (file: prefix) vs MPQ paths - auto playZoneMusic = [&](const std::string& music) { - if (music.empty()) return; - if (music.rfind("file:", 0) == 0) { - audioCoordinator_->getMusicManager()->crossfadeToFile(music.substr(5)); - } else { - audioCoordinator_->getMusicManager()->crossfadeTo(music); - } - }; - - // Update zone detection and music - if (zoneManager && audioCoordinator_->getMusicManager() && terrainManager && camera) { - // Prefer server-authoritative zone ID (from SMSG_INIT_WORLD_STATES); - // fall back to tile-based lookup for single-player / offline mode. - const auto* gh = core::Application::getInstance().getGameHandler(); - uint32_t serverZoneId = gh ? gh->getWorldStateZoneId() : 0; - auto tile = terrainManager->getCurrentTile(); - uint32_t zoneId = (serverZoneId != 0) ? serverZoneId : zoneManager->getZoneId(tile.x, tile.y); - - bool insideTavern = false; - bool insideBlacksmith = false; - std::string tavernMusic; - - // Override with WMO-based detection (e.g., inside Stormwind, taverns, blacksmiths) - if (wmoRenderer) { - uint32_t wmoModelId = insideWmoId; - if (insideWmo) { - // Check if inside Stormwind WMO (model ID 10047) - if (wmoModelId == 10047) { - zoneId = 1519; // Stormwind City - } - - // Detect taverns/inns/blacksmiths by WMO model ID - // Log WMO ID for debugging - static uint32_t lastLoggedWmoId = 0; - if (wmoModelId != lastLoggedWmoId) { - LOG_INFO("Inside WMO model ID: ", wmoModelId); - lastLoggedWmoId = wmoModelId; - } - - // Detect blacksmith WMO for ambient forge sounds - if (wmoModelId == 96048) { // Goldshire blacksmith interior - insideBlacksmith = true; - LOG_INFO("Detected blacksmith WMO ", wmoModelId); - } - - // These IDs represent typical Alliance and Horde inn buildings - if (wmoModelId == 191 || // Goldshire inn (old ID) - wmoModelId == 71414 || // Goldshire inn (actual) - wmoModelId == 190 || // Small inn (common) - wmoModelId == 220 || // Tavern building - wmoModelId == 221 || // Large tavern - wmoModelId == 5392 || // Horde inn - wmoModelId == 5393) { // Another inn variant - insideTavern = true; - // WoW tavern music (cozy ambient tracks) - FIXED PATHS - static const std::vector tavernTracks = { - "Sound\\Music\\ZoneMusic\\TavernAlliance\\TavernAlliance01.mp3", - "Sound\\Music\\ZoneMusic\\TavernAlliance\\TavernAlliance02.mp3", - "Sound\\Music\\ZoneMusic\\TavernHuman\\RA_HumanTavern1A.mp3", - "Sound\\Music\\ZoneMusic\\TavernHuman\\RA_HumanTavern2A.mp3", - }; - // Rotate through tracks so the player doesn't always hear the same one. - // Post-increment: first visit plays index 0, next plays 1, etc. - static int tavernTrackIndex = 0; - tavernMusic = tavernTracks[tavernTrackIndex++ % tavernTracks.size()]; - LOG_INFO("Detected tavern WMO ", wmoModelId, ", playing: ", tavernMusic); - } - } - } - - // Handle tavern music transitions - if (insideTavern) { - if (!inTavern_ && !tavernMusic.empty()) { - inTavern_ = true; - LOG_INFO("Entered tavern"); - audioCoordinator_->getMusicManager()->playMusic(tavernMusic, true); // Immediate playback, looping - musicSwitchCooldown_ = 6.0f; - } - } else if (inTavern_) { - // Exited tavern - restore zone music with crossfade - inTavern_ = false; - LOG_INFO("Exited tavern"); - auto* info = zoneManager->getZoneInfo(currentZoneId); - if (info) { - std::string music = zoneManager->getRandomMusic(currentZoneId); - if (!music.empty()) { - playZoneMusic(music); - musicSwitchCooldown_ = 6.0f; - } - } - } - - // Handle blacksmith music (stop music when entering blacksmith, let ambience play) - if (insideBlacksmith) { - if (!inBlacksmith_) { - inBlacksmith_ = true; - LOG_INFO("Entered blacksmith - stopping music"); - audioCoordinator_->getMusicManager()->stopMusic(); - } - } else if (inBlacksmith_) { - // Exited blacksmith - restore zone music with crossfade - inBlacksmith_ = false; - LOG_INFO("Exited blacksmith - restoring music"); - auto* info = zoneManager->getZoneInfo(currentZoneId); - if (info) { - std::string music = zoneManager->getRandomMusic(currentZoneId); - if (!music.empty()) { - playZoneMusic(music); - musicSwitchCooldown_ = 6.0f; - } - } - } - - // Handle normal zone transitions (only if not in tavern or blacksmith) - if (!insideTavern && !insideBlacksmith && zoneId != currentZoneId && zoneId != 0) { - currentZoneId = zoneId; - auto* info = zoneManager->getZoneInfo(zoneId); - if (info) { - currentZoneName = info->name; - LOG_INFO("Entered zone: ", info->name); - if (musicSwitchCooldown_ <= 0.0f) { - std::string music = zoneManager->getRandomMusic(zoneId); - if (!music.empty()) { - playZoneMusic(music); - musicSwitchCooldown_ = 6.0f; - } - } - } - // Update ambient sound manager zone type - if (audioCoordinator_->getAmbientSoundManager()) { - audioCoordinator_->getAmbientSoundManager()->setZoneId(zoneId); - } - } - - audioCoordinator_->getMusicManager()->update(deltaTime); - - // When a track finishes, pick a new random track from the current zone - if (!audioCoordinator_->getMusicManager()->isPlaying() && !inTavern_ && !inBlacksmith_ && - currentZoneId != 0 && musicSwitchCooldown_ <= 0.0f) { - std::string music = zoneManager->getRandomMusic(currentZoneId); - if (!music.empty()) { - playZoneMusic(music); - musicSwitchCooldown_ = 2.0f; - } - } - } - // Update performance HUD if (performanceHUD) { performanceHUD->update(deltaTime); @@ -1691,215 +1412,12 @@ void Renderer::runDeferredWorldInitStep(float deltaTime) { deferredWorldInitCooldown_ = 0.12f; } -// ============================================================ -// Selection Circle -// ============================================================ - -void Renderer::initSelectionCircle() { - if (selCirclePipeline != VK_NULL_HANDLE) return; - if (!vkCtx) return; - VkDevice device = vkCtx->getDevice(); - - // Load shaders - VkShaderModule vertShader, fragShader; - if (!vertShader.loadFromFile(device, "assets/shaders/selection_circle.vert.spv")) { - LOG_ERROR("initSelectionCircle: failed to load vertex shader"); - return; - } - if (!fragShader.loadFromFile(device, "assets/shaders/selection_circle.frag.spv")) { - LOG_ERROR("initSelectionCircle: failed to load fragment shader"); - vertShader.destroy(); - return; - } - - // Pipeline layout: push constants only (mat4 mvp=64 + vec4 color=16), VERTEX|FRAGMENT - VkPushConstantRange pcRange{}; - pcRange.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; - pcRange.offset = 0; - pcRange.size = 80; - selCirclePipelineLayout = createPipelineLayout(device, {}, {pcRange}); - - // Vertex input: binding 0, stride 12, vec3 at location 0 - VkVertexInputBindingDescription vertBind{0, 12, VK_VERTEX_INPUT_RATE_VERTEX}; - VkVertexInputAttributeDescription vertAttr{0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}; - - // Build disc geometry as TRIANGLE_LIST (replaces GL_TRIANGLE_FAN) - // N=48 segments: center at origin + ring verts - constexpr int SEGMENTS = 48; - std::vector verts; - verts.reserve((SEGMENTS + 1) * 3); - // Center vertex - verts.insert(verts.end(), {0.0f, 0.0f, 0.0f}); - // Ring vertices - for (int i = 0; i <= SEGMENTS; ++i) { - float angle = 2.0f * 3.14159265f * static_cast(i) / static_cast(SEGMENTS); - verts.push_back(std::cos(angle)); - verts.push_back(std::sin(angle)); - verts.push_back(0.0f); - } - - // Build TRIANGLE_LIST indices: N triangles (center=0, ring[i]=i+1, ring[i+1]=i+2) - std::vector indices; - indices.reserve(SEGMENTS * 3); - for (int i = 0; i < SEGMENTS; ++i) { - indices.push_back(0); - indices.push_back(static_cast(i + 1)); - indices.push_back(static_cast(i + 2)); - } - selCircleVertCount = SEGMENTS * 3; // index count for drawing - - // Upload vertex buffer - AllocatedBuffer vbuf = uploadBuffer(*vkCtx, verts.data(), - verts.size() * sizeof(float), VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); - selCircleVertBuf = vbuf.buffer; - selCircleVertAlloc = vbuf.allocation; - - // Upload index buffer - AllocatedBuffer ibuf = uploadBuffer(*vkCtx, indices.data(), - indices.size() * sizeof(uint16_t), VK_BUFFER_USAGE_INDEX_BUFFER_BIT); - selCircleIdxBuf = ibuf.buffer; - selCircleIdxAlloc = ibuf.allocation; - - // Build pipeline: alpha blend, no depth write/test, TRIANGLE_LIST, CULL_NONE - selCirclePipeline = PipelineBuilder() - .setShaders(vertShader.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), - fragShader.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) - .setVertexInput({vertBind}, {vertAttr}) - .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) - .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) - .setNoDepthTest() - .setColorBlendAttachment(PipelineBuilder::blendAlpha()) - .setMultisample(vkCtx->getMsaaSamples()) - .setLayout(selCirclePipelineLayout) - .setRenderPass(vkCtx->getImGuiRenderPass()) - .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) - .build(device, vkCtx->getPipelineCache()); - - vertShader.destroy(); - fragShader.destroy(); - - if (!selCirclePipeline) { - LOG_ERROR("initSelectionCircle: failed to build pipeline"); - } -} - void Renderer::setSelectionCircle(const glm::vec3& pos, float radius, const glm::vec3& color) { - selCirclePos = pos; - selCircleRadius = radius; - selCircleColor = color; - selCircleVisible = true; + if (overlaySystem_) overlaySystem_->setSelectionCircle(pos, radius, color); } void Renderer::clearSelectionCircle() { - selCircleVisible = false; -} - -void Renderer::renderSelectionCircle(const glm::mat4& view, const glm::mat4& projection, VkCommandBuffer overrideCmd) { - if (!selCircleVisible) return; - initSelectionCircle(); - VkCommandBuffer cmd = (overrideCmd != VK_NULL_HANDLE) ? overrideCmd : currentCmd; - if (selCirclePipeline == VK_NULL_HANDLE || cmd == VK_NULL_HANDLE) return; - - // Keep circle anchored near target foot Z. Accept nearby floor probes only, - // so distant upper/lower WMO planes don't yank the ring away from feet. - const float baseZ = selCirclePos.z; - float floorZ = baseZ; - auto considerFloor = [&](std::optional sample) { - if (!sample) return; - const float h = *sample; - // Ignore unrelated floors/ceilings far from target feet. - if (h < baseZ - 1.25f || h > baseZ + 0.85f) return; - floorZ = std::max(floorZ, h); - }; - - if (terrainManager) { - considerFloor(terrainManager->getHeightAt(selCirclePos.x, selCirclePos.y)); - } - if (wmoRenderer) { - considerFloor(wmoRenderer->getFloorHeight(selCirclePos.x, selCirclePos.y, selCirclePos.z + 3.0f)); - } - if (m2Renderer) { - considerFloor(m2Renderer->getFloorHeight(selCirclePos.x, selCirclePos.y, selCirclePos.z + 2.0f)); - } - - glm::vec3 raisedPos = selCirclePos; - raisedPos.z = floorZ + 0.17f; - glm::mat4 model = glm::translate(glm::mat4(1.0f), raisedPos); - model = glm::scale(model, glm::vec3(selCircleRadius)); - - glm::mat4 mvp = projection * view * model; - glm::vec4 color4(selCircleColor, 1.0f); - - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, selCirclePipeline); - VkDeviceSize offset = 0; - vkCmdBindVertexBuffers(cmd, 0, 1, &selCircleVertBuf, &offset); - vkCmdBindIndexBuffer(cmd, selCircleIdxBuf, 0, VK_INDEX_TYPE_UINT16); - // Push mvp (64 bytes) at offset 0 - vkCmdPushConstants(cmd, selCirclePipelineLayout, - VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, - 0, 64, &mvp[0][0]); - // Push color (16 bytes) at offset 64 - vkCmdPushConstants(cmd, selCirclePipelineLayout, - VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT, - 64, 16, &color4[0]); - vkCmdDrawIndexed(cmd, static_cast(selCircleVertCount), 1, 0, 0, 0); -} - -// ────────────────────────────────────────────────────────────── -// Fullscreen overlay pipeline (underwater tint, etc.) -// ────────────────────────────────────────────────────────────── - -void Renderer::initOverlayPipeline() { - if (!vkCtx) return; - VkDevice device = vkCtx->getDevice(); - - // Push constant: vec4 color (16 bytes), visible to both stages - VkPushConstantRange pc{}; - pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; - pc.offset = 0; - pc.size = 16; - - VkPipelineLayoutCreateInfo plCI{}; - plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; - plCI.pushConstantRangeCount = 1; - plCI.pPushConstantRanges = &pc; - vkCreatePipelineLayout(device, &plCI, nullptr, &overlayPipelineLayout); - - VkShaderModule vertMod, fragMod; - if (!vertMod.loadFromFile(device, "assets/shaders/postprocess.vert.spv") || - !fragMod.loadFromFile(device, "assets/shaders/overlay.frag.spv")) { - LOG_ERROR("Renderer: failed to load overlay shaders"); - vertMod.destroy(); fragMod.destroy(); - return; - } - - overlayPipeline = PipelineBuilder() - .setShaders(vertMod.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), - fragMod.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) - .setVertexInput({}, {}) // fullscreen triangle, no VBOs - .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST) - .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) - .setNoDepthTest() - .setColorBlendAttachment(PipelineBuilder::blendAlpha()) - .setMultisample(vkCtx->getMsaaSamples()) - .setLayout(overlayPipelineLayout) - .setRenderPass(vkCtx->getImGuiRenderPass()) - .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) - .build(device, vkCtx->getPipelineCache()); - - vertMod.destroy(); fragMod.destroy(); - - if (overlayPipeline) LOG_INFO("Renderer: overlay pipeline initialized"); -} - -void Renderer::renderOverlay(const glm::vec4& color, VkCommandBuffer overrideCmd) { - if (!overlayPipeline) initOverlayPipeline(); - VkCommandBuffer cmd = (overrideCmd != VK_NULL_HANDLE) ? overrideCmd : currentCmd; - if (!overlayPipeline || cmd == VK_NULL_HANDLE) return; - vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, overlayPipeline); - vkCmdPushConstants(cmd, overlayPipelineLayout, - VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, &color[0]); - vkCmdDraw(cmd, 3, 1, 0, 0); // fullscreen triangle + if (overlaySystem_) overlaySystem_->clearSelectionCircle(); } // ========================= PostProcessPipeline delegation stubs (§4.3) ========================= @@ -1908,13 +1426,6 @@ PostProcessPipeline* Renderer::getPostProcessPipeline() const { return postProcessPipeline_.get(); } -void Renderer::setFXAAEnabled(bool enabled) { - if (postProcessPipeline_) postProcessPipeline_->setFXAAEnabled(enabled); -} -bool Renderer::isFXAAEnabled() const { - return postProcessPipeline_ && postProcessPipeline_->isFXAAEnabled(); -} - void Renderer::setFSREnabled(bool enabled) { if (!postProcessPipeline_) return; auto req = postProcessPipeline_->setFSREnabled(enabled); @@ -1923,22 +1434,6 @@ void Renderer::setFSREnabled(bool enabled) { msaaChangePending_ = true; } } -bool Renderer::isFSREnabled() const { - return postProcessPipeline_ && postProcessPipeline_->isFSREnabled(); -} -void Renderer::setFSRQuality(float scaleFactor) { - if (postProcessPipeline_) postProcessPipeline_->setFSRQuality(scaleFactor); -} -void Renderer::setFSRSharpness(float sharpness) { - if (postProcessPipeline_) postProcessPipeline_->setFSRSharpness(sharpness); -} -float Renderer::getFSRScaleFactor() const { - return postProcessPipeline_ ? postProcessPipeline_->getFSRScaleFactor() : 1.0f; -} -float Renderer::getFSRSharpness() const { - return postProcessPipeline_ ? postProcessPipeline_->getFSRSharpness() : 0.0f; -} - void Renderer::setFSR2Enabled(bool enabled) { if (!postProcessPipeline_) return; auto req = postProcessPipeline_->setFSR2Enabled(enabled, camera.get()); @@ -1952,63 +1447,6 @@ void Renderer::setFSR2Enabled(bool enabled) { pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT; } } -bool Renderer::isFSR2Enabled() const { - return postProcessPipeline_ && postProcessPipeline_->isFSR2Enabled(); -} -void Renderer::setFSR2DebugTuning(float jitterSign, float motionVecScaleX, float motionVecScaleY) { - if (postProcessPipeline_) postProcessPipeline_->setFSR2DebugTuning(jitterSign, motionVecScaleX, motionVecScaleY); -} - -void Renderer::setAmdFsr3FramegenEnabled(bool enabled) { - if (postProcessPipeline_) postProcessPipeline_->setAmdFsr3FramegenEnabled(enabled); -} -bool Renderer::isAmdFsr3FramegenEnabled() const { - return postProcessPipeline_ && postProcessPipeline_->isAmdFsr3FramegenEnabled(); -} -float Renderer::getFSR2JitterSign() const { - return postProcessPipeline_ ? postProcessPipeline_->getFSR2JitterSign() : 1.0f; -} -float Renderer::getFSR2MotionVecScaleX() const { - return postProcessPipeline_ ? postProcessPipeline_->getFSR2MotionVecScaleX() : 1.0f; -} -float Renderer::getFSR2MotionVecScaleY() const { - return postProcessPipeline_ ? postProcessPipeline_->getFSR2MotionVecScaleY() : 1.0f; -} -bool Renderer::isAmdFsr2SdkAvailable() const { - return postProcessPipeline_ && postProcessPipeline_->isAmdFsr2SdkAvailable(); -} -bool Renderer::isAmdFsr3FramegenSdkAvailable() const { - return postProcessPipeline_ && postProcessPipeline_->isAmdFsr3FramegenSdkAvailable(); -} -bool Renderer::isAmdFsr3FramegenRuntimeActive() const { - return postProcessPipeline_ && postProcessPipeline_->isAmdFsr3FramegenRuntimeActive(); -} -bool Renderer::isAmdFsr3FramegenRuntimeReady() const { - return postProcessPipeline_ && postProcessPipeline_->isAmdFsr3FramegenRuntimeReady(); -} -const char* Renderer::getAmdFsr3FramegenRuntimePath() const { - return postProcessPipeline_ ? postProcessPipeline_->getAmdFsr3FramegenRuntimePath() : ""; -} -const std::string& Renderer::getAmdFsr3FramegenRuntimeError() const { - static const std::string empty; - return postProcessPipeline_ ? postProcessPipeline_->getAmdFsr3FramegenRuntimeError() : empty; -} -size_t Renderer::getAmdFsr3UpscaleDispatchCount() const { - return postProcessPipeline_ ? postProcessPipeline_->getAmdFsr3UpscaleDispatchCount() : 0; -} -size_t Renderer::getAmdFsr3FramegenDispatchCount() const { - return postProcessPipeline_ ? postProcessPipeline_->getAmdFsr3FramegenDispatchCount() : 0; -} -size_t Renderer::getAmdFsr3FallbackCount() const { - return postProcessPipeline_ ? postProcessPipeline_->getAmdFsr3FallbackCount() : 0; -} -void Renderer::setBrightness(float b) { - if (postProcessPipeline_) postProcessPipeline_->setBrightness(b); -} -float Renderer::getBrightness() const { - return postProcessPipeline_ ? postProcessPipeline_->getBrightness() : 1.0f; -} - void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { ZoneScopedN("Renderer::renderWorld"); (void)world; @@ -2132,7 +1570,12 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { { VkCommandBuffer cmd = beginSecondary(SEC_CHARS); setSecondaryViewportScissor(cmd); - renderSelectionCircle(view, projection, cmd); + if (overlaySystem_) { + overlaySystem_->renderSelectionCircle(view, projection, cmd, + terrainManager ? OverlaySystem::HeightQuery2D([&](float x, float y) { return terrainManager->getHeightAt(x, y); }) : OverlaySystem::HeightQuery2D{}, + wmoRenderer ? OverlaySystem::HeightQuery3D([&](float x, float y, float z) { return wmoRenderer->getFloorHeight(x, y, z); }) : OverlaySystem::HeightQuery3D{}, + m2Renderer ? OverlaySystem::HeightQuery3D([&](float x, float y, float z) { return m2Renderer->getFloorHeight(x, y, z); }) : OverlaySystem::HeightQuery3D{}); + } if (characterRenderer && camera && !skipChars) { characterRenderer->render(cmd, perFrameSet, *camera); } @@ -2164,7 +1607,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (questMarkerRenderer && camera) questMarkerRenderer->render(cmd, perFrameSet, *camera); // Underwater overlay + minimap - if (overlayPipeline && waterRenderer && camera) { + if (overlaySystem_ && waterRenderer && camera) { glm::vec3 camPos = camera->getPosition(); auto waterH = waterRenderer->getNearestWaterHeightAt(camPos.x, camPos.y, camPos.z); constexpr float MIN_SUBMERSION_OVERLAY = 1.5f; @@ -2179,21 +1622,21 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { glm::vec4 tint = canal ? glm::vec4(0.01f, 0.04f, 0.10f, fogStrength) : glm::vec4(0.03f, 0.09f, 0.18f, fogStrength); - renderOverlay(tint, cmd); + if (overlaySystem_) overlaySystem_->renderOverlay(tint, cmd); } } // Ghost mode desaturation: cold blue-grey overlay when dead/ghost - if (ghostMode_) { - renderOverlay(glm::vec4(0.30f, 0.35f, 0.42f, 0.45f), cmd); + if (ghostMode_ && overlaySystem_) { + overlaySystem_->renderOverlay(glm::vec4(0.30f, 0.35f, 0.42f, 0.45f), cmd); } // Brightness overlay (applied before minimap so it doesn't affect UI) - { + if (overlaySystem_) { float br = postProcessPipeline_ ? postProcessPipeline_->getBrightness() : 1.0f; if (br < 0.99f) { - renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - br), cmd); + overlaySystem_->renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - br), cmd); } else if (br > 1.01f) { float alpha = (br - 1.0f) / 1.0f; - renderOverlay(glm::vec4(1.0f, 1.0f, 1.0f, alpha), cmd); + overlaySystem_->renderOverlay(glm::vec4(1.0f, 1.0f, 1.0f, alpha), cmd); } } if (minimap && minimap->isEnabled() && camera && window) { @@ -2277,7 +1720,12 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { std::chrono::steady_clock::now() - wmoStart).count(); } - renderSelectionCircle(view, projection); + if (overlaySystem_) { + overlaySystem_->renderSelectionCircle(view, projection, currentCmd, + terrainManager ? OverlaySystem::HeightQuery2D([&](float x, float y) { return terrainManager->getHeightAt(x, y); }) : OverlaySystem::HeightQuery2D{}, + wmoRenderer ? OverlaySystem::HeightQuery3D([&](float x, float y, float z) { return wmoRenderer->getFloorHeight(x, y, z); }) : OverlaySystem::HeightQuery3D{}, + m2Renderer ? OverlaySystem::HeightQuery3D([&](float x, float y, float z) { return m2Renderer->getFloorHeight(x, y, z); }) : OverlaySystem::HeightQuery3D{}); + } if (characterRenderer && camera && !skipChars) { characterRenderer->prepareRender(frameIdx); @@ -2312,7 +1760,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { // Underwater overlay and minimap — in the fallback path these run inline; // in the parallel path they were already recorded into SEC_POST above. if (!parallelRecordingEnabled_) { - if (overlayPipeline && waterRenderer && camera) { + if (overlaySystem_ && waterRenderer && camera) { glm::vec3 camPos = camera->getPosition(); auto waterH = waterRenderer->getNearestWaterHeightAt(camPos.x, camPos.y, camPos.z); constexpr float MIN_SUBMERSION_OVERLAY = 1.5f; @@ -2327,21 +1775,21 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { glm::vec4 tint = canal ? glm::vec4(0.01f, 0.04f, 0.10f, fogStrength) : glm::vec4(0.03f, 0.09f, 0.18f, fogStrength); - renderOverlay(tint); + if (overlaySystem_) overlaySystem_->renderOverlay(tint, currentCmd); } } // Ghost mode desaturation: cold blue-grey overlay when dead/ghost - if (ghostMode_) { - renderOverlay(glm::vec4(0.30f, 0.35f, 0.42f, 0.45f)); + if (ghostMode_ && overlaySystem_) { + overlaySystem_->renderOverlay(glm::vec4(0.30f, 0.35f, 0.42f, 0.45f), currentCmd); } // Brightness overlay (applied before minimap so it doesn't affect UI) - { + if (overlaySystem_) { float br = postProcessPipeline_ ? postProcessPipeline_->getBrightness() : 1.0f; if (br < 0.99f) { - renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - br)); + overlaySystem_->renderOverlay(glm::vec4(0.0f, 0.0f, 0.0f, 1.0f - br), currentCmd); } else if (br > 1.01f) { float alpha = (br - 1.0f) / 1.0f; - renderOverlay(glm::vec4(1.0f, 1.0f, 1.0f, alpha)); + overlaySystem_->renderOverlay(glm::vec4(1.0f, 1.0f, 1.0f, alpha), currentCmd); } } if (minimap && minimap->isEnabled() && camera && window) { diff --git a/src/ui/chat_panel.cpp b/src/ui/chat_panel.cpp index 49d37249..c5d9fadb 100644 --- a/src/ui/chat_panel.cpp +++ b/src/ui/chat_panel.cpp @@ -9,6 +9,7 @@ #include "core/coordinates.hpp" #include "core/input.hpp" #include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" #include "rendering/camera.hpp" #include "rendering/camera_controller.hpp" #include "audio/audio_coordinator.hpp" @@ -45,68 +46,6 @@ namespace { // Common ImGui window flags for popup dialogs const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; - - std::string trim(const std::string& s) { - size_t first = s.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) return ""; - size_t last = s.find_last_not_of(" \t\r\n"); - return s.substr(first, last - first + 1); - } - - std::string toLower(std::string s) { - std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { - return static_cast(std::tolower(c)); - }); - return s; - } - - bool isPortBotTarget(const std::string& target) { - std::string t = toLower(trim(target)); - return t == "portbot" || t == "gmbot" || t == "telebot"; - } - - std::string buildPortBotCommand(const std::string& rawInput) { - std::string input = trim(rawInput); - if (input.empty()) return ""; - - std::string lower = toLower(input); - if (lower == "help" || lower == "?") { - return "__help__"; - } - - if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) { - return input; - } - - if (lower.rfind("xyz ", 0) == 0) { - return ".go " + input; - } - - if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; - if (lower == "if" || lower == "ironforge") return ".tele ironforge"; - if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; - if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; - if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; - if (lower == "uc" || lower == "undercity") return ".tele undercity"; - if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; - if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; - - return ".tele " + input; - } - - std::string getEntityName(const std::shared_ptr& entity) { - if (entity->getType() == wowee::game::ObjectType::PLAYER) { - auto player = std::static_pointer_cast(entity); - if (!player->getName().empty()) return player->getName(); - } else if (entity->getType() == wowee::game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - if (!unit->getName().empty()) return unit->getName(); - } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { - auto go = std::static_pointer_cast(entity); - if (!go->getName().empty()) return go->getName(); - } - return "Unknown"; - } } namespace wowee { namespace ui { @@ -169,12 +108,6 @@ bool ChatPanel::shouldShowMessage(const game::MessageChatData& msg, int tabIndex } -// Forward declaration — defined below -static std::vector allMacroCommands(const std::string& macroText); -static std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride); - void ChatPanel::render(game::GameHandler& gameHandler, InventoryScreen& inventoryScreen, SpellbookScreen& spellbookScreen, @@ -1690,3145 +1623,6 @@ void ChatPanel::render(game::GameHandler& gameHandler, // Collect all non-comment, non-empty lines from a macro body. -static std::vector allMacroCommands(const std::string& macroText) { - std::vector cmds; - size_t pos = 0; - while (pos <= macroText.size()) { - size_t nl = macroText.find('\n', pos); - std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); - if (!line.empty() && line.back() == '\r') line.pop_back(); - size_t start = line.find_first_not_of(" \t"); - if (start != std::string::npos) line = line.substr(start); - if (!line.empty() && line.front() != '#') - cmds.push_back(std::move(line)); - if (nl == std::string::npos) break; - pos = nl + 1; - } - return cmds; -} - -// --------------------------------------------------------------------------- -// WoW macro conditional evaluator -// Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell -// Returns the first matching alternative's argument, or "" if none matches. -// targetOverride is set to a specific GUID if [target=X] was in the conditions, -// or left as UINT64_MAX to mean "use the normal target". -// --------------------------------------------------------------------------- -static std::string evaluateMacroConditionals(const std::string& rawArg, - game::GameHandler& gameHandler, - uint64_t& targetOverride) { - targetOverride = static_cast(-1); - - auto& input = core::Input::getInstance(); - - const bool shiftHeld = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || - input.isKeyPressed(SDL_SCANCODE_RSHIFT); - const bool ctrlHeld = input.isKeyPressed(SDL_SCANCODE_LCTRL) || - input.isKeyPressed(SDL_SCANCODE_RCTRL); - const bool altHeld = input.isKeyPressed(SDL_SCANCODE_LALT) || - input.isKeyPressed(SDL_SCANCODE_RALT); - const bool anyMod = shiftHeld || ctrlHeld || altHeld; - - // Split rawArg on ';' → alternatives - std::vector alts; - { - std::string cur; - for (char c : rawArg) { - if (c == ';') { alts.push_back(cur); cur.clear(); } - else cur += c; - } - alts.push_back(cur); - } - - // Evaluate a single comma-separated condition token. - // tgt is updated if a target= or @ specifier is found. - auto evalCond = [&](const std::string& raw, uint64_t& tgt) -> bool { - std::string c = raw; - // trim - size_t s = c.find_first_not_of(" \t"); if (s) c = (s != std::string::npos) ? c.substr(s) : ""; - size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1); - if (c.empty()) return true; - - // @target specifiers: @player, @focus, @pet, @mouseover, @target - if (!c.empty() && c[0] == '@') { - std::string spec = c.substr(1); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); - else if (spec == "focus") tgt = gameHandler.getFocusGuid(); - else if (spec == "target") tgt = gameHandler.getTargetGuid(); - else if (spec == "pet") { - uint64_t pg = gameHandler.getPetGuid(); - if (pg != 0) tgt = pg; - else return false; // no pet — skip this alternative - } - else if (spec == "mouseover") { - uint64_t mo = gameHandler.getMouseoverGuid(); - if (mo != 0) tgt = mo; - else return false; // no mouseover — skip this alternative - } - return true; - } - // target=X specifiers - if (c.rfind("target=", 0) == 0) { - std::string spec = c.substr(7); - if (spec == "player") tgt = gameHandler.getPlayerGuid(); - else if (spec == "focus") tgt = gameHandler.getFocusGuid(); - else if (spec == "target") tgt = gameHandler.getTargetGuid(); - else if (spec == "pet") { - uint64_t pg = gameHandler.getPetGuid(); - if (pg != 0) tgt = pg; - else return false; // no pet — skip this alternative - } - else if (spec == "mouseover") { - uint64_t mo = gameHandler.getMouseoverGuid(); - if (mo != 0) tgt = mo; - else return false; // no mouseover — skip this alternative - } - return true; - } - - // mod / nomod - if (c == "nomod" || c == "mod:none") return !anyMod; - if (c.rfind("mod:", 0) == 0) { - std::string mods = c.substr(4); - bool ok = true; - if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; - if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; - if (mods.find("alt") != std::string::npos && !altHeld) ok = false; - return ok; - } - - // combat / nocombat - if (c == "combat") return gameHandler.isInCombat(); - if (c == "nocombat") return !gameHandler.isInCombat(); - - // Helper to get the effective target entity - auto effTarget = [&]() -> std::shared_ptr { - if (tgt != static_cast(-1) && tgt != 0) - return gameHandler.getEntityManager().getEntity(tgt); - return gameHandler.getTarget(); - }; - - // exists / noexists - if (c == "exists") return effTarget() != nullptr; - if (c == "noexists") return effTarget() == nullptr; - - // dead / nodead - if (c == "dead") { - auto t = effTarget(); - auto u = t ? std::dynamic_pointer_cast(t) : nullptr; - return u && u->getHealth() == 0; - } - if (c == "nodead") { - auto t = effTarget(); - auto u = t ? std::dynamic_pointer_cast(t) : nullptr; - return u && u->getHealth() > 0; - } - - // help (friendly) / harm (hostile) and their no- variants - auto unitHostile = [&](const std::shared_ptr& t) -> bool { - if (!t) return false; - auto u = std::dynamic_pointer_cast(t); - return u && gameHandler.isHostileFactionPublic(u->getFactionTemplate()); - }; - if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } - if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } - - // mounted / nomounted - if (c == "mounted") return gameHandler.isMounted(); - if (c == "nomounted") return !gameHandler.isMounted(); - - // swimming / noswimming - if (c == "swimming") return gameHandler.isSwimming(); - if (c == "noswimming") return !gameHandler.isSwimming(); - - // flying / noflying (CAN_FLY + FLYING flags active) - if (c == "flying") return gameHandler.isPlayerFlying(); - if (c == "noflying") return !gameHandler.isPlayerFlying(); - - // channeling / nochanneling - if (c == "channeling") return gameHandler.isCasting() && gameHandler.isChanneling(); - if (c == "nochanneling") return !(gameHandler.isCasting() && gameHandler.isChanneling()); - - // stealthed / nostealthed (unit flag 0x02000000 = UNIT_FLAG_SNEAKING) - auto isStealthedFn = [&]() -> bool { - auto pe = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); - if (!pe) return false; - auto pu = std::dynamic_pointer_cast(pe); - return pu && (pu->getUnitFlags() & 0x02000000u) != 0; - }; - if (c == "stealthed") return isStealthedFn(); - if (c == "nostealthed") return !isStealthedFn(); - - // pet / nopet — player has an active pet (hunters, warlocks, DKs) - if (c == "pet") return gameHandler.hasPet(); - if (c == "nopet") return !gameHandler.hasPet(); - - // indoors / outdoors — WMO interior detection (affects mount type selection) - if (c == "indoors" || c == "nooutdoors") { - auto* r = core::Application::getInstance().getRenderer(); - return r && r->isPlayerIndoors(); - } - if (c == "outdoors" || c == "noindoors") { - auto* r = core::Application::getInstance().getRenderer(); - return !r || !r->isPlayerIndoors(); - } - - // group / nogroup — player is in a party or raid - if (c == "group" || c == "party") return gameHandler.isInGroup(); - if (c == "nogroup") return !gameHandler.isInGroup(); - - // raid / noraid — player is in a raid group (groupType == 1) - if (c == "raid") return gameHandler.isInGroup() && gameHandler.getPartyData().groupType == 1; - if (c == "noraid") return !gameHandler.isInGroup() || gameHandler.getPartyData().groupType != 1; - - // spec:N — active talent spec (1-based: spec:1 = primary, spec:2 = secondary) - if (c.rfind("spec:", 0) == 0) { - uint8_t wantSpec = 0; - try { wantSpec = static_cast(std::stoul(c.substr(5))); } catch (...) {} - return wantSpec > 0 && gameHandler.getActiveTalentSpec() == (wantSpec - 1); - } - - // noform / nostance — player is NOT in a shapeshift/stance - if (c == "noform" || c == "nostance") { - for (const auto& a : gameHandler.getPlayerAuras()) - if (!a.isEmpty() && a.maxDurationMs == -1) return false; - return true; - } - // form:0 same as noform - if (c == "form:0" || c == "stance:0") { - for (const auto& a : gameHandler.getPlayerAuras()) - if (!a.isEmpty() && a.maxDurationMs == -1) return false; - return true; - } - - // buff:SpellName / nobuff:SpellName — check if the effective target (or player - // if no target specified) has a buff with the given name. - // debuff:SpellName / nodebuff:SpellName — same for debuffs (harmful auras). - auto checkAuraByName = [&](const std::string& spellName, bool wantDebuff, - bool negate) -> bool { - // Determine which aura list to check: effective target or player - const std::vector* auras = nullptr; - if (tgt != static_cast(-1) && tgt != 0 && tgt != gameHandler.getPlayerGuid()) { - // Check target's auras - auras = &gameHandler.getTargetAuras(); - } else { - auras = &gameHandler.getPlayerAuras(); - } - std::string nameLow = spellName; - for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); - for (const auto& a : *auras) { - if (a.isEmpty() || a.spellId == 0) continue; - // Filter: debuffs have the HARMFUL flag (0x80) or spell has a dispel type - bool isDebuff = (a.flags & 0x80) != 0; - if (wantDebuff ? !isDebuff : isDebuff) continue; - std::string sn = gameHandler.getSpellName(a.spellId); - for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); - if (sn == nameLow) return !negate; - } - return negate; - }; - if (c.rfind("buff:", 0) == 0 && c.size() > 5) - return checkAuraByName(c.substr(5), false, false); - if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) - return checkAuraByName(c.substr(7), false, true); - if (c.rfind("debuff:", 0) == 0 && c.size() > 7) - return checkAuraByName(c.substr(7), true, false); - if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) - return checkAuraByName(c.substr(9), true, true); - - // mounted / nomounted - if (c == "mounted") return gameHandler.isMounted(); - if (c == "nomounted") return !gameHandler.isMounted(); - - // group (any group) / nogroup / raid - if (c == "group") return !gameHandler.getPartyData().isEmpty(); - if (c == "nogroup") return gameHandler.getPartyData().isEmpty(); - if (c == "raid") { - const auto& pd = gameHandler.getPartyData(); - return pd.groupType >= 1; // groupType 1 = raid, 0 = party - } - - // channeling:SpellName — player is currently channeling that spell - if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { - if (!gameHandler.isChanneling()) return false; - std::string want = c.substr(11); - for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); - uint32_t castSpellId = gameHandler.getCurrentCastSpellId(); - std::string sn = gameHandler.getSpellName(castSpellId); - for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); - return sn == want; - } - if (c == "channeling") return gameHandler.isChanneling(); - if (c == "nochanneling") return !gameHandler.isChanneling(); - - // casting (any active cast or channel) - if (c == "casting") return gameHandler.isCasting(); - if (c == "nocasting") return !gameHandler.isCasting(); - - // vehicle / novehicle (WotLK) - if (c == "vehicle") return gameHandler.getVehicleId() != 0; - if (c == "novehicle") return gameHandler.getVehicleId() == 0; - - // Unknown → permissive (don't block) - return true; - }; - - for (auto& alt : alts) { - // trim - size_t fs = alt.find_first_not_of(" \t"); - if (fs == std::string::npos) continue; - alt = alt.substr(fs); - size_t ls = alt.find_last_not_of(" \t"); - if (ls != std::string::npos) alt.resize(ls + 1); - - if (!alt.empty() && alt[0] == '[') { - size_t close = alt.find(']'); - if (close == std::string::npos) continue; - std::string condStr = alt.substr(1, close - 1); - std::string argPart = alt.substr(close + 1); - // Trim argPart - size_t as = argPart.find_first_not_of(" \t"); - argPart = (as != std::string::npos) ? argPart.substr(as) : ""; - - // Evaluate comma-separated conditions - uint64_t tgt = static_cast(-1); - bool pass = true; - size_t cp = 0; - while (pass) { - size_t comma = condStr.find(',', cp); - std::string tok = condStr.substr(cp, comma == std::string::npos ? std::string::npos : comma - cp); - if (!evalCond(tok, tgt)) { pass = false; break; } - if (comma == std::string::npos) break; - cp = comma + 1; - } - if (pass) { - if (tgt != static_cast(-1)) targetOverride = tgt; - return argPart; - } - } else { - // No condition block — default fallback always matches - return alt; - } - } - return {}; -} - -// Execute all non-comment lines of a macro body in sequence. -// In WoW, every line executes per click; the server enforces spell-cast limits. -// /stopmacro (with optional conditionals) halts the remaining commands early. - -void ChatPanel::executeMacroText(game::GameHandler& gameHandler, - InventoryScreen& inventoryScreen, - SpellbookScreen& spellbookScreen, - QuestLogScreen& questLogScreen, - const std::string& macroText) { - macroStopped_ = false; - for (const auto& cmd : allMacroCommands(macroText)) { - strncpy(chatInputBuffer_, cmd.c_str(), sizeof(chatInputBuffer_) - 1); - chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; - sendChatMessage(gameHandler, inventoryScreen, spellbookScreen, questLogScreen); - if (macroStopped_) break; - } - macroStopped_ = false; -} - -// /castsequence persistent state — shared across all macros using the same spell list. -// Keyed by the normalized (lowercase, comma-joined) spell sequence string. -namespace { -struct CastSeqState { - size_t index = 0; - float lastPressSec = 0.0f; - uint64_t lastTargetGuid = 0; - bool lastInCombat = false; -}; -std::unordered_map s_castSeqStates; -} // namespace - - -void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, - InventoryScreen& /*inventoryScreen*/, - SpellbookScreen& /*spellbookScreen*/, - QuestLogScreen& /*questLogScreen*/) { - if (strlen(chatInputBuffer_) > 0) { - std::string input(chatInputBuffer_); - - // Save to sent-message history (skip pure whitespace, cap at 50 entries) - { - bool allSpace = true; - for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } - if (!allSpace) { - // Remove duplicate of last entry if identical - if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { - chatSentHistory_.push_back(input); - if (chatSentHistory_.size() > 50) - chatSentHistory_.erase(chatSentHistory_.begin()); - } - } - } - chatHistoryIdx_ = -1; // reset browsing position after send - - game::ChatType type = game::ChatType::SAY; - std::string message = input; - std::string target; - - // Track if a channel shortcut should change the chat type dropdown - int switchChatType = -1; - - // Check for slash commands - if (input.size() > 1 && input[0] == '/') { - std::string command = input.substr(1); - size_t spacePos = command.find(' '); - std::string cmd = (spacePos != std::string::npos) ? command.substr(0, spacePos) : command; - - // Convert command to lowercase for comparison - std::string cmdLower = cmd; - for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); - - // /run — execute Lua script via addon system - if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { - std::string luaCode = command.substr(spacePos + 1); - auto* am = services_.addonManager; - if (am) { - am->runScript(luaCode); - } else { - gameHandler.addUIError("Addon system not initialized."); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /dump — evaluate Lua expression and print result - if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { - std::string expr = command.substr(spacePos + 1); - auto* am = services_.addonManager; - if (am && am->isInitialized()) { - // Wrap expression in print(tostring(...)) to display the value - std::string wrapped = "local __v = " + expr + - "; if type(__v) == 'table' then " - " local parts = {} " - " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " - " print('{' .. table.concat(parts, ', ') .. '}') " - "else print(tostring(__v)) end"; - am->runScript(wrapped); - } else { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "Addon system not initialized."; - gameHandler.addLocalChatMessage(errMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // Check addon slash commands (SlashCmdList) before built-in commands - { - auto* am = services_.addonManager; - if (am && am->isInitialized()) { - std::string slashCmd = "/" + cmdLower; - std::string slashArgs; - if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); - if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { - chatInputBuffer_[0] = '\0'; - return; - } - } - } - - // Special commands - if (cmdLower == "logout") { - core::Application::getInstance().logoutToLogin(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "clear") { - gameHandler.clearChatHistory(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /reload or /reloadui — reload all addons (save variables, re-init Lua, re-scan .toc files) - if (cmdLower == "reload" || cmdLower == "reloadui" || cmdLower == "rl") { - auto* am = services_.addonManager; - if (am) { - am->reload(); - am->fireEvent("VARIABLES_LOADED"); - am->fireEvent("PLAYER_LOGIN"); - am->fireEvent("PLAYER_ENTERING_WORLD"); - game::MessageChatData rlMsg; - rlMsg.type = game::ChatType::SYSTEM; - rlMsg.language = game::ChatLanguage::UNIVERSAL; - rlMsg.message = "Interface reloaded."; - gameHandler.addLocalChatMessage(rlMsg); - } else { - game::MessageChatData rlMsg; - rlMsg.type = game::ChatType::SYSTEM; - rlMsg.language = game::ChatLanguage::UNIVERSAL; - rlMsg.message = "Addon system not available."; - gameHandler.addLocalChatMessage(rlMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /stopmacro [conditions] - // Halts execution of the current macro (remaining lines are skipped). - // With a condition block, only stops if the conditions evaluate to true. - // /stopmacro → always stops - // /stopmacro [combat] → stops only while in combat - // /stopmacro [nocombat] → stops only when not in combat - if (cmdLower == "stopmacro") { - bool shouldStop = true; - if (spacePos != std::string::npos) { - std::string condArg = command.substr(spacePos + 1); - while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); - if (!condArg.empty() && condArg.front() == '[') { - // Append a sentinel action so evaluateMacroConditionals can signal a match. - uint64_t tgtOver = static_cast(-1); - std::string hit = evaluateMacroConditionals(condArg + " __stop__", gameHandler, tgtOver); - shouldStop = !hit.empty(); - } - } - if (shouldStop) macroStopped_ = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /invite command - if (cmdLower == "invite" && spacePos != std::string::npos) { - std::string targetName = command.substr(spacePos + 1); - gameHandler.inviteToGroup(targetName); - chatInputBuffer_[0] = '\0'; - return; - } - - // /inspect command - if (cmdLower == "inspect") { - gameHandler.inspectTarget(); - slashCmds_.showInspect = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /threat command - if (cmdLower == "threat") { - slashCmds_.toggleThreat = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /score command — BG scoreboard - if (cmdLower == "score") { - gameHandler.requestPvpLog(); - slashCmds_.showBgScore = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /time command - if (cmdLower == "time") { - gameHandler.queryServerTime(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /loc command — print player coordinates and zone name - if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") { - const auto& pmi = gameHandler.getMovementInfo(); - std::string zoneName; - if (auto* rend = services_.renderer) - zoneName = rend->getCurrentZoneName(); - char buf[256]; - snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", - pmi.x, pmi.y, pmi.z, - zoneName.empty() ? "" : " — ", - zoneName.c_str()); - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = buf; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /screenshot command — capture current frame to PNG - if (cmdLower == "screenshot" || cmdLower == "ss") { - slashCmds_.takeScreenshot = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /zone command — print current zone name - if (cmdLower == "zone") { - std::string zoneName; - if (auto* rend = services_.renderer) - zoneName = rend->getCurrentZoneName(); - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /played command - if (cmdLower == "played") { - gameHandler.requestPlayedTime(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ticket command — open GM ticket window - if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") { - slashCmds_.showGmTicket = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /chathelp command — list chat-channel slash commands - if (cmdLower == "chathelp") { - static constexpr const char* kChatHelp[] = { - "--- Chat Channel Commands ---", - "/s [msg] Say to nearby players", - "/y [msg] Yell to a wider area", - "/w [msg] Whisper to player", - "/r [msg] Reply to last whisper", - "/p [msg] Party chat", - "/g [msg] Guild chat", - "/o [msg] Guild officer chat", - "/raid [msg] Raid chat", - "/rw [msg] Raid warning", - "/bg [msg] Battleground chat", - "/1 [msg] General channel", - "/2 [msg] Trade channel (also /wts /wtb)", - "/ [msg] Channel by number", - "/join Join a channel", - "/leave Leave a channel", - "/afk [msg] Set AFK status", - "/dnd [msg] Set Do Not Disturb", - }; - for (const char* line : kChatHelp) { - game::MessageChatData helpMsg; - helpMsg.type = game::ChatType::SYSTEM; - helpMsg.language = game::ChatLanguage::UNIVERSAL; - helpMsg.message = line; - gameHandler.addLocalChatMessage(helpMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /macrohelp command — list available macro conditionals - if (cmdLower == "macrohelp") { - static constexpr const char* kMacroHelp[] = { - "--- Macro Conditionals ---", - "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", - "State: [combat] [mounted] [swimming] [flying] [stealthed]", - " [channeling] [pet] [group] [raid] [indoors] [outdoors]", - "Spec: [spec:1] [spec:2] (active talent spec, 1-based)", - " (prefix no- to negate any condition)", - "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", - " [target=focus] [target=pet] [target=mouseover] [target=player]", - " (also: @focus, @pet, @mouseover, @player, @target)", - "Form: [noform] [nostance] [form:0]", - "Keys: [mod:shift] [mod:ctrl] [mod:alt]", - "Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]", - "Other: #showtooltip, /stopmacro [cond], /castsequence", - }; - for (const char* line : kMacroHelp) { - game::MessageChatData m; - m.type = game::ChatType::SYSTEM; - m.language = game::ChatLanguage::UNIVERSAL; - m.message = line; - gameHandler.addLocalChatMessage(m); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /help command — list available slash commands - if (cmdLower == "help" || cmdLower == "?") { - static constexpr const char* kHelpLines[] = { - "--- Wowee Slash Commands ---", - "Chat: /s /y /p /g /raid /rw /o /bg /w /r /join /leave", - "Social: /who /friend add/remove /ignore /unignore", - "Party: /invite /uninvite /leave /readycheck /mark /roll", - " /maintank /mainassist /raidconvert /raidinfo", - " /lootmethod /lootthreshold", - "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", - " /gleader /groster /ginfo /gcreate /gdisband", - "Combat: /cast /castsequence /use /startattack /stopattack", - " /stopcasting /duel /forfeit /pvp /assist", - " /follow /stopfollow /threat /combatlog", - "Items: /use /equip /equipset [name]", - "Target: /target /cleartarget /focus /clearfocus /inspect", - "Movement: /sit /stand /kneel /dismount", - "Misc: /played /time /zone /loc /afk /dnd /helm /cloak", - " /trade /score /unstuck /logout /quit /exit /ticket", - " /screenshot /difficulty", - " /macrohelp /chathelp /help", - }; - for (const char* line : kHelpLines) { - game::MessageChatData helpMsg; - helpMsg.type = game::ChatType::SYSTEM; - helpMsg.language = game::ChatLanguage::UNIVERSAL; - helpMsg.message = line; - gameHandler.addLocalChatMessage(helpMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /who commands - if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") { - std::string query; - if (spacePos != std::string::npos) { - query = command.substr(spacePos + 1); - // Trim leading/trailing whitespace - size_t first = query.find_first_not_of(" \t\r\n"); - if (first == std::string::npos) { - query.clear(); - } else { - size_t last = query.find_last_not_of(" \t\r\n"); - query = query.substr(first, last - first + 1); - } - } - - if ((cmdLower == "whois") && query.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /whois "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "who" && (query == "help" || query == "?")) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Who commands: /who [name/filter], /whois , /online"; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - gameHandler.queryWho(query); - slashCmds_.showWho = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /combatlog command - if (cmdLower == "combatlog" || cmdLower == "cl") { - slashCmds_.toggleCombatLog = true; - chatInputBuffer_[0] = '\0'; - return; - } - - // /roll command - if (cmdLower == "roll" || cmdLower == "random" || cmdLower == "rnd") { - uint32_t minRoll = 1; - uint32_t maxRoll = 100; - - if (spacePos != std::string::npos) { - std::string args = command.substr(spacePos + 1); - size_t dashPos = args.find('-'); - size_t spacePos2 = args.find(' '); - - if (dashPos != std::string::npos) { - // Format: /roll 1-100 - try { - minRoll = std::stoul(args.substr(0, dashPos)); - maxRoll = std::stoul(args.substr(dashPos + 1)); - } catch (...) {} - } else if (spacePos2 != std::string::npos) { - // Format: /roll 1 100 - try { - minRoll = std::stoul(args.substr(0, spacePos2)); - maxRoll = std::stoul(args.substr(spacePos2 + 1)); - } catch (...) {} - } else { - // Format: /roll 100 (means 1-100) - try { - maxRoll = std::stoul(args); - } catch (...) {} - } - } - - gameHandler.randomRoll(minRoll, maxRoll); - chatInputBuffer_[0] = '\0'; - return; - } - - // /friend or /addfriend command - if (cmdLower == "friend" || cmdLower == "addfriend") { - if (spacePos != std::string::npos) { - std::string args = command.substr(spacePos + 1); - size_t subCmdSpace = args.find(' '); - - if (cmdLower == "friend" && subCmdSpace != std::string::npos) { - std::string subCmd = args.substr(0, subCmdSpace); - std::transform(subCmd.begin(), subCmd.end(), subCmd.begin(), ::tolower); - - if (subCmd == "add") { - std::string playerName = args.substr(subCmdSpace + 1); - gameHandler.addFriend(playerName); - chatInputBuffer_[0] = '\0'; - return; - } else if (subCmd == "remove" || subCmd == "delete" || subCmd == "rem") { - std::string playerName = args.substr(subCmdSpace + 1); - gameHandler.removeFriend(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - } else { - // /addfriend name or /friend name (assume add) - gameHandler.addFriend(args); - chatInputBuffer_[0] = '\0'; - return; - } - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /friend add or /friend remove "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /removefriend or /delfriend command - if (cmdLower == "removefriend" || cmdLower == "delfriend" || cmdLower == "remfriend") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.removeFriend(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /removefriend "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ignore command - if (cmdLower == "ignore") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.addIgnore(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /ignore "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /unignore command - if (cmdLower == "unignore") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.removeIgnore(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /unignore "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /dismount command - if (cmdLower == "dismount") { - gameHandler.dismount(); - chatInputBuffer_[0] = '\0'; - return; - } - - // Pet control commands (common macro use) - // Action IDs: 1=passive, 2=follow, 3=stay, 4=defensive, 5=attack, 6=aggressive - if (cmdLower == "petattack") { - uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.sendPetAction(5, target); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petfollow") { - gameHandler.sendPetAction(2, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petstay" || cmdLower == "pethalt") { - gameHandler.sendPetAction(3, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petpassive") { - gameHandler.sendPetAction(1, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petdefensive") { - gameHandler.sendPetAction(4, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petaggressive") { - gameHandler.sendPetAction(6, 0); - chatInputBuffer_[0] = '\0'; - return; - } - if (cmdLower == "petdismiss") { - gameHandler.dismissPet(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /cancelform / /cancelshapeshift — leave current shapeshift/stance - if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { - // Cancel the first permanent shapeshift aura the player has - for (const auto& aura : gameHandler.getPlayerAuras()) { - if (aura.spellId == 0) continue; - // Permanent shapeshift auras have the permanent flag (0x20) set - if (aura.flags & 0x20) { - gameHandler.cancelAura(aura.spellId); - break; - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /cancelaura — cancel a specific buff by name or ID - if (cmdLower == "cancelaura" && spacePos != std::string::npos) { - std::string auraArg = command.substr(spacePos + 1); - while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); - while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); - // Try numeric ID first - { - std::string numStr = auraArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNum = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNum) { - uint32_t spellId = 0; - try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} - if (spellId) gameHandler.cancelAura(spellId); - chatInputBuffer_[0] = '\0'; - return; - } - } - // Name match against player auras - std::string argLow = auraArg; - for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); - for (const auto& aura : gameHandler.getPlayerAuras()) { - if (aura.spellId == 0) continue; - std::string sn = gameHandler.getSpellName(aura.spellId); - for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); - if (sn == argLow) { - gameHandler.cancelAura(aura.spellId); - break; - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /sit command - if (cmdLower == "sit") { - gameHandler.setStandState(1); // 1 = sit - chatInputBuffer_[0] = '\0'; - return; - } - - // /stand command - if (cmdLower == "stand") { - gameHandler.setStandState(0); // 0 = stand - chatInputBuffer_[0] = '\0'; - return; - } - - // /kneel command - if (cmdLower == "kneel") { - gameHandler.setStandState(8); // 8 = kneel - chatInputBuffer_[0] = '\0'; - return; - } - - // /logout command (also /camp, /quit, /exit) - if (cmdLower == "logout" || cmdLower == "camp" || cmdLower == "quit" || cmdLower == "exit") { - gameHandler.requestLogout(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /cancellogout command - if (cmdLower == "cancellogout") { - gameHandler.cancelLogout(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /difficulty command — set dungeon/raid difficulty (WotLK) - if (cmdLower == "difficulty") { - std::string arg; - if (spacePos != std::string::npos) { - arg = command.substr(spacePos + 1); - // Trim whitespace - size_t first = arg.find_first_not_of(" \t"); - size_t last = arg.find_last_not_of(" \t"); - if (first != std::string::npos) - arg = arg.substr(first, last - first + 1); - else - arg.clear(); - for (auto& ch : arg) ch = static_cast(std::tolower(static_cast(ch))); - } - - uint32_t diff = 0; - bool valid = true; - if (arg == "normal" || arg == "0") diff = 0; - else if (arg == "heroic" || arg == "1") diff = 1; - else if (arg == "25" || arg == "25normal" || arg == "25man" || arg == "2") - diff = 2; - else if (arg == "25heroic" || arg == "25manheroic" || arg == "3") - diff = 3; - else valid = false; - - if (!valid || arg.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /difficulty normal|heroic|25|25heroic (0-3)"; - gameHandler.addLocalChatMessage(msg); - } else { - static constexpr const char* kDiffNames[] = { - "Normal (5-man)", "Heroic (5-man)", "Normal (25-man)", "Heroic (25-man)" - }; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Setting difficulty to: ") + kDiffNames[diff]; - gameHandler.addLocalChatMessage(msg); - gameHandler.sendSetDifficulty(diff); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /helm command - if (cmdLower == "helm" || cmdLower == "helmet" || cmdLower == "showhelm") { - gameHandler.toggleHelm(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /cloak command - if (cmdLower == "cloak" || cmdLower == "showcloak") { - gameHandler.toggleCloak(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /follow command - if (cmdLower == "follow" || cmdLower == "f") { - gameHandler.followTarget(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /stopfollow command - if (cmdLower == "stopfollow") { - gameHandler.cancelFollow(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /assist command - if (cmdLower == "assist") { - // /assist → assist current target (use their target) - // /assist PlayerName → find PlayerName, target their target - // /assist [target=X] → evaluate conditional, target that entity's target - auto assistEntityTarget = [&](uint64_t srcGuid) { - auto srcEnt = gameHandler.getEntityManager().getEntity(srcGuid); - if (!srcEnt) { gameHandler.assistTarget(); return; } - uint64_t atkGuid = 0; - const auto& flds = srcEnt->getFields(); - auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); - if (iLo != flds.end()) { - atkGuid = iLo->second; - auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); - if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); - } - if (atkGuid != 0) { - gameHandler.setTarget(atkGuid); - } else { - std::string sn = getEntityName(srcEnt); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = (sn.empty() ? "Target" : sn) + " has no target."; - gameHandler.addLocalChatMessage(msg); - } - }; - - if (spacePos != std::string::npos) { - std::string assistArg = command.substr(spacePos + 1); - while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); - - // Evaluate conditionals if present - uint64_t assistOver = static_cast(-1); - if (!assistArg.empty() && assistArg.front() == '[') { - assistArg = evaluateMacroConditionals(assistArg, gameHandler, assistOver); - if (assistArg.empty() && assistOver == static_cast(-1)) { - chatInputBuffer_[0] = '\0'; return; // no condition matched - } - while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); - while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); - } - - if (assistOver != static_cast(-1) && assistOver != 0) { - assistEntityTarget(assistOver); - } else if (!assistArg.empty()) { - // Name search - std::string argLow = assistArg; - for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - for (const auto& [guid, ent] : gameHandler.getEntityManager().getEntities()) { - if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; - std::string nm = getEntityName(ent); - std::string nml = nm; - for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); - if (nml.find(argLow) != 0) continue; - float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) - + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); - if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } - } - if (bestGuid) assistEntityTarget(bestGuid); - else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "No unit matching '" + assistArg + "' found."; - gameHandler.addLocalChatMessage(msg); - } - } else { - gameHandler.assistTarget(); - } - } else { - gameHandler.assistTarget(); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /pvp command - if (cmdLower == "pvp") { - gameHandler.togglePvp(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ginfo command - if (cmdLower == "ginfo" || cmdLower == "guildinfo") { - gameHandler.requestGuildInfo(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /groster command - if (cmdLower == "groster" || cmdLower == "guildroster") { - gameHandler.requestGuildRoster(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gmotd command - if (cmdLower == "gmotd" || cmdLower == "guildmotd") { - if (spacePos != std::string::npos) { - std::string motd = command.substr(spacePos + 1); - gameHandler.setGuildMotd(motd); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gmotd "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gpromote command - if (cmdLower == "gpromote" || cmdLower == "guildpromote") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.promoteGuildMember(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gpromote "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gdemote command - if (cmdLower == "gdemote" || cmdLower == "guilddemote") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.demoteGuildMember(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gdemote "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gquit command - if (cmdLower == "gquit" || cmdLower == "guildquit" || cmdLower == "leaveguild") { - gameHandler.leaveGuild(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ginvite command - if (cmdLower == "ginvite" || cmdLower == "guildinvite") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.inviteToGuild(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /ginvite "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gkick command - if (cmdLower == "gkick" || cmdLower == "guildkick") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.kickGuildMember(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gkick "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gcreate command - if (cmdLower == "gcreate" || cmdLower == "guildcreate") { - if (spacePos != std::string::npos) { - std::string guildName = command.substr(spacePos + 1); - gameHandler.createGuild(guildName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gcreate "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gdisband command - if (cmdLower == "gdisband" || cmdLower == "guilddisband") { - gameHandler.disbandGuild(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /gleader command - if (cmdLower == "gleader" || cmdLower == "guildleader") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.setGuildLeader(playerName); - chatInputBuffer_[0] = '\0'; - return; - } - - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /gleader "; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // /readycheck command - if (cmdLower == "readycheck" || cmdLower == "rc") { - gameHandler.initiateReadyCheck(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /ready command (respond yes to ready check) - if (cmdLower == "ready") { - gameHandler.respondToReadyCheck(true); - chatInputBuffer_[0] = '\0'; - return; - } - - // /notready command (respond no to ready check) - if (cmdLower == "notready" || cmdLower == "nr") { - gameHandler.respondToReadyCheck(false); - chatInputBuffer_[0] = '\0'; - return; - } - - // /yield or /forfeit command - if (cmdLower == "yield" || cmdLower == "forfeit" || cmdLower == "surrender") { - gameHandler.forfeitDuel(); - chatInputBuffer_[0] = '\0'; - return; - } - - // AFK command - if (cmdLower == "afk" || cmdLower == "away") { - std::string afkMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - gameHandler.toggleAfk(afkMsg); - chatInputBuffer_[0] = '\0'; - return; - } - - // DND command - if (cmdLower == "dnd" || cmdLower == "busy") { - std::string dndMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - gameHandler.toggleDnd(dndMsg); - chatInputBuffer_[0] = '\0'; - return; - } - - // Reply command - if (cmdLower == "r" || cmdLower == "reply") { - std::string lastSender = gameHandler.getLastWhisperSender(); - if (lastSender.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "No one has whispered you yet."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer_[0] = '\0'; - return; - } - // Set whisper target to last whisper sender - strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - if (spacePos != std::string::npos) { - // /r message — send reply immediately - std::string replyMsg = command.substr(spacePos + 1); - gameHandler.sendChatMessage(game::ChatType::WHISPER, replyMsg, lastSender); - } - // Switch to whisper tab - selectedChatType_ = 4; - chatInputBuffer_[0] = '\0'; - return; - } - - // Party/Raid management commands - if (cmdLower == "uninvite" || cmdLower == "kick") { - if (spacePos != std::string::npos) { - std::string playerName = command.substr(spacePos + 1); - gameHandler.uninvitePlayer(playerName); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Usage: /uninvite "; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "leave" || cmdLower == "leaveparty") { - gameHandler.leaveParty(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "maintank" || cmdLower == "mt") { - if (gameHandler.hasTarget()) { - gameHandler.setMainTank(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to set as main tank."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "mainassist" || cmdLower == "ma") { - if (gameHandler.hasTarget()) { - gameHandler.setMainAssist(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to set as main assist."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "clearmaintank") { - gameHandler.clearMainTank(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "clearmainassist") { - gameHandler.clearMainAssist(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "raidinfo") { - gameHandler.requestRaidInfo(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "raidconvert") { - gameHandler.convertToRaid(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /lootmethod (or /grouploot, /setloot) — set party/raid loot method - if (cmdLower == "lootmethod" || cmdLower == "grouploot" || cmdLower == "setloot") { - if (!gameHandler.isInGroup()) { - gameHandler.addUIError("You are not in a group."); - } else if (spacePos == std::string::npos) { - // No argument — show current method and usage - static constexpr const char* kMethodNames[] = { - "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" - }; - const auto& pd = gameHandler.getPartyData(); - const char* cur = (pd.lootMethod < 5) ? kMethodNames[pd.lootMethod] : "Unknown"; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Current loot method: ") + cur; - gameHandler.addLocalChatMessage(msg); - msg.message = "Usage: /lootmethod ffa|roundrobin|master|group|needbeforegreed"; - gameHandler.addLocalChatMessage(msg); - } else { - std::string arg = command.substr(spacePos + 1); - // Lowercase the argument - for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); - uint32_t method = 0xFFFFFFFF; - if (arg == "ffa" || arg == "freeforall") method = 0; - else if (arg == "roundrobin" || arg == "rr") method = 1; - else if (arg == "master" || arg == "masterloot") method = 2; - else if (arg == "group" || arg == "grouploot") method = 3; - else if (arg == "needbeforegreed" || arg == "nbg" || arg == "need") method = 4; - - if (method == 0xFFFFFFFF) { - gameHandler.addUIError("Unknown loot method. Use: ffa, roundrobin, master, group, needbeforegreed"); - } else { - const auto& pd = gameHandler.getPartyData(); - // Master loot uses player guid as master looter; otherwise 0 - uint64_t masterGuid = (method == 2) ? gameHandler.getPlayerGuid() : 0; - gameHandler.sendSetLootMethod(method, pd.lootThreshold, masterGuid); - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /lootthreshold — set minimum item quality for group loot rolls - if (cmdLower == "lootthreshold") { - if (!gameHandler.isInGroup()) { - gameHandler.addUIError("You are not in a group."); - } else if (spacePos == std::string::npos) { - const auto& pd = gameHandler.getPartyData(); - static constexpr const char* kQualityNames[] = { - "Poor (grey)", "Common (white)", "Uncommon (green)", - "Rare (blue)", "Epic (purple)", "Legendary (orange)" - }; - const char* cur = (pd.lootThreshold < 6) ? kQualityNames[pd.lootThreshold] : "Unknown"; - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = std::string("Current loot threshold: ") + cur; - gameHandler.addLocalChatMessage(msg); - msg.message = "Usage: /lootthreshold <0-5> (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary)"; - gameHandler.addLocalChatMessage(msg); - } else { - std::string arg = command.substr(spacePos + 1); - // Trim whitespace - while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); - uint32_t threshold = 0xFFFFFFFF; - if (arg.size() == 1 && arg[0] >= '0' && arg[0] <= '5') { - threshold = static_cast(arg[0] - '0'); - } else { - // Accept quality names - for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); - if (arg == "poor" || arg == "grey" || arg == "gray") threshold = 0; - else if (arg == "common" || arg == "white") threshold = 1; - else if (arg == "uncommon" || arg == "green") threshold = 2; - else if (arg == "rare" || arg == "blue") threshold = 3; - else if (arg == "epic" || arg == "purple") threshold = 4; - else if (arg == "legendary" || arg == "orange") threshold = 5; - } - - if (threshold == 0xFFFFFFFF) { - gameHandler.addUIError("Invalid threshold. Use 0-5 or: poor, common, uncommon, rare, epic, legendary"); - } else { - const auto& pd = gameHandler.getPartyData(); - uint64_t masterGuid = (pd.lootMethod == 2) ? gameHandler.getPlayerGuid() : 0; - gameHandler.sendSetLootMethod(pd.lootMethod, threshold, masterGuid); - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /mark [icon] — set or clear a raid target mark on the current target. - // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull - // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear) - // /mark — no arg marks with skull (icon 7) - if (cmdLower == "mark" || cmdLower == "marktarget" || cmdLower == "raidtarget") { - if (!gameHandler.hasTarget()) { - game::MessageChatData noTgt; - noTgt.type = game::ChatType::SYSTEM; - noTgt.language = game::ChatLanguage::UNIVERSAL; - noTgt.message = "No target selected."; - gameHandler.addLocalChatMessage(noTgt); - chatInputBuffer_[0] = '\0'; - return; - } - static constexpr const char* kMarkWords[] = { - "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" - }; - uint8_t icon = 7; // default: skull - if (spacePos != std::string::npos) { - std::string arg = command.substr(spacePos + 1); - while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); - std::string argLow = arg; - for (auto& c : argLow) c = static_cast(std::tolower(c)); - if (argLow == "clear" || argLow == "0" || argLow == "none") { - gameHandler.setRaidMark(gameHandler.getTargetGuid(), 0xFF); - chatInputBuffer_[0] = '\0'; - return; - } - bool found = false; - for (int mi = 0; mi < 8; ++mi) { - if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } - } - if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { - icon = static_cast(argLow[0] - '1'); - found = true; - } - if (!found) { - game::MessageChatData badArg; - badArg.type = game::ChatType::SYSTEM; - badArg.language = game::ChatLanguage::UNIVERSAL; - badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; - gameHandler.addLocalChatMessage(badArg); - chatInputBuffer_[0] = '\0'; - return; - } - } - gameHandler.setRaidMark(gameHandler.getTargetGuid(), icon); - chatInputBuffer_[0] = '\0'; - return; - } - - // Combat and Trade commands - if (cmdLower == "duel") { - if (gameHandler.hasTarget()) { - gameHandler.proposeDuel(gameHandler.getTargetGuid()); - } else if (spacePos != std::string::npos) { - // Target player by name (would need name-to-GUID lookup) - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to challenge to a duel."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to challenge to a duel."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "trade") { - if (gameHandler.hasTarget()) { - gameHandler.initiateTrade(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a player to trade with."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "startattack") { - // Support macro conditionals: /startattack [harm,nodead] - bool condPass = true; - uint64_t saOverride = static_cast(-1); - if (spacePos != std::string::npos) { - std::string saArg = command.substr(spacePos + 1); - while (!saArg.empty() && saArg.front() == ' ') saArg.erase(saArg.begin()); - if (!saArg.empty() && saArg.front() == '[') { - std::string result = evaluateMacroConditionals(saArg, gameHandler, saOverride); - condPass = !(result.empty() && saOverride == static_cast(-1)); - } - } - if (condPass) { - uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) - ? saOverride : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - if (atkTarget != 0) { - gameHandler.startAutoAttack(atkTarget); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You have no target."; - gameHandler.addLocalChatMessage(msg); - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "stopattack") { - gameHandler.stopAutoAttack(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "stopcasting") { - gameHandler.stopCasting(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "cancelqueuedspell" || cmdLower == "stopspellqueue") { - gameHandler.cancelQueuedSpell(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) - // /equipset — list available sets in chat - if (cmdLower == "equipset") { - const auto& sets = gameHandler.getEquipmentSets(); - auto sysSay = [&](const std::string& msg) { - game::MessageChatData m; - m.type = game::ChatType::SYSTEM; - m.language = game::ChatLanguage::UNIVERSAL; - m.message = msg; - gameHandler.addLocalChatMessage(m); - }; - if (spacePos == std::string::npos) { - // No argument: list available sets - if (sets.empty()) { - sysSay("[System] No equipment sets saved."); - } else { - sysSay("[System] Equipment sets:"); - for (const auto& es : sets) - sysSay(" " + es.name); - } - } else { - std::string setName = command.substr(spacePos + 1); - while (!setName.empty() && setName.front() == ' ') setName.erase(setName.begin()); - while (!setName.empty() && setName.back() == ' ') setName.pop_back(); - // Case-insensitive prefix match - std::string setLower = setName; - std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); - const game::GameHandler::EquipmentSetInfo* found = nullptr; - for (const auto& es : sets) { - std::string nameLow = es.name; - std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); - if (nameLow == setLower || nameLow.find(setLower) == 0) { - found = &es; - break; - } - } - if (found) { - gameHandler.useEquipmentSet(found->setId); - } else { - sysSay("[System] No equipment set matching '" + setName + "'."); - } - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... - // Cycles through the spell list on successive presses; resets per the reset= spec. - if (cmdLower == "castsequence" && spacePos != std::string::npos) { - std::string seqArg = command.substr(spacePos + 1); - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - - // Macro conditionals - uint64_t seqTgtOver = static_cast(-1); - if (!seqArg.empty() && seqArg.front() == '[') { - seqArg = evaluateMacroConditionals(seqArg, gameHandler, seqTgtOver); - if (seqArg.empty() && seqTgtOver == static_cast(-1)) { - chatInputBuffer_[0] = '\0'; return; - } - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - while (!seqArg.empty() && seqArg.back() == ' ') seqArg.pop_back(); - } - - // Optional reset= spec (may contain slash-separated conditions: reset=5/target) - std::string resetSpec; - if (seqArg.rfind("reset=", 0) == 0) { - size_t spAfter = seqArg.find(' '); - if (spAfter != std::string::npos) { - resetSpec = seqArg.substr(6, spAfter - 6); - seqArg = seqArg.substr(spAfter + 1); - while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); - } - } - - // Parse comma-separated spell list - std::vector seqSpells; - { - std::string cur; - for (char c : seqArg) { - if (c == ',') { - while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); - while (!cur.empty() && cur.back() == ' ') cur.pop_back(); - if (!cur.empty()) seqSpells.push_back(cur); - cur.clear(); - } else { cur += c; } - } - while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); - while (!cur.empty() && cur.back() == ' ') cur.pop_back(); - if (!cur.empty()) seqSpells.push_back(cur); - } - if (seqSpells.empty()) { chatInputBuffer_[0] = '\0'; return; } - - // Build stable key from lowercase spell list - std::string seqKey; - for (size_t k = 0; k < seqSpells.size(); ++k) { - if (k) seqKey += ','; - std::string sl = seqSpells[k]; - for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); - seqKey += sl; - } - - auto& seqState = s_castSeqStates[seqKey]; - - // Check reset conditions (slash-separated: e.g. "5/target") - float nowSec = static_cast(ImGui::GetTime()); - bool shouldReset = false; - if (!resetSpec.empty()) { - size_t rpos = 0; - while (rpos <= resetSpec.size()) { - size_t slash = resetSpec.find('/', rpos); - std::string part = (slash != std::string::npos) - ? resetSpec.substr(rpos, slash - rpos) - : resetSpec.substr(rpos); - std::string plow = part; - for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); - bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), - [](unsigned char c){ return std::isdigit(c) || c == '.'; }); - if (isNum) { - float rSec = 0.0f; - try { rSec = std::stof(plow); } catch (...) {} - if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; - } else if (plow == "target") { - if (gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; - } else if (plow == "combat") { - if (gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; - } - if (slash == std::string::npos) break; - rpos = slash + 1; - } - } - if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; - - const std::string& seqSpell = seqSpells[seqState.index]; - seqState.index = (seqState.index + 1) % seqSpells.size(); - seqState.lastPressSec = nowSec; - seqState.lastTargetGuid = gameHandler.getTargetGuid(); - seqState.lastInCombat = gameHandler.isInCombat(); - - // Cast the selected spell — mirrors /cast spell lookup - std::string ssLow = seqSpell; - for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); - if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); - - uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) - ? seqTgtOver : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - - // Numeric ID - if (!ssLow.empty() && ssLow.front() == '#') ssLow.erase(ssLow.begin()); - bool ssNumeric = !ssLow.empty() && std::all_of(ssLow.begin(), ssLow.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (ssNumeric) { - uint32_t ssId = 0; - try { ssId = static_cast(std::stoul(ssLow)); } catch (...) {} - if (ssId) gameHandler.castSpell(ssId, seqTargetGuid); - } else { - uint32_t ssBest = 0; int ssBestRank = -1; - for (uint32_t sid : gameHandler.getKnownSpells()) { - const std::string& sn = gameHandler.getSpellName(sid); - if (sn.empty()) continue; - std::string snl = sn; - for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); - if (snl != ssLow) continue; - int sRnk = 0; - const std::string& rk = gameHandler.getSpellRank(sid); - if (!rk.empty()) { - std::string rkl = rk; - for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); - if (rkl.rfind("rank ", 0) == 0) { try { sRnk = std::stoi(rkl.substr(5)); } catch (...) {} } - } - if (sRnk > ssBestRank) { ssBestRank = sRnk; ssBest = sid; } - } - if (ssBest) gameHandler.castSpell(ssBest, seqTargetGuid); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "cast" && spacePos != std::string::npos) { - std::string spellArg = command.substr(spacePos + 1); - // Trim leading/trailing whitespace - while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); - while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); - - // Evaluate WoW macro conditionals: /cast [mod:shift] Greater Heal; Flash Heal - uint64_t castTargetOverride = static_cast(-1); - if (!spellArg.empty() && spellArg.front() == '[') { - spellArg = evaluateMacroConditionals(spellArg, gameHandler, castTargetOverride); - if (spellArg.empty()) { - chatInputBuffer_[0] = '\0'; - return; // No conditional matched — skip cast - } - while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); - while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); - } - - // Strip leading '!' (WoW /cast !Spell forces recast without toggling off) - if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); - - // Support numeric spell ID: /cast 133 or /cast #133 - { - std::string numStr = spellArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNumeric = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNumeric) { - uint32_t spellId = 0; - try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} - if (spellId != 0) { - uint64_t targetGuid = (castTargetOverride != static_cast(-1)) - ? castTargetOverride - : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - gameHandler.castSpell(spellId, targetGuid); - } - chatInputBuffer_[0] = '\0'; - return; - } - } - - // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" - int requestedRank = -1; // -1 = highest rank - std::string spellName = spellArg; - { - auto rankPos = spellArg.find('('); - if (rankPos != std::string::npos) { - std::string rankStr = spellArg.substr(rankPos + 1); - // Strip closing paren and whitespace - auto closePos = rankStr.find(')'); - if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); - for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); - // Expect "rank N" - if (rankStr.rfind("rank ", 0) == 0) { - try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} - } - spellName = spellArg.substr(0, rankPos); - while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); - } - } - - std::string spellNameLower = spellName; - for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); - - // Search known spells for a name match; pick highest rank (or specific rank) - uint32_t bestSpellId = 0; - int bestRank = -1; - for (uint32_t sid : gameHandler.getKnownSpells()) { - const std::string& sName = gameHandler.getSpellName(sid); - if (sName.empty()) continue; - std::string sNameLower = sName; - for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); - if (sNameLower != spellNameLower) continue; - - // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) - int sRank = 0; - const std::string& rankStr = gameHandler.getSpellRank(sid); - if (!rankStr.empty()) { - std::string rLow = rankStr; - for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); - if (rLow.rfind("rank ", 0) == 0) { - try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} - } - } - - if (requestedRank >= 0) { - if (sRank == requestedRank) { bestSpellId = sid; break; } - } else { - if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } - } - } - - if (bestSpellId) { - uint64_t targetGuid = (castTargetOverride != static_cast(-1)) - ? castTargetOverride - : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); - gameHandler.castSpell(bestSpellId, targetGuid); - } else { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = requestedRank >= 0 - ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." - : "Unknown spell: '" + spellName + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /use - // Supports: item name, numeric item ID (#N or N), bag/slot (/use 0 1 = backpack slot 1, - // /use 1-4 slot = bag slot), equipment slot number (/use 16 = main hand) - if (cmdLower == "use" && spacePos != std::string::npos) { - std::string useArg = command.substr(spacePos + 1); - while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); - while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); - - // Handle macro conditionals: /use [mod:shift] ItemName; OtherItem - if (!useArg.empty() && useArg.front() == '[') { - uint64_t dummy = static_cast(-1); - useArg = evaluateMacroConditionals(useArg, gameHandler, dummy); - if (useArg.empty()) { chatInputBuffer_[0] = '\0'; return; } - while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); - while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); - } - - // Check for bag/slot notation: two numbers separated by whitespace - { - std::istringstream iss(useArg); - int bagNum = -1, slotNum = -1; - iss >> bagNum >> slotNum; - if (!iss.fail() && slotNum >= 1) { - if (bagNum == 0) { - // Backpack: bag=0, slot 1-based → 0-based - gameHandler.useItemBySlot(slotNum - 1); - chatInputBuffer_[0] = '\0'; - return; - } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { - // Equip bag: bags are 1-indexed (bag 1 = bagIndex 0) - gameHandler.useItemInBag(bagNum - 1, slotNum - 1); - chatInputBuffer_[0] = '\0'; - return; - } - } - } - - // Numeric equip slot: /use 16 = slot 16 (1-based, WoW equip slot enum) - { - std::string numStr = useArg; - if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); - bool isNumeric = !numStr.empty() && - std::all_of(numStr.begin(), numStr.end(), - [](unsigned char c){ return std::isdigit(c); }); - if (isNumeric) { - // Treat as equip slot (1-based, maps to EquipSlot enum 0-based) - int slotNum = 0; - try { slotNum = std::stoi(numStr); } catch (...) {} - if (slotNum >= 1 && slotNum <= static_cast(game::EquipSlot::BAG4) + 1) { - auto eslot = static_cast(slotNum - 1); - const auto& esl = gameHandler.getInventory().getEquipSlot(eslot); - if (!esl.empty()) - gameHandler.useItemById(esl.item.itemId); - } - chatInputBuffer_[0] = '\0'; - return; - } - } - - std::string useArgLower = useArg; - for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); - - bool found = false; - const auto& inv = gameHandler.getInventory(); - // Search backpack - for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { - const auto& slot = inv.getBackpackSlot(s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == useArgLower) { - gameHandler.useItemBySlot(s); - found = true; - } - } - // Search bags - for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { - for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { - const auto& slot = inv.getBagSlot(b, s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == useArgLower) { - gameHandler.useItemInBag(b, s); - found = true; - } - } - } - if (!found) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "Item not found: '" + useArg + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /equip — auto-equip an item from backpack/bags by name - if (cmdLower == "equip" && spacePos != std::string::npos) { - std::string equipArg = command.substr(spacePos + 1); - while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); - while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); - std::string equipArgLower = equipArg; - for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); - - bool found = false; - const auto& inv = gameHandler.getInventory(); - // Search backpack - for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { - const auto& slot = inv.getBackpackSlot(s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == equipArgLower) { - gameHandler.autoEquipItemBySlot(s); - found = true; - } - } - // Search bags - for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { - for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { - const auto& slot = inv.getBagSlot(b, s); - if (slot.empty()) continue; - const auto* info = gameHandler.getItemInfo(slot.item.itemId); - if (!info) continue; - std::string nameLow = info->name; - for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); - if (nameLow == equipArgLower) { - gameHandler.autoEquipItemInBag(b, s); - found = true; - } - } - } - if (!found) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "Item not found: '" + equipArg + "'."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // Targeting commands - if (cmdLower == "cleartarget") { - // Support macro conditionals: /cleartarget [dead] clears only if target is dead - bool ctCondPass = true; - if (spacePos != std::string::npos) { - std::string ctArg = command.substr(spacePos + 1); - while (!ctArg.empty() && ctArg.front() == ' ') ctArg.erase(ctArg.begin()); - if (!ctArg.empty() && ctArg.front() == '[') { - uint64_t ctOver = static_cast(-1); - std::string res = evaluateMacroConditionals(ctArg, gameHandler, ctOver); - ctCondPass = !(res.empty() && ctOver == static_cast(-1)); - } - } - if (ctCondPass) gameHandler.clearTarget(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "target" && spacePos != std::string::npos) { - // Search visible entities for name match (case-insensitive prefix). - // Among all matches, pick the nearest living unit to the player. - // Supports WoW macro conditionals: /target [target=mouseover]; /target [mod:shift] Boss - std::string targetArg = command.substr(spacePos + 1); - - // Evaluate conditionals if present - uint64_t targetCmdOverride = static_cast(-1); - if (!targetArg.empty() && targetArg.front() == '[') { - targetArg = evaluateMacroConditionals(targetArg, gameHandler, targetCmdOverride); - if (targetArg.empty() && targetCmdOverride == static_cast(-1)) { - // No condition matched — silently skip (macro fallthrough) - chatInputBuffer_[0] = '\0'; - return; - } - while (!targetArg.empty() && targetArg.front() == ' ') targetArg.erase(targetArg.begin()); - while (!targetArg.empty() && targetArg.back() == ' ') targetArg.pop_back(); - } - - // If conditionals resolved to a specific GUID, target it directly - if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { - gameHandler.setTarget(targetCmdOverride); - chatInputBuffer_[0] = '\0'; - return; - } - - // If no name remains (bare conditional like [target=mouseover] with 0 guid), skip silently - if (targetArg.empty()) { - chatInputBuffer_[0] = '\0'; - return; - } - - std::string targetArgLower = targetArg; - for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; - float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - const float playerX = pmi.x; - const float playerY = pmi.y; - const float playerZ = pmi.z; - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; - std::string name; - if (entity->getType() == game::ObjectType::PLAYER || - entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - name = unit->getName(); - } - if (name.empty()) continue; - std::string nameLower = name; - for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); - if (nameLower.find(targetArgLower) == 0) { - float dx = entity->getX() - playerX; - float dy = entity->getY() - playerY; - float dz = entity->getZ() - playerZ; - float dist = dx*dx + dy*dy + dz*dz; - if (dist < bestDist) { - bestDist = dist; - bestGuid = guid; - } - } - } - if (bestGuid) { - gameHandler.setTarget(bestGuid); - } else { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "No target matching '" + targetArg + "' found."; - gameHandler.addLocalChatMessage(sysMsg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetenemy") { - gameHandler.targetEnemy(false); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetfriend") { - gameHandler.targetFriend(false); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetlasttarget" || cmdLower == "targetlast") { - gameHandler.targetLastTarget(); - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetlastenemy") { - gameHandler.targetEnemy(true); // Reverse direction - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "targetlastfriend") { - gameHandler.targetFriend(true); // Reverse direction - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "focus") { - // /focus → set current target as focus - // /focus PlayerName → search for entity by name and set as focus - // /focus [target=X] Name → macro conditional: set focus to resolved target - if (spacePos != std::string::npos) { - std::string focusArg = command.substr(spacePos + 1); - - // Evaluate conditionals if present - uint64_t focusCmdOverride = static_cast(-1); - if (!focusArg.empty() && focusArg.front() == '[') { - focusArg = evaluateMacroConditionals(focusArg, gameHandler, focusCmdOverride); - if (focusArg.empty() && focusCmdOverride == static_cast(-1)) { - chatInputBuffer_[0] = '\0'; - return; - } - while (!focusArg.empty() && focusArg.front() == ' ') focusArg.erase(focusArg.begin()); - while (!focusArg.empty() && focusArg.back() == ' ') focusArg.pop_back(); - } - - if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { - // Conditional resolved to a specific GUID (e.g. [target=mouseover]) - gameHandler.setFocus(focusCmdOverride); - } else if (!focusArg.empty()) { - // Name search — same logic as /target - std::string focusArgLower = focusArg; - for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); - uint64_t bestGuid = 0; - float bestDist = std::numeric_limits::max(); - const auto& pmi = gameHandler.getMovementInfo(); - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; - std::string name; - if (entity->getType() == game::ObjectType::PLAYER || - entity->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(entity); - name = unit->getName(); - } - if (name.empty()) continue; - std::string nameLower = name; - for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); - if (nameLower.find(focusArgLower) == 0) { - float dx = entity->getX() - pmi.x; - float dy = entity->getY() - pmi.y; - float dz = entity->getZ() - pmi.z; - float dist = dx*dx + dy*dy + dz*dz; - if (dist < bestDist) { bestDist = dist; bestGuid = guid; } - } - } - if (bestGuid) { - gameHandler.setFocus(bestGuid); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "No unit matching '" + focusArg + "' found."; - gameHandler.addLocalChatMessage(msg); - } - } - } else if (gameHandler.hasTarget()) { - gameHandler.setFocus(gameHandler.getTargetGuid()); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must target a unit to set as focus."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - if (cmdLower == "clearfocus") { - gameHandler.clearFocus(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /unstuck command — resets player position to floor height - if (cmdLower == "unstuck") { - gameHandler.unstuck(); - chatInputBuffer_[0] = '\0'; - return; - } - // /unstuckgy command — move to nearest graveyard - if (cmdLower == "unstuckgy") { - gameHandler.unstuckGy(); - chatInputBuffer_[0] = '\0'; - return; - } - // /unstuckhearth command — teleport to hearthstone bind point - if (cmdLower == "unstuckhearth") { - gameHandler.unstuckHearth(); - chatInputBuffer_[0] = '\0'; - return; - } - - // /transport board — board test transport - if (cmdLower == "transport board") { - auto* tm = gameHandler.getTransportManager(); - if (tm) { - // Test transport GUID - uint64_t testTransportGuid = 0x1000000000000001ULL; - // Place player at center of deck (rough estimate) - glm::vec3 deckCenter(0.0f, 0.0f, 5.0f); - gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Boarded test transport. Use '/transport leave' to disembark."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Transport system not available."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // /transport leave — disembark from transport - if (cmdLower == "transport leave") { - if (gameHandler.isOnTransport()) { - gameHandler.clearPlayerTransport(); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "Disembarked from transport."; - gameHandler.addLocalChatMessage(msg); - } else { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You are not on a transport."; - gameHandler.addLocalChatMessage(msg); - } - chatInputBuffer_[0] = '\0'; - return; - } - - // Chat channel slash commands - // If used without a message (e.g. just "/s"), switch the chat type dropdown - bool isChannelCommand = false; - if (cmdLower == "s" || cmdLower == "say") { - type = game::ChatType::SAY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 0; - } else if (cmdLower == "y" || cmdLower == "yell" || cmdLower == "shout") { - type = game::ChatType::YELL; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 1; - } else if (cmdLower == "p" || cmdLower == "party") { - type = game::ChatType::PARTY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 2; - } else if (cmdLower == "g" || cmdLower == "guild") { - type = game::ChatType::GUILD; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 3; - } else if (cmdLower == "raid" || cmdLower == "rsay" || cmdLower == "ra") { - type = game::ChatType::RAID; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 5; - } else if (cmdLower == "raidwarning" || cmdLower == "rw") { - type = game::ChatType::RAID_WARNING; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 8; - } else if (cmdLower == "officer" || cmdLower == "o" || cmdLower == "osay") { - type = game::ChatType::OFFICER; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 6; - } else if (cmdLower == "battleground" || cmdLower == "bg") { - type = game::ChatType::BATTLEGROUND; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 7; - } else if (cmdLower == "instance" || cmdLower == "i") { - // Instance chat uses PARTY chat type - type = game::ChatType::PARTY; - message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; - isChannelCommand = true; - switchChatType = 9; - } else if (cmdLower == "join") { - // /join with no args: accept pending BG invite if any - if (spacePos == std::string::npos && gameHandler.hasPendingBgInvite()) { - gameHandler.acceptBattlefield(); - chatInputBuffer_[0] = '\0'; - return; - } - // /join ChannelName [password] - if (spacePos != std::string::npos) { - std::string rest = command.substr(spacePos + 1); - size_t pwStart = rest.find(' '); - std::string channelName = (pwStart != std::string::npos) ? rest.substr(0, pwStart) : rest; - std::string password = (pwStart != std::string::npos) ? rest.substr(pwStart + 1) : ""; - gameHandler.joinChannel(channelName, password); - } - chatInputBuffer_[0] = '\0'; - return; - } else if (cmdLower == "leave") { - // /leave ChannelName - if (spacePos != std::string::npos) { - std::string channelName = command.substr(spacePos + 1); - gameHandler.leaveChannel(channelName); - } - chatInputBuffer_[0] = '\0'; - return; - } else if ((cmdLower == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { - // /wts and /wtb — send to Trade channel - // Prefix with [WTS] / [WTB] and route to the Trade channel - const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; - const std::string body = command.substr(spacePos + 1); - // Find the Trade channel among joined channels (case-insensitive prefix match) - std::string tradeChan; - for (const auto& ch : gameHandler.getJoinedChannels()) { - std::string chLow = ch; - for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); - if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } - } - if (tradeChan.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.language = game::ChatLanguage::UNIVERSAL; - errMsg.message = "You are not in the Trade channel."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer_[0] = '\0'; - return; - } - message = tag + body; - type = game::ChatType::CHANNEL; - target = tradeChan; - isChannelCommand = true; - } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { - // /1 msg, /2 msg — channel shortcuts - int channelIdx = cmdLower[0] - '0'; - std::string channelName = gameHandler.getChannelByIndex(channelIdx); - if (!channelName.empty() && spacePos != std::string::npos) { - message = command.substr(spacePos + 1); - type = game::ChatType::CHANNEL; - target = channelName; - isChannelCommand = true; - } else if (channelName.empty()) { - game::MessageChatData errMsg; - errMsg.type = game::ChatType::SYSTEM; - errMsg.message = "You are not in channel " + std::to_string(channelIdx) + "."; - gameHandler.addLocalChatMessage(errMsg); - chatInputBuffer_[0] = '\0'; - return; - } else { - chatInputBuffer_[0] = '\0'; - return; - } - } else if (cmdLower == "w" || cmdLower == "whisper" || cmdLower == "tell" || cmdLower == "t") { - switchChatType = 4; - if (spacePos != std::string::npos) { - std::string rest = command.substr(spacePos + 1); - size_t msgStart = rest.find(' '); - if (msgStart != std::string::npos) { - // /w PlayerName message — send whisper immediately - target = rest.substr(0, msgStart); - message = rest.substr(msgStart + 1); - type = game::ChatType::WHISPER; - isChannelCommand = true; - // Set whisper target for future messages - strncpy(whisperTargetBuffer_, target.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - } else { - // /w PlayerName — switch to whisper mode with target set - strncpy(whisperTargetBuffer_, rest.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - message = ""; - isChannelCommand = true; - } - } else { - // Just "/w" — switch to whisper mode - message = ""; - isChannelCommand = true; - } - } else if (cmdLower == "r" || cmdLower == "reply") { - switchChatType = 4; - std::string lastSender = gameHandler.getLastWhisperSender(); - if (lastSender.empty()) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "No one has whispered you yet."; - gameHandler.addLocalChatMessage(sysMsg); - chatInputBuffer_[0] = '\0'; - return; - } - target = lastSender; - strncpy(whisperTargetBuffer_, target.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - if (spacePos != std::string::npos) { - message = command.substr(spacePos + 1); - type = game::ChatType::WHISPER; - } else { - message = ""; - } - isChannelCommand = true; - } - - // Check for emote commands - if (!isChannelCommand) { - std::string targetName; - const std::string* targetNamePtr = nullptr; - if (gameHandler.hasTarget()) { - auto targetEntity = gameHandler.getTarget(); - if (targetEntity) { - targetName = getEntityName(targetEntity); - if (!targetName.empty()) targetNamePtr = &targetName; - } - } - - std::string emoteText = rendering::Renderer::getEmoteText(cmdLower, targetNamePtr); - if (!emoteText.empty()) { - // Play the emote animation - auto* renderer = services_.renderer; - if (renderer) { - renderer->playEmote(cmdLower); - } - - // Send CMSG_TEXT_EMOTE to server - uint32_t dbcId = rendering::Renderer::getEmoteDbcId(cmdLower); - if (dbcId != 0) { - uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; - gameHandler.sendTextEmote(dbcId, targetGuid); - } - - // Add local chat message - game::MessageChatData msg; - msg.type = game::ChatType::TEXT_EMOTE; - msg.language = game::ChatLanguage::COMMON; - msg.message = emoteText; - gameHandler.addLocalChatMessage(msg); - - chatInputBuffer_[0] = '\0'; - return; - } - - // Not a recognized command — fall through and send as normal chat - if (!isChannelCommand) { - message = input; - } - } - - // If no valid command found and starts with /, just send as-is - if (!isChannelCommand && message == input) { - // Use the selected chat type from dropdown - switch (selectedChatType_) { - case 0: type = game::ChatType::SAY; break; - case 1: type = game::ChatType::YELL; break; - case 2: type = game::ChatType::PARTY; break; - case 3: type = game::ChatType::GUILD; break; - case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; - case 5: type = game::ChatType::RAID; break; - case 6: type = game::ChatType::OFFICER; break; - case 7: type = game::ChatType::BATTLEGROUND; break; - case 8: type = game::ChatType::RAID_WARNING; break; - case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY - case 10: { // CHANNEL - const auto& chans = gameHandler.getJoinedChannels(); - if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { - type = game::ChatType::CHANNEL; - target = chans[selectedChannelIdx_]; - } else { type = game::ChatType::SAY; } - break; - } - default: type = game::ChatType::SAY; break; - } - } - } else { - // No slash command, use the selected chat type from dropdown - switch (selectedChatType_) { - case 0: type = game::ChatType::SAY; break; - case 1: type = game::ChatType::YELL; break; - case 2: type = game::ChatType::PARTY; break; - case 3: type = game::ChatType::GUILD; break; - case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; - case 5: type = game::ChatType::RAID; break; - case 6: type = game::ChatType::OFFICER; break; - case 7: type = game::ChatType::BATTLEGROUND; break; - case 8: type = game::ChatType::RAID_WARNING; break; - case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY - case 10: { // CHANNEL - const auto& chans = gameHandler.getJoinedChannels(); - if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { - type = game::ChatType::CHANNEL; - target = chans[selectedChannelIdx_]; - } else { type = game::ChatType::SAY; } - break; - } - default: type = game::ChatType::SAY; break; - } - } - - // Whisper shortcuts to PortBot/GMBot: translate to GM teleport commands. - if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { - std::string cmd = buildPortBotCommand(message); - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - if (cmd.empty() || cmd == "__help__") { - msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); - msg.message = "PortBot executed: " + cmd; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // Validate whisper has a target - if (type == game::ChatType::WHISPER && target.empty()) { - game::MessageChatData msg; - msg.type = game::ChatType::SYSTEM; - msg.language = game::ChatLanguage::UNIVERSAL; - msg.message = "You must specify a player name for whisper."; - gameHandler.addLocalChatMessage(msg); - chatInputBuffer_[0] = '\0'; - return; - } - - // Don't send empty messages — but switch chat type if a channel shortcut was used - if (!message.empty()) { - gameHandler.sendChatMessage(type, message, target); - } - - // Switch chat type dropdown when channel shortcut used (with or without message) - if (switchChatType >= 0) { - selectedChatType_ = switchChatType; - } - - // Clear input - chatInputBuffer_[0] = '\0'; - } -} - - -const char* ChatPanel::getChatTypeName(game::ChatType type) const { - switch (type) { - case game::ChatType::SAY: return "Say"; - case game::ChatType::YELL: return "Yell"; - case game::ChatType::EMOTE: return "Emote"; - case game::ChatType::TEXT_EMOTE: return "Emote"; - case game::ChatType::PARTY: return "Party"; - case game::ChatType::GUILD: return "Guild"; - case game::ChatType::OFFICER: return "Officer"; - case game::ChatType::RAID: return "Raid"; - case game::ChatType::RAID_LEADER: return "Raid Leader"; - case game::ChatType::RAID_WARNING: return "Raid Warning"; - case game::ChatType::BATTLEGROUND: return "Battleground"; - case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; - case game::ChatType::WHISPER: return "Whisper"; - case game::ChatType::WHISPER_INFORM: return "To"; - case game::ChatType::SYSTEM: return "System"; - case game::ChatType::MONSTER_SAY: return "Say"; - case game::ChatType::MONSTER_YELL: return "Yell"; - case game::ChatType::MONSTER_EMOTE: return "Emote"; - case game::ChatType::CHANNEL: return "Channel"; - case game::ChatType::ACHIEVEMENT: return "Achievement"; - case game::ChatType::DND: return "DND"; - case game::ChatType::AFK: return "AFK"; - case game::ChatType::BG_SYSTEM_NEUTRAL: - case game::ChatType::BG_SYSTEM_ALLIANCE: - case game::ChatType::BG_SYSTEM_HORDE: return "System"; - default: return "Unknown"; - } -} - - -ImVec4 ChatPanel::getChatTypeColor(game::ChatType type) const { - switch (type) { - case game::ChatType::SAY: - return ui::colors::kWhite; // White - case game::ChatType::YELL: - return kColorRed; // Red - case game::ChatType::EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange - case game::ChatType::TEXT_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange - case game::ChatType::PARTY: - return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue - case game::ChatType::GUILD: - return kColorBrightGreen; // Green - case game::ChatType::OFFICER: - return ImVec4(0.3f, 0.8f, 0.3f, 1.0f); // Dark green - case game::ChatType::RAID: - return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange - case game::ChatType::RAID_LEADER: - return ImVec4(1.0f, 0.4f, 0.0f, 1.0f); // Darker orange - case game::ChatType::RAID_WARNING: - return ImVec4(1.0f, 0.0f, 0.0f, 1.0f); // Red - case game::ChatType::BATTLEGROUND: - return ImVec4(1.0f, 0.6f, 0.0f, 1.0f); // Orange-gold - case game::ChatType::BATTLEGROUND_LEADER: - return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange - case game::ChatType::WHISPER: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink - case game::ChatType::WHISPER_INFORM: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink - case game::ChatType::SYSTEM: - return kColorYellow; // Yellow - case game::ChatType::MONSTER_SAY: - return ui::colors::kWhite; // White (same as SAY) - case game::ChatType::MONSTER_YELL: - return kColorRed; // Red (same as YELL) - case game::ChatType::MONSTER_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) - case game::ChatType::CHANNEL: - return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink - case game::ChatType::ACHIEVEMENT: - return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow - case game::ChatType::GUILD_ACHIEVEMENT: - return colors::kWarmGold; // Gold - case game::ChatType::SKILL: - return colors::kCyan; // Cyan - case game::ChatType::LOOT: - return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple - case game::ChatType::MONSTER_WHISPER: - case game::ChatType::RAID_BOSS_WHISPER: - return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink (same as WHISPER) - case game::ChatType::RAID_BOSS_EMOTE: - return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) - case game::ChatType::MONSTER_PARTY: - return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue (same as PARTY) - case game::ChatType::BG_SYSTEM_NEUTRAL: - return colors::kWarmGold; // Gold - case game::ChatType::BG_SYSTEM_ALLIANCE: - return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue - case game::ChatType::BG_SYSTEM_HORDE: - return kColorRed; // Red - case game::ChatType::AFK: - case game::ChatType::DND: - return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray - default: - return ui::colors::kLightGray; // Gray - } -} - - -std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { - // Get player gender, pronouns, and name - game::Gender gender = game::Gender::NONBINARY; - std::string playerName = "Adventurer"; - const auto* character = gameHandler.getActiveCharacter(); - if (character) { - gender = character->gender; - if (!character->name.empty()) { - playerName = character->name; - } - } - game::Pronouns pronouns = game::Pronouns::forGender(gender); - - std::string result = text; - - // Helper to trim whitespace - auto trim = [](std::string& s) { - const char* ws = " \t\n\r"; - size_t start = s.find_first_not_of(ws); - if (start == std::string::npos) { s.clear(); return; } - size_t end = s.find_last_not_of(ws); - s = s.substr(start, end - start + 1); - }; - - // Replace $g/$G placeholders first. - size_t pos = 0; - while ((pos = result.find('$', pos)) != std::string::npos) { - if (pos + 1 >= result.length()) break; - char marker = result[pos + 1]; - if (marker != 'g' && marker != 'G') { pos++; continue; } - - size_t endPos = result.find(';', pos); - if (endPos == std::string::npos) { pos += 2; continue; } - - std::string placeholder = result.substr(pos + 2, endPos - pos - 2); - - // Split by colons - std::vector parts; - size_t start = 0; - size_t colonPos; - while ((colonPos = placeholder.find(':', start)) != std::string::npos) { - std::string part = placeholder.substr(start, colonPos - start); - trim(part); - parts.push_back(part); - start = colonPos + 1; - } - // Add the last part - std::string lastPart = placeholder.substr(start); - trim(lastPart); - parts.push_back(lastPart); - - // Select appropriate text based on gender - std::string replacement; - if (parts.size() >= 3) { - // Three options: male, female, nonbinary - switch (gender) { - case game::Gender::MALE: - replacement = parts[0]; - break; - case game::Gender::FEMALE: - replacement = parts[1]; - break; - case game::Gender::NONBINARY: - replacement = parts[2]; - break; - } - } else if (parts.size() >= 2) { - // Two options: male, female (use first for nonbinary) - switch (gender) { - case game::Gender::MALE: - replacement = parts[0]; - break; - case game::Gender::FEMALE: - replacement = parts[1]; - break; - case game::Gender::NONBINARY: - // Default to gender-neutral: use the shorter/simpler option - replacement = parts[0].length() <= parts[1].length() ? parts[0] : parts[1]; - break; - } - } else { - // Malformed placeholder - pos = endPos + 1; - continue; - } - - result.replace(pos, endPos - pos + 1, replacement); - pos += replacement.length(); - } - - // Resolve class and race names for $C and $R placeholders - std::string className = "Adventurer"; - std::string raceName = "Unknown"; - if (character) { - className = game::getClassName(character->characterClass); - raceName = game::getRaceName(character->race); - } - - // Replace simple placeholders. - // $n/$N = player name, $c/$C = class name, $r/$R = race name - // $p = subject pronoun (he/she/they) - // $o = object pronoun (him/her/them) - // $s = possessive adjective (his/her/their) - // $S = possessive pronoun (his/hers/theirs) - // $b/$B = line break - pos = 0; - while ((pos = result.find('$', pos)) != std::string::npos) { - if (pos + 1 >= result.length()) break; - - char code = result[pos + 1]; - std::string replacement; - switch (code) { - case 'n': case 'N': replacement = playerName; break; - case 'c': case 'C': replacement = className; break; - case 'r': case 'R': replacement = raceName; break; - case 'p': replacement = pronouns.subject; break; - case 'o': replacement = pronouns.object; break; - case 's': replacement = pronouns.possessive; break; - case 'S': replacement = pronouns.possessiveP; break; - case 'b': case 'B': replacement = "\n"; break; - case 'g': case 'G': pos++; continue; - default: pos++; continue; - } - - result.replace(pos, 2, replacement); - pos += replacement.length(); - } - - // WoW markup linebreak token. - pos = 0; - while ((pos = result.find("|n", pos)) != std::string::npos) { - result.replace(pos, 2, "\n"); - pos += 1; - } - pos = 0; - while ((pos = result.find("|N", pos)) != std::string::npos) { - result.replace(pos, 2, "\n"); - pos += 1; - } - - return result; -} - -void ChatPanel::renderBubbles(game::GameHandler& gameHandler) { - if (chatBubbles_.empty()) return; - - auto* renderer = services_.renderer; - auto* camera = renderer ? renderer->getCamera() : nullptr; - if (!camera) return; - - auto* window = services_.window; - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - // Get delta time from ImGui - float dt = ImGui::GetIO().DeltaTime; - - glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - - // Update and render bubbles - for (int i = static_cast(chatBubbles_.size()) - 1; i >= 0; --i) { - auto& bubble = chatBubbles_[i]; - bubble.timeRemaining -= dt; - if (bubble.timeRemaining <= 0.0f) { - chatBubbles_.erase(chatBubbles_.begin() + i); - continue; - } - - // Get entity position - auto entity = gameHandler.getEntityManager().getEntity(bubble.senderGuid); - if (!entity) continue; - - // Convert canonical → render coordinates, offset up by 2.5 units for bubble above head - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ() + 2.5f); - glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - - // Project to screen - glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); - if (clipPos.w <= 0.0f) continue; // Behind camera - - glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); - float screenX = (ndc.x * 0.5f + 0.5f) * screenW; - // Camera bakes the Vulkan Y-flip into the projection matrix: - // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. - float screenY = (ndc.y * 0.5f + 0.5f) * screenH; - - // Skip if off-screen - if (screenX < -200.0f || screenX > screenW + 200.0f || - screenY < -100.0f || screenY > screenH + 100.0f) continue; - - // Fade alpha over last 2 seconds - float alpha = 1.0f; - if (bubble.timeRemaining < 2.0f) { - alpha = bubble.timeRemaining / 2.0f; - } - - // Draw bubble window - std::string winId = "##ChatBubble" + std::to_string(bubble.senderGuid); - ImGui::SetNextWindowPos(ImVec2(screenX, screenY), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); - ImGui::SetNextWindowBgAlpha(0.7f * alpha); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs | - ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 4)); - - ImGui::Begin(winId.c_str(), nullptr, flags); - - ImVec4 textColor = bubble.isYell - ? ImVec4(1.0f, 0.2f, 0.2f, alpha) - : ImVec4(1.0f, 1.0f, 1.0f, alpha); - - ImGui::PushStyleColor(ImGuiCol_Text, textColor); - ImGui::PushTextWrapPos(200.0f); - ImGui::TextWrapped("%s", bubble.message.c_str()); - ImGui::PopTextWrapPos(); - ImGui::PopStyleColor(); - - ImGui::End(); - ImGui::PopStyleVar(2); - } -} - - -// ---- Public interface methods ---- - -void ChatPanel::setupCallbacks(game::GameHandler& gameHandler) { - if (!chatBubbleCallbackSet_) { - gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { - float duration = 8.0f + static_cast(msg.size()) * 0.06f; - if (isYell) duration += 2.0f; - if (duration > 15.0f) duration = 15.0f; - - // Replace existing bubble for same sender - for (auto& b : chatBubbles_) { - if (b.senderGuid == guid) { - b.message = msg; - b.timeRemaining = duration; - b.totalDuration = duration; - b.isYell = isYell; - return; - } - } - // Evict oldest if too many - if (chatBubbles_.size() >= 10) { - chatBubbles_.erase(chatBubbles_.begin()); - } - chatBubbles_.push_back({guid, msg, duration, duration, isYell}); - }); - chatBubbleCallbackSet_ = true; - } -} - -void ChatPanel::insertChatLink(const std::string& link) { - if (link.empty()) return; - size_t curLen = strlen(chatInputBuffer_); - if (curLen + link.size() + 1 < sizeof(chatInputBuffer_)) { - strncat(chatInputBuffer_, link.c_str(), sizeof(chatInputBuffer_) - curLen - 1); - chatInputMoveCursorToEnd_ = true; - refocusChatInput_ = true; - } -} - -void ChatPanel::activateSlashInput() { - refocusChatInput_ = true; - chatInputBuffer_[0] = '/'; - chatInputBuffer_[1] = '\0'; - chatInputMoveCursorToEnd_ = true; -} - -void ChatPanel::activateInput() { - refocusChatInput_ = true; -} - -void ChatPanel::setWhisperTarget(const std::string& name) { - selectedChatType_ = 4; // WHISPER - strncpy(whisperTargetBuffer_, name.c_str(), sizeof(whisperTargetBuffer_) - 1); - whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; - refocusChatInput_ = true; -} - -ChatPanel::SlashCommands ChatPanel::consumeSlashCommands() { - SlashCommands result = slashCmds_; - slashCmds_ = {}; - return result; -} - -void ChatPanel::renderSettingsTab(std::function saveSettingsFn) { - ImGui::Spacing(); - - ImGui::Text("Appearance"); - ImGui::Separator(); - - if (ImGui::Checkbox("Show Timestamps", &chatShowTimestamps)) { - saveSettingsFn(); - } - ImGui::SetItemTooltip("Show [HH:MM] before each chat message"); - - const char* fontSizes[] = { "Small", "Medium", "Large" }; - if (ImGui::Combo("Chat Font Size", &chatFontSize, fontSizes, 3)) { - saveSettingsFn(); - } - - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::Text("Auto-Join Channels"); - ImGui::Separator(); - - if (ImGui::Checkbox("General", &chatAutoJoinGeneral)) saveSettingsFn(); - if (ImGui::Checkbox("Trade", &chatAutoJoinTrade)) saveSettingsFn(); - if (ImGui::Checkbox("LocalDefense", &chatAutoJoinLocalDefense)) saveSettingsFn(); - if (ImGui::Checkbox("LookingForGroup", &chatAutoJoinLFG)) saveSettingsFn(); - if (ImGui::Checkbox("Local", &chatAutoJoinLocal)) saveSettingsFn(); - - ImGui::Spacing(); - ImGui::Spacing(); - ImGui::Text("Joined Channels"); - ImGui::Separator(); - - ImGui::TextDisabled("Use /join and /leave commands in chat to manage channels."); - - ImGui::Spacing(); - ImGui::Separator(); - ImGui::Spacing(); - - if (ImGui::Button("Restore Chat Defaults", ImVec2(-1, 0))) { - restoreDefaults(); - saveSettingsFn(); - } -} - -void ChatPanel::restoreDefaults() { - chatShowTimestamps = false; - chatFontSize = 1; - chatAutoJoinGeneral = true; - chatAutoJoinTrade = true; - chatAutoJoinLocalDefense = true; - chatAutoJoinLFG = true; - chatAutoJoinLocal = true; -} } // namespace ui } // namespace wowee diff --git a/src/ui/chat_panel_commands.cpp b/src/ui/chat_panel_commands.cpp new file mode 100644 index 00000000..d82ea32e --- /dev/null +++ b/src/ui/chat_panel_commands.cpp @@ -0,0 +1,2803 @@ +#include "ui/chat_panel.hpp" +#include "ui/inventory_screen.hpp" +#include "ui/spellbook_screen.hpp" +#include "ui/quest_log_screen.hpp" +#include "ui/ui_colors.hpp" +#include "rendering/vk_context.hpp" +#include "core/application.hpp" +#include "addons/addon_manager.hpp" +#include "core/coordinates.hpp" +#include "core/input.hpp" +#include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" +#include "rendering/camera.hpp" +#include "rendering/camera_controller.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/audio_engine.hpp" +#include "audio/ui_sound_manager.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" +#include "game/expansion_profile.hpp" +#include "game/character.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace { + using namespace wowee::ui::colors; + + std::string trim(const std::string& s) { + size_t first = s.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) return ""; + size_t last = s.find_last_not_of(" \t\r\n"); + return s.substr(first, last - first + 1); + } + + std::string toLower(std::string s) { + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return s; + } + + bool isPortBotTarget(const std::string& target) { + std::string t = toLower(trim(target)); + return t == "portbot" || t == "gmbot" || t == "telebot"; + } + + std::string buildPortBotCommand(const std::string& rawInput) { + std::string input = trim(rawInput); + if (input.empty()) return ""; + + std::string lower = toLower(input); + if (lower == "help" || lower == "?") { + return "__help__"; + } + + if (lower.rfind(".tele ", 0) == 0 || lower.rfind(".go ", 0) == 0) { + return input; + } + + if (lower.rfind("xyz ", 0) == 0) { + return ".go " + input; + } + + if (lower == "sw" || lower == "stormwind") return ".tele stormwind"; + if (lower == "if" || lower == "ironforge") return ".tele ironforge"; + if (lower == "darn" || lower == "darnassus") return ".tele darnassus"; + if (lower == "org" || lower == "orgrimmar") return ".tele orgrimmar"; + if (lower == "tb" || lower == "thunderbluff") return ".tele thunderbluff"; + if (lower == "uc" || lower == "undercity") return ".tele undercity"; + if (lower == "shatt" || lower == "shattrath") return ".tele shattrath"; + if (lower == "dal" || lower == "dalaran") return ".tele dalaran"; + + return ".tele " + input; + } + + std::string getEntityName(const std::shared_ptr& entity) { + if (entity->getType() == wowee::game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) return player->getName(); + } else if (entity->getType() == wowee::game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (!unit->getName().empty()) return unit->getName(); + } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (!go->getName().empty()) return go->getName(); + } + return "Unknown"; + } +} // namespace + +namespace wowee { namespace ui { + +static std::vector allMacroCommands(const std::string& macroText) { + std::vector cmds; + size_t pos = 0; + while (pos <= macroText.size()) { + size_t nl = macroText.find('\n', pos); + std::string line = (nl != std::string::npos) ? macroText.substr(pos, nl - pos) : macroText.substr(pos); + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t start = line.find_first_not_of(" \t"); + if (start != std::string::npos) line = line.substr(start); + if (!line.empty() && line.front() != '#') + cmds.push_back(std::move(line)); + if (nl == std::string::npos) break; + pos = nl + 1; + } + return cmds; +} + +// --------------------------------------------------------------------------- +// WoW macro conditional evaluator +// Parses: [cond1,cond2] Spell1; [cond3] Spell2; DefaultSpell +// Returns the first matching alternative's argument, or "" if none matches. +// targetOverride is set to a specific GUID if [target=X] was in the conditions, +// or left as UINT64_MAX to mean "use the normal target". +// --------------------------------------------------------------------------- +static std::string evaluateMacroConditionals(const std::string& rawArg, + game::GameHandler& gameHandler, + uint64_t& targetOverride) { + targetOverride = static_cast(-1); + + auto& input = core::Input::getInstance(); + + const bool shiftHeld = input.isKeyPressed(SDL_SCANCODE_LSHIFT) || + input.isKeyPressed(SDL_SCANCODE_RSHIFT); + const bool ctrlHeld = input.isKeyPressed(SDL_SCANCODE_LCTRL) || + input.isKeyPressed(SDL_SCANCODE_RCTRL); + const bool altHeld = input.isKeyPressed(SDL_SCANCODE_LALT) || + input.isKeyPressed(SDL_SCANCODE_RALT); + const bool anyMod = shiftHeld || ctrlHeld || altHeld; + + // Split rawArg on ';' → alternatives + std::vector alts; + { + std::string cur; + for (char c : rawArg) { + if (c == ';') { alts.push_back(cur); cur.clear(); } + else cur += c; + } + alts.push_back(cur); + } + + // Evaluate a single comma-separated condition token. + // tgt is updated if a target= or @ specifier is found. + auto evalCond = [&](const std::string& raw, uint64_t& tgt) -> bool { + std::string c = raw; + // trim + size_t s = c.find_first_not_of(" \t"); if (s) c = (s != std::string::npos) ? c.substr(s) : ""; + size_t e = c.find_last_not_of(" \t"); if (e != std::string::npos) c.resize(e + 1); + if (c.empty()) return true; + + // @target specifiers: @player, @focus, @pet, @mouseover, @target + if (!c.empty() && c[0] == '@') { + std::string spec = c.substr(1); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameHandler.getPetGuid(); + if (pg != 0) tgt = pg; + else return false; // no pet — skip this alternative + } + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } + return true; + } + // target=X specifiers + if (c.rfind("target=", 0) == 0) { + std::string spec = c.substr(7); + if (spec == "player") tgt = gameHandler.getPlayerGuid(); + else if (spec == "focus") tgt = gameHandler.getFocusGuid(); + else if (spec == "target") tgt = gameHandler.getTargetGuid(); + else if (spec == "pet") { + uint64_t pg = gameHandler.getPetGuid(); + if (pg != 0) tgt = pg; + else return false; // no pet — skip this alternative + } + else if (spec == "mouseover") { + uint64_t mo = gameHandler.getMouseoverGuid(); + if (mo != 0) tgt = mo; + else return false; // no mouseover — skip this alternative + } + return true; + } + + // mod / nomod + if (c == "nomod" || c == "mod:none") return !anyMod; + if (c.rfind("mod:", 0) == 0) { + std::string mods = c.substr(4); + bool ok = true; + if (mods.find("shift") != std::string::npos && !shiftHeld) ok = false; + if (mods.find("ctrl") != std::string::npos && !ctrlHeld) ok = false; + if (mods.find("alt") != std::string::npos && !altHeld) ok = false; + return ok; + } + + // combat / nocombat + if (c == "combat") return gameHandler.isInCombat(); + if (c == "nocombat") return !gameHandler.isInCombat(); + + // Helper to get the effective target entity + auto effTarget = [&]() -> std::shared_ptr { + if (tgt != static_cast(-1) && tgt != 0) + return gameHandler.getEntityManager().getEntity(tgt); + return gameHandler.getTarget(); + }; + + // exists / noexists + if (c == "exists") return effTarget() != nullptr; + if (c == "noexists") return effTarget() == nullptr; + + // dead / nodead + if (c == "dead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() == 0; + } + if (c == "nodead") { + auto t = effTarget(); + auto u = t ? std::dynamic_pointer_cast(t) : nullptr; + return u && u->getHealth() > 0; + } + + // help (friendly) / harm (hostile) and their no- variants + auto unitHostile = [&](const std::shared_ptr& t) -> bool { + if (!t) return false; + auto u = std::dynamic_pointer_cast(t); + return u && gameHandler.isHostileFactionPublic(u->getFactionTemplate()); + }; + if (c == "harm" || c == "nohelp") { return unitHostile(effTarget()); } + if (c == "help" || c == "noharm") { return !unitHostile(effTarget()); } + + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // swimming / noswimming + if (c == "swimming") return gameHandler.isSwimming(); + if (c == "noswimming") return !gameHandler.isSwimming(); + + // flying / noflying (CAN_FLY + FLYING flags active) + if (c == "flying") return gameHandler.isPlayerFlying(); + if (c == "noflying") return !gameHandler.isPlayerFlying(); + + // channeling / nochanneling + if (c == "channeling") return gameHandler.isCasting() && gameHandler.isChanneling(); + if (c == "nochanneling") return !(gameHandler.isCasting() && gameHandler.isChanneling()); + + // stealthed / nostealthed (unit flag 0x02000000 = UNIT_FLAG_SNEAKING) + auto isStealthedFn = [&]() -> bool { + auto pe = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (!pe) return false; + auto pu = std::dynamic_pointer_cast(pe); + return pu && (pu->getUnitFlags() & 0x02000000u) != 0; + }; + if (c == "stealthed") return isStealthedFn(); + if (c == "nostealthed") return !isStealthedFn(); + + // pet / nopet — player has an active pet (hunters, warlocks, DKs) + if (c == "pet") return gameHandler.hasPet(); + if (c == "nopet") return !gameHandler.hasPet(); + + // indoors / outdoors — WMO interior detection (affects mount type selection) + if (c == "indoors" || c == "nooutdoors") { + auto* r = core::Application::getInstance().getRenderer(); + return r && r->isPlayerIndoors(); + } + if (c == "outdoors" || c == "noindoors") { + auto* r = core::Application::getInstance().getRenderer(); + return !r || !r->isPlayerIndoors(); + } + + // group / nogroup — player is in a party or raid + if (c == "group" || c == "party") return gameHandler.isInGroup(); + if (c == "nogroup") return !gameHandler.isInGroup(); + + // raid / noraid — player is in a raid group (groupType == 1) + if (c == "raid") return gameHandler.isInGroup() && gameHandler.getPartyData().groupType == 1; + if (c == "noraid") return !gameHandler.isInGroup() || gameHandler.getPartyData().groupType != 1; + + // spec:N — active talent spec (1-based: spec:1 = primary, spec:2 = secondary) + if (c.rfind("spec:", 0) == 0) { + uint8_t wantSpec = 0; + try { wantSpec = static_cast(std::stoul(c.substr(5))); } catch (...) {} + return wantSpec > 0 && gameHandler.getActiveTalentSpec() == (wantSpec - 1); + } + + // noform / nostance — player is NOT in a shapeshift/stance + if (c == "noform" || c == "nostance") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + // form:0 same as noform + if (c == "form:0" || c == "stance:0") { + for (const auto& a : gameHandler.getPlayerAuras()) + if (!a.isEmpty() && a.maxDurationMs == -1) return false; + return true; + } + + // buff:SpellName / nobuff:SpellName — check if the effective target (or player + // if no target specified) has a buff with the given name. + // debuff:SpellName / nodebuff:SpellName — same for debuffs (harmful auras). + auto checkAuraByName = [&](const std::string& spellName, bool wantDebuff, + bool negate) -> bool { + // Determine which aura list to check: effective target or player + const std::vector* auras = nullptr; + if (tgt != static_cast(-1) && tgt != 0 && tgt != gameHandler.getPlayerGuid()) { + // Check target's auras + auras = &gameHandler.getTargetAuras(); + } else { + auras = &gameHandler.getPlayerAuras(); + } + std::string nameLow = spellName; + for (char& ch : nameLow) ch = static_cast(std::tolower(static_cast(ch))); + for (const auto& a : *auras) { + if (a.isEmpty() || a.spellId == 0) continue; + // Filter: debuffs have the HARMFUL flag (0x80) or spell has a dispel type + bool isDebuff = (a.flags & 0x80) != 0; + if (wantDebuff ? !isDebuff : isDebuff) continue; + std::string sn = gameHandler.getSpellName(a.spellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + if (sn == nameLow) return !negate; + } + return negate; + }; + if (c.rfind("buff:", 0) == 0 && c.size() > 5) + return checkAuraByName(c.substr(5), false, false); + if (c.rfind("nobuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), false, true); + if (c.rfind("debuff:", 0) == 0 && c.size() > 7) + return checkAuraByName(c.substr(7), true, false); + if (c.rfind("nodebuff:", 0) == 0 && c.size() > 9) + return checkAuraByName(c.substr(9), true, true); + + // mounted / nomounted + if (c == "mounted") return gameHandler.isMounted(); + if (c == "nomounted") return !gameHandler.isMounted(); + + // group (any group) / nogroup / raid + if (c == "group") return !gameHandler.getPartyData().isEmpty(); + if (c == "nogroup") return gameHandler.getPartyData().isEmpty(); + if (c == "raid") { + const auto& pd = gameHandler.getPartyData(); + return pd.groupType >= 1; // groupType 1 = raid, 0 = party + } + + // channeling:SpellName — player is currently channeling that spell + if (c.rfind("channeling:", 0) == 0 && c.size() > 11) { + if (!gameHandler.isChanneling()) return false; + std::string want = c.substr(11); + for (char& ch : want) ch = static_cast(std::tolower(static_cast(ch))); + uint32_t castSpellId = gameHandler.getCurrentCastSpellId(); + std::string sn = gameHandler.getSpellName(castSpellId); + for (char& ch : sn) ch = static_cast(std::tolower(static_cast(ch))); + return sn == want; + } + if (c == "channeling") return gameHandler.isChanneling(); + if (c == "nochanneling") return !gameHandler.isChanneling(); + + // casting (any active cast or channel) + if (c == "casting") return gameHandler.isCasting(); + if (c == "nocasting") return !gameHandler.isCasting(); + + // vehicle / novehicle (WotLK) + if (c == "vehicle") return gameHandler.getVehicleId() != 0; + if (c == "novehicle") return gameHandler.getVehicleId() == 0; + + // Unknown → permissive (don't block) + return true; + }; + + for (auto& alt : alts) { + // trim + size_t fs = alt.find_first_not_of(" \t"); + if (fs == std::string::npos) continue; + alt = alt.substr(fs); + size_t ls = alt.find_last_not_of(" \t"); + if (ls != std::string::npos) alt.resize(ls + 1); + + if (!alt.empty() && alt[0] == '[') { + size_t close = alt.find(']'); + if (close == std::string::npos) continue; + std::string condStr = alt.substr(1, close - 1); + std::string argPart = alt.substr(close + 1); + // Trim argPart + size_t as = argPart.find_first_not_of(" \t"); + argPart = (as != std::string::npos) ? argPart.substr(as) : ""; + + // Evaluate comma-separated conditions + uint64_t tgt = static_cast(-1); + bool pass = true; + size_t cp = 0; + while (pass) { + size_t comma = condStr.find(',', cp); + std::string tok = condStr.substr(cp, comma == std::string::npos ? std::string::npos : comma - cp); + if (!evalCond(tok, tgt)) { pass = false; break; } + if (comma == std::string::npos) break; + cp = comma + 1; + } + if (pass) { + if (tgt != static_cast(-1)) targetOverride = tgt; + return argPart; + } + } else { + // No condition block — default fallback always matches + return alt; + } + } + return {}; +} + +// Execute all non-comment lines of a macro body in sequence. +// In WoW, every line executes per click; the server enforces spell-cast limits. +// /stopmacro (with optional conditionals) halts the remaining commands early. + +void ChatPanel::executeMacroText(game::GameHandler& gameHandler, + InventoryScreen& inventoryScreen, + SpellbookScreen& spellbookScreen, + QuestLogScreen& questLogScreen, + const std::string& macroText) { + macroStopped_ = false; + for (const auto& cmd : allMacroCommands(macroText)) { + strncpy(chatInputBuffer_, cmd.c_str(), sizeof(chatInputBuffer_) - 1); + chatInputBuffer_[sizeof(chatInputBuffer_) - 1] = '\0'; + sendChatMessage(gameHandler, inventoryScreen, spellbookScreen, questLogScreen); + if (macroStopped_) break; + } + macroStopped_ = false; +} + +// /castsequence persistent state — shared across all macros using the same spell list. +// Keyed by the normalized (lowercase, comma-joined) spell sequence string. +namespace { +struct CastSeqState { + size_t index = 0; + float lastPressSec = 0.0f; + uint64_t lastTargetGuid = 0; + bool lastInCombat = false; +}; +std::unordered_map s_castSeqStates; +} // namespace + + +void ChatPanel::sendChatMessage(game::GameHandler& gameHandler, + InventoryScreen& /*inventoryScreen*/, + SpellbookScreen& /*spellbookScreen*/, + QuestLogScreen& /*questLogScreen*/) { + if (strlen(chatInputBuffer_) > 0) { + std::string input(chatInputBuffer_); + + // Save to sent-message history (skip pure whitespace, cap at 50 entries) + { + bool allSpace = true; + for (char c : input) { if (!std::isspace(static_cast(c))) { allSpace = false; break; } } + if (!allSpace) { + // Remove duplicate of last entry if identical + if (chatSentHistory_.empty() || chatSentHistory_.back() != input) { + chatSentHistory_.push_back(input); + if (chatSentHistory_.size() > 50) + chatSentHistory_.erase(chatSentHistory_.begin()); + } + } + } + chatHistoryIdx_ = -1; // reset browsing position after send + + game::ChatType type = game::ChatType::SAY; + std::string message = input; + std::string target; + + // Track if a channel shortcut should change the chat type dropdown + int switchChatType = -1; + + // Check for slash commands + if (input.size() > 1 && input[0] == '/') { + std::string command = input.substr(1); + size_t spacePos = command.find(' '); + std::string cmd = (spacePos != std::string::npos) ? command.substr(0, spacePos) : command; + + // Convert command to lowercase for comparison + std::string cmdLower = cmd; + for (char& c : cmdLower) c = static_cast(std::tolower(static_cast(c))); + + // /run — execute Lua script via addon system + if ((cmdLower == "run" || cmdLower == "script") && spacePos != std::string::npos) { + std::string luaCode = command.substr(spacePos + 1); + auto* am = services_.addonManager; + if (am) { + am->runScript(luaCode); + } else { + gameHandler.addUIError("Addon system not initialized."); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /dump — evaluate Lua expression and print result + if ((cmdLower == "dump" || cmdLower == "print") && spacePos != std::string::npos) { + std::string expr = command.substr(spacePos + 1); + auto* am = services_.addonManager; + if (am && am->isInitialized()) { + // Wrap expression in print(tostring(...)) to display the value + std::string wrapped = "local __v = " + expr + + "; if type(__v) == 'table' then " + " local parts = {} " + " for k,v in pairs(__v) do parts[#parts+1] = tostring(k)..'='..tostring(v) end " + " print('{' .. table.concat(parts, ', ') .. '}') " + "else print(tostring(__v)) end"; + am->runScript(wrapped); + } else { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "Addon system not initialized."; + gameHandler.addLocalChatMessage(errMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // Check addon slash commands (SlashCmdList) before built-in commands + { + auto* am = services_.addonManager; + if (am && am->isInitialized()) { + std::string slashCmd = "/" + cmdLower; + std::string slashArgs; + if (spacePos != std::string::npos) slashArgs = command.substr(spacePos + 1); + if (am->getLuaEngine()->dispatchSlashCommand(slashCmd, slashArgs)) { + chatInputBuffer_[0] = '\0'; + return; + } + } + } + + // Special commands + if (cmdLower == "logout") { + core::Application::getInstance().logoutToLogin(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "clear") { + gameHandler.clearChatHistory(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /reload or /reloadui — reload all addons (save variables, re-init Lua, re-scan .toc files) + if (cmdLower == "reload" || cmdLower == "reloadui" || cmdLower == "rl") { + auto* am = services_.addonManager; + if (am) { + am->reload(); + am->fireEvent("VARIABLES_LOADED"); + am->fireEvent("PLAYER_LOGIN"); + am->fireEvent("PLAYER_ENTERING_WORLD"); + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Interface reloaded."; + gameHandler.addLocalChatMessage(rlMsg); + } else { + game::MessageChatData rlMsg; + rlMsg.type = game::ChatType::SYSTEM; + rlMsg.language = game::ChatLanguage::UNIVERSAL; + rlMsg.message = "Addon system not available."; + gameHandler.addLocalChatMessage(rlMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /stopmacro [conditions] + // Halts execution of the current macro (remaining lines are skipped). + // With a condition block, only stops if the conditions evaluate to true. + // /stopmacro → always stops + // /stopmacro [combat] → stops only while in combat + // /stopmacro [nocombat] → stops only when not in combat + if (cmdLower == "stopmacro") { + bool shouldStop = true; + if (spacePos != std::string::npos) { + std::string condArg = command.substr(spacePos + 1); + while (!condArg.empty() && condArg.front() == ' ') condArg.erase(condArg.begin()); + if (!condArg.empty() && condArg.front() == '[') { + // Append a sentinel action so evaluateMacroConditionals can signal a match. + uint64_t tgtOver = static_cast(-1); + std::string hit = evaluateMacroConditionals(condArg + " __stop__", gameHandler, tgtOver); + shouldStop = !hit.empty(); + } + } + if (shouldStop) macroStopped_ = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /invite command + if (cmdLower == "invite" && spacePos != std::string::npos) { + std::string targetName = command.substr(spacePos + 1); + gameHandler.inviteToGroup(targetName); + chatInputBuffer_[0] = '\0'; + return; + } + + // /inspect command + if (cmdLower == "inspect") { + gameHandler.inspectTarget(); + slashCmds_.showInspect = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /threat command + if (cmdLower == "threat") { + slashCmds_.toggleThreat = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /score command — BG scoreboard + if (cmdLower == "score") { + gameHandler.requestPvpLog(); + slashCmds_.showBgScore = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /time command + if (cmdLower == "time") { + gameHandler.queryServerTime(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /loc command — print player coordinates and zone name + if (cmdLower == "loc" || cmdLower == "coords" || cmdLower == "whereami") { + const auto& pmi = gameHandler.getMovementInfo(); + std::string zoneName; + if (auto* rend = services_.renderer) + zoneName = rend->getCurrentZoneName(); + char buf[256]; + snprintf(buf, sizeof(buf), "%.1f, %.1f, %.1f%s%s", + pmi.x, pmi.y, pmi.z, + zoneName.empty() ? "" : " — ", + zoneName.c_str()); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = buf; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /screenshot command — capture current frame to PNG + if (cmdLower == "screenshot" || cmdLower == "ss") { + slashCmds_.takeScreenshot = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /zone command — print current zone name + if (cmdLower == "zone") { + std::string zoneName; + if (auto* rend = services_.renderer) + zoneName = rend->getCurrentZoneName(); + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = zoneName.empty() ? "You are not in a known zone." : "You are in: " + zoneName; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /played command + if (cmdLower == "played") { + gameHandler.requestPlayedTime(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ticket command — open GM ticket window + if (cmdLower == "ticket" || cmdLower == "gmticket" || cmdLower == "gm") { + slashCmds_.showGmTicket = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /chathelp command — list chat-channel slash commands + if (cmdLower == "chathelp") { + static constexpr const char* kChatHelp[] = { + "--- Chat Channel Commands ---", + "/s [msg] Say to nearby players", + "/y [msg] Yell to a wider area", + "/w [msg] Whisper to player", + "/r [msg] Reply to last whisper", + "/p [msg] Party chat", + "/g [msg] Guild chat", + "/o [msg] Guild officer chat", + "/raid [msg] Raid chat", + "/rw [msg] Raid warning", + "/bg [msg] Battleground chat", + "/1 [msg] General channel", + "/2 [msg] Trade channel (also /wts /wtb)", + "/ [msg] Channel by number", + "/join Join a channel", + "/leave Leave a channel", + "/afk [msg] Set AFK status", + "/dnd [msg] Set Do Not Disturb", + }; + for (const char* line : kChatHelp) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /macrohelp command — list available macro conditionals + if (cmdLower == "macrohelp") { + static constexpr const char* kMacroHelp[] = { + "--- Macro Conditionals ---", + "Usage: /cast [cond1,cond2] Spell1; [cond3] Spell2; Default", + "State: [combat] [mounted] [swimming] [flying] [stealthed]", + " [channeling] [pet] [group] [raid] [indoors] [outdoors]", + "Spec: [spec:1] [spec:2] (active talent spec, 1-based)", + " (prefix no- to negate any condition)", + "Target: [harm] [help] [exists] [noexists] [dead] [nodead]", + " [target=focus] [target=pet] [target=mouseover] [target=player]", + " (also: @focus, @pet, @mouseover, @player, @target)", + "Form: [noform] [nostance] [form:0]", + "Keys: [mod:shift] [mod:ctrl] [mod:alt]", + "Aura: [buff:Name] [nobuff:Name] [debuff:Name] [nodebuff:Name]", + "Other: #showtooltip, /stopmacro [cond], /castsequence", + }; + for (const char* line : kMacroHelp) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = line; + gameHandler.addLocalChatMessage(m); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /help command — list available slash commands + if (cmdLower == "help" || cmdLower == "?") { + static constexpr const char* kHelpLines[] = { + "--- Wowee Slash Commands ---", + "Chat: /s /y /p /g /raid /rw /o /bg /w /r /join /leave", + "Social: /who /friend add/remove /ignore /unignore", + "Party: /invite /uninvite /leave /readycheck /mark /roll", + " /maintank /mainassist /raidconvert /raidinfo", + " /lootmethod /lootthreshold", + "Guild: /ginvite /gkick /gquit /gpromote /gdemote /gmotd", + " /gleader /groster /ginfo /gcreate /gdisband", + "Combat: /cast /castsequence /use /startattack /stopattack", + " /stopcasting /duel /forfeit /pvp /assist", + " /follow /stopfollow /threat /combatlog", + "Items: /use /equip /equipset [name]", + "Target: /target /cleartarget /focus /clearfocus /inspect", + "Movement: /sit /stand /kneel /dismount", + "Misc: /played /time /zone /loc /afk /dnd /helm /cloak", + " /trade /score /unstuck /logout /quit /exit /ticket", + " /screenshot /difficulty", + " /macrohelp /chathelp /help", + }; + for (const char* line : kHelpLines) { + game::MessageChatData helpMsg; + helpMsg.type = game::ChatType::SYSTEM; + helpMsg.language = game::ChatLanguage::UNIVERSAL; + helpMsg.message = line; + gameHandler.addLocalChatMessage(helpMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /who commands + if (cmdLower == "who" || cmdLower == "whois" || cmdLower == "online" || cmdLower == "players") { + std::string query; + if (spacePos != std::string::npos) { + query = command.substr(spacePos + 1); + // Trim leading/trailing whitespace + size_t first = query.find_first_not_of(" \t\r\n"); + if (first == std::string::npos) { + query.clear(); + } else { + size_t last = query.find_last_not_of(" \t\r\n"); + query = query.substr(first, last - first + 1); + } + } + + if ((cmdLower == "whois") && query.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /whois "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "who" && (query == "help" || query == "?")) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Who commands: /who [name/filter], /whois , /online"; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + gameHandler.queryWho(query); + slashCmds_.showWho = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /combatlog command + if (cmdLower == "combatlog" || cmdLower == "cl") { + slashCmds_.toggleCombatLog = true; + chatInputBuffer_[0] = '\0'; + return; + } + + // /roll command + if (cmdLower == "roll" || cmdLower == "random" || cmdLower == "rnd") { + uint32_t minRoll = 1; + uint32_t maxRoll = 100; + + if (spacePos != std::string::npos) { + std::string args = command.substr(spacePos + 1); + size_t dashPos = args.find('-'); + size_t spacePos2 = args.find(' '); + + if (dashPos != std::string::npos) { + // Format: /roll 1-100 + try { + minRoll = std::stoul(args.substr(0, dashPos)); + maxRoll = std::stoul(args.substr(dashPos + 1)); + } catch (...) {} + } else if (spacePos2 != std::string::npos) { + // Format: /roll 1 100 + try { + minRoll = std::stoul(args.substr(0, spacePos2)); + maxRoll = std::stoul(args.substr(spacePos2 + 1)); + } catch (...) {} + } else { + // Format: /roll 100 (means 1-100) + try { + maxRoll = std::stoul(args); + } catch (...) {} + } + } + + gameHandler.randomRoll(minRoll, maxRoll); + chatInputBuffer_[0] = '\0'; + return; + } + + // /friend or /addfriend command + if (cmdLower == "friend" || cmdLower == "addfriend") { + if (spacePos != std::string::npos) { + std::string args = command.substr(spacePos + 1); + size_t subCmdSpace = args.find(' '); + + if (cmdLower == "friend" && subCmdSpace != std::string::npos) { + std::string subCmd = args.substr(0, subCmdSpace); + std::transform(subCmd.begin(), subCmd.end(), subCmd.begin(), ::tolower); + + if (subCmd == "add") { + std::string playerName = args.substr(subCmdSpace + 1); + gameHandler.addFriend(playerName); + chatInputBuffer_[0] = '\0'; + return; + } else if (subCmd == "remove" || subCmd == "delete" || subCmd == "rem") { + std::string playerName = args.substr(subCmdSpace + 1); + gameHandler.removeFriend(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + } else { + // /addfriend name or /friend name (assume add) + gameHandler.addFriend(args); + chatInputBuffer_[0] = '\0'; + return; + } + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /friend add or /friend remove "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /removefriend or /delfriend command + if (cmdLower == "removefriend" || cmdLower == "delfriend" || cmdLower == "remfriend") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.removeFriend(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /removefriend "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ignore command + if (cmdLower == "ignore") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.addIgnore(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /ignore "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /unignore command + if (cmdLower == "unignore") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.removeIgnore(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /unignore "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /dismount command + if (cmdLower == "dismount") { + gameHandler.dismount(); + chatInputBuffer_[0] = '\0'; + return; + } + + // Pet control commands (common macro use) + // Action IDs: 1=passive, 2=follow, 3=stay, 4=defensive, 5=attack, 6=aggressive + if (cmdLower == "petattack") { + uint64_t target = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendPetAction(5, target); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petfollow") { + gameHandler.sendPetAction(2, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petstay" || cmdLower == "pethalt") { + gameHandler.sendPetAction(3, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petpassive") { + gameHandler.sendPetAction(1, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petdefensive") { + gameHandler.sendPetAction(4, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petaggressive") { + gameHandler.sendPetAction(6, 0); + chatInputBuffer_[0] = '\0'; + return; + } + if (cmdLower == "petdismiss") { + gameHandler.dismissPet(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /cancelform / /cancelshapeshift — leave current shapeshift/stance + if (cmdLower == "cancelform" || cmdLower == "cancelshapeshift") { + // Cancel the first permanent shapeshift aura the player has + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + // Permanent shapeshift auras have the permanent flag (0x20) set + if (aura.flags & 0x20) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /cancelaura — cancel a specific buff by name or ID + if (cmdLower == "cancelaura" && spacePos != std::string::npos) { + std::string auraArg = command.substr(spacePos + 1); + while (!auraArg.empty() && auraArg.front() == ' ') auraArg.erase(auraArg.begin()); + while (!auraArg.empty() && auraArg.back() == ' ') auraArg.pop_back(); + // Try numeric ID first + { + std::string numStr = auraArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNum = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNum) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId) gameHandler.cancelAura(spellId); + chatInputBuffer_[0] = '\0'; + return; + } + } + // Name match against player auras + std::string argLow = auraArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + for (const auto& aura : gameHandler.getPlayerAuras()) { + if (aura.spellId == 0) continue; + std::string sn = gameHandler.getSpellName(aura.spellId); + for (char& c : sn) c = static_cast(std::tolower(static_cast(c))); + if (sn == argLow) { + gameHandler.cancelAura(aura.spellId); + break; + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /sit command + if (cmdLower == "sit") { + gameHandler.setStandState(1); // 1 = sit + chatInputBuffer_[0] = '\0'; + return; + } + + // /stand command + if (cmdLower == "stand") { + gameHandler.setStandState(0); // 0 = stand + chatInputBuffer_[0] = '\0'; + return; + } + + // /kneel command + if (cmdLower == "kneel") { + gameHandler.setStandState(8); // 8 = kneel + chatInputBuffer_[0] = '\0'; + return; + } + + // /logout command (also /camp, /quit, /exit) + if (cmdLower == "logout" || cmdLower == "camp" || cmdLower == "quit" || cmdLower == "exit") { + gameHandler.requestLogout(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /cancellogout command + if (cmdLower == "cancellogout") { + gameHandler.cancelLogout(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /difficulty command — set dungeon/raid difficulty (WotLK) + if (cmdLower == "difficulty") { + std::string arg; + if (spacePos != std::string::npos) { + arg = command.substr(spacePos + 1); + // Trim whitespace + size_t first = arg.find_first_not_of(" \t"); + size_t last = arg.find_last_not_of(" \t"); + if (first != std::string::npos) + arg = arg.substr(first, last - first + 1); + else + arg.clear(); + for (auto& ch : arg) ch = static_cast(std::tolower(static_cast(ch))); + } + + uint32_t diff = 0; + bool valid = true; + if (arg == "normal" || arg == "0") diff = 0; + else if (arg == "heroic" || arg == "1") diff = 1; + else if (arg == "25" || arg == "25normal" || arg == "25man" || arg == "2") + diff = 2; + else if (arg == "25heroic" || arg == "25manheroic" || arg == "3") + diff = 3; + else valid = false; + + if (!valid || arg.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /difficulty normal|heroic|25|25heroic (0-3)"; + gameHandler.addLocalChatMessage(msg); + } else { + static constexpr const char* kDiffNames[] = { + "Normal (5-man)", "Heroic (5-man)", "Normal (25-man)", "Heroic (25-man)" + }; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Setting difficulty to: ") + kDiffNames[diff]; + gameHandler.addLocalChatMessage(msg); + gameHandler.sendSetDifficulty(diff); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /helm command + if (cmdLower == "helm" || cmdLower == "helmet" || cmdLower == "showhelm") { + gameHandler.toggleHelm(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /cloak command + if (cmdLower == "cloak" || cmdLower == "showcloak") { + gameHandler.toggleCloak(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /follow command + if (cmdLower == "follow" || cmdLower == "f") { + gameHandler.followTarget(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /stopfollow command + if (cmdLower == "stopfollow") { + gameHandler.cancelFollow(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /assist command + if (cmdLower == "assist") { + // /assist → assist current target (use their target) + // /assist PlayerName → find PlayerName, target their target + // /assist [target=X] → evaluate conditional, target that entity's target + auto assistEntityTarget = [&](uint64_t srcGuid) { + auto srcEnt = gameHandler.getEntityManager().getEntity(srcGuid); + if (!srcEnt) { gameHandler.assistTarget(); return; } + uint64_t atkGuid = 0; + const auto& flds = srcEnt->getFields(); + auto iLo = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (iLo != flds.end()) { + atkGuid = iLo->second; + auto iHi = flds.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (iHi != flds.end()) atkGuid |= (static_cast(iHi->second) << 32); + } + if (atkGuid != 0) { + gameHandler.setTarget(atkGuid); + } else { + std::string sn = getEntityName(srcEnt); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = (sn.empty() ? "Target" : sn) + " has no target."; + gameHandler.addLocalChatMessage(msg); + } + }; + + if (spacePos != std::string::npos) { + std::string assistArg = command.substr(spacePos + 1); + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + + // Evaluate conditionals if present + uint64_t assistOver = static_cast(-1); + if (!assistArg.empty() && assistArg.front() == '[') { + assistArg = evaluateMacroConditionals(assistArg, gameHandler, assistOver); + if (assistArg.empty() && assistOver == static_cast(-1)) { + chatInputBuffer_[0] = '\0'; return; // no condition matched + } + while (!assistArg.empty() && assistArg.front() == ' ') assistArg.erase(assistArg.begin()); + while (!assistArg.empty() && assistArg.back() == ' ') assistArg.pop_back(); + } + + if (assistOver != static_cast(-1) && assistOver != 0) { + assistEntityTarget(assistOver); + } else if (!assistArg.empty()) { + // Name search + std::string argLow = assistArg; + for (char& c : argLow) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, ent] : gameHandler.getEntityManager().getEntities()) { + if (!ent || ent->getType() == game::ObjectType::OBJECT) continue; + std::string nm = getEntityName(ent); + std::string nml = nm; + for (char& c : nml) c = static_cast(std::tolower(static_cast(c))); + if (nml.find(argLow) != 0) continue; + float d2 = (ent->getX()-pmi.x)*(ent->getX()-pmi.x) + + (ent->getY()-pmi.y)*(ent->getY()-pmi.y); + if (d2 < bestDist) { bestDist = d2; bestGuid = guid; } + } + if (bestGuid) assistEntityTarget(bestGuid); + else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + assistArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } else { + gameHandler.assistTarget(); + } + } else { + gameHandler.assistTarget(); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /pvp command + if (cmdLower == "pvp") { + gameHandler.togglePvp(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ginfo command + if (cmdLower == "ginfo" || cmdLower == "guildinfo") { + gameHandler.requestGuildInfo(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /groster command + if (cmdLower == "groster" || cmdLower == "guildroster") { + gameHandler.requestGuildRoster(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gmotd command + if (cmdLower == "gmotd" || cmdLower == "guildmotd") { + if (spacePos != std::string::npos) { + std::string motd = command.substr(spacePos + 1); + gameHandler.setGuildMotd(motd); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gmotd "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gpromote command + if (cmdLower == "gpromote" || cmdLower == "guildpromote") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.promoteGuildMember(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gpromote "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gdemote command + if (cmdLower == "gdemote" || cmdLower == "guilddemote") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.demoteGuildMember(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gdemote "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gquit command + if (cmdLower == "gquit" || cmdLower == "guildquit" || cmdLower == "leaveguild") { + gameHandler.leaveGuild(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ginvite command + if (cmdLower == "ginvite" || cmdLower == "guildinvite") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.inviteToGuild(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /ginvite "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gkick command + if (cmdLower == "gkick" || cmdLower == "guildkick") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.kickGuildMember(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gkick "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gcreate command + if (cmdLower == "gcreate" || cmdLower == "guildcreate") { + if (spacePos != std::string::npos) { + std::string guildName = command.substr(spacePos + 1); + gameHandler.createGuild(guildName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gcreate "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gdisband command + if (cmdLower == "gdisband" || cmdLower == "guilddisband") { + gameHandler.disbandGuild(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /gleader command + if (cmdLower == "gleader" || cmdLower == "guildleader") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.setGuildLeader(playerName); + chatInputBuffer_[0] = '\0'; + return; + } + + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /gleader "; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // /readycheck command + if (cmdLower == "readycheck" || cmdLower == "rc") { + gameHandler.initiateReadyCheck(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /ready command (respond yes to ready check) + if (cmdLower == "ready") { + gameHandler.respondToReadyCheck(true); + chatInputBuffer_[0] = '\0'; + return; + } + + // /notready command (respond no to ready check) + if (cmdLower == "notready" || cmdLower == "nr") { + gameHandler.respondToReadyCheck(false); + chatInputBuffer_[0] = '\0'; + return; + } + + // /yield or /forfeit command + if (cmdLower == "yield" || cmdLower == "forfeit" || cmdLower == "surrender") { + gameHandler.forfeitDuel(); + chatInputBuffer_[0] = '\0'; + return; + } + + // AFK command + if (cmdLower == "afk" || cmdLower == "away") { + std::string afkMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + gameHandler.toggleAfk(afkMsg); + chatInputBuffer_[0] = '\0'; + return; + } + + // DND command + if (cmdLower == "dnd" || cmdLower == "busy") { + std::string dndMsg = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + gameHandler.toggleDnd(dndMsg); + chatInputBuffer_[0] = '\0'; + return; + } + + // Reply command + if (cmdLower == "r" || cmdLower == "reply") { + std::string lastSender = gameHandler.getLastWhisperSender(); + if (lastSender.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "No one has whispered you yet."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer_[0] = '\0'; + return; + } + // Set whisper target to last whisper sender + strncpy(whisperTargetBuffer_, lastSender.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + if (spacePos != std::string::npos) { + // /r message — send reply immediately + std::string replyMsg = command.substr(spacePos + 1); + gameHandler.sendChatMessage(game::ChatType::WHISPER, replyMsg, lastSender); + } + // Switch to whisper tab + selectedChatType_ = 4; + chatInputBuffer_[0] = '\0'; + return; + } + + // Party/Raid management commands + if (cmdLower == "uninvite" || cmdLower == "kick") { + if (spacePos != std::string::npos) { + std::string playerName = command.substr(spacePos + 1); + gameHandler.uninvitePlayer(playerName); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Usage: /uninvite "; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "leave" || cmdLower == "leaveparty") { + gameHandler.leaveParty(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "maintank" || cmdLower == "mt") { + if (gameHandler.hasTarget()) { + gameHandler.setMainTank(gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to set as main tank."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "mainassist" || cmdLower == "ma") { + if (gameHandler.hasTarget()) { + gameHandler.setMainAssist(gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to set as main assist."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "clearmaintank") { + gameHandler.clearMainTank(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "clearmainassist") { + gameHandler.clearMainAssist(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "raidinfo") { + gameHandler.requestRaidInfo(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "raidconvert") { + gameHandler.convertToRaid(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /lootmethod (or /grouploot, /setloot) — set party/raid loot method + if (cmdLower == "lootmethod" || cmdLower == "grouploot" || cmdLower == "setloot") { + if (!gameHandler.isInGroup()) { + gameHandler.addUIError("You are not in a group."); + } else if (spacePos == std::string::npos) { + // No argument — show current method and usage + static constexpr const char* kMethodNames[] = { + "Free for All", "Round Robin", "Master Looter", "Group Loot", "Need Before Greed" + }; + const auto& pd = gameHandler.getPartyData(); + const char* cur = (pd.lootMethod < 5) ? kMethodNames[pd.lootMethod] : "Unknown"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Current loot method: ") + cur; + gameHandler.addLocalChatMessage(msg); + msg.message = "Usage: /lootmethod ffa|roundrobin|master|group|needbeforegreed"; + gameHandler.addLocalChatMessage(msg); + } else { + std::string arg = command.substr(spacePos + 1); + // Lowercase the argument + for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); + uint32_t method = 0xFFFFFFFF; + if (arg == "ffa" || arg == "freeforall") method = 0; + else if (arg == "roundrobin" || arg == "rr") method = 1; + else if (arg == "master" || arg == "masterloot") method = 2; + else if (arg == "group" || arg == "grouploot") method = 3; + else if (arg == "needbeforegreed" || arg == "nbg" || arg == "need") method = 4; + + if (method == 0xFFFFFFFF) { + gameHandler.addUIError("Unknown loot method. Use: ffa, roundrobin, master, group, needbeforegreed"); + } else { + const auto& pd = gameHandler.getPartyData(); + // Master loot uses player guid as master looter; otherwise 0 + uint64_t masterGuid = (method == 2) ? gameHandler.getPlayerGuid() : 0; + gameHandler.sendSetLootMethod(method, pd.lootThreshold, masterGuid); + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /lootthreshold — set minimum item quality for group loot rolls + if (cmdLower == "lootthreshold") { + if (!gameHandler.isInGroup()) { + gameHandler.addUIError("You are not in a group."); + } else if (spacePos == std::string::npos) { + const auto& pd = gameHandler.getPartyData(); + static constexpr const char* kQualityNames[] = { + "Poor (grey)", "Common (white)", "Uncommon (green)", + "Rare (blue)", "Epic (purple)", "Legendary (orange)" + }; + const char* cur = (pd.lootThreshold < 6) ? kQualityNames[pd.lootThreshold] : "Unknown"; + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = std::string("Current loot threshold: ") + cur; + gameHandler.addLocalChatMessage(msg); + msg.message = "Usage: /lootthreshold <0-5> (0=Poor, 1=Common, 2=Uncommon, 3=Rare, 4=Epic, 5=Legendary)"; + gameHandler.addLocalChatMessage(msg); + } else { + std::string arg = command.substr(spacePos + 1); + // Trim whitespace + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + uint32_t threshold = 0xFFFFFFFF; + if (arg.size() == 1 && arg[0] >= '0' && arg[0] <= '5') { + threshold = static_cast(arg[0] - '0'); + } else { + // Accept quality names + for (auto& c : arg) c = static_cast(std::tolower(static_cast(c))); + if (arg == "poor" || arg == "grey" || arg == "gray") threshold = 0; + else if (arg == "common" || arg == "white") threshold = 1; + else if (arg == "uncommon" || arg == "green") threshold = 2; + else if (arg == "rare" || arg == "blue") threshold = 3; + else if (arg == "epic" || arg == "purple") threshold = 4; + else if (arg == "legendary" || arg == "orange") threshold = 5; + } + + if (threshold == 0xFFFFFFFF) { + gameHandler.addUIError("Invalid threshold. Use 0-5 or: poor, common, uncommon, rare, epic, legendary"); + } else { + const auto& pd = gameHandler.getPartyData(); + uint64_t masterGuid = (pd.lootMethod == 2) ? gameHandler.getPlayerGuid() : 0; + gameHandler.sendSetLootMethod(pd.lootMethod, threshold, masterGuid); + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /mark [icon] — set or clear a raid target mark on the current target. + // Icon names (case-insensitive): star, circle, diamond, triangle, moon, square, cross, skull + // /mark clear | /mark 0 — remove all marks (sets icon 0xFF = clear) + // /mark — no arg marks with skull (icon 7) + if (cmdLower == "mark" || cmdLower == "marktarget" || cmdLower == "raidtarget") { + if (!gameHandler.hasTarget()) { + game::MessageChatData noTgt; + noTgt.type = game::ChatType::SYSTEM; + noTgt.language = game::ChatLanguage::UNIVERSAL; + noTgt.message = "No target selected."; + gameHandler.addLocalChatMessage(noTgt); + chatInputBuffer_[0] = '\0'; + return; + } + static constexpr const char* kMarkWords[] = { + "star", "circle", "diamond", "triangle", "moon", "square", "cross", "skull" + }; + uint8_t icon = 7; // default: skull + if (spacePos != std::string::npos) { + std::string arg = command.substr(spacePos + 1); + while (!arg.empty() && arg.front() == ' ') arg.erase(arg.begin()); + std::string argLow = arg; + for (auto& c : argLow) c = static_cast(std::tolower(c)); + if (argLow == "clear" || argLow == "0" || argLow == "none") { + gameHandler.setRaidMark(gameHandler.getTargetGuid(), 0xFF); + chatInputBuffer_[0] = '\0'; + return; + } + bool found = false; + for (int mi = 0; mi < 8; ++mi) { + if (argLow == kMarkWords[mi]) { icon = static_cast(mi); found = true; break; } + } + if (!found && !argLow.empty() && argLow[0] >= '1' && argLow[0] <= '8') { + icon = static_cast(argLow[0] - '1'); + found = true; + } + if (!found) { + game::MessageChatData badArg; + badArg.type = game::ChatType::SYSTEM; + badArg.language = game::ChatLanguage::UNIVERSAL; + badArg.message = "Unknown mark. Use: star circle diamond triangle moon square cross skull"; + gameHandler.addLocalChatMessage(badArg); + chatInputBuffer_[0] = '\0'; + return; + } + } + gameHandler.setRaidMark(gameHandler.getTargetGuid(), icon); + chatInputBuffer_[0] = '\0'; + return; + } + + // Combat and Trade commands + if (cmdLower == "duel") { + if (gameHandler.hasTarget()) { + gameHandler.proposeDuel(gameHandler.getTargetGuid()); + } else if (spacePos != std::string::npos) { + // Target player by name (would need name-to-GUID lookup) + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to challenge to a duel."; + gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to challenge to a duel."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "trade") { + if (gameHandler.hasTarget()) { + gameHandler.initiateTrade(gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a player to trade with."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "startattack") { + // Support macro conditionals: /startattack [harm,nodead] + bool condPass = true; + uint64_t saOverride = static_cast(-1); + if (spacePos != std::string::npos) { + std::string saArg = command.substr(spacePos + 1); + while (!saArg.empty() && saArg.front() == ' ') saArg.erase(saArg.begin()); + if (!saArg.empty() && saArg.front() == '[') { + std::string result = evaluateMacroConditionals(saArg, gameHandler, saOverride); + condPass = !(result.empty() && saOverride == static_cast(-1)); + } + } + if (condPass) { + uint64_t atkTarget = (saOverride != static_cast(-1) && saOverride != 0) + ? saOverride : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + if (atkTarget != 0) { + gameHandler.startAutoAttack(atkTarget); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You have no target."; + gameHandler.addLocalChatMessage(msg); + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "stopattack") { + gameHandler.stopAutoAttack(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "stopcasting") { + gameHandler.stopCasting(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "cancelqueuedspell" || cmdLower == "stopspellqueue") { + gameHandler.cancelQueuedSpell(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /equipset [name] — equip a saved equipment set by name (partial match, case-insensitive) + // /equipset — list available sets in chat + if (cmdLower == "equipset") { + const auto& sets = gameHandler.getEquipmentSets(); + auto sysSay = [&](const std::string& msg) { + game::MessageChatData m; + m.type = game::ChatType::SYSTEM; + m.language = game::ChatLanguage::UNIVERSAL; + m.message = msg; + gameHandler.addLocalChatMessage(m); + }; + if (spacePos == std::string::npos) { + // No argument: list available sets + if (sets.empty()) { + sysSay("[System] No equipment sets saved."); + } else { + sysSay("[System] Equipment sets:"); + for (const auto& es : sets) + sysSay(" " + es.name); + } + } else { + std::string setName = command.substr(spacePos + 1); + while (!setName.empty() && setName.front() == ' ') setName.erase(setName.begin()); + while (!setName.empty() && setName.back() == ' ') setName.pop_back(); + // Case-insensitive prefix match + std::string setLower = setName; + std::transform(setLower.begin(), setLower.end(), setLower.begin(), ::tolower); + const game::GameHandler::EquipmentSetInfo* found = nullptr; + for (const auto& es : sets) { + std::string nameLow = es.name; + std::transform(nameLow.begin(), nameLow.end(), nameLow.begin(), ::tolower); + if (nameLow == setLower || nameLow.find(setLower) == 0) { + found = &es; + break; + } + } + if (found) { + gameHandler.useEquipmentSet(found->setId); + } else { + sysSay("[System] No equipment set matching '" + setName + "'."); + } + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /castsequence [conds] [reset=N/target/combat] Spell1, Spell2, ... + // Cycles through the spell list on successive presses; resets per the reset= spec. + if (cmdLower == "castsequence" && spacePos != std::string::npos) { + std::string seqArg = command.substr(spacePos + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + + // Macro conditionals + uint64_t seqTgtOver = static_cast(-1); + if (!seqArg.empty() && seqArg.front() == '[') { + seqArg = evaluateMacroConditionals(seqArg, gameHandler, seqTgtOver); + if (seqArg.empty() && seqTgtOver == static_cast(-1)) { + chatInputBuffer_[0] = '\0'; return; + } + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + while (!seqArg.empty() && seqArg.back() == ' ') seqArg.pop_back(); + } + + // Optional reset= spec (may contain slash-separated conditions: reset=5/target) + std::string resetSpec; + if (seqArg.rfind("reset=", 0) == 0) { + size_t spAfter = seqArg.find(' '); + if (spAfter != std::string::npos) { + resetSpec = seqArg.substr(6, spAfter - 6); + seqArg = seqArg.substr(spAfter + 1); + while (!seqArg.empty() && seqArg.front() == ' ') seqArg.erase(seqArg.begin()); + } + } + + // Parse comma-separated spell list + std::vector seqSpells; + { + std::string cur; + for (char c : seqArg) { + if (c == ',') { + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + cur.clear(); + } else { cur += c; } + } + while (!cur.empty() && cur.front() == ' ') cur.erase(cur.begin()); + while (!cur.empty() && cur.back() == ' ') cur.pop_back(); + if (!cur.empty()) seqSpells.push_back(cur); + } + if (seqSpells.empty()) { chatInputBuffer_[0] = '\0'; return; } + + // Build stable key from lowercase spell list + std::string seqKey; + for (size_t k = 0; k < seqSpells.size(); ++k) { + if (k) seqKey += ','; + std::string sl = seqSpells[k]; + for (char& c : sl) c = static_cast(std::tolower(static_cast(c))); + seqKey += sl; + } + + auto& seqState = s_castSeqStates[seqKey]; + + // Check reset conditions (slash-separated: e.g. "5/target") + float nowSec = static_cast(ImGui::GetTime()); + bool shouldReset = false; + if (!resetSpec.empty()) { + size_t rpos = 0; + while (rpos <= resetSpec.size()) { + size_t slash = resetSpec.find('/', rpos); + std::string part = (slash != std::string::npos) + ? resetSpec.substr(rpos, slash - rpos) + : resetSpec.substr(rpos); + std::string plow = part; + for (char& c : plow) c = static_cast(std::tolower(static_cast(c))); + bool isNum = !plow.empty() && std::all_of(plow.begin(), plow.end(), + [](unsigned char c){ return std::isdigit(c) || c == '.'; }); + if (isNum) { + float rSec = 0.0f; + try { rSec = std::stof(plow); } catch (...) {} + if (rSec > 0.0f && nowSec - seqState.lastPressSec > rSec) shouldReset = true; + } else if (plow == "target") { + if (gameHandler.getTargetGuid() != seqState.lastTargetGuid) shouldReset = true; + } else if (plow == "combat") { + if (gameHandler.isInCombat() != seqState.lastInCombat) shouldReset = true; + } + if (slash == std::string::npos) break; + rpos = slash + 1; + } + } + if (shouldReset || seqState.index >= seqSpells.size()) seqState.index = 0; + + const std::string& seqSpell = seqSpells[seqState.index]; + seqState.index = (seqState.index + 1) % seqSpells.size(); + seqState.lastPressSec = nowSec; + seqState.lastTargetGuid = gameHandler.getTargetGuid(); + seqState.lastInCombat = gameHandler.isInCombat(); + + // Cast the selected spell — mirrors /cast spell lookup + std::string ssLow = seqSpell; + for (char& c : ssLow) c = static_cast(std::tolower(static_cast(c))); + if (!ssLow.empty() && ssLow.front() == '!') ssLow.erase(ssLow.begin()); + + uint64_t seqTargetGuid = (seqTgtOver != static_cast(-1) && seqTgtOver != 0) + ? seqTgtOver : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + + // Numeric ID + if (!ssLow.empty() && ssLow.front() == '#') ssLow.erase(ssLow.begin()); + bool ssNumeric = !ssLow.empty() && std::all_of(ssLow.begin(), ssLow.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (ssNumeric) { + uint32_t ssId = 0; + try { ssId = static_cast(std::stoul(ssLow)); } catch (...) {} + if (ssId) gameHandler.castSpell(ssId, seqTargetGuid); + } else { + uint32_t ssBest = 0; int ssBestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sn = gameHandler.getSpellName(sid); + if (sn.empty()) continue; + std::string snl = sn; + for (char& c : snl) c = static_cast(std::tolower(static_cast(c))); + if (snl != ssLow) continue; + int sRnk = 0; + const std::string& rk = gameHandler.getSpellRank(sid); + if (!rk.empty()) { + std::string rkl = rk; + for (char& c : rkl) c = static_cast(std::tolower(static_cast(c))); + if (rkl.rfind("rank ", 0) == 0) { try { sRnk = std::stoi(rkl.substr(5)); } catch (...) {} } + } + if (sRnk > ssBestRank) { ssBestRank = sRnk; ssBest = sid; } + } + if (ssBest) gameHandler.castSpell(ssBest, seqTargetGuid); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "cast" && spacePos != std::string::npos) { + std::string spellArg = command.substr(spacePos + 1); + // Trim leading/trailing whitespace + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + + // Evaluate WoW macro conditionals: /cast [mod:shift] Greater Heal; Flash Heal + uint64_t castTargetOverride = static_cast(-1); + if (!spellArg.empty() && spellArg.front() == '[') { + spellArg = evaluateMacroConditionals(spellArg, gameHandler, castTargetOverride); + if (spellArg.empty()) { + chatInputBuffer_[0] = '\0'; + return; // No conditional matched — skip cast + } + while (!spellArg.empty() && spellArg.front() == ' ') spellArg.erase(spellArg.begin()); + while (!spellArg.empty() && spellArg.back() == ' ') spellArg.pop_back(); + } + + // Strip leading '!' (WoW /cast !Spell forces recast without toggling off) + if (!spellArg.empty() && spellArg.front() == '!') spellArg.erase(spellArg.begin()); + + // Support numeric spell ID: /cast 133 or /cast #133 + { + std::string numStr = spellArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + uint32_t spellId = 0; + try { spellId = static_cast(std::stoul(numStr)); } catch (...) {} + if (spellId != 0) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + gameHandler.castSpell(spellId, targetGuid); + } + chatInputBuffer_[0] = '\0'; + return; + } + } + + // Parse optional "(Rank N)" suffix: "Fireball(Rank 3)" or "Fireball (Rank 3)" + int requestedRank = -1; // -1 = highest rank + std::string spellName = spellArg; + { + auto rankPos = spellArg.find('('); + if (rankPos != std::string::npos) { + std::string rankStr = spellArg.substr(rankPos + 1); + // Strip closing paren and whitespace + auto closePos = rankStr.find(')'); + if (closePos != std::string::npos) rankStr = rankStr.substr(0, closePos); + for (char& c : rankStr) c = static_cast(std::tolower(static_cast(c))); + // Expect "rank N" + if (rankStr.rfind("rank ", 0) == 0) { + try { requestedRank = std::stoi(rankStr.substr(5)); } catch (...) {} + } + spellName = spellArg.substr(0, rankPos); + while (!spellName.empty() && spellName.back() == ' ') spellName.pop_back(); + } + } + + std::string spellNameLower = spellName; + for (char& c : spellNameLower) c = static_cast(std::tolower(static_cast(c))); + + // Search known spells for a name match; pick highest rank (or specific rank) + uint32_t bestSpellId = 0; + int bestRank = -1; + for (uint32_t sid : gameHandler.getKnownSpells()) { + const std::string& sName = gameHandler.getSpellName(sid); + if (sName.empty()) continue; + std::string sNameLower = sName; + for (char& c : sNameLower) c = static_cast(std::tolower(static_cast(c))); + if (sNameLower != spellNameLower) continue; + + // Parse numeric rank from rank string ("Rank 3" → 3, "" → 0) + int sRank = 0; + const std::string& rankStr = gameHandler.getSpellRank(sid); + if (!rankStr.empty()) { + std::string rLow = rankStr; + for (char& c : rLow) c = static_cast(std::tolower(static_cast(c))); + if (rLow.rfind("rank ", 0) == 0) { + try { sRank = std::stoi(rLow.substr(5)); } catch (...) {} + } + } + + if (requestedRank >= 0) { + if (sRank == requestedRank) { bestSpellId = sid; break; } + } else { + if (sRank > bestRank) { bestRank = sRank; bestSpellId = sid; } + } + } + + if (bestSpellId) { + uint64_t targetGuid = (castTargetOverride != static_cast(-1)) + ? castTargetOverride + : (gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0); + gameHandler.castSpell(bestSpellId, targetGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = requestedRank >= 0 + ? "You don't know '" + spellName + "' (Rank " + std::to_string(requestedRank) + ")." + : "Unknown spell: '" + spellName + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /use + // Supports: item name, numeric item ID (#N or N), bag/slot (/use 0 1 = backpack slot 1, + // /use 1-4 slot = bag slot), equipment slot number (/use 16 = main hand) + if (cmdLower == "use" && spacePos != std::string::npos) { + std::string useArg = command.substr(spacePos + 1); + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + + // Handle macro conditionals: /use [mod:shift] ItemName; OtherItem + if (!useArg.empty() && useArg.front() == '[') { + uint64_t dummy = static_cast(-1); + useArg = evaluateMacroConditionals(useArg, gameHandler, dummy); + if (useArg.empty()) { chatInputBuffer_[0] = '\0'; return; } + while (!useArg.empty() && useArg.front() == ' ') useArg.erase(useArg.begin()); + while (!useArg.empty() && useArg.back() == ' ') useArg.pop_back(); + } + + // Check for bag/slot notation: two numbers separated by whitespace + { + std::istringstream iss(useArg); + int bagNum = -1, slotNum = -1; + iss >> bagNum >> slotNum; + if (!iss.fail() && slotNum >= 1) { + if (bagNum == 0) { + // Backpack: bag=0, slot 1-based → 0-based + gameHandler.useItemBySlot(slotNum - 1); + chatInputBuffer_[0] = '\0'; + return; + } else if (bagNum >= 1 && bagNum <= game::Inventory::NUM_BAG_SLOTS) { + // Equip bag: bags are 1-indexed (bag 1 = bagIndex 0) + gameHandler.useItemInBag(bagNum - 1, slotNum - 1); + chatInputBuffer_[0] = '\0'; + return; + } + } + } + + // Numeric equip slot: /use 16 = slot 16 (1-based, WoW equip slot enum) + { + std::string numStr = useArg; + if (!numStr.empty() && numStr.front() == '#') numStr.erase(numStr.begin()); + bool isNumeric = !numStr.empty() && + std::all_of(numStr.begin(), numStr.end(), + [](unsigned char c){ return std::isdigit(c); }); + if (isNumeric) { + // Treat as equip slot (1-based, maps to EquipSlot enum 0-based) + int slotNum = 0; + try { slotNum = std::stoi(numStr); } catch (...) {} + if (slotNum >= 1 && slotNum <= static_cast(game::EquipSlot::BAG4) + 1) { + auto eslot = static_cast(slotNum - 1); + const auto& esl = gameHandler.getInventory().getEquipSlot(eslot); + if (!esl.empty()) + gameHandler.useItemById(esl.item.itemId); + } + chatInputBuffer_[0] = '\0'; + return; + } + } + + std::string useArgLower = useArg; + for (char& c : useArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == useArgLower) { + gameHandler.useItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + useArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /equip — auto-equip an item from backpack/bags by name + if (cmdLower == "equip" && spacePos != std::string::npos) { + std::string equipArg = command.substr(spacePos + 1); + while (!equipArg.empty() && equipArg.front() == ' ') equipArg.erase(equipArg.begin()); + while (!equipArg.empty() && equipArg.back() == ' ') equipArg.pop_back(); + std::string equipArgLower = equipArg; + for (char& c : equipArgLower) c = static_cast(std::tolower(static_cast(c))); + + bool found = false; + const auto& inv = gameHandler.getInventory(); + // Search backpack + for (int s = 0; s < inv.getBackpackSize() && !found; ++s) { + const auto& slot = inv.getBackpackSlot(s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemBySlot(s); + found = true; + } + } + // Search bags + for (int b = 0; b < game::Inventory::NUM_BAG_SLOTS && !found; ++b) { + for (int s = 0; s < inv.getBagSize(b) && !found; ++s) { + const auto& slot = inv.getBagSlot(b, s); + if (slot.empty()) continue; + const auto* info = gameHandler.getItemInfo(slot.item.itemId); + if (!info) continue; + std::string nameLow = info->name; + for (char& c : nameLow) c = static_cast(std::tolower(static_cast(c))); + if (nameLow == equipArgLower) { + gameHandler.autoEquipItemInBag(b, s); + found = true; + } + } + } + if (!found) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Item not found: '" + equipArg + "'."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // Targeting commands + if (cmdLower == "cleartarget") { + // Support macro conditionals: /cleartarget [dead] clears only if target is dead + bool ctCondPass = true; + if (spacePos != std::string::npos) { + std::string ctArg = command.substr(spacePos + 1); + while (!ctArg.empty() && ctArg.front() == ' ') ctArg.erase(ctArg.begin()); + if (!ctArg.empty() && ctArg.front() == '[') { + uint64_t ctOver = static_cast(-1); + std::string res = evaluateMacroConditionals(ctArg, gameHandler, ctOver); + ctCondPass = !(res.empty() && ctOver == static_cast(-1)); + } + } + if (ctCondPass) gameHandler.clearTarget(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "target" && spacePos != std::string::npos) { + // Search visible entities for name match (case-insensitive prefix). + // Among all matches, pick the nearest living unit to the player. + // Supports WoW macro conditionals: /target [target=mouseover]; /target [mod:shift] Boss + std::string targetArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t targetCmdOverride = static_cast(-1); + if (!targetArg.empty() && targetArg.front() == '[') { + targetArg = evaluateMacroConditionals(targetArg, gameHandler, targetCmdOverride); + if (targetArg.empty() && targetCmdOverride == static_cast(-1)) { + // No condition matched — silently skip (macro fallthrough) + chatInputBuffer_[0] = '\0'; + return; + } + while (!targetArg.empty() && targetArg.front() == ' ') targetArg.erase(targetArg.begin()); + while (!targetArg.empty() && targetArg.back() == ' ') targetArg.pop_back(); + } + + // If conditionals resolved to a specific GUID, target it directly + if (targetCmdOverride != static_cast(-1) && targetCmdOverride != 0) { + gameHandler.setTarget(targetCmdOverride); + chatInputBuffer_[0] = '\0'; + return; + } + + // If no name remains (bare conditional like [target=mouseover] with 0 guid), skip silently + if (targetArg.empty()) { + chatInputBuffer_[0] = '\0'; + return; + } + + std::string targetArgLower = targetArg; + for (char& c : targetArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + const float playerX = pmi.x; + const float playerY = pmi.y; + const float playerZ = pmi.z; + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(targetArgLower) == 0) { + float dx = entity->getX() - playerX; + float dy = entity->getY() - playerY; + float dz = entity->getZ() - playerZ; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { + bestDist = dist; + bestGuid = guid; + } + } + } + if (bestGuid) { + gameHandler.setTarget(bestGuid); + } else { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No target matching '" + targetArg + "' found."; + gameHandler.addLocalChatMessage(sysMsg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetenemy") { + gameHandler.targetEnemy(false); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetfriend") { + gameHandler.targetFriend(false); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetlasttarget" || cmdLower == "targetlast") { + gameHandler.targetLastTarget(); + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetlastenemy") { + gameHandler.targetEnemy(true); // Reverse direction + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "targetlastfriend") { + gameHandler.targetFriend(true); // Reverse direction + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "focus") { + // /focus → set current target as focus + // /focus PlayerName → search for entity by name and set as focus + // /focus [target=X] Name → macro conditional: set focus to resolved target + if (spacePos != std::string::npos) { + std::string focusArg = command.substr(spacePos + 1); + + // Evaluate conditionals if present + uint64_t focusCmdOverride = static_cast(-1); + if (!focusArg.empty() && focusArg.front() == '[') { + focusArg = evaluateMacroConditionals(focusArg, gameHandler, focusCmdOverride); + if (focusArg.empty() && focusCmdOverride == static_cast(-1)) { + chatInputBuffer_[0] = '\0'; + return; + } + while (!focusArg.empty() && focusArg.front() == ' ') focusArg.erase(focusArg.begin()); + while (!focusArg.empty() && focusArg.back() == ' ') focusArg.pop_back(); + } + + if (focusCmdOverride != static_cast(-1) && focusCmdOverride != 0) { + // Conditional resolved to a specific GUID (e.g. [target=mouseover]) + gameHandler.setFocus(focusCmdOverride); + } else if (!focusArg.empty()) { + // Name search — same logic as /target + std::string focusArgLower = focusArg; + for (char& c : focusArgLower) c = static_cast(std::tolower(static_cast(c))); + uint64_t bestGuid = 0; + float bestDist = std::numeric_limits::max(); + const auto& pmi = gameHandler.getMovementInfo(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() == game::ObjectType::OBJECT) continue; + std::string name; + if (entity->getType() == game::ObjectType::PLAYER || + entity->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + name = unit->getName(); + } + if (name.empty()) continue; + std::string nameLower = name; + for (char& c : nameLower) c = static_cast(std::tolower(static_cast(c))); + if (nameLower.find(focusArgLower) == 0) { + float dx = entity->getX() - pmi.x; + float dy = entity->getY() - pmi.y; + float dz = entity->getZ() - pmi.z; + float dist = dx*dx + dy*dy + dz*dz; + if (dist < bestDist) { bestDist = dist; bestGuid = guid; } + } + } + if (bestGuid) { + gameHandler.setFocus(bestGuid); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "No unit matching '" + focusArg + "' found."; + gameHandler.addLocalChatMessage(msg); + } + } + } else if (gameHandler.hasTarget()) { + gameHandler.setFocus(gameHandler.getTargetGuid()); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must target a unit to set as focus."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + if (cmdLower == "clearfocus") { + gameHandler.clearFocus(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /unstuck command — resets player position to floor height + if (cmdLower == "unstuck") { + gameHandler.unstuck(); + chatInputBuffer_[0] = '\0'; + return; + } + // /unstuckgy command — move to nearest graveyard + if (cmdLower == "unstuckgy") { + gameHandler.unstuckGy(); + chatInputBuffer_[0] = '\0'; + return; + } + // /unstuckhearth command — teleport to hearthstone bind point + if (cmdLower == "unstuckhearth") { + gameHandler.unstuckHearth(); + chatInputBuffer_[0] = '\0'; + return; + } + + // /transport board — board test transport + if (cmdLower == "transport board") { + auto* tm = gameHandler.getTransportManager(); + if (tm) { + // Test transport GUID + uint64_t testTransportGuid = 0x1000000000000001ULL; + // Place player at center of deck (rough estimate) + glm::vec3 deckCenter(0.0f, 0.0f, 5.0f); + gameHandler.setPlayerOnTransport(testTransportGuid, deckCenter); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Boarded test transport. Use '/transport leave' to disembark."; + gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Transport system not available."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // /transport leave — disembark from transport + if (cmdLower == "transport leave") { + if (gameHandler.isOnTransport()) { + gameHandler.clearPlayerTransport(); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "Disembarked from transport."; + gameHandler.addLocalChatMessage(msg); + } else { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You are not on a transport."; + gameHandler.addLocalChatMessage(msg); + } + chatInputBuffer_[0] = '\0'; + return; + } + + // Chat channel slash commands + // If used without a message (e.g. just "/s"), switch the chat type dropdown + bool isChannelCommand = false; + if (cmdLower == "s" || cmdLower == "say") { + type = game::ChatType::SAY; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 0; + } else if (cmdLower == "y" || cmdLower == "yell" || cmdLower == "shout") { + type = game::ChatType::YELL; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 1; + } else if (cmdLower == "p" || cmdLower == "party") { + type = game::ChatType::PARTY; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 2; + } else if (cmdLower == "g" || cmdLower == "guild") { + type = game::ChatType::GUILD; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 3; + } else if (cmdLower == "raid" || cmdLower == "rsay" || cmdLower == "ra") { + type = game::ChatType::RAID; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 5; + } else if (cmdLower == "raidwarning" || cmdLower == "rw") { + type = game::ChatType::RAID_WARNING; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 8; + } else if (cmdLower == "officer" || cmdLower == "o" || cmdLower == "osay") { + type = game::ChatType::OFFICER; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 6; + } else if (cmdLower == "battleground" || cmdLower == "bg") { + type = game::ChatType::BATTLEGROUND; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 7; + } else if (cmdLower == "instance" || cmdLower == "i") { + // Instance chat uses PARTY chat type + type = game::ChatType::PARTY; + message = (spacePos != std::string::npos) ? command.substr(spacePos + 1) : ""; + isChannelCommand = true; + switchChatType = 9; + } else if (cmdLower == "join") { + // /join with no args: accept pending BG invite if any + if (spacePos == std::string::npos && gameHandler.hasPendingBgInvite()) { + gameHandler.acceptBattlefield(); + chatInputBuffer_[0] = '\0'; + return; + } + // /join ChannelName [password] + if (spacePos != std::string::npos) { + std::string rest = command.substr(spacePos + 1); + size_t pwStart = rest.find(' '); + std::string channelName = (pwStart != std::string::npos) ? rest.substr(0, pwStart) : rest; + std::string password = (pwStart != std::string::npos) ? rest.substr(pwStart + 1) : ""; + gameHandler.joinChannel(channelName, password); + } + chatInputBuffer_[0] = '\0'; + return; + } else if (cmdLower == "leave") { + // /leave ChannelName + if (spacePos != std::string::npos) { + std::string channelName = command.substr(spacePos + 1); + gameHandler.leaveChannel(channelName); + } + chatInputBuffer_[0] = '\0'; + return; + } else if ((cmdLower == "wts" || cmdLower == "wtb") && spacePos != std::string::npos) { + // /wts and /wtb — send to Trade channel + // Prefix with [WTS] / [WTB] and route to the Trade channel + const std::string tag = (cmdLower == "wts") ? "[WTS] " : "[WTB] "; + const std::string body = command.substr(spacePos + 1); + // Find the Trade channel among joined channels (case-insensitive prefix match) + std::string tradeChan; + for (const auto& ch : gameHandler.getJoinedChannels()) { + std::string chLow = ch; + for (char& c : chLow) c = static_cast(std::tolower(static_cast(c))); + if (chLow.rfind("trade", 0) == 0) { tradeChan = ch; break; } + } + if (tradeChan.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.language = game::ChatLanguage::UNIVERSAL; + errMsg.message = "You are not in the Trade channel."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer_[0] = '\0'; + return; + } + message = tag + body; + type = game::ChatType::CHANNEL; + target = tradeChan; + isChannelCommand = true; + } else if (cmdLower.size() == 1 && cmdLower[0] >= '1' && cmdLower[0] <= '9') { + // /1 msg, /2 msg — channel shortcuts + int channelIdx = cmdLower[0] - '0'; + std::string channelName = gameHandler.getChannelByIndex(channelIdx); + if (!channelName.empty() && spacePos != std::string::npos) { + message = command.substr(spacePos + 1); + type = game::ChatType::CHANNEL; + target = channelName; + isChannelCommand = true; + } else if (channelName.empty()) { + game::MessageChatData errMsg; + errMsg.type = game::ChatType::SYSTEM; + errMsg.message = "You are not in channel " + std::to_string(channelIdx) + "."; + gameHandler.addLocalChatMessage(errMsg); + chatInputBuffer_[0] = '\0'; + return; + } else { + chatInputBuffer_[0] = '\0'; + return; + } + } else if (cmdLower == "w" || cmdLower == "whisper" || cmdLower == "tell" || cmdLower == "t") { + switchChatType = 4; + if (spacePos != std::string::npos) { + std::string rest = command.substr(spacePos + 1); + size_t msgStart = rest.find(' '); + if (msgStart != std::string::npos) { + // /w PlayerName message — send whisper immediately + target = rest.substr(0, msgStart); + message = rest.substr(msgStart + 1); + type = game::ChatType::WHISPER; + isChannelCommand = true; + // Set whisper target for future messages + strncpy(whisperTargetBuffer_, target.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + } else { + // /w PlayerName — switch to whisper mode with target set + strncpy(whisperTargetBuffer_, rest.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + message = ""; + isChannelCommand = true; + } + } else { + // Just "/w" — switch to whisper mode + message = ""; + isChannelCommand = true; + } + } else if (cmdLower == "r" || cmdLower == "reply") { + switchChatType = 4; + std::string lastSender = gameHandler.getLastWhisperSender(); + if (lastSender.empty()) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "No one has whispered you yet."; + gameHandler.addLocalChatMessage(sysMsg); + chatInputBuffer_[0] = '\0'; + return; + } + target = lastSender; + strncpy(whisperTargetBuffer_, target.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + if (spacePos != std::string::npos) { + message = command.substr(spacePos + 1); + type = game::ChatType::WHISPER; + } else { + message = ""; + } + isChannelCommand = true; + } + + // Check for emote commands + if (!isChannelCommand) { + std::string targetName; + const std::string* targetNamePtr = nullptr; + if (gameHandler.hasTarget()) { + auto targetEntity = gameHandler.getTarget(); + if (targetEntity) { + targetName = getEntityName(targetEntity); + if (!targetName.empty()) targetNamePtr = &targetName; + } + } + + std::string emoteText = rendering::AnimationController::getEmoteText(cmdLower, targetNamePtr); + if (!emoteText.empty()) { + // Play the emote animation + auto* renderer = services_.renderer; + if (renderer) { + if (auto* ac = renderer->getAnimationController()) ac->playEmote(cmdLower); + } + + // Send CMSG_TEXT_EMOTE to server + uint32_t dbcId = rendering::AnimationController::getEmoteDbcId(cmdLower); + if (dbcId != 0) { + uint64_t targetGuid = gameHandler.hasTarget() ? gameHandler.getTargetGuid() : 0; + gameHandler.sendTextEmote(dbcId, targetGuid); + } + + // Add local chat message + game::MessageChatData msg; + msg.type = game::ChatType::TEXT_EMOTE; + msg.language = game::ChatLanguage::COMMON; + msg.message = emoteText; + gameHandler.addLocalChatMessage(msg); + + chatInputBuffer_[0] = '\0'; + return; + } + + // Not a recognized command — fall through and send as normal chat + if (!isChannelCommand) { + message = input; + } + } + + // If no valid command found and starts with /, just send as-is + if (!isChannelCommand && message == input) { + // Use the selected chat type from dropdown + switch (selectedChatType_) { + case 0: type = game::ChatType::SAY; break; + case 1: type = game::ChatType::YELL; break; + case 2: type = game::ChatType::PARTY; break; + case 3: type = game::ChatType::GUILD; break; + case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; + case 5: type = game::ChatType::RAID; break; + case 6: type = game::ChatType::OFFICER; break; + case 7: type = game::ChatType::BATTLEGROUND; break; + case 8: type = game::ChatType::RAID_WARNING; break; + case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx_]; + } else { type = game::ChatType::SAY; } + break; + } + default: type = game::ChatType::SAY; break; + } + } + } else { + // No slash command, use the selected chat type from dropdown + switch (selectedChatType_) { + case 0: type = game::ChatType::SAY; break; + case 1: type = game::ChatType::YELL; break; + case 2: type = game::ChatType::PARTY; break; + case 3: type = game::ChatType::GUILD; break; + case 4: type = game::ChatType::WHISPER; target = whisperTargetBuffer_; break; + case 5: type = game::ChatType::RAID; break; + case 6: type = game::ChatType::OFFICER; break; + case 7: type = game::ChatType::BATTLEGROUND; break; + case 8: type = game::ChatType::RAID_WARNING; break; + case 9: type = game::ChatType::PARTY; break; // INSTANCE uses PARTY + case 10: { // CHANNEL + const auto& chans = gameHandler.getJoinedChannels(); + if (!chans.empty() && selectedChannelIdx_ < static_cast(chans.size())) { + type = game::ChatType::CHANNEL; + target = chans[selectedChannelIdx_]; + } else { type = game::ChatType::SAY; } + break; + } + default: type = game::ChatType::SAY; break; + } + } + + // Whisper shortcuts to PortBot/GMBot: translate to GM teleport commands. + if (type == game::ChatType::WHISPER && isPortBotTarget(target)) { + std::string cmd = buildPortBotCommand(message); + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + if (cmd.empty() || cmd == "__help__") { + msg.message = "PortBot: /w PortBot . Aliases: sw if darn org tb uc shatt dal. Also supports '.tele ...' or 'xyz x y z [map [o]]'."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + gameHandler.sendChatMessage(game::ChatType::SAY, cmd, ""); + msg.message = "PortBot executed: " + cmd; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // Validate whisper has a target + if (type == game::ChatType::WHISPER && target.empty()) { + game::MessageChatData msg; + msg.type = game::ChatType::SYSTEM; + msg.language = game::ChatLanguage::UNIVERSAL; + msg.message = "You must specify a player name for whisper."; + gameHandler.addLocalChatMessage(msg); + chatInputBuffer_[0] = '\0'; + return; + } + + // Don't send empty messages — but switch chat type if a channel shortcut was used + if (!message.empty()) { + gameHandler.sendChatMessage(type, message, target); + } + + // Switch chat type dropdown when channel shortcut used (with or without message) + if (switchChatType >= 0) { + selectedChatType_ = switchChatType; + } + + // Clear input + chatInputBuffer_[0] = '\0'; + } +} + + + +} // namespace ui +} // namespace wowee diff --git a/src/ui/chat_panel_utils.cpp b/src/ui/chat_panel_utils.cpp new file mode 100644 index 00000000..642a68c6 --- /dev/null +++ b/src/ui/chat_panel_utils.cpp @@ -0,0 +1,480 @@ +#include "ui/chat_panel.hpp" +#include "ui/ui_colors.hpp" +#include "rendering/vk_context.hpp" +#include "core/application.hpp" +#include "rendering/renderer.hpp" +#include "rendering/camera.hpp" +#include "rendering/camera_controller.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/ui_sound_manager.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" +#include "game/expansion_profile.hpp" +#include "game/character.hpp" +#include "core/logger.hpp" +#include +#include +#include "core/coordinates.hpp" +#include +#include +#include +#include +#include + +namespace { + using namespace wowee::ui::colors; + constexpr auto& kColorRed = kRed; + constexpr auto& kColorBrightGreen= kBrightGreen; + constexpr auto& kColorYellow = kYellow; +} // namespace + +namespace wowee { namespace ui { + +const char* ChatPanel::getChatTypeName(game::ChatType type) const { + switch (type) { + case game::ChatType::SAY: return "Say"; + case game::ChatType::YELL: return "Yell"; + case game::ChatType::EMOTE: return "Emote"; + case game::ChatType::TEXT_EMOTE: return "Emote"; + case game::ChatType::PARTY: return "Party"; + case game::ChatType::GUILD: return "Guild"; + case game::ChatType::OFFICER: return "Officer"; + case game::ChatType::RAID: return "Raid"; + case game::ChatType::RAID_LEADER: return "Raid Leader"; + case game::ChatType::RAID_WARNING: return "Raid Warning"; + case game::ChatType::BATTLEGROUND: return "Battleground"; + case game::ChatType::BATTLEGROUND_LEADER: return "Battleground Leader"; + case game::ChatType::WHISPER: return "Whisper"; + case game::ChatType::WHISPER_INFORM: return "To"; + case game::ChatType::SYSTEM: return "System"; + case game::ChatType::MONSTER_SAY: return "Say"; + case game::ChatType::MONSTER_YELL: return "Yell"; + case game::ChatType::MONSTER_EMOTE: return "Emote"; + case game::ChatType::CHANNEL: return "Channel"; + case game::ChatType::ACHIEVEMENT: return "Achievement"; + case game::ChatType::DND: return "DND"; + case game::ChatType::AFK: return "AFK"; + case game::ChatType::BG_SYSTEM_NEUTRAL: + case game::ChatType::BG_SYSTEM_ALLIANCE: + case game::ChatType::BG_SYSTEM_HORDE: return "System"; + default: return "Unknown"; + } +} + + +ImVec4 ChatPanel::getChatTypeColor(game::ChatType type) const { + switch (type) { + case game::ChatType::SAY: + return ui::colors::kWhite; // White + case game::ChatType::YELL: + return kColorRed; // Red + case game::ChatType::EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange + case game::ChatType::TEXT_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange + case game::ChatType::PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue + case game::ChatType::GUILD: + return kColorBrightGreen; // Green + case game::ChatType::OFFICER: + return ImVec4(0.3f, 0.8f, 0.3f, 1.0f); // Dark green + case game::ChatType::RAID: + return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange + case game::ChatType::RAID_LEADER: + return ImVec4(1.0f, 0.4f, 0.0f, 1.0f); // Darker orange + case game::ChatType::RAID_WARNING: + return ImVec4(1.0f, 0.0f, 0.0f, 1.0f); // Red + case game::ChatType::BATTLEGROUND: + return ImVec4(1.0f, 0.6f, 0.0f, 1.0f); // Orange-gold + case game::ChatType::BATTLEGROUND_LEADER: + return ImVec4(1.0f, 0.5f, 0.0f, 1.0f); // Orange + case game::ChatType::WHISPER: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink + case game::ChatType::WHISPER_INFORM: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink + case game::ChatType::SYSTEM: + return kColorYellow; // Yellow + case game::ChatType::MONSTER_SAY: + return ui::colors::kWhite; // White (same as SAY) + case game::ChatType::MONSTER_YELL: + return kColorRed; // Red (same as YELL) + case game::ChatType::MONSTER_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) + case game::ChatType::CHANNEL: + return ImVec4(1.0f, 0.7f, 0.7f, 1.0f); // Light pink + case game::ChatType::ACHIEVEMENT: + return ImVec4(1.0f, 1.0f, 0.0f, 1.0f); // Bright yellow + case game::ChatType::GUILD_ACHIEVEMENT: + return colors::kWarmGold; // Gold + case game::ChatType::SKILL: + return colors::kCyan; // Cyan + case game::ChatType::LOOT: + return ImVec4(0.8f, 0.5f, 1.0f, 1.0f); // Light purple + case game::ChatType::MONSTER_WHISPER: + case game::ChatType::RAID_BOSS_WHISPER: + return ImVec4(1.0f, 0.5f, 1.0f, 1.0f); // Pink (same as WHISPER) + case game::ChatType::RAID_BOSS_EMOTE: + return ImVec4(1.0f, 0.7f, 0.3f, 1.0f); // Orange (same as EMOTE) + case game::ChatType::MONSTER_PARTY: + return ImVec4(0.5f, 0.5f, 1.0f, 1.0f); // Light blue (same as PARTY) + case game::ChatType::BG_SYSTEM_NEUTRAL: + return colors::kWarmGold; // Gold + case game::ChatType::BG_SYSTEM_ALLIANCE: + return ImVec4(0.3f, 0.6f, 1.0f, 1.0f); // Blue + case game::ChatType::BG_SYSTEM_HORDE: + return kColorRed; // Red + case game::ChatType::AFK: + case game::ChatType::DND: + return ImVec4(0.85f, 0.85f, 0.85f, 0.8f); // Light gray + default: + return ui::colors::kLightGray; // Gray + } +} + + +std::string ChatPanel::replaceGenderPlaceholders(const std::string& text, game::GameHandler& gameHandler) { + // Get player gender, pronouns, and name + game::Gender gender = game::Gender::NONBINARY; + std::string playerName = "Adventurer"; + const auto* character = gameHandler.getActiveCharacter(); + if (character) { + gender = character->gender; + if (!character->name.empty()) { + playerName = character->name; + } + } + game::Pronouns pronouns = game::Pronouns::forGender(gender); + + std::string result = text; + + // Helper to trim whitespace + auto trim = [](std::string& s) { + const char* ws = " \t\n\r"; + size_t start = s.find_first_not_of(ws); + if (start == std::string::npos) { s.clear(); return; } + size_t end = s.find_last_not_of(ws); + s = s.substr(start, end - start + 1); + }; + + // Replace $g/$G placeholders first. + size_t pos = 0; + while ((pos = result.find('$', pos)) != std::string::npos) { + if (pos + 1 >= result.length()) break; + char marker = result[pos + 1]; + if (marker != 'g' && marker != 'G') { pos++; continue; } + + size_t endPos = result.find(';', pos); + if (endPos == std::string::npos) { pos += 2; continue; } + + std::string placeholder = result.substr(pos + 2, endPos - pos - 2); + + // Split by colons + std::vector parts; + size_t start = 0; + size_t colonPos; + while ((colonPos = placeholder.find(':', start)) != std::string::npos) { + std::string part = placeholder.substr(start, colonPos - start); + trim(part); + parts.push_back(part); + start = colonPos + 1; + } + // Add the last part + std::string lastPart = placeholder.substr(start); + trim(lastPart); + parts.push_back(lastPart); + + // Select appropriate text based on gender + std::string replacement; + if (parts.size() >= 3) { + // Three options: male, female, nonbinary + switch (gender) { + case game::Gender::MALE: + replacement = parts[0]; + break; + case game::Gender::FEMALE: + replacement = parts[1]; + break; + case game::Gender::NONBINARY: + replacement = parts[2]; + break; + } + } else if (parts.size() >= 2) { + // Two options: male, female (use first for nonbinary) + switch (gender) { + case game::Gender::MALE: + replacement = parts[0]; + break; + case game::Gender::FEMALE: + replacement = parts[1]; + break; + case game::Gender::NONBINARY: + // Default to gender-neutral: use the shorter/simpler option + replacement = parts[0].length() <= parts[1].length() ? parts[0] : parts[1]; + break; + } + } else { + // Malformed placeholder + pos = endPos + 1; + continue; + } + + result.replace(pos, endPos - pos + 1, replacement); + pos += replacement.length(); + } + + // Resolve class and race names for $C and $R placeholders + std::string className = "Adventurer"; + std::string raceName = "Unknown"; + if (character) { + className = game::getClassName(character->characterClass); + raceName = game::getRaceName(character->race); + } + + // Replace simple placeholders. + // $n/$N = player name, $c/$C = class name, $r/$R = race name + // $p = subject pronoun (he/she/they) + // $o = object pronoun (him/her/them) + // $s = possessive adjective (his/her/their) + // $S = possessive pronoun (his/hers/theirs) + // $b/$B = line break + pos = 0; + while ((pos = result.find('$', pos)) != std::string::npos) { + if (pos + 1 >= result.length()) break; + + char code = result[pos + 1]; + std::string replacement; + switch (code) { + case 'n': case 'N': replacement = playerName; break; + case 'c': case 'C': replacement = className; break; + case 'r': case 'R': replacement = raceName; break; + case 'p': replacement = pronouns.subject; break; + case 'o': replacement = pronouns.object; break; + case 's': replacement = pronouns.possessive; break; + case 'S': replacement = pronouns.possessiveP; break; + case 'b': case 'B': replacement = "\n"; break; + case 'g': case 'G': pos++; continue; + default: pos++; continue; + } + + result.replace(pos, 2, replacement); + pos += replacement.length(); + } + + // WoW markup linebreak token. + pos = 0; + while ((pos = result.find("|n", pos)) != std::string::npos) { + result.replace(pos, 2, "\n"); + pos += 1; + } + pos = 0; + while ((pos = result.find("|N", pos)) != std::string::npos) { + result.replace(pos, 2, "\n"); + pos += 1; + } + + return result; +} + +void ChatPanel::renderBubbles(game::GameHandler& gameHandler) { + if (chatBubbles_.empty()) return; + + auto* renderer = services_.renderer; + auto* camera = renderer ? renderer->getCamera() : nullptr; + if (!camera) return; + + auto* window = services_.window; + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Get delta time from ImGui + float dt = ImGui::GetIO().DeltaTime; + + glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); + + // Update and render bubbles + for (int i = static_cast(chatBubbles_.size()) - 1; i >= 0; --i) { + auto& bubble = chatBubbles_[i]; + bubble.timeRemaining -= dt; + if (bubble.timeRemaining <= 0.0f) { + chatBubbles_.erase(chatBubbles_.begin() + i); + continue; + } + + // Get entity position + auto entity = gameHandler.getEntityManager().getEntity(bubble.senderGuid); + if (!entity) continue; + + // Convert canonical → render coordinates, offset up by 2.5 units for bubble above head + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ() + 2.5f); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w <= 0.0f) continue; // Behind camera + + glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); + float screenX = (ndc.x * 0.5f + 0.5f) * screenW; + // Camera bakes the Vulkan Y-flip into the projection matrix: + // NDC y=-1 is top, y=1 is bottom — same convention as nameplate/minimap projection. + float screenY = (ndc.y * 0.5f + 0.5f) * screenH; + + // Skip if off-screen + if (screenX < -200.0f || screenX > screenW + 200.0f || + screenY < -100.0f || screenY > screenH + 100.0f) continue; + + // Fade alpha over last 2 seconds + float alpha = 1.0f; + if (bubble.timeRemaining < 2.0f) { + alpha = bubble.timeRemaining / 2.0f; + } + + // Draw bubble window + std::string winId = "##ChatBubble" + std::to_string(bubble.senderGuid); + ImGui::SetNextWindowPos(ImVec2(screenX, screenY), ImGuiCond_Always, ImVec2(0.5f, 1.0f)); + ImGui::SetNextWindowBgAlpha(0.7f * alpha); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 8.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 4)); + + ImGui::Begin(winId.c_str(), nullptr, flags); + + ImVec4 textColor = bubble.isYell + ? ImVec4(1.0f, 0.2f, 0.2f, alpha) + : ImVec4(1.0f, 1.0f, 1.0f, alpha); + + ImGui::PushStyleColor(ImGuiCol_Text, textColor); + ImGui::PushTextWrapPos(200.0f); + ImGui::TextWrapped("%s", bubble.message.c_str()); + ImGui::PopTextWrapPos(); + ImGui::PopStyleColor(); + + ImGui::End(); + ImGui::PopStyleVar(2); + } +} + + +// ---- Public interface methods ---- + +void ChatPanel::setupCallbacks(game::GameHandler& gameHandler) { + if (!chatBubbleCallbackSet_) { + gameHandler.setChatBubbleCallback([this](uint64_t guid, const std::string& msg, bool isYell) { + float duration = 8.0f + static_cast(msg.size()) * 0.06f; + if (isYell) duration += 2.0f; + if (duration > 15.0f) duration = 15.0f; + + // Replace existing bubble for same sender + for (auto& b : chatBubbles_) { + if (b.senderGuid == guid) { + b.message = msg; + b.timeRemaining = duration; + b.totalDuration = duration; + b.isYell = isYell; + return; + } + } + // Evict oldest if too many + if (chatBubbles_.size() >= 10) { + chatBubbles_.erase(chatBubbles_.begin()); + } + chatBubbles_.push_back({guid, msg, duration, duration, isYell}); + }); + chatBubbleCallbackSet_ = true; + } +} + +void ChatPanel::insertChatLink(const std::string& link) { + if (link.empty()) return; + size_t curLen = strlen(chatInputBuffer_); + if (curLen + link.size() + 1 < sizeof(chatInputBuffer_)) { + strncat(chatInputBuffer_, link.c_str(), sizeof(chatInputBuffer_) - curLen - 1); + chatInputMoveCursorToEnd_ = true; + refocusChatInput_ = true; + } +} + +void ChatPanel::activateSlashInput() { + refocusChatInput_ = true; + chatInputBuffer_[0] = '/'; + chatInputBuffer_[1] = '\0'; + chatInputMoveCursorToEnd_ = true; +} + +void ChatPanel::activateInput() { + refocusChatInput_ = true; +} + +void ChatPanel::setWhisperTarget(const std::string& name) { + selectedChatType_ = 4; // WHISPER + strncpy(whisperTargetBuffer_, name.c_str(), sizeof(whisperTargetBuffer_) - 1); + whisperTargetBuffer_[sizeof(whisperTargetBuffer_) - 1] = '\0'; + refocusChatInput_ = true; +} + +ChatPanel::SlashCommands ChatPanel::consumeSlashCommands() { + SlashCommands result = slashCmds_; + slashCmds_ = {}; + return result; +} + +void ChatPanel::renderSettingsTab(std::function saveSettingsFn) { + ImGui::Spacing(); + + ImGui::Text("Appearance"); + ImGui::Separator(); + + if (ImGui::Checkbox("Show Timestamps", &chatShowTimestamps)) { + saveSettingsFn(); + } + ImGui::SetItemTooltip("Show [HH:MM] before each chat message"); + + const char* fontSizes[] = { "Small", "Medium", "Large" }; + if (ImGui::Combo("Chat Font Size", &chatFontSize, fontSizes, 3)) { + saveSettingsFn(); + } + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::Text("Auto-Join Channels"); + ImGui::Separator(); + + if (ImGui::Checkbox("General", &chatAutoJoinGeneral)) saveSettingsFn(); + if (ImGui::Checkbox("Trade", &chatAutoJoinTrade)) saveSettingsFn(); + if (ImGui::Checkbox("LocalDefense", &chatAutoJoinLocalDefense)) saveSettingsFn(); + if (ImGui::Checkbox("LookingForGroup", &chatAutoJoinLFG)) saveSettingsFn(); + if (ImGui::Checkbox("Local", &chatAutoJoinLocal)) saveSettingsFn(); + + ImGui::Spacing(); + ImGui::Spacing(); + ImGui::Text("Joined Channels"); + ImGui::Separator(); + + ImGui::TextDisabled("Use /join and /leave commands in chat to manage channels."); + + ImGui::Spacing(); + ImGui::Separator(); + ImGui::Spacing(); + + if (ImGui::Button("Restore Chat Defaults", ImVec2(-1, 0))) { + restoreDefaults(); + saveSettingsFn(); + } +} + +void ChatPanel::restoreDefaults() { + chatShowTimestamps = false; + chatFontSize = 1; + chatAutoJoinGeneral = true; + chatAutoJoinTrade = true; + chatAutoJoinLocalDefense = true; + chatAutoJoinLFG = true; + chatAutoJoinLocal = true; +} + +} // namespace ui +} // namespace wowee diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 68954608..ed02bfff 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -8,6 +8,7 @@ #include "core/coordinates.hpp" #include "core/input.hpp" #include "rendering/renderer.hpp" +#include "rendering/post_process_pipeline.hpp" #include "rendering/animation_controller.hpp" #include "rendering/wmo_renderer.hpp" #include "rendering/terrain_manager.hpp" @@ -226,7 +227,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (!settingsPanel_.fxaaSettingsApplied_) { auto* renderer = services_.renderer; if (renderer) { - renderer->setFXAAEnabled(settingsPanel_.pendingFXAA); + renderer->getPostProcessPipeline()->setFXAAEnabled(settingsPanel_.pendingFXAA); settingsPanel_.fxaaSettingsApplied_ = true; } } @@ -266,10 +267,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { if (renderer) { static constexpr float fsrScales[] = { 0.77f, 0.67f, 0.59f, 1.00f }; settingsPanel_.pendingFSRQuality = std::clamp(settingsPanel_.pendingFSRQuality, 0, 3); - renderer->setFSRQuality(fsrScales[settingsPanel_.pendingFSRQuality]); - renderer->setFSRSharpness(settingsPanel_.pendingFSRSharpness); - renderer->setFSR2DebugTuning(settingsPanel_.pendingFSR2JitterSign, settingsPanel_.pendingFSR2MotionVecScaleX, settingsPanel_.pendingFSR2MotionVecScaleY); - renderer->setAmdFsr3FramegenEnabled(settingsPanel_.pendingAMDFramegen); + renderer->getPostProcessPipeline()->setFSRQuality(fsrScales[settingsPanel_.pendingFSRQuality]); + renderer->getPostProcessPipeline()->setFSRSharpness(settingsPanel_.pendingFSRSharpness); + renderer->getPostProcessPipeline()->setFSR2DebugTuning(settingsPanel_.pendingFSR2JitterSign, settingsPanel_.pendingFSR2MotionVecScaleX, settingsPanel_.pendingFSR2MotionVecScaleY); + renderer->getPostProcessPipeline()->setAmdFsr3FramegenEnabled(settingsPanel_.pendingAMDFramegen); int effectiveMode = settingsPanel_.pendingUpscalingMode; // Defer FSR2/FSR3 activation until fully in-world to avoid @@ -506,7 +507,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { const auto& mh = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND); const auto& oh = gameHandler.getInventory().getEquipSlot(game::EquipSlot::OFF_HAND); if (mh.empty()) { - r->setEquippedWeaponType(0, false); + if (auto* ac = r->getAnimationController()) ac->setEquippedWeaponType(0, false); } else { // Polearms and staves use ATTACK_2H_LOOSE instead of ATTACK_2H bool is2HLoose = (mh.item.subclassName == "Polearm" || mh.item.subclassName == "Staff"); @@ -516,24 +517,25 @@ void GameScreen::render(game::GameHandler& gameHandler) { (oh.item.inventoryType == game::InvType::ONE_HAND || oh.item.subclassName == "Fist Weapon"); bool hasShield = !oh.empty() && oh.item.inventoryType == game::InvType::SHIELD; - r->setEquippedWeaponType(mh.item.inventoryType, is2HLoose, isFist, isDagger, hasOffHand, hasShield); + if (auto* ac = r->getAnimationController()) ac->setEquippedWeaponType(mh.item.inventoryType, is2HLoose, isFist, isDagger, hasOffHand, hasShield); } // Detect ranged weapon type from RANGED slot const auto& rangedSlot = gameHandler.getInventory().getEquipSlot(game::EquipSlot::RANGED); if (rangedSlot.empty()) { - r->setEquippedRangedType(rendering::RangedWeaponType::NONE); + if (auto* ac = r->getAnimationController()) ac->setEquippedRangedType(rendering::RangedWeaponType::NONE); } else if (rangedSlot.item.inventoryType == game::InvType::RANGED_BOW) { // subclassName distinguishes Bow vs Crossbow - if (rangedSlot.item.subclassName == "Crossbow") - r->setEquippedRangedType(rendering::RangedWeaponType::CROSSBOW); - else - r->setEquippedRangedType(rendering::RangedWeaponType::BOW); + if (rangedSlot.item.subclassName == "Crossbow") { + if (auto* ac = r->getAnimationController()) ac->setEquippedRangedType(rendering::RangedWeaponType::CROSSBOW); + } else { + if (auto* ac = r->getAnimationController()) ac->setEquippedRangedType(rendering::RangedWeaponType::BOW); + } } else if (rangedSlot.item.inventoryType == game::InvType::RANGED_GUN) { - r->setEquippedRangedType(rendering::RangedWeaponType::GUN); + if (auto* ac = r->getAnimationController()) ac->setEquippedRangedType(rendering::RangedWeaponType::GUN); } else if (rangedSlot.item.inventoryType == game::InvType::THROWN) { - r->setEquippedRangedType(rendering::RangedWeaponType::THROWN); + if (auto* ac = r->getAnimationController()) ac->setEquippedRangedType(rendering::RangedWeaponType::THROWN); } else { - r->setEquippedRangedType(rendering::RangedWeaponType::NONE); + if (auto* ac = r->getAnimationController()) ac->setEquippedRangedType(rendering::RangedWeaponType::NONE); } } } @@ -541,7 +543,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // Update renderer face-target position and selection circle auto* renderer = services_.renderer; if (renderer) { - renderer->setInCombat(gameHandler.isInCombat() && + if (auto* ac = renderer->getAnimationController()) ac->setInCombat(gameHandler.isInCombat() && !gameHandler.isPlayerDead() && !gameHandler.isPlayerGhost()); if (auto* cr = renderer->getCharacterRenderer()) { @@ -582,7 +584,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { targetGLPos.z = footZ; } } - renderer->setTargetPosition(&targetGLPos); + if (auto* ac = renderer->getAnimationController()) ac->setTargetPosition(&targetGLPos); // Selection circle color: WoW-canonical level-based colors bool showSelectionCircle = false; @@ -629,11 +631,11 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderer->clearSelectionCircle(); } } else { - renderer->setTargetPosition(nullptr); + if (auto* ac = renderer->getAnimationController()) ac->setTargetPosition(nullptr); renderer->clearSelectionCircle(); } } else { - renderer->setTargetPosition(nullptr); + if (auto* ac = renderer->getAnimationController()) ac->setTargetPosition(nullptr); renderer->clearSelectionCircle(); } } @@ -1439,5756 +1441,5 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { } } -void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { - bool isDead = gameHandler.isPlayerDead(); - ImGui::SetNextWindowPos(ImVec2(10.0f, 30.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(250.0f, 0.0f), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); - const bool inCombatConfirmed = gameHandler.isInCombat(); - const bool attackIntentOnly = gameHandler.hasAutoAttackIntent() && !inCombatConfirmed; - ImVec4 playerBorder = isDead - ? kColorDarkGray - : (inCombatConfirmed - ? colors::kBrightRed - : (attackIntentOnly - ? ImVec4(1.0f, 0.7f, 0.2f, 1.0f) - : ImVec4(0.4f, 0.4f, 0.4f, 1.0f))); - ImGui::PushStyleColor(ImGuiCol_Border, playerBorder); - - if (ImGui::Begin("##PlayerFrame", nullptr, flags)) { - // Use selected character info if available, otherwise defaults - std::string playerName = "Adventurer"; - uint32_t playerLevel = 1; - uint32_t playerHp = 100; - uint32_t playerMaxHp = 100; - - const auto& characters = gameHandler.getCharacters(); - uint64_t activeGuid = gameHandler.getActiveCharacterGuid(); - const game::Character* activeChar = nullptr; - for (const auto& c : characters) { - if (c.guid == activeGuid) { activeChar = &c; break; } - } - if (!activeChar && !characters.empty()) activeChar = &characters[0]; - if (activeChar) { - const auto& ch = *activeChar; - playerName = ch.name; - // Use live server level if available, otherwise character struct - playerLevel = gameHandler.getPlayerLevel(); - if (playerLevel == 0) playerLevel = ch.level; - playerMaxHp = 20 + playerLevel * 10; - playerHp = playerMaxHp; - } - - // Derive class color via shared helper - ImVec4 classColor = activeChar - ? classColorVec4(static_cast(activeChar->characterClass)) - : kColorBrightGreen; - - // Name in class color — clickable for self-target, right-click for menu - ImGui::PushStyleColor(ImGuiCol_Text, classColor); - if (ImGui::Selectable(playerName.c_str(), false, 0, ImVec2(0, 0))) { - gameHandler.setTarget(gameHandler.getPlayerGuid()); - } - if (ImGui::BeginPopupContextItem("PlayerSelfCtx")) { - ImGui::TextDisabled("%s", playerName.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Open Character")) { - inventoryScreen.setCharacterOpen(true); - } - if (ImGui::MenuItem("Toggle PvP")) { - gameHandler.togglePvp(); - } - ImGui::Separator(); - bool afk = gameHandler.isAfk(); - bool dnd = gameHandler.isDnd(); - if (ImGui::MenuItem(afk ? "Cancel AFK" : "Set AFK")) { - gameHandler.toggleAfk(); - } - if (ImGui::MenuItem(dnd ? "Cancel DND" : "Set DND")) { - gameHandler.toggleDnd(); - } - if (gameHandler.isInGroup()) { - ImGui::Separator(); - if (ImGui::MenuItem("Leave Group")) { - gameHandler.leaveGroup(); - } - } - ImGui::EndPopup(); - } - ImGui::PopStyleColor(); - ImGui::SameLine(); - ImGui::TextDisabled("Lv %u", playerLevel); - if (isDead) { - ImGui::SameLine(); - ImGui::TextColored(colors::kDarkRed, "DEAD"); - } - // Group leader crown on self frame when you lead the party/raid - if (gameHandler.isInGroup() && - gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()) { - ImGui::SameLine(0, 4); - ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are the group leader"); - } - if (gameHandler.isAfk()) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), ""); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Away from keyboard — /afk to cancel"); - } else if (gameHandler.isDnd()) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); - } - if (auto* ren = services_.renderer) { - if (auto* cam = ren->getCameraController()) { - if (cam->isAutoRunning()) { - ImGui::SameLine(); - ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "[Auto-Run]"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Auto-running — press ` or NumLock to stop"); - } - } - } - if (inCombatConfirmed && !isDead) { - float combatPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 0.2f * combatPulse, 0.2f * combatPulse, 1.0f), "[Combat]"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat"); - } - - // Active title — shown in gold below the name/level line - { - int32_t titleBit = gameHandler.getChosenTitleBit(); - if (titleBit >= 0) { - const std::string titleText = gameHandler.getFormattedTitle( - static_cast(titleBit)); - if (!titleText.empty()) { - ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 0.9f), "%s", titleText.c_str()); - } - } - } - - // Try to get real HP/mana from the player entity - auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); - if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { - auto unit = std::static_pointer_cast(playerEntity); - if (unit->getMaxHealth() > 0) { - playerHp = unit->getHealth(); - playerMaxHp = unit->getMaxHealth(); - } - } - - // Health bar — color transitions green→yellow→red as HP drops - float pct = static_cast(playerHp) / static_cast(playerMaxHp); - ImVec4 hpColor; - if (isDead) { - hpColor = kColorDarkGray; - } else if (pct > 0.5f) { - hpColor = colors::kHealthGreen; // green - } else if (pct > 0.2f) { - float t = (pct - 0.2f) / 0.3f; // 0 at 20%, 1 at 50% - hpColor = ImVec4(0.9f - 0.7f * t, 0.4f + 0.4f * t, 0.0f, 1.0f); // orange→yellow - } else { - // Critical — pulse red when < 20% - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.5f); - hpColor = ImVec4(0.9f * pulse, 0.05f, 0.05f, 1.0f); // pulsing red - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor); - char overlay[64]; - snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp); - ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay); - ImGui::PopStyleColor(); - - // Mana/Power bar - if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { - auto unit = std::static_pointer_cast(playerEntity); - uint8_t powerType = unit->getPowerType(); - uint32_t power = unit->getPower(); - uint32_t maxPower = unit->getMaxPower(); - // Rage (1), Focus (2), Energy (3), and Runic Power (6) always cap at 100. - // Show bar even if server hasn't sent UNIT_FIELD_MAXPOWER1 yet. - if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3 || powerType == 6)) maxPower = 100; - if (maxPower > 0) { - float mpPct = static_cast(power) / static_cast(maxPower); - ImVec4 powerColor; - switch (powerType) { - case 0: { - // Mana: pulse desaturated blue when critically low (< 20%) - if (mpPct < 0.2f) { - float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); - powerColor = ImVec4(0.1f, 0.1f, 0.8f * pulse, 1.0f); - } else { - powerColor = colors::kManaBlue; - } - break; - } - case 1: powerColor = colors::kDarkRed; break; // Rage (red) - case 2: powerColor = colors::kOrange; break; // Focus (orange) - case 3: powerColor = colors::kEnergyYellow; break; // Energy (yellow) - case 4: powerColor = colors::kHappinessGreen; break; // Happiness (green) - case 6: powerColor = colors::kRunicRed; break; // Runic Power (crimson) - case 7: powerColor = colors::kSoulShardPurple; break; // Soul Shards (purple) - default: powerColor = colors::kManaBlue; break; - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); - char mpOverlay[64]; - snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", power, maxPower); - ImGui::ProgressBar(mpPct, ImVec2(-1, 18), mpOverlay); - ImGui::PopStyleColor(); - } - } - - // Death Knight rune bar (class 6) — 6 colored squares with fill fraction - if (gameHandler.getPlayerClass() == 6) { - const auto& runes = gameHandler.getPlayerRunes(); - float dt = ImGui::GetIO().DeltaTime; - - ImGui::Spacing(); - ImVec2 cursor = ImGui::GetCursorScreenPos(); - float totalW = ImGui::GetContentRegionAvail().x; - float spacing = 3.0f; - float squareW = (totalW - spacing * 5.0f) / 6.0f; - float squareH = 14.0f; - ImDrawList* dl = ImGui::GetWindowDrawList(); - - for (int i = 0; i < 6; i++) { - // Client-side prediction: advance fill over ~10s cooldown - runeClientFill_[i] = runes[i].ready ? 1.0f - : std::min(runeClientFill_[i] + dt / 10.0f, runes[i].readyFraction + 0.02f); - runeClientFill_[i] = std::clamp(runeClientFill_[i], 0.0f, runes[i].ready ? 1.0f : 0.97f); - - float x0 = cursor.x + i * (squareW + spacing); - float y0 = cursor.y; - float x1 = x0 + squareW; - float y1 = y0 + squareH; - - // Background (dark) - dl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), - IM_COL32(30, 30, 30, 200), 2.0f); - - // Fill color by rune type - ImVec4 fc; - switch (runes[i].type) { - case game::GameHandler::RuneType::Blood: fc = ImVec4(0.85f, 0.12f, 0.12f, 1.0f); break; - case game::GameHandler::RuneType::Unholy: fc = ImVec4(0.20f, 0.72f, 0.20f, 1.0f); break; - case game::GameHandler::RuneType::Frost: fc = ImVec4(0.30f, 0.55f, 0.90f, 1.0f); break; - case game::GameHandler::RuneType::Death: fc = ImVec4(0.55f, 0.20f, 0.70f, 1.0f); break; - default: fc = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); break; - } - float fillX = x0 + (x1 - x0) * runeClientFill_[i]; - dl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), - ImGui::ColorConvertFloat4ToU32(fc), 2.0f); - - // Border - ImU32 borderCol = runes[i].ready - ? IM_COL32(220, 220, 220, 180) - : IM_COL32(100, 100, 100, 160); - dl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); - } - ImGui::Dummy(ImVec2(totalW, squareH)); - } - - // Combo point display — Rogue (4) and Druid (11) in Cat Form - { - uint8_t cls = gameHandler.getPlayerClass(); - const bool isRogue = (cls == 4); - const bool isDruid = (cls == 11); - if (isRogue || isDruid) { - uint8_t cp = gameHandler.getComboPoints(); - if (cp > 0 || isRogue) { // always show for rogue; only when non-zero for druid - ImGui::Spacing(); - ImVec2 cursor = ImGui::GetCursorScreenPos(); - float totalW = ImGui::GetContentRegionAvail().x; - constexpr int MAX_CP = 5; - constexpr float DOT_R = 7.0f; - constexpr float SPACING = 4.0f; - float totalDotsW = MAX_CP * (DOT_R * 2.0f) + (MAX_CP - 1) * SPACING; - float startX = cursor.x + (totalW - totalDotsW) * 0.5f; - float cy = cursor.y + DOT_R; - ImDrawList* dl = ImGui::GetWindowDrawList(); - for (int i = 0; i < MAX_CP; ++i) { - float cx = startX + i * (DOT_R * 2.0f + SPACING) + DOT_R; - ImU32 col = (i < static_cast(cp)) - ? IM_COL32(255, 210, 0, 240) // bright gold — active - : IM_COL32(60, 60, 60, 160); // dark — empty - dl->AddCircleFilled(ImVec2(cx, cy), DOT_R, col); - dl->AddCircle(ImVec2(cx, cy), DOT_R, IM_COL32(160, 140, 0, 180), 0, 1.5f); - } - ImGui::Dummy(ImVec2(totalW, DOT_R * 2.0f)); - } - } - } - - // Shaman totem bar (class 7) — 4 slots: Earth, Fire, Water, Air - if (gameHandler.getPlayerClass() == 7) { - static constexpr ImVec4 kTotemColors[] = { - ImVec4(0.80f, 0.55f, 0.25f, 1.0f), // Earth — brown - ImVec4(1.00f, 0.35f, 0.10f, 1.0f), // Fire — orange-red - ImVec4(0.20f, 0.55f, 0.90f, 1.0f), // Water — blue - ImVec4(0.70f, 0.90f, 1.00f, 1.0f), // Air — pale sky - }; - static constexpr const char* kTotemNames[] = { "Earth", "Fire", "Water", "Air" }; - - ImGui::Spacing(); - ImVec2 cursor = ImGui::GetCursorScreenPos(); - float totalW = ImGui::GetContentRegionAvail().x; - float spacing = 3.0f; - float slotW = (totalW - spacing * 3.0f) / 4.0f; - float slotH = 14.0f; - ImDrawList* tdl = ImGui::GetWindowDrawList(); - - for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; i++) { - const auto& ts = gameHandler.getTotemSlot(i); - float x0 = cursor.x + i * (slotW + spacing); - float y0 = cursor.y; - float x1 = x0 + slotW; - float y1 = y0 + slotH; - - // Background - tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), IM_COL32(20, 20, 20, 200), 2.0f); - - if (ts.active()) { - float rem = ts.remainingMs(); - float frac = rem / static_cast(ts.durationMs); - float fillX = x0 + (x1 - x0) * frac; - tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), - ImGui::ColorConvertFloat4ToU32(kTotemColors[i]), 2.0f); - // Remaining seconds label - char secBuf[16]; - snprintf(secBuf, sizeof(secBuf), "%.0f", rem / 1000.0f); - ImVec2 tsz = ImGui::CalcTextSize(secBuf); - float lx = x0 + (slotW - tsz.x) * 0.5f; - float ly = y0 + (slotH - tsz.y) * 0.5f; - tdl->AddText(ImVec2(lx + 1, ly + 1), IM_COL32(0, 0, 0, 180), secBuf); - tdl->AddText(ImVec2(lx, ly), IM_COL32(255, 255, 255, 230), secBuf); - } else { - // Inactive — show element letter - const char* letter = kTotemNames[i]; - char single[2] = { letter[0], '\0' }; - ImVec2 tsz = ImGui::CalcTextSize(single); - float lx = x0 + (slotW - tsz.x) * 0.5f; - float ly = y0 + (slotH - tsz.y) * 0.5f; - tdl->AddText(ImVec2(lx, ly), IM_COL32(80, 80, 80, 200), single); - } - - // Border - ImU32 borderCol = ts.active() - ? ImGui::ColorConvertFloat4ToU32(kTotemColors[i]) - : IM_COL32(60, 60, 60, 160); - tdl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); - - // Tooltip on hover - ImGui::SetCursorScreenPos(ImVec2(x0, y0)); - char totemBtnId[16]; snprintf(totemBtnId, sizeof(totemBtnId), "##totem%d", i); - ImGui::InvisibleButton(totemBtnId, ImVec2(slotW, slotH)); - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - if (ts.active()) { - const std::string& spellNm = gameHandler.getSpellName(ts.spellId); - ImGui::TextColored(ImVec4(kTotemColors[i].x, kTotemColors[i].y, - kTotemColors[i].z, 1.0f), - "%s Totem", kTotemNames[i]); - if (!spellNm.empty()) ImGui::Text("%s", spellNm.c_str()); - ImGui::Text("%.1fs remaining", ts.remainingMs() / 1000.0f); - } else { - ImGui::TextDisabled("%s Totem (empty)", kTotemNames[i]); - } - ImGui::EndTooltip(); - } - } - ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + slotH + 2.0f)); - } - } - - // Melee swing timer — shown when player is auto-attacking - if (gameHandler.isAutoAttacking()) { - const uint64_t lastSwingMs = gameHandler.getLastMeleeSwingMs(); - if (lastSwingMs > 0) { - // Determine weapon speed from the equipped main-hand weapon - uint32_t weaponDelayMs = 2000; // Default: 2.0s unarmed - const auto& mainSlot = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND); - if (!mainSlot.empty() && mainSlot.item.itemId != 0) { - const auto* info = gameHandler.getItemInfo(mainSlot.item.itemId); - if (info && info->delayMs > 0) { - weaponDelayMs = info->delayMs; - } - } - - // Compute elapsed since last swing - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()).count()); - uint64_t elapsedMs = (nowMs >= lastSwingMs) ? (nowMs - lastSwingMs) : 0; - - // Clamp to weapon delay (cap at 1.0 so the bar fills but doesn't exceed) - float pct = std::min(static_cast(elapsedMs) / static_cast(weaponDelayMs), 1.0f); - - // Light silver-orange color indicating auto-attack readiness - ImVec4 swingColor = (pct >= 0.95f) - ? ImVec4(1.0f, 0.75f, 0.15f, 1.0f) // gold when ready to swing - : ImVec4(0.65f, 0.55f, 0.40f, 1.0f); // muted brown-orange while filling - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, swingColor); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.12f, 0.08f, 0.8f)); - char swingLabel[24]; - float remainSec = std::max(0.0f, (weaponDelayMs - static_cast(elapsedMs)) / 1000.0f); - if (pct >= 0.98f) - snprintf(swingLabel, sizeof(swingLabel), "Swing!"); - else - snprintf(swingLabel, sizeof(swingLabel), "%.1fs", remainSec); - ImGui::ProgressBar(pct, ImVec2(-1.0f, 8.0f), swingLabel); - ImGui::PopStyleColor(2); - } - } - - ImGui::End(); - - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); -} - -void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { - uint64_t petGuid = gameHandler.getPetGuid(); - if (petGuid == 0) return; - - auto petEntity = gameHandler.getEntityManager().getEntity(petGuid); - if (!petEntity) return; - auto* petUnit = petEntity->isUnit() ? static_cast(petEntity.get()) : nullptr; - if (!petUnit) return; - - // Position below player frame. If in a group, push below party frames - // (party frame at y=120, each member ~50px, up to 4 members → max ~320px + y=120 = ~440). - // When not grouped, the player frame ends at ~110px so y=125 is fine. - const int partyMemberCount = gameHandler.isInGroup() - ? static_cast(gameHandler.getPartyData().members.size()) : 0; - float petY = (partyMemberCount > 0) - ? 120.0f + partyMemberCount * 52.0f + 8.0f - : 125.0f; - ImGui::SetNextWindowPos(ImVec2(10.0f, petY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.1f, 0.08f, 0.85f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.2f, 0.6f, 0.2f, 1.0f)); - - if (ImGui::Begin("##PetFrame", nullptr, flags)) { - const std::string& petName = petUnit->getName(); - uint32_t petLevel = petUnit->getLevel(); - - // Name + level on one row — clicking the pet name targets it - ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.9f, 0.4f, 1.0f)); - char petLabel[96]; - snprintf(petLabel, sizeof(petLabel), "%s", - petName.empty() ? "Pet" : petName.c_str()); - if (ImGui::Selectable(petLabel, false, 0, ImVec2(0, 0))) { - gameHandler.setTarget(petGuid); - } - // Right-click context menu on pet name - if (ImGui::BeginPopupContextItem("PetNameCtx")) { - ImGui::TextDisabled("%s", petLabel); - ImGui::Separator(); - if (ImGui::MenuItem("Target Pet")) { - gameHandler.setTarget(petGuid); - } - if (ImGui::MenuItem("Rename Pet")) { - ImGui::CloseCurrentPopup(); - petRenameOpen_ = true; - petRenameBuf_[0] = '\0'; - } - if (ImGui::MenuItem("Dismiss Pet")) { - gameHandler.dismissPet(); - } - ImGui::EndPopup(); - } - // Pet rename modal (opened via context menu) - if (petRenameOpen_) { - ImGui::OpenPopup("Rename Pet###PetRename"); - petRenameOpen_ = false; - } - if (ImGui::BeginPopupModal("Rename Pet###PetRename", nullptr, - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { - ImGui::Text("Enter new pet name (max 12 characters):"); - ImGui::SetNextItemWidth(180.0f); - bool submitted = ImGui::InputText("##PetRenameInput", petRenameBuf_, sizeof(petRenameBuf_), - ImGuiInputTextFlags_EnterReturnsTrue); - ImGui::SameLine(); - if (ImGui::Button("OK") || submitted) { - std::string newName(petRenameBuf_); - if (!newName.empty() && newName.size() <= 12) { - gameHandler.renamePet(newName); - } - ImGui::CloseCurrentPopup(); - } - ImGui::SameLine(); - if (ImGui::Button("Cancel")) { - ImGui::CloseCurrentPopup(); - } - ImGui::EndPopup(); - } - ImGui::PopStyleColor(); - if (petLevel > 0) { - ImGui::SameLine(); - ImGui::TextDisabled("Lv %u", petLevel); - } - - // Health bar - uint32_t hp = petUnit->getHealth(); - uint32_t maxHp = petUnit->getMaxHealth(); - if (maxHp > 0) { - float pct = static_cast(hp) / static_cast(maxHp); - ImVec4 petHpColor = pct > 0.5f ? colors::kHealthGreen - : pct > 0.2f ? ImVec4(0.9f, 0.6f, 0.0f, 1.0f) - : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, petHpColor); - char hpText[32]; - snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); - ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); - ImGui::PopStyleColor(); - } - - // Power/mana bar (hunters' pets use focus) - uint8_t powerType = petUnit->getPowerType(); - uint32_t power = petUnit->getPower(); - uint32_t maxPower = petUnit->getMaxPower(); - if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3)) maxPower = 100; - if (maxPower > 0) { - float mpPct = static_cast(power) / static_cast(maxPower); - ImVec4 powerColor; - switch (powerType) { - case 0: powerColor = colors::kManaBlue; break; // Mana - case 1: powerColor = colors::kDarkRed; break; // Rage - case 2: powerColor = colors::kOrange; break; // Focus (hunter pets) - case 3: powerColor = colors::kEnergyYellow; break; // Energy - default: powerColor = colors::kManaBlue; break; - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); - char mpText[32]; - snprintf(mpText, sizeof(mpText), "%u/%u", power, maxPower); - ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpText); - ImGui::PopStyleColor(); - } - - // Happiness bar — hunter pets store happiness as power type 4 - { - uint32_t happiness = petUnit->getPowerByType(4); - uint32_t maxHappiness = petUnit->getMaxPowerByType(4); - if (maxHappiness > 0 && happiness > 0) { - float hapPct = static_cast(happiness) / static_cast(maxHappiness); - // Tier: < 33% = Unhappy (red), < 67% = Content (yellow), >= 67% = Happy (green) - ImVec4 hapColor = hapPct >= 0.667f ? ImVec4(0.2f, 0.85f, 0.2f, 1.0f) - : hapPct >= 0.333f ? ImVec4(0.9f, 0.75f, 0.1f, 1.0f) - : ImVec4(0.85f, 0.2f, 0.2f, 1.0f); - const char* hapLabel = hapPct >= 0.667f ? "Happy" : hapPct >= 0.333f ? "Content" : "Unhappy"; - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hapColor); - ImGui::ProgressBar(hapPct, ImVec2(-1, 8), hapLabel); - ImGui::PopStyleColor(); - } - } - - // Pet cast bar - if (auto* pcs = gameHandler.getUnitCastState(petGuid)) { - float castPct = (pcs->timeTotal > 0.0f) - ? (pcs->timeTotal - pcs->timeRemaining) / pcs->timeTotal : 0.0f; - // Orange color to distinguish from health/power bars - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.85f, 0.55f, 0.1f, 1.0f)); - char petCastLabel[48]; - const std::string& spellNm = gameHandler.getSpellName(pcs->spellId); - if (!spellNm.empty()) - snprintf(petCastLabel, sizeof(petCastLabel), "%s (%.1fs)", spellNm.c_str(), pcs->timeRemaining); - else - snprintf(petCastLabel, sizeof(petCastLabel), "Casting... (%.1fs)", pcs->timeRemaining); - ImGui::ProgressBar(castPct, ImVec2(-1, 10), petCastLabel); - ImGui::PopStyleColor(); - } - - // Stance row: Passive / Defensive / Aggressive — with Dismiss right-aligned - { - static constexpr const char* kReactLabels[] = { "Psv", "Def", "Agg" }; - static constexpr const char* kReactTooltips[] = { "Passive", "Defensive", "Aggressive" }; - static constexpr ImVec4 kReactColors[] = { - colors::kLightBlue, // passive — blue - ImVec4(0.3f, 0.85f, 0.3f, 1.0f), // defensive — green - colors::kHostileRed,// aggressive — red - }; - static constexpr ImVec4 kReactDimColors[] = { - ImVec4(0.15f, 0.2f, 0.4f, 0.8f), - ImVec4(0.1f, 0.3f, 0.1f, 0.8f), - ImVec4(0.4f, 0.1f, 0.1f, 0.8f), - }; - uint8_t curReact = gameHandler.getPetReact(); // 0=passive,1=defensive,2=aggressive - - // Find each react-type slot in the action bar by known built-in IDs: - // 1=Passive, 4=Defensive, 6=Aggressive (WoW wire protocol) - static const uint32_t kReactActionIds[] = { 1u, 4u, 6u }; - uint32_t reactSlotVals[3] = { 0, 0, 0 }; - const int slotTotal = game::GameHandler::PET_ACTION_BAR_SLOTS; - for (int i = 0; i < slotTotal; ++i) { - uint32_t sv = gameHandler.getPetActionSlot(i); - uint32_t aid = sv & 0x00FFFFFFu; - for (int r = 0; r < 3; ++r) { - if (aid == kReactActionIds[r]) { reactSlotVals[r] = sv; break; } - } - } - - for (int r = 0; r < 3; ++r) { - if (r > 0) ImGui::SameLine(0.0f, 3.0f); - bool active = (curReact == static_cast(r)); - ImVec4 btnCol = active ? kReactColors[r] : kReactDimColors[r]; - ImGui::PushID(r + 1000); - ImGui::PushStyleColor(ImGuiCol_Button, btnCol); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kReactColors[r]); - ImGui::PushStyleColor(ImGuiCol_ButtonActive, kReactColors[r]); - if (ImGui::Button(kReactLabels[r], ImVec2(34.0f, 16.0f))) { - // Use server-provided slot value if available; fall back to raw ID - uint32_t action = (reactSlotVals[r] != 0) - ? reactSlotVals[r] - : kReactActionIds[r]; - gameHandler.sendPetAction(action, 0); - } - ImGui::PopStyleColor(3); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("%s", kReactTooltips[r]); - ImGui::PopID(); - } - - // Dismiss button right-aligned on the same row - ImGui::SameLine(ImGui::GetContentRegionAvail().x - 58.0f); - if (ImGui::SmallButton("Dismiss")) { - gameHandler.dismissPet(); - } - } - - // Pet action bar — show up to 10 action slots from SMSG_PET_SPELLS - { - const int slotCount = game::GameHandler::PET_ACTION_BAR_SLOTS; - // Filter to non-zero slots; lay them out as small icon/text buttons. - // Raw slot value layout (WotLK 3.3.5): low 24 bits = spell/action ID, - // high byte = flag (0x80=autocast on, 0x40=can-autocast, 0x0C=type). - // Built-in commands: id=2 follow, id=3 stay/move, id=5 attack. - auto* assetMgr = services_.assetManager; - const float iconSz = 20.0f; - const float spacing = 2.0f; - ImGui::Separator(); - - int rendered = 0; - for (int i = 0; i < slotCount; ++i) { - uint32_t slotVal = gameHandler.getPetActionSlot(i); - if (slotVal == 0) continue; - - uint32_t actionId = slotVal & 0x00FFFFFFu; - // Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags. - bool autocastOn = gameHandler.isPetSpellAutocast(actionId); - - // Cooldown tracking for pet spells (actionId > 6 are spell IDs) - float petCd = (actionId > 6) ? gameHandler.getSpellCooldown(actionId) : 0.0f; - bool petOnCd = (petCd > 0.0f); - - ImGui::PushID(i); - if (rendered > 0) ImGui::SameLine(0.0f, spacing); - - // Try to show spell icon; fall back to abbreviated text label. - VkDescriptorSet iconTex = VK_NULL_HANDLE; - const char* builtinLabel = nullptr; - if (actionId == 1) builtinLabel = "Psv"; - else if (actionId == 2) builtinLabel = "Fol"; - else if (actionId == 3) builtinLabel = "Sty"; - else if (actionId == 4) builtinLabel = "Def"; - else if (actionId == 5) builtinLabel = "Atk"; - else if (actionId == 6) builtinLabel = "Agg"; - else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr); - - // Dim when on cooldown; tint green when autocast is on - ImVec4 tint = petOnCd - ? ImVec4(0.35f, 0.35f, 0.35f, 0.7f) - : (autocastOn ? colors::kLightGreen : ui::colors::kWhite); - bool clicked = false; - if (iconTex) { - clicked = ImGui::ImageButton("##pa", - (ImTextureID)(uintptr_t)iconTex, - ImVec2(iconSz, iconSz), - ImVec2(0,0), ImVec2(1,1), - ImVec4(0.1f,0.1f,0.1f,0.9f), tint); - } else { - char label[8]; - if (builtinLabel) { - snprintf(label, sizeof(label), "%s", builtinLabel); - } else { - // Show first 3 chars of spell name or spell ID. - std::string nm = gameHandler.getSpellName(actionId); - if (nm.empty()) snprintf(label, sizeof(label), "?%u", actionId % 100); - else snprintf(label, sizeof(label), "%.3s", nm.c_str()); - } - ImVec4 btnCol = petOnCd ? ImVec4(0.1f,0.1f,0.15f,0.9f) - : (autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f) - : ImVec4(0.2f,0.2f,0.3f,0.9f)); - ImGui::PushStyleColor(ImGuiCol_Button, btnCol); - clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz)); - ImGui::PopStyleColor(); - } - - // Cooldown overlay: dark fill + time text centered on the button - if (petOnCd && !builtinLabel) { - ImVec2 bMin = ImGui::GetItemRectMin(); - ImVec2 bMax = ImGui::GetItemRectMax(); - auto* cdDL = ImGui::GetWindowDrawList(); - cdDL->AddRectFilled(bMin, bMax, IM_COL32(0, 0, 0, 140)); - char cdTxt[8]; - if (petCd >= 60.0f) - snprintf(cdTxt, sizeof(cdTxt), "%dm", static_cast(petCd / 60.0f)); - else if (petCd >= 1.0f) - snprintf(cdTxt, sizeof(cdTxt), "%d", static_cast(petCd)); - else - snprintf(cdTxt, sizeof(cdTxt), "%.1f", petCd); - ImVec2 tsz = ImGui::CalcTextSize(cdTxt); - float cx = (bMin.x + bMax.x) * 0.5f; - float cy = (bMin.y + bMax.y) * 0.5f; - cdDL->AddText(ImVec2(cx - tsz.x * 0.5f, cy - tsz.y * 0.5f), - IM_COL32(255, 255, 255, 230), cdTxt); - } - - if (clicked && !petOnCd) { - // Send pet action; use current target for spells. - uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; - gameHandler.sendPetAction(slotVal, targetGuid); - } - // Right-click toggles autocast for castable pet spells (actionId > 6) - if (actionId > 6 && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { - gameHandler.togglePetSpellAutocast(actionId); - } - - // Tooltip: rich spell info for pet spells, simple label for built-in commands - if (ImGui::IsItemHovered()) { - if (builtinLabel) { - const char* tip = nullptr; - if (actionId == 1) tip = "Passive"; - else if (actionId == 2) tip = "Follow"; - else if (actionId == 3) tip = "Stay"; - else if (actionId == 4) tip = "Defensive"; - else if (actionId == 5) tip = "Attack"; - else if (actionId == 6) tip = "Aggressive"; - if (tip) ImGui::SetTooltip("%s", tip); - } else if (actionId > 6) { - auto* spellAsset = services_.assetManager; - ImGui::BeginTooltip(); - bool richOk = spellbookScreen.renderSpellInfoTooltip(actionId, gameHandler, spellAsset); - if (!richOk) { - std::string nm = gameHandler.getSpellName(actionId); - if (nm.empty()) nm = "Spell #" + std::to_string(actionId); - ImGui::Text("%s", nm.c_str()); - } - ImGui::TextColored(autocastOn - ? kColorGreen - : kColorGray, - "Autocast: %s (right-click to toggle)", autocastOn ? "On" : "Off"); - if (petOnCd) { - if (petCd >= 60.0f) - ImGui::TextColored(kColorRed, - "Cooldown: %d min %d sec", - static_cast(petCd) / 60, static_cast(petCd) % 60); - else - ImGui::TextColored(kColorRed, - "Cooldown: %.1f sec", petCd); - } - ImGui::EndTooltip(); - } - } - - ImGui::PopID(); - ++rendered; - } - } - } - ImGui::End(); - - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); -} - -// ============================================================ -// Totem Frame (Shaman — below pet frame / player frame) -// ============================================================ - -void GameScreen::renderTotemFrame(game::GameHandler& gameHandler) { - // Only show if at least one totem is active - bool anyActive = false; - for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { - if (gameHandler.getTotemSlot(i).active()) { anyActive = true; break; } - } - if (!anyActive) return; - - static constexpr struct { const char* name; ImU32 color; } kTotemInfo[4] = { - { "Earth", IM_COL32(139, 90, 43, 255) }, // brown - { "Fire", IM_COL32(220, 80, 30, 255) }, // red-orange - { "Water", IM_COL32( 30,120, 220, 255) }, // blue - { "Air", IM_COL32(180,220, 255, 255) }, // light blue - }; - - // Position: below pet frame / player frame, left side - // Pet frame is at ~y=200 if active, player frame is at y=20; totem frame near y=300 - // We anchor relative to screen left edge like pet frame - ImGui::SetNextWindowPos(ImVec2(8.0f, 300.0f), ImGuiCond_FirstUseEver); - ImGui::SetNextWindowSize(ImVec2(130.0f, 0.0f), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize | - ImGuiWindowFlags_NoTitleBar; - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.08f, 0.06f, 0.88f)); - - if (ImGui::Begin("##TotemFrame", nullptr, flags)) { - ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.3f, 1.0f), "Totems"); - ImGui::Separator(); - - for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { - const auto& slot = gameHandler.getTotemSlot(i); - if (!slot.active()) continue; - - ImGui::PushID(i); - - // Colored element dot - ImVec2 dotPos = ImGui::GetCursorScreenPos(); - dotPos.x += 4.0f; dotPos.y += 6.0f; - ImGui::GetWindowDrawList()->AddCircleFilled( - ImVec2(dotPos.x + 4.0f, dotPos.y + 4.0f), 4.0f, kTotemInfo[i].color); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); - - // Totem name or spell name - const std::string& spellName = gameHandler.getSpellName(slot.spellId); - const char* displayName = spellName.empty() ? kTotemInfo[i].name : spellName.c_str(); - ImGui::Text("%s", displayName); - - // Duration countdown bar - float remMs = slot.remainingMs(); - float totMs = static_cast(slot.durationMs); - float frac = (totMs > 0.0f) ? std::min(remMs / totMs, 1.0f) : 0.0f; - float remSec = remMs / 1000.0f; - - // Color bar with totem element tint - ImVec4 barCol( - static_cast((kTotemInfo[i].color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f, - static_cast((kTotemInfo[i].color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f, - static_cast((kTotemInfo[i].color >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f, - 0.9f); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barCol); - char timeBuf[16]; - snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remSec); - ImGui::ProgressBar(frac, ImVec2(-1, 8), timeBuf); - ImGui::PopStyleColor(); - - ImGui::PopID(); - } - } - ImGui::End(); - - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); -} - -void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { - auto target = gameHandler.getTarget(); - if (!target) return; - - auto* window = services_.window; - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - float frameW = 250.0f; - float frameX = (screenW - frameW) / 2.0f; - - ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; - - // Determine hostility/level color for border and name (WoW-canonical) - ImVec4 hostileColor(0.7f, 0.7f, 0.7f, 1.0f); - if (target->getType() == game::ObjectType::PLAYER) { - hostileColor = kColorBrightGreen; - } else if (target->getType() == game::ObjectType::UNIT) { - auto u = std::static_pointer_cast(target); - if (u->getHealth() == 0 && u->getMaxHealth() > 0) { - hostileColor = kColorDarkGray; - } else if (u->isHostile()) { - // Check tapped-by-other: grey name for mobs tagged by someone else - uint32_t tgtDynFlags = u->getDynamicFlags(); - bool tgtTapped = (tgtDynFlags & 0x0004) != 0 && (tgtDynFlags & 0x0008) == 0; - if (tgtTapped) { - hostileColor = kColorGray; // Grey — tapped by other - } else { - // WoW level-based color for hostile mobs - uint32_t playerLv = gameHandler.getPlayerLevel(); - uint32_t mobLv = u->getLevel(); - if (mobLv == 0) { - // Level 0 = unknown/?? (e.g. high-level raid bosses) — always skull red - hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); - } else { - int32_t diff = static_cast(mobLv) - static_cast(playerLv); - if (game::GameHandler::killXp(playerLv, mobLv) == 0) { - hostileColor = kColorGray; // Grey - no XP - } else if (diff >= 10) { - hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // Red - skull/very hard - } else if (diff >= 5) { - hostileColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange - hard - } else if (diff >= -2) { - hostileColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); // Yellow - even - } else { - hostileColor = kColorBrightGreen; // Green - easy - } - } - } // end tapped else - } else { - hostileColor = kColorBrightGreen; // Friendly - } - } - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); - const uint64_t targetGuid = target->getGuid(); - const bool confirmedCombatWithTarget = gameHandler.isInCombatWith(targetGuid); - const bool intentTowardTarget = - gameHandler.hasAutoAttackIntent() && - gameHandler.getAutoAttackTargetGuid() == targetGuid && - !confirmedCombatWithTarget; - ImVec4 borderColor = ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f); - if (confirmedCombatWithTarget) { - float t = ImGui::GetTime(); - float pulse = (std::fmod(t, 0.6f) < 0.3f) ? 1.0f : 0.0f; - borderColor = ImVec4(1.0f, 0.1f, 0.1f, pulse); - } else if (intentTowardTarget) { - borderColor = ImVec4(1.0f, 0.7f, 0.2f, 1.0f); - } - ImGui::PushStyleColor(ImGuiCol_Border, borderColor); - - if (ImGui::Begin("##TargetFrame", nullptr, flags)) { - // Raid mark icon (Star/Circle/Diamond/Triangle/Moon/Square/Cross/Skull) - static constexpr 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 — Selectable so we can attach a right-click context menu - std::string name = getEntityName(target); - - // Player targets: use class color instead of the generic green - ImVec4 nameColor = hostileColor; - if (target->getType() == game::ObjectType::PLAYER) { - uint8_t cid = entityClassId(target.get()); - if (cid != 0) nameColor = classColorVec4(cid); - } - - ImGui::SameLine(0.0f, 0.0f); - ImGui::PushStyleColor(ImGuiCol_Text, nameColor); - ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); - ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); - ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); - ImGui::Selectable(name.c_str(), false, ImGuiSelectableFlags_DontClosePopups, - ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); - ImGui::PopStyleColor(4); - - // Right-click context menu on target frame - if (ImGui::BeginPopupContextItem("##TargetFrameCtx")) { - const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); - const uint64_t tGuid = target->getGuid(); - - ImGui::TextDisabled("%s", name.c_str()); - ImGui::Separator(); - - if (ImGui::MenuItem("Set Focus")) - gameHandler.setFocus(tGuid); - if (ImGui::MenuItem("Clear Target")) - gameHandler.clearTarget(); - if (isPlayer) { - ImGui::Separator(); - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(name); - } - if (ImGui::MenuItem("Follow")) - gameHandler.followTarget(); - if (ImGui::MenuItem("Invite to Group")) - gameHandler.inviteToGroup(name); - if (ImGui::MenuItem("Trade")) - gameHandler.initiateTrade(tGuid); - if (ImGui::MenuItem("Duel")) - gameHandler.proposeDuel(tGuid); - if (ImGui::MenuItem("Inspect")) { - gameHandler.inspectTarget(); - socialPanel_.showInspectWindow_ = true; - } - ImGui::Separator(); - if (ImGui::MenuItem("Add Friend")) - gameHandler.addFriend(name); - if (ImGui::MenuItem("Ignore")) - gameHandler.addIgnore(name); - if (ImGui::MenuItem("Report Player")) - gameHandler.reportPlayer(tGuid, "Reported via UI"); - } - ImGui::Separator(); - if (ImGui::BeginMenu("Set Raid Mark")) { - for (int mi = 0; mi < 8; ++mi) { - if (ImGui::MenuItem(kRaidMarkNames[mi])) - gameHandler.setRaidMark(tGuid, static_cast(mi)); - } - ImGui::Separator(); - if (ImGui::MenuItem("Clear Mark")) - gameHandler.setRaidMark(tGuid, 0xFF); - ImGui::EndMenu(); - } - ImGui::EndPopup(); - } - - // Group leader crown — golden ♛ when the targeted player is the party/raid leader - if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) { - if (gameHandler.getPartyData().leaderGuid == target->getGuid()) { - ImGui::SameLine(0, 4); - ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); - } - } - - // Quest giver indicator — "!" for available quests, "?" for completable quests - { - using QGS = game::QuestGiverStatus; - QGS qgs = gameHandler.getQuestGiverStatus(target->getGuid()); - if (qgs == QGS::AVAILABLE) { - ImGui::SameLine(0, 4); - ImGui::TextColored(colors::kBrightGold, "!"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a quest available"); - } else if (qgs == QGS::AVAILABLE_LOW) { - ImGui::SameLine(0, 4); - ImGui::TextColored(kColorGray, "!"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a low-level quest available"); - } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { - ImGui::SameLine(0, 4); - ImGui::TextColored(colors::kBrightGold, "?"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest ready to turn in"); - } else if (qgs == QGS::INCOMPLETE) { - ImGui::SameLine(0, 4); - ImGui::TextColored(kColorGray, "?"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest incomplete"); - } - } - - // Creature subtitle (e.g. "", "Captain of the Guard") - if (target->getType() == game::ObjectType::UNIT) { - auto unit = std::static_pointer_cast(target); - const std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); - if (!sub.empty()) { - ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", sub.c_str()); - } - } - - // Player guild name (e.g. "") — mirrors NPC subtitle styling - if (target->getType() == game::ObjectType::PLAYER) { - uint32_t guildId = gameHandler.getEntityGuildId(target->getGuid()); - if (guildId != 0) { - const std::string& gn = gameHandler.lookupGuildName(guildId); - if (!gn.empty()) { - ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); - } - } - } - - // Right-click context menu on the target name - if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { - const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); - const uint64_t tGuid = target->getGuid(); - - ImGui::TextDisabled("%s", name.c_str()); - ImGui::Separator(); - - if (ImGui::MenuItem("Set Focus")) { - gameHandler.setFocus(tGuid); - } - if (ImGui::MenuItem("Clear Target")) { - gameHandler.clearTarget(); - } - if (isPlayer) { - ImGui::Separator(); - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(name); - } - if (ImGui::MenuItem("Follow")) { - gameHandler.followTarget(); - } - if (ImGui::MenuItem("Invite to Group")) { - gameHandler.inviteToGroup(name); - } - if (ImGui::MenuItem("Trade")) { - gameHandler.initiateTrade(tGuid); - } - if (ImGui::MenuItem("Duel")) { - gameHandler.proposeDuel(tGuid); - } - if (ImGui::MenuItem("Inspect")) { - gameHandler.inspectTarget(); - socialPanel_.showInspectWindow_ = true; - } - ImGui::Separator(); - if (ImGui::MenuItem("Add Friend")) { - gameHandler.addFriend(name); - } - if (ImGui::MenuItem("Ignore")) { - gameHandler.addIgnore(name); - } - } - ImGui::Separator(); - if (ImGui::BeginMenu("Set Raid Mark")) { - for (int mi = 0; mi < 8; ++mi) { - if (ImGui::MenuItem(kRaidMarkNames[mi])) - gameHandler.setRaidMark(tGuid, static_cast(mi)); - } - ImGui::Separator(); - if (ImGui::MenuItem("Clear Mark")) - gameHandler.setRaidMark(tGuid, 0xFF); - ImGui::EndMenu(); - } - ImGui::EndPopup(); - } - - // Level (for units/players) — colored by difficulty - if (target->getType() == game::ObjectType::UNIT || target->getType() == game::ObjectType::PLAYER) { - auto unit = std::static_pointer_cast(target); - ImGui::SameLine(); - // Level color matches the hostility/difficulty color - ImVec4 levelColor = hostileColor; - if (target->getType() == game::ObjectType::PLAYER) { - levelColor = ui::colors::kLightGray; - } - if (unit->getLevel() == 0) - ImGui::TextColored(levelColor, "Lv ??"); - else - ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); - // Classification badge: Elite / Rare Elite / Boss / Rare - if (target->getType() == game::ObjectType::UNIT) { - int rank = gameHandler.getCreatureRank(unit->getEntry()); - if (rank == 1) { - ImGui::SameLine(0, 4); - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "[Elite]"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Elite — requires a group"); - } else if (rank == 2) { - ImGui::SameLine(0, 4); - ImGui::TextColored(ImVec4(0.8f, 0.4f, 1.0f, 1.0f), "[Rare Elite]"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare Elite — uncommon spawn, group recommended"); - } else if (rank == 3) { - ImGui::SameLine(0, 4); - ImGui::TextColored(kColorRed, "[Boss]"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Boss — raid / dungeon boss"); - } else if (rank == 4) { - ImGui::SameLine(0, 4); - ImGui::TextColored(ImVec4(0.5f, 0.9f, 1.0f, 1.0f), "[Rare]"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot"); - } - } - // Creature type label (Beast, Humanoid, Demon, etc.) - if (target->getType() == game::ObjectType::UNIT) { - uint32_t ctype = gameHandler.getCreatureType(unit->getEntry()); - const char* ctypeName = nullptr; - switch (ctype) { - case 1: ctypeName = "Beast"; break; - case 2: ctypeName = "Dragonkin"; break; - case 3: ctypeName = "Demon"; break; - case 4: ctypeName = "Elemental"; break; - case 5: ctypeName = "Giant"; break; - case 6: ctypeName = "Undead"; break; - case 7: ctypeName = "Humanoid"; break; - case 8: ctypeName = "Critter"; break; - case 9: ctypeName = "Mechanical"; break; - case 11: ctypeName = "Totem"; break; - case 12: ctypeName = "Non-combat Pet"; break; - case 13: ctypeName = "Gas Cloud"; break; - default: break; - } - if (ctypeName) { - ImGui::SameLine(0, 4); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", ctypeName); - } - } - if (confirmedCombatWithTarget) { - float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); - ImGui::SameLine(); - ImGui::TextColored(ImVec4(1.0f, 0.2f * cPulse, 0.2f * cPulse, 1.0f), "[Attacking]"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Engaged in combat with this target"); - } - - // Health bar - uint32_t hp = unit->getHealth(); - uint32_t maxHp = unit->getMaxHealth(); - if (maxHp > 0) { - float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, - pct > 0.5f ? colors::kHealthGreen : - pct > 0.2f ? colors::kMidHealthYellow : - colors::kLowHealthRed); - - char overlay[64]; - snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp); - ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay); - ImGui::PopStyleColor(); - // Target power bar (mana/rage/energy) - uint8_t targetPowerType = unit->getPowerType(); - uint32_t targetPower = unit->getPower(); - uint32_t targetMaxPower = unit->getMaxPower(); - if (targetMaxPower == 0 && (targetPowerType == 1 || targetPowerType == 3)) targetMaxPower = 100; - if (targetMaxPower > 0) { - float mpPct = static_cast(targetPower) / static_cast(targetMaxPower); - ImVec4 targetPowerColor; - switch (targetPowerType) { - case 0: targetPowerColor = colors::kManaBlue; break; // Mana (blue) - case 1: targetPowerColor = colors::kDarkRed; break; // Rage (red) - case 2: targetPowerColor = colors::kOrange; break; // Focus (orange) - case 3: targetPowerColor = colors::kEnergyYellow; break; // Energy (yellow) - case 4: targetPowerColor = colors::kHappinessGreen; break; // Happiness (green) - case 6: targetPowerColor = colors::kRunicRed; break; // Runic Power (crimson) - case 7: targetPowerColor = colors::kSoulShardPurple; break; // Soul Shards (purple) - default: targetPowerColor = colors::kManaBlue; break; - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, targetPowerColor); - char mpOverlay[64]; - snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", targetPower, targetMaxPower); - ImGui::ProgressBar(mpPct, ImVec2(-1, 18), mpOverlay); - ImGui::PopStyleColor(); - } - } else { - ImGui::TextDisabled("No health data"); - } - } - - // Combo points — shown when the player has combo points on this target - { - uint8_t cp = gameHandler.getComboPoints(); - if (cp > 0 && gameHandler.getComboTarget() == target->getGuid()) { - const float dotSize = 12.0f; - const float dotSpacing = 4.0f; - const int maxCP = 5; - float totalW = maxCP * dotSize + (maxCP - 1) * dotSpacing; - float startX = (frameW - totalW) * 0.5f; - ImGui::SetCursorPosX(startX); - ImVec2 cursor = ImGui::GetCursorScreenPos(); - ImDrawList* dl = ImGui::GetWindowDrawList(); - for (int ci = 0; ci < maxCP; ++ci) { - float cx = cursor.x + ci * (dotSize + dotSpacing) + dotSize * 0.5f; - float cy = cursor.y + dotSize * 0.5f; - if (ci < static_cast(cp)) { - // Lit: yellow for 1-4, red glow for 5 - ImU32 col = (cp >= 5) - ? IM_COL32(255, 50, 30, 255) - : IM_COL32(255, 210, 30, 255); - dl->AddCircleFilled(ImVec2(cx, cy), dotSize * 0.45f, col); - // Subtle glow - dl->AddCircle(ImVec2(cx, cy), dotSize * 0.5f, IM_COL32(255, 255, 200, 80), 0, 1.5f); - } else { - // Unlit: dark outline - dl->AddCircle(ImVec2(cx, cy), dotSize * 0.4f, IM_COL32(80, 80, 80, 180), 0, 1.5f); - } - } - ImGui::Dummy(ImVec2(totalW, dotSize + 2.0f)); - } - } - - // Target cast bar — shown when the target is casting - if (gameHandler.isTargetCasting()) { - float castPct = gameHandler.getTargetCastProgress(); - float castLeft = gameHandler.getTargetCastTimeRemaining(); - uint32_t tspell = gameHandler.getTargetCastSpellId(); - bool interruptible = gameHandler.isTargetCastInterruptible(); - const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; - // Color: interruptible = green (can Kick/CS), not interruptible = red, both pulse when >80% - ImVec4 castBarColor; - if (castPct > 0.8f) { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - if (interruptible) - castBarColor = ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f); // green pulse - else - castBarColor = ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); // red pulse - } else { - castBarColor = interruptible ? colors::kCastGreen // green = can interrupt - : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); // red = uninterruptible - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor); - char castLabel[72]; - if (!castName.empty()) - snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft); - else if (tspell != 0) - snprintf(castLabel, sizeof(castLabel), "Spell #%u (%.1fs)", tspell, castLeft); - else - snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft); - { - auto* tcastAsset = services_.assetManager; - VkDescriptorSet tIcon = (tspell != 0 && tcastAsset) - ? getSpellIcon(tspell, tcastAsset) : VK_NULL_HANDLE; - if (tIcon) { - ImGui::Image((ImTextureID)(uintptr_t)tIcon, ImVec2(14, 14)); - ImGui::SameLine(0, 2); - ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); - } else { - ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); - } - } - ImGui::PopStyleColor(); - } - - // Target-of-Target (ToT): show who the current target is targeting - { - uint64_t totGuid = 0; - const auto& tFields = target->getFields(); - auto itLo = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); - if (itLo != tFields.end()) { - totGuid = itLo->second; - auto itHi = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); - if (itHi != tFields.end()) - totGuid |= (static_cast(itHi->second) << 32); - } - if (totGuid != 0) { - auto totEnt = gameHandler.getEntityManager().getEntity(totGuid); - std::string totName; - ImVec4 totColor(0.7f, 0.7f, 0.7f, 1.0f); - if (totGuid == gameHandler.getPlayerGuid()) { - auto playerEnt = gameHandler.getEntityManager().getEntity(totGuid); - totName = playerEnt ? getEntityName(playerEnt) : "You"; - totColor = kColorBrightGreen; - } else if (totEnt) { - totName = getEntityName(totEnt); - uint8_t cid = entityClassId(totEnt.get()); - if (cid != 0) totColor = classColorVec4(cid); - } - if (!totName.empty()) { - ImGui::TextDisabled("▶"); - ImGui::SameLine(0, 2); - ImGui::TextColored(totColor, "%s", totName.c_str()); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("Target's target: %s\nClick to target", totName.c_str()); - } - if (ImGui::IsItemClicked()) { - gameHandler.setTarget(totGuid); - } - - // Compact health bar for the ToT — essential for healers tracking boss target - if (totEnt) { - auto totUnit = std::dynamic_pointer_cast(totEnt); - if (totUnit && totUnit->getMaxHealth() > 0) { - uint32_t totHp = totUnit->getHealth(); - uint32_t totMaxHp = totUnit->getMaxHealth(); - float totPct = static_cast(totHp) / static_cast(totMaxHp); - ImVec4 totBarColor = - totPct > 0.5f ? colors::kCastGreen : - totPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : - ImVec4(0.75f, 0.2f, 0.2f, 1.0f); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, totBarColor); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - char totOverlay[32]; - snprintf(totOverlay, sizeof(totOverlay), "%u%%", - static_cast(totPct * 100.0f + 0.5f)); - ImGui::ProgressBar(totPct, ImVec2(-1, 10), totOverlay); - ImGui::PopStyleColor(2); - } - } - } - } - } - - // Distance - const auto& movement = gameHandler.getMovementInfo(); - float dx = target->getX() - movement.x; - float dy = target->getY() - movement.y; - float dz = target->getZ() - movement.z; - float distance = std::sqrt(dx*dx + dy*dy + dz*dz); - ImGui::TextDisabled("%.1f yd", distance); - - // Threat button (shown when in combat and threat data is available) - if (gameHandler.getTargetThreatList()) { - ImGui::SameLine(); - ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f)); - ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f)); - if (ImGui::SmallButton("Threat")) combatUI_.showThreatWindow_ = !combatUI_.showThreatWindow_; - ImGui::PopStyleColor(2); - } - - // Target auras (buffs/debuffs) - const auto& targetAuras = gameHandler.getTargetAuras(); - int activeAuras = 0; - for (const auto& a : targetAuras) { - if (!a.isEmpty()) activeAuras++; - } - if (activeAuras > 0) { - auto* assetMgr = services_.assetManager; - constexpr float ICON_SIZE = 24.0f; - constexpr int ICONS_PER_ROW = 8; - - ImGui::Separator(); - - // Build sorted index list: debuffs before buffs, shorter duration first - uint64_t tNowSort = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - std::vector sortedIdx; - sortedIdx.reserve(targetAuras.size()); - for (size_t i = 0; i < targetAuras.size(); ++i) - if (!targetAuras[i].isEmpty()) sortedIdx.push_back(i); - std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) { - const auto& aa = targetAuras[a]; const auto& ab = targetAuras[b]; - bool aDebuff = (aa.flags & 0x80) != 0; - bool bDebuff = (ab.flags & 0x80) != 0; - if (aDebuff != bDebuff) return aDebuff > bDebuff; // debuffs first - int32_t ra = aa.getRemainingMs(tNowSort); - int32_t rb = ab.getRemainingMs(tNowSort); - // Permanent (-1) goes last; shorter remaining goes first - if (ra < 0 && rb < 0) return false; - if (ra < 0) return false; - if (rb < 0) return true; - return ra < rb; - }); - - int shown = 0; - for (size_t si = 0; si < sortedIdx.size() && shown < 16; ++si) { - size_t i = sortedIdx[si]; - const auto& aura = targetAuras[i]; - if (aura.isEmpty()) continue; - - if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); - - ImGui::PushID(static_cast(10000 + i)); - - bool isBuff = (aura.flags & 0x80) == 0; - ImVec4 auraBorderColor; - if (isBuff) { - auraBorderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); - } else { - // Debuff: color by dispel type, matching player buff bar convention - uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); - switch (dt) { - case 1: auraBorderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue - case 2: auraBorderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple - case 3: auraBorderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown - case 4: auraBorderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green - default: auraBorderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red - } - } - - VkDescriptorSet iconTex = VK_NULL_HANDLE; - if (assetMgr) { - iconTex = getSpellIcon(aura.spellId, assetMgr); - } - - if (iconTex) { - ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); - ImGui::ImageButton("##taura", - (ImTextureID)(uintptr_t)iconTex, - ImVec2(ICON_SIZE - 2, ICON_SIZE - 2)); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); - } else { - ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor); - const std::string& tAuraName = gameHandler.getSpellName(aura.spellId); - char label[32]; - if (!tAuraName.empty()) - snprintf(label, sizeof(label), "%.6s", tAuraName.c_str()); - else - snprintf(label, sizeof(label), "%u", aura.spellId); - ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE)); - ImGui::PopStyleColor(); - } - - // Compute remaining once for overlay + tooltip - uint64_t tNowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - int32_t tRemainMs = aura.getRemainingMs(tNowMs); - - // Clock-sweep overlay (elapsed = dark area, WoW style) - if (tRemainMs > 0 && aura.maxDurationMs > 0) { - ImVec2 tIconMin = ImGui::GetItemRectMin(); - ImVec2 tIconMax = ImGui::GetItemRectMax(); - float tcx = (tIconMin.x + tIconMax.x) * 0.5f; - float tcy = (tIconMin.y + tIconMax.y) * 0.5f; - float tR = (tIconMax.x - tIconMin.x) * 0.5f; - float tTot = static_cast(aura.maxDurationMs); - float tFrac = std::clamp( - 1.0f - static_cast(tRemainMs) / tTot, 0.0f, 1.0f); - if (tFrac > 0.005f) { - constexpr int TSEGS = 24; - float tSa = -IM_PI * 0.5f; - float tEa = tSa + tFrac * 2.0f * IM_PI; - ImVec2 tPts[TSEGS + 2]; - tPts[0] = ImVec2(tcx, tcy); - for (int s = 0; s <= TSEGS; ++s) { - float a = tSa + (tEa - tSa) * s / static_cast(TSEGS); - tPts[s + 1] = ImVec2(tcx + std::cos(a) * tR, - tcy + std::sin(a) * tR); - } - ImGui::GetWindowDrawList()->AddConvexPolyFilled( - tPts, TSEGS + 2, IM_COL32(0, 0, 0, 145)); - } - } - - // Duration countdown overlay - if (tRemainMs > 0) { - ImVec2 iconMin = ImGui::GetItemRectMin(); - ImVec2 iconMax = ImGui::GetItemRectMax(); - char timeStr[12]; - int secs = (tRemainMs + 999) / 1000; - if (secs >= 3600) - snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600); - else if (secs >= 60) - snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); - else - snprintf(timeStr, sizeof(timeStr), "%d", secs); - ImVec2 textSize = ImGui::CalcTextSize(timeStr); - float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; - float cy = iconMax.y - textSize.y - 1.0f; - // Color by urgency (matches player buff bar) - ImU32 tTimerColor; - if (tRemainMs < 10000) { - float pulse = 0.7f + 0.3f * std::sin( - static_cast(ImGui::GetTime()) * 6.0f); - tTimerColor = IM_COL32( - static_cast(255 * pulse), - static_cast(80 * pulse), - static_cast(60 * pulse), 255); - } else if (tRemainMs < 30000) { - tTimerColor = IM_COL32(255, 165, 0, 255); - } else { - tTimerColor = IM_COL32(255, 255, 255, 255); - } - ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), - IM_COL32(0, 0, 0, 200), timeStr); - ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), - tTimerColor, timeStr); - } - - // Stack / charge count — upper-left corner - if (aura.charges > 1) { - ImVec2 iconMin = ImGui::GetItemRectMin(); - char chargeStr[8]; - snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); - ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3), - IM_COL32(0, 0, 0, 200), chargeStr); - ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2), - IM_COL32(255, 220, 50, 255), chargeStr); - } - - // Tooltip: rich spell info + remaining duration - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); - if (!richOk) { - std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); - if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); - ImGui::Text("%s", name.c_str()); - } - renderAuraRemaining(tRemainMs); - ImGui::EndTooltip(); - } - - ImGui::PopID(); - shown++; - } - } - } - ImGui::End(); - - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); - - // ---- Target-of-Target (ToT) mini frame ---- - // Read target's current target from UNIT_FIELD_TARGET_LO/HI update fields - if (target) { - const auto& fields = target->getFields(); - uint64_t totGuid = 0; - auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); - if (loIt != fields.end()) { - totGuid = loIt->second; - auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); - if (hiIt != fields.end()) - totGuid |= (static_cast(hiIt->second) << 32); - } - - if (totGuid != 0) { - auto totEntity = gameHandler.getEntityManager().getEntity(totGuid); - if (totEntity) { - // Position ToT frame just below and right-aligned with the target frame - float totW = 160.0f; - float totX = (screenW - totW) / 2.0f + (frameW - totW); - ImGui::SetNextWindowPos(ImVec2(totX, 30.0f + 130.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(totW, 0.0f), ImGuiCond_Always); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.80f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 0.7f)); - - if (ImGui::Begin("##ToTFrame", nullptr, - ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { - std::string totName = getEntityName(totEntity); - // Class color for players; gray for NPCs - ImVec4 totNameColor = colors::kSilver; - if (totEntity->getType() == game::ObjectType::PLAYER) { - uint8_t cid = entityClassId(totEntity.get()); - if (cid != 0) totNameColor = classColorVec4(cid); - } - // Selectable so we can attach a right-click context menu - ImGui::PushStyleColor(ImGuiCol_Text, totNameColor); - ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); - ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); - ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); - if (ImGui::Selectable(totName.c_str(), false, - ImGuiSelectableFlags_DontClosePopups, - ImVec2(ImGui::CalcTextSize(totName.c_str()).x, 0))) { - gameHandler.setTarget(totGuid); - } - ImGui::PopStyleColor(4); - - if (ImGui::BeginPopupContextItem("##ToTCtx")) { - ImGui::TextDisabled("%s", totName.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Target")) - gameHandler.setTarget(totGuid); - if (ImGui::MenuItem("Set Focus")) - gameHandler.setFocus(totGuid); - ImGui::EndPopup(); - } - - if (totEntity->getType() == game::ObjectType::UNIT || - totEntity->getType() == game::ObjectType::PLAYER) { - auto totUnit = std::static_pointer_cast(totEntity); - if (totUnit->getLevel() > 0) { - ImGui::SameLine(); - ImGui::TextDisabled("Lv%u", totUnit->getLevel()); - } - uint32_t hp = totUnit->getHealth(); - uint32_t maxHp = totUnit->getMaxHealth(); - if (maxHp > 0) { - float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, - pct > 0.5f ? colors::kFriendlyGreen : - pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) : - colors::kDangerRed); - ImGui::ProgressBar(pct, ImVec2(-1, 10), ""); - ImGui::PopStyleColor(); - } - - // ToT cast bar — green if interruptible, red if not; pulses near completion - if (auto* totCs = gameHandler.getUnitCastState(totGuid)) { - float totCastPct = (totCs->timeTotal > 0.0f) - ? (totCs->timeTotal - totCs->timeRemaining) / totCs->timeTotal : 0.0f; - ImVec4 tcColor; - if (totCastPct > 0.8f) { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - tcColor = totCs->interruptible - ? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f) - : ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); - } else { - tcColor = totCs->interruptible - ? colors::kCastGreen - : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor); - char tcLabel[48]; - const std::string& tcName = gameHandler.getSpellName(totCs->spellId); - if (!tcName.empty()) - snprintf(tcLabel, sizeof(tcLabel), "%s (%.1fs)", tcName.c_str(), totCs->timeRemaining); - else - snprintf(tcLabel, sizeof(tcLabel), "Casting... (%.1fs)", totCs->timeRemaining); - ImGui::ProgressBar(totCastPct, ImVec2(-1, 8), tcLabel); - ImGui::PopStyleColor(); - } - - // ToT aura row — compact icons, debuffs first - { - const std::vector* totAuras = nullptr; - if (totGuid == gameHandler.getPlayerGuid()) - totAuras = &gameHandler.getPlayerAuras(); - else if (totGuid == gameHandler.getTargetGuid()) - totAuras = &gameHandler.getTargetAuras(); - else - totAuras = gameHandler.getUnitAuras(totGuid); - - if (totAuras) { - int totActive = 0; - for (const auto& a : *totAuras) if (!a.isEmpty()) totActive++; - if (totActive > 0) { - auto* totAsset = services_.assetManager; - constexpr float TA_ICON = 16.0f; - constexpr int TA_PER_ROW = 8; - - ImGui::Separator(); - - uint64_t taNowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - - std::vector taIdx; - taIdx.reserve(totAuras->size()); - for (size_t i = 0; i < totAuras->size(); ++i) - if (!(*totAuras)[i].isEmpty()) taIdx.push_back(i); - std::sort(taIdx.begin(), taIdx.end(), [&](size_t a, size_t b) { - bool aD = ((*totAuras)[a].flags & 0x80) != 0; - bool bD = ((*totAuras)[b].flags & 0x80) != 0; - if (aD != bD) return aD > bD; - int32_t ra = (*totAuras)[a].getRemainingMs(taNowMs); - int32_t rb = (*totAuras)[b].getRemainingMs(taNowMs); - if (ra < 0 && rb < 0) return false; - if (ra < 0) return false; - if (rb < 0) return true; - return ra < rb; - }); - - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); - int taShown = 0; - for (size_t si = 0; si < taIdx.size() && taShown < 16; ++si) { - const auto& aura = (*totAuras)[taIdx[si]]; - bool isBuff = (aura.flags & 0x80) == 0; - - if (taShown > 0 && taShown % TA_PER_ROW != 0) ImGui::SameLine(); - ImGui::PushID(static_cast(taIdx[si]) + 5000); - - ImVec4 borderCol; - if (isBuff) { - borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); - } else { - uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); - switch (dt) { - case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; - case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; - case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; - case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; - default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; - } - } - - VkDescriptorSet taIcon = (totAsset) - ? getSpellIcon(aura.spellId, totAsset) : VK_NULL_HANDLE; - if (taIcon) { - ImGui::PushStyleColor(ImGuiCol_Button, borderCol); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); - ImGui::ImageButton("##taura", - (ImTextureID)(uintptr_t)taIcon, - ImVec2(TA_ICON - 2, TA_ICON - 2)); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); - } else { - ImGui::PushStyleColor(ImGuiCol_Button, borderCol); - char lab[8]; - snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); - ImGui::Button(lab, ImVec2(TA_ICON, TA_ICON)); - ImGui::PopStyleColor(); - } - - // Duration overlay - int32_t taRemain = aura.getRemainingMs(taNowMs); - if (taRemain > 0) { - ImVec2 imin = ImGui::GetItemRectMin(); - ImVec2 imax = ImGui::GetItemRectMax(); - char ts[12]; - fmtDurationCompact(ts, sizeof(ts), (taRemain + 999) / 1000); - ImVec2 tsz = ImGui::CalcTextSize(ts); - float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; - float cy = imax.y - tsz.y; - ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); - ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); - } - - // Tooltip - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - bool richOk = spellbookScreen.renderSpellInfoTooltip( - aura.spellId, gameHandler, totAsset); - if (!richOk) { - std::string nm = spellbookScreen.lookupSpellName(aura.spellId, totAsset); - if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); - ImGui::Text("%s", nm.c_str()); - } - renderAuraRemaining(taRemain); - ImGui::EndTooltip(); - } - - ImGui::PopID(); - taShown++; - } - ImGui::PopStyleVar(); - } - } - } - } - } - ImGui::End(); - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); - } - } - } -} - -void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { - auto focus = gameHandler.getFocus(); - if (!focus) return; - - auto* window = services_.window; - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - // Position: right side of screen, mirroring the target frame on the opposite side - float frameW = 200.0f; - float frameX = screenW - frameW - 10.0f; - - ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | - ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; - - // Determine color based on relation (same logic as target frame) - ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f); - if (focus->getType() == game::ObjectType::PLAYER) { - // Use class color for player focus targets - uint8_t cid = entityClassId(focus.get()); - focusColor = (cid != 0) ? classColorVec4(cid) : kColorBrightGreen; - } else if (focus->getType() == game::ObjectType::UNIT) { - auto u = std::static_pointer_cast(focus); - if (u->getHealth() == 0 && u->getMaxHealth() > 0) { - focusColor = kColorDarkGray; - } else if (u->isHostile()) { - // Tapped-by-other: grey focus frame name - uint32_t focDynFlags = u->getDynamicFlags(); - bool focTapped = (focDynFlags & 0x0004) != 0 && (focDynFlags & 0x0008) == 0; - if (focTapped) { - focusColor = kColorGray; - } else { - uint32_t playerLv = gameHandler.getPlayerLevel(); - uint32_t mobLv = u->getLevel(); - if (mobLv == 0) { - focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // ?? level = skull red - } else { - int32_t diff = static_cast(mobLv) - static_cast(playerLv); - if (game::GameHandler::killXp(playerLv, mobLv) == 0) - focusColor = kColorGray; - else if (diff >= 10) - focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); - else if (diff >= 5) - focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); - else if (diff >= -2) - focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); - else - focusColor = kColorBrightGreen; - } - } // end tapped else - } else { - focusColor = kColorBrightGreen; - } - } - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.15f, 0.85f)); - ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.9f, 0.8f)); // Blue tint = focus - - if (ImGui::Begin("##FocusFrame", nullptr, flags)) { - // "Focus" label - ImGui::TextDisabled("[Focus]"); - ImGui::SameLine(); - - // Raid mark icon (star, circle, diamond, …) preceding the name - { - static constexpr struct { const char* sym; ImU32 col; } kFocusMarks[] = { - { "\xe2\x98\x85", IM_COL32(255, 204, 0, 255) }, // 0 Star (yellow) - { "\xe2\x97\x8f", IM_COL32(255, 103, 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 fmark = gameHandler.getEntityRaidMark(focus->getGuid()); - if (fmark < game::GameHandler::kRaidMarkCount) { - ImGui::GetWindowDrawList()->AddText( - ImGui::GetCursorScreenPos(), - kFocusMarks[fmark].col, kFocusMarks[fmark].sym); - ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); - } - } - - std::string focusName = getEntityName(focus); - ImGui::PushStyleColor(ImGuiCol_Text, focusColor); - ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); - ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); - ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); - ImGui::Selectable(focusName.c_str(), false, ImGuiSelectableFlags_DontClosePopups, - ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); - ImGui::PopStyleColor(4); - - // Right-click context menu on focus frame - if (ImGui::BeginPopupContextItem("##FocusFrameCtx")) { - ImGui::TextDisabled("%s", focusName.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Target")) - gameHandler.setTarget(focus->getGuid()); - if (ImGui::MenuItem("Clear Focus")) - gameHandler.clearFocus(); - if (focus->getType() == game::ObjectType::PLAYER) { - ImGui::Separator(); - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(focusName); - } - if (ImGui::MenuItem("Invite to Group")) - gameHandler.inviteToGroup(focusName); - if (ImGui::MenuItem("Trade")) - gameHandler.initiateTrade(focus->getGuid()); - if (ImGui::MenuItem("Duel")) - gameHandler.proposeDuel(focus->getGuid()); - if (ImGui::MenuItem("Inspect")) { - gameHandler.setTarget(focus->getGuid()); - gameHandler.inspectTarget(); - socialPanel_.showInspectWindow_ = true; - } - ImGui::Separator(); - if (ImGui::MenuItem("Add Friend")) - gameHandler.addFriend(focusName); - if (ImGui::MenuItem("Ignore")) - gameHandler.addIgnore(focusName); - } - ImGui::EndPopup(); - } - - // Group leader crown — golden ♛ when the focused player is the party/raid leader - if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) { - if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) { - ImGui::SameLine(0, 4); - ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b"); - if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); - } - } - - // Quest giver indicator and classification badge for NPC focus targets - if (focus->getType() == game::ObjectType::UNIT) { - auto focusUnit = std::static_pointer_cast(focus); - - // Quest indicator: ! / ? - { - using QGS = game::QuestGiverStatus; - QGS qgs = gameHandler.getQuestGiverStatus(focus->getGuid()); - if (qgs == QGS::AVAILABLE) { - ImGui::SameLine(0, 4); - ImGui::TextColored(colors::kBrightGold, "!"); - } else if (qgs == QGS::AVAILABLE_LOW) { - ImGui::SameLine(0, 4); - ImGui::TextColored(kColorGray, "!"); - } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { - ImGui::SameLine(0, 4); - ImGui::TextColored(colors::kBrightGold, "?"); - } else if (qgs == QGS::INCOMPLETE) { - ImGui::SameLine(0, 4); - ImGui::TextColored(kColorGray, "?"); - } - } - - // Classification badge - int fRank = gameHandler.getCreatureRank(focusUnit->getEntry()); - if (fRank == 1) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.8f,0.2f,1.0f), "[Elite]"); } - else if (fRank == 2) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.8f,0.4f,1.0f,1.0f), "[Rare Elite]"); } - else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(colors::kRed, "[Boss]"); } - else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); } - - // Creature type - { - uint32_t fctype = gameHandler.getCreatureType(focusUnit->getEntry()); - const char* fctName = nullptr; - switch (fctype) { - case 1: fctName="Beast"; break; case 2: fctName="Dragonkin"; break; - case 3: fctName="Demon"; break; case 4: fctName="Elemental"; break; - case 5: fctName="Giant"; break; case 6: fctName="Undead"; break; - case 7: fctName="Humanoid"; break; case 8: fctName="Critter"; break; - case 9: fctName="Mechanical"; break; case 11: fctName="Totem"; break; - case 12: fctName="Non-combat Pet"; break; case 13: fctName="Gas Cloud"; break; - default: break; - } - if (fctName) { - ImGui::SameLine(0, 4); - ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", fctName); - } - } - - // Creature subtitle - const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry()); - if (!fSub.empty()) - ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str()); - } - - // Player guild name on focus frame - if (focus->getType() == game::ObjectType::PLAYER) { - uint32_t guildId = gameHandler.getEntityGuildId(focus->getGuid()); - if (guildId != 0) { - const std::string& gn = gameHandler.lookupGuildName(guildId); - if (!gn.empty()) { - ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); - } - } - } - - if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { - const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); - const uint64_t fGuid = focus->getGuid(); - ImGui::TextDisabled("%s", focusName.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Target")) - gameHandler.setTarget(fGuid); - if (ImGui::MenuItem("Clear Focus")) - gameHandler.clearFocus(); - if (focusIsPlayer) { - ImGui::Separator(); - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(focusName); - } - if (ImGui::MenuItem("Invite to Group")) - gameHandler.inviteToGroup(focusName); - if (ImGui::MenuItem("Trade")) - gameHandler.initiateTrade(fGuid); - if (ImGui::MenuItem("Duel")) - gameHandler.proposeDuel(fGuid); - if (ImGui::MenuItem("Inspect")) { - gameHandler.setTarget(fGuid); - gameHandler.inspectTarget(); - socialPanel_.showInspectWindow_ = true; - } - ImGui::Separator(); - if (ImGui::MenuItem("Add Friend")) - gameHandler.addFriend(focusName); - if (ImGui::MenuItem("Ignore")) - gameHandler.addIgnore(focusName); - } - ImGui::EndPopup(); - } - - if (focus->getType() == game::ObjectType::UNIT || - focus->getType() == game::ObjectType::PLAYER) { - auto unit = std::static_pointer_cast(focus); - - // Level + health on same row - ImGui::SameLine(); - if (unit->getLevel() == 0) - ImGui::TextDisabled("Lv ??"); - else - ImGui::TextDisabled("Lv %u", unit->getLevel()); - - uint32_t hp = unit->getHealth(); - uint32_t maxHp = unit->getMaxHealth(); - if (maxHp > 0) { - float pct = static_cast(hp) / static_cast(maxHp); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, - pct > 0.5f ? colors::kFriendlyGreen : - pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) : - colors::kDangerRed); - char overlay[32]; - snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp); - ImGui::ProgressBar(pct, ImVec2(-1, 14), overlay); - ImGui::PopStyleColor(); - - // Power bar - uint8_t pType = unit->getPowerType(); - uint32_t pwr = unit->getPower(); - uint32_t maxPwr = unit->getMaxPower(); - if (maxPwr == 0 && (pType == 1 || pType == 3)) maxPwr = 100; - if (maxPwr > 0) { - float mpPct = static_cast(pwr) / static_cast(maxPwr); - ImVec4 pwrColor; - switch (pType) { - case 0: pwrColor = colors::kManaBlue; break; - case 1: pwrColor = colors::kDarkRed; break; - case 3: pwrColor = colors::kEnergyYellow; break; - case 6: pwrColor = colors::kRunicRed; break; - default: pwrColor = colors::kManaBlue; break; - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pwrColor); - ImGui::ProgressBar(mpPct, ImVec2(-1, 10), ""); - ImGui::PopStyleColor(); - } - } - - // Focus cast bar - const auto* focusCast = gameHandler.getUnitCastState(focus->getGuid()); - if (focusCast) { - float total = focusCast->timeTotal > 0.f ? focusCast->timeTotal : 1.f; - float rem = focusCast->timeRemaining; - float prog = std::clamp(1.0f - rem / total, 0.f, 1.f); - const std::string& spName = gameHandler.getSpellName(focusCast->spellId); - // Pulse orange when > 80% complete — interrupt window closing - ImVec4 focusCastColor; - if (prog > 0.8f) { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - focusCastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); - } else { - focusCastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); - } - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, focusCastColor); - char castBuf[64]; - if (!spName.empty()) - snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem); - else - snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem); - { - auto* fcAsset = services_.assetManager; - VkDescriptorSet fcIcon = (focusCast->spellId != 0 && fcAsset) - ? getSpellIcon(focusCast->spellId, fcAsset) : VK_NULL_HANDLE; - if (fcIcon) { - ImGui::Image((ImTextureID)(uintptr_t)fcIcon, ImVec2(12, 12)); - ImGui::SameLine(0, 2); - ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); - } else { - ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); - } - } - ImGui::PopStyleColor(); - } - } - - // Focus auras — buffs first, then debuffs, up to 8 icons wide - { - const std::vector* focusAuras = - (focus->getGuid() == gameHandler.getTargetGuid()) - ? &gameHandler.getTargetAuras() - : gameHandler.getUnitAuras(focus->getGuid()); - - if (focusAuras) { - int activeCount = 0; - for (const auto& a : *focusAuras) if (!a.isEmpty()) activeCount++; - if (activeCount > 0) { - auto* focusAsset = services_.assetManager; - constexpr float FA_ICON = 20.0f; - constexpr int FA_PER_ROW = 10; - - ImGui::Separator(); - - uint64_t faNowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - - // Sort: debuffs first (so hostile-caster info is prominent), then buffs - std::vector faIdx; - faIdx.reserve(focusAuras->size()); - for (size_t i = 0; i < focusAuras->size(); ++i) - if (!(*focusAuras)[i].isEmpty()) faIdx.push_back(i); - std::sort(faIdx.begin(), faIdx.end(), [&](size_t a, size_t b) { - bool aD = ((*focusAuras)[a].flags & 0x80) != 0; - bool bD = ((*focusAuras)[b].flags & 0x80) != 0; - if (aD != bD) return aD > bD; // debuffs first - int32_t ra = (*focusAuras)[a].getRemainingMs(faNowMs); - int32_t rb = (*focusAuras)[b].getRemainingMs(faNowMs); - if (ra < 0 && rb < 0) return false; - if (ra < 0) return false; - if (rb < 0) return true; - return ra < rb; - }); - - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); - int faShown = 0; - for (size_t si = 0; si < faIdx.size() && faShown < 20; ++si) { - const auto& aura = (*focusAuras)[faIdx[si]]; - bool isBuff = (aura.flags & 0x80) == 0; - - if (faShown > 0 && faShown % FA_PER_ROW != 0) ImGui::SameLine(); - ImGui::PushID(static_cast(faIdx[si]) + 3000); - - ImVec4 borderCol; - if (isBuff) { - borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); - } else { - uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); - switch (dt) { - case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; - case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; - case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; - case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; - default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; - } - } - - VkDescriptorSet faIcon = (focusAsset) - ? getSpellIcon(aura.spellId, focusAsset) : VK_NULL_HANDLE; - if (faIcon) { - ImGui::PushStyleColor(ImGuiCol_Button, borderCol); - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); - ImGui::ImageButton("##faura", - (ImTextureID)(uintptr_t)faIcon, - ImVec2(FA_ICON - 2, FA_ICON - 2)); - ImGui::PopStyleVar(); - ImGui::PopStyleColor(); - } else { - ImGui::PushStyleColor(ImGuiCol_Button, borderCol); - char lab[8]; - snprintf(lab, sizeof(lab), "%u", aura.spellId); - ImGui::Button(lab, ImVec2(FA_ICON, FA_ICON)); - ImGui::PopStyleColor(); - } - - // Duration overlay - int32_t faRemain = aura.getRemainingMs(faNowMs); - if (faRemain > 0) { - ImVec2 imin = ImGui::GetItemRectMin(); - ImVec2 imax = ImGui::GetItemRectMax(); - char ts[12]; - fmtDurationCompact(ts, sizeof(ts), (faRemain + 999) / 1000); - ImVec2 tsz = ImGui::CalcTextSize(ts); - float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; - float cy = imax.y - tsz.y - 1.0f; - ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); - ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); - } - - // Stack / charge count — upper-left corner (parity with target frame) - if (aura.charges > 1) { - ImVec2 faMin = ImGui::GetItemRectMin(); - char chargeStr[8]; - snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); - ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 3, faMin.y + 3), - IM_COL32(0, 0, 0, 200), chargeStr); - ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 2, faMin.y + 2), - IM_COL32(255, 220, 50, 255), chargeStr); - } - - // Tooltip - if (ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - bool richOk = spellbookScreen.renderSpellInfoTooltip( - aura.spellId, gameHandler, focusAsset); - if (!richOk) { - std::string nm = spellbookScreen.lookupSpellName(aura.spellId, focusAsset); - if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); - ImGui::Text("%s", nm.c_str()); - } - renderAuraRemaining(faRemain); - ImGui::EndTooltip(); - } - - ImGui::PopID(); - faShown++; - } - ImGui::PopStyleVar(); - } - } - } - - // Target-of-Focus: who the focus target is currently targeting - { - uint64_t fofGuid = 0; - const auto& fFields = focus->getFields(); - auto fItLo = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); - if (fItLo != fFields.end()) { - fofGuid = fItLo->second; - auto fItHi = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); - if (fItHi != fFields.end()) - fofGuid |= (static_cast(fItHi->second) << 32); - } - if (fofGuid != 0) { - auto fofEnt = gameHandler.getEntityManager().getEntity(fofGuid); - std::string fofName; - ImVec4 fofColor(0.7f, 0.7f, 0.7f, 1.0f); - if (fofGuid == gameHandler.getPlayerGuid()) { - fofName = "You"; - fofColor = kColorBrightGreen; - } else if (fofEnt) { - fofName = getEntityName(fofEnt); - uint8_t fcid = entityClassId(fofEnt.get()); - if (fcid != 0) fofColor = classColorVec4(fcid); - } - if (!fofName.empty()) { - ImGui::TextDisabled("▶"); - ImGui::SameLine(0, 2); - ImGui::TextColored(fofColor, "%s", fofName.c_str()); - if (ImGui::IsItemHovered()) - ImGui::SetTooltip("Focus's target: %s\nClick to target", fofName.c_str()); - if (ImGui::IsItemClicked()) - gameHandler.setTarget(fofGuid); - - // Compact health bar for target-of-focus - if (fofEnt) { - auto fofUnit = std::dynamic_pointer_cast(fofEnt); - if (fofUnit && fofUnit->getMaxHealth() > 0) { - float fofPct = static_cast(fofUnit->getHealth()) / - static_cast(fofUnit->getMaxHealth()); - ImVec4 fofBarColor = - fofPct > 0.5f ? colors::kCastGreen : - fofPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : - ImVec4(0.75f, 0.2f, 0.2f, 1.0f); - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, fofBarColor); - ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); - char fofOverlay[32]; - snprintf(fofOverlay, sizeof(fofOverlay), "%u%%", - static_cast(fofPct * 100.0f + 0.5f)); - ImGui::ProgressBar(fofPct, ImVec2(-1, 10), fofOverlay); - ImGui::PopStyleColor(2); - } - } - } - } - } - - // Distance to focus target - { - const auto& mv = gameHandler.getMovementInfo(); - float fdx = focus->getX() - mv.x; - float fdy = focus->getY() - mv.y; - float fdz = focus->getZ() - mv.z; - float fdist = std::sqrt(fdx * fdx + fdy * fdy + fdz * fdz); - ImGui::TextDisabled("%.1f yd", fdist); - } - - // Clicking the focus frame targets it - if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { - gameHandler.setTarget(focus->getGuid()); - } - } - ImGui::End(); - - ImGui::PopStyleColor(2); - ImGui::PopStyleVar(); -} - -void GameScreen::updateCharacterGeosets(game::Inventory& inventory) { - auto& app = core::Application::getInstance(); - auto* renderer = app.getRenderer(); - if (!renderer) return; - - uint32_t instanceId = renderer->getCharacterInstanceId(); - if (instanceId == 0) return; - - auto* charRenderer = renderer->getCharacterRenderer(); - if (!charRenderer) return; - - auto* assetManager = app.getAssetManager(); - - // Load ItemDisplayInfo.dbc for geosetGroup lookup - std::shared_ptr displayInfoDbc; - if (assetManager) { - displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); - } - - // Helper: get geosetGroup field for an equipped item's displayInfoId - // DBC binary fields: 7=geosetGroup_1, 8=geosetGroup_2, 9=geosetGroup_3 - auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t { - if (!displayInfoDbc || displayInfoId == 0) return 0; - int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); - if (recIdx < 0) return 0; - return displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); - }; - - // Helper: find first equipped item matching inventoryType, return its displayInfoId - auto findEquippedDisplayId = [&](std::initializer_list types) -> uint32_t { - for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { - const auto& slot = inventory.getEquipSlot(static_cast(s)); - if (!slot.empty()) { - for (uint8_t t : types) { - if (slot.item.inventoryType == t) - return slot.item.displayInfoId; - } - } - } - return 0; - }; - - // Helper: check if any equipment slot has the given inventoryType - auto hasEquippedType = [&](std::initializer_list types) -> bool { - for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { - const auto& slot = inventory.getEquipSlot(static_cast(s)); - if (!slot.empty()) { - for (uint8_t t : types) { - if (slot.item.inventoryType == t) return true; - } - } - } - return false; - }; - - // Base geosets always present (group 0: IDs 0-99, some models use up to 27) - std::unordered_set geosets; - for (uint16_t i = 0; i <= 99; i++) { - geosets.insert(i); - } - // Hair/facial geosets must match the active character's appearance, otherwise - // we end up forcing a default hair mesh (often perceived as "wrong hair"). - { - uint8_t hairStyleId = 0; - uint8_t facialId = 0; - if (auto* gh = app.getGameHandler()) { - if (const auto* ch = gh->getActiveCharacter()) { - hairStyleId = static_cast((ch->appearanceBytes >> 16) & 0xFF); - facialId = ch->facialFeatures; - } - } - geosets.insert(static_cast(100 + hairStyleId + 1)); // Group 1 hair - geosets.insert(static_cast(200 + facialId + 1)); // Group 2 facial - } - geosets.insert(702); // Ears: visible (default) - geosets.insert(2002); // Bare feet mesh (group 20 = CG_FEET, always on) - - // CharGeosets mapping (verified via vertex bounding boxes): - // Group 4 (401+) = GLOVES (forearm area, Z~1.1-1.4) - // Group 5 (501+) = BOOTS (shin area, Z~0.1-0.6) - // Group 8 (801+) = WRISTBANDS/SLEEVES (controlled by chest armor) - // Group 9 (901+) = KNEEPADS - // Group 13 (1301+) = TROUSERS/PANTS - // Group 15 (1501+) = CAPE/CLOAK - // Group 20 (2002) = FEET - - // Gloves: inventoryType 10 → group 4 (forearms) - // 401=bare forearms, 402+=glove styles covering forearm - { - uint32_t did = findEquippedDisplayId({10}); - uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 401 + gg : 401)); - } - - // Boots: inventoryType 8 → group 5 (shins/lower legs) - // 501=narrow bare shin, 502=wider (matches thigh width better). Use 502 as bare default. - // When boots equipped, gg selects boot style: 501+gg (gg=1→502, gg=2→503, etc.) - { - uint32_t did = findEquippedDisplayId({8}); - uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 501 + gg : 502)); - } - - // Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe) - // Controls group 8 (wristbands/sleeve length): 801=bare wrists, 802+=sleeve styles - // Also controls group 13 (trousers) via GeosetGroup[2] for robes - { - uint32_t did = findEquippedDisplayId({4, 5, 20}); - uint32_t gg = getGeosetGroup(did, 0); - geosets.insert(static_cast(gg > 0 ? 801 + gg : 801)); - // Robe kilt: GeosetGroup[2] > 0 → show kilt legs (1302+) - uint32_t gg3 = getGeosetGroup(did, 2); - if (gg3 > 0) { - geosets.insert(static_cast(1301 + gg3)); - } - } - - // Kneepads: group 9 (always default 902) - geosets.insert(902); - - // Legs/Pants: inventoryType 7 → group 13 (trousers/thighs) - // 1301=bare legs, 1302+=pant/kilt styles - { - uint32_t did = findEquippedDisplayId({7}); - uint32_t gg = getGeosetGroup(did, 0); - // Only add if robe hasn't already set a kilt geoset - if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { - geosets.insert(static_cast(gg > 0 ? 1301 + gg : 1301)); - } - } - - // Back/Cloak: inventoryType 16 → group 15 - geosets.insert(hasEquippedType({16}) ? 1502 : 1501); - - // Tabard: inventoryType 19 → group 12 - if (hasEquippedType({19})) { - geosets.insert(1201); - } - - charRenderer->setActiveGeosets(instanceId, geosets); -} - -void GameScreen::updateCharacterTextures(game::Inventory& inventory) { - auto& app = core::Application::getInstance(); - auto* renderer = app.getRenderer(); - if (!renderer) return; - - auto* charRenderer = renderer->getCharacterRenderer(); - if (!charRenderer) return; - - auto* assetManager = app.getAssetManager(); - if (!assetManager) return; - - const auto& bodySkinPath = app.getBodySkinPath(); - const auto& underwearPaths = app.getUnderwearPaths(); - uint32_t skinSlot = app.getSkinTextureSlotIndex(); - - if (bodySkinPath.empty()) return; - - // Component directory names indexed by region - static constexpr const char* componentDirs[] = { - "ArmUpperTexture", // 0 - "ArmLowerTexture", // 1 - "HandTexture", // 2 - "TorsoUpperTexture", // 3 - "TorsoLowerTexture", // 4 - "LegUpperTexture", // 5 - "LegLowerTexture", // 6 - "FootTexture", // 7 - }; - - // Load ItemDisplayInfo.dbc - auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); - if (!displayInfoDbc) return; - const auto* idiL = pipeline::getActiveDBCLayout() - ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - uint32_t texRegionFields[8]; - pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields); - - // Collect equipment texture regions from all equipped items - std::vector> regionLayers; - - for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { - const auto& slot = inventory.getEquipSlot(static_cast(s)); - if (slot.empty() || slot.item.displayInfoId == 0) continue; - - int32_t recIdx = displayInfoDbc->findRecordById(slot.item.displayInfoId); - if (recIdx < 0) continue; - - for (int region = 0; region < 8; region++) { - std::string texName = displayInfoDbc->getString( - static_cast(recIdx), texRegionFields[region]); - if (texName.empty()) continue; - - // Actual MPQ files have a gender suffix: _M (male), _F (female), _U (unisex) - // Try gender-specific first, then unisex fallback - std::string base = "Item\\TextureComponents\\" + - std::string(componentDirs[region]) + "\\" + texName; - // Determine gender suffix from active character - bool isFemale = false; - if (auto* gh = app.getGameHandler()) { - if (auto* ch = gh->getActiveCharacter()) { - isFemale = (ch->gender == game::Gender::FEMALE) || - (ch->gender == game::Gender::NONBINARY && ch->useFemaleModel); - } - } - std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); - std::string unisexPath = base + "_U.blp"; - std::string fullPath; - if (assetManager->fileExists(genderPath)) { - fullPath = genderPath; - } else if (assetManager->fileExists(unisexPath)) { - fullPath = unisexPath; - } else { - // Last resort: try without suffix - fullPath = base + ".blp"; - } - regionLayers.emplace_back(region, fullPath); - } - } - - // Re-composite: base skin + underwear + equipment regions - // Clear composite cache first to prevent stale textures from being reused - charRenderer->clearCompositeCache(); - // Use per-instance texture override (not model-level) to avoid deleting cached composites. - uint32_t instanceId = renderer->getCharacterInstanceId(); - auto* newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers); - if (newTex != nullptr && instanceId != 0) { - charRenderer->setTextureSlotOverride(instanceId, static_cast(skinSlot), newTex); - } - - // Cloak cape texture — separate from skin atlas, uses texture slot type-2 (Object Skin) - uint32_t cloakSlot = app.getCloakTextureSlotIndex(); - if (cloakSlot > 0 && instanceId != 0) { - // Find equipped cloak (inventoryType 16) - uint32_t cloakDisplayId = 0; - for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { - const auto& slot = inventory.getEquipSlot(static_cast(s)); - if (!slot.empty() && slot.item.inventoryType == 16 && slot.item.displayInfoId != 0) { - cloakDisplayId = slot.item.displayInfoId; - break; - } - } - - if (cloakDisplayId > 0) { - int32_t recIdx = displayInfoDbc->findRecordById(cloakDisplayId); - if (recIdx >= 0) { - // DBC field 3 = modelTexture_1 (cape texture name) - const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; - std::string capeName = displayInfoDbc->getString(static_cast(recIdx), dispL ? (*dispL)["LeftModelTexture"] : 3); - if (!capeName.empty()) { - std::string capePath = "Item\\ObjectComponents\\Cape\\" + capeName + ".blp"; - auto* capeTex = charRenderer->loadTexture(capePath); - if (capeTex != nullptr) { - charRenderer->setTextureSlotOverride(instanceId, static_cast(cloakSlot), capeTex); - LOG_INFO("Cloak texture applied: ", capePath); - } - } - } - } else { - // No cloak equipped — clear override so model's default (white) shows - charRenderer->clearTextureSlotOverride(instanceId, static_cast(cloakSlot)); - } - } -} - -// ============================================================ -// World Map -// ============================================================ - -void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { - if (!showWorldMap_) return; - - auto& app = core::Application::getInstance(); - auto* renderer = app.getRenderer(); - if (!renderer) return; - - auto* wm = renderer->getWorldMap(); - if (!wm) return; - - // Keep map name in sync with minimap's map name - auto* minimap = renderer->getMinimap(); - if (minimap) { - wm->setMapName(minimap->getMapName()); - } - wm->setServerExplorationMask( - gameHandler.getPlayerExploredZoneMasks(), - gameHandler.hasPlayerExploredZoneMasks()); - - // Party member dots on world map - { - std::vector dots; - if (gameHandler.isInGroup()) { - const auto& partyData = gameHandler.getPartyData(); - for (const auto& member : partyData.members) { - if (!member.isOnline || !member.hasPartyStats) continue; - if (member.posX == 0 && member.posY == 0) continue; - // posY → canonical X (north), posX → canonical Y (west) - float wowX = static_cast(member.posY); - float wowY = static_cast(member.posX); - glm::vec3 rpos = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f)); - auto ent = gameHandler.getEntityManager().getEntity(member.guid); - uint8_t cid = entityClassId(ent.get()); - ImU32 col = (cid != 0) - ? classColorU32(cid, 230) - : (member.guid == partyData.leaderGuid - ? IM_COL32(255, 210, 0, 230) - : IM_COL32(100, 180, 255, 230)); - dots.push_back({ rpos, col, member.name }); - } - } - wm->setPartyDots(std::move(dots)); - } - - // Taxi node markers on world map - { - std::vector taxiNodes; - const auto& nodes = gameHandler.getTaxiNodes(); - taxiNodes.reserve(nodes.size()); - for (const auto& [id, node] : nodes) { - rendering::WorldMapTaxiNode wtn; - wtn.id = node.id; - wtn.mapId = node.mapId; - wtn.wowX = node.x; - wtn.wowY = node.y; - wtn.wowZ = node.z; - wtn.name = node.name; - wtn.known = gameHandler.isKnownTaxiNode(id); - taxiNodes.push_back(std::move(wtn)); - } - wm->setTaxiNodes(std::move(taxiNodes)); - } - - // Quest POI markers on world map (from SMSG_QUEST_POI_QUERY_RESPONSE / gossip POIs) - { - std::vector qpois; - for (const auto& poi : gameHandler.getGossipPois()) { - rendering::WorldMap::QuestPoi qp; - qp.wowX = poi.x; - qp.wowY = poi.y; - qp.name = poi.name; - qpois.push_back(std::move(qp)); - } - wm->setQuestPois(std::move(qpois)); - } - - // Corpse marker: show skull X on world map when ghost with unclaimed corpse - { - float corpseCanX = 0.0f, corpseCanY = 0.0f; - bool ghostWithCorpse = gameHandler.isPlayerGhost() && - gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY); - glm::vec3 corpseRender = ghostWithCorpse - ? core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)) - : glm::vec3{}; - wm->setCorpsePos(ghostWithCorpse, corpseRender); - } - - glm::vec3 playerPos = renderer->getCharacterPosition(); - float playerYaw = renderer->getCharacterYaw(); - auto* window = app.getWindow(); - int screenW = window ? window->getWidth() : 1280; - int screenH = window ? window->getHeight() : 720; - wm->render(playerPos, screenW, screenH, playerYaw); - - // Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay). - if (!wm->isOpen()) showWorldMap_ = false; -} - -// ============================================================ -// Action Bar -// ============================================================ - -VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) { - if (spellId == 0 || !am) return VK_NULL_HANDLE; - - // Check cache first - auto cit = spellIconCache_.find(spellId); - if (cit != spellIconCache_.end()) return cit->second; - - // Lazy-load SpellIcon.dbc and Spell.dbc icon IDs - if (!spellIconDbLoaded_) { - spellIconDbLoaded_ = true; - - // Load SpellIcon.dbc: field 0 = ID, field 1 = icon path - auto iconDbc = am->loadDBC("SpellIcon.dbc"); - const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr; - if (iconDbc && iconDbc->isLoaded()) { - for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) { - uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0); - std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1); - if (!path.empty() && id > 0) { - spellIconPaths_[id] = path; - } - } - } - - // Load Spell.dbc: SpellIconID field - auto spellDbc = am->loadDBC("Spell.dbc"); - const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; - if (spellDbc && spellDbc->isLoaded()) { - uint32_t fieldCount = spellDbc->getFieldCount(); - // Helper to load icons for a given field layout - auto tryLoadIcons = [&](uint32_t idField, uint32_t iconField) { - spellIconIds_.clear(); - if (iconField >= fieldCount) return; - for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) { - uint32_t id = spellDbc->getUInt32(i, idField); - uint32_t iconId = spellDbc->getUInt32(i, iconField); - if (id > 0 && iconId > 0) { - spellIconIds_[id] = iconId; - } - } - }; - - // Use expansion-aware layout if available AND the DBC field count - // matches the expansion's expected format. Classic=173, TBC=216, - // WotLK=234 fields. When Classic is active but the base WotLK DBC - // is loaded (234 fields), field 117 is NOT IconID — we must use - // the WotLK field 133 instead. - uint32_t iconField = 133; // WotLK default - uint32_t idField = 0; - if (spellL) { - uint32_t layoutIcon = (*spellL)["IconID"]; - // Only trust the expansion layout if the DBC has a compatible - // field count (within ~20 of the layout's icon field). - if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) { - iconField = layoutIcon; - idField = (*spellL)["ID"]; - } - } - tryLoadIcons(idField, iconField); - } - } - - // Rate-limit GPU uploads per frame to prevent stalls when many icons are uncached - // (e.g., first login, after loading screen, or many new auras appearing at once). - static int gsLoadsThisFrame = 0; - static int gsLastImGuiFrame = -1; - int gsCurFrame = ImGui::GetFrameCount(); - if (gsCurFrame != gsLastImGuiFrame) { gsLoadsThisFrame = 0; gsLastImGuiFrame = gsCurFrame; } - if (gsLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here - - // Look up spellId -> SpellIconID -> icon path - auto iit = spellIconIds_.find(spellId); - if (iit == spellIconIds_.end()) { - spellIconCache_[spellId] = VK_NULL_HANDLE; - return VK_NULL_HANDLE; - } - - auto pit = spellIconPaths_.find(iit->second); - if (pit == spellIconPaths_.end()) { - spellIconCache_[spellId] = VK_NULL_HANDLE; - return VK_NULL_HANDLE; - } - - // Path from DBC has no extension — append .blp - std::string iconPath = pit->second + ".blp"; - auto blpData = am->readFile(iconPath); - if (blpData.empty()) { - spellIconCache_[spellId] = VK_NULL_HANDLE; - return VK_NULL_HANDLE; - } - - auto image = pipeline::BLPLoader::load(blpData); - if (!image.isValid()) { - spellIconCache_[spellId] = VK_NULL_HANDLE; - return VK_NULL_HANDLE; - } - - // Upload to Vulkan via VkContext - auto* window = services_.window; - auto* vkCtx = window ? window->getVkContext() : nullptr; - if (!vkCtx) { - spellIconCache_[spellId] = VK_NULL_HANDLE; - return VK_NULL_HANDLE; - } - - ++gsLoadsThisFrame; - VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); - spellIconCache_[spellId] = ds; - return ds; -} - -// ============================================================ -// Mirror Timers (breath / fatigue / feign death) -// ============================================================ - -void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { - ImVec2 displaySize = ImGui::GetIO().DisplaySize; - float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; - float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; - - static constexpr struct { const char* label; ImVec4 color; } kTimerInfo[3] = { - { "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) }, - { "Breath", ImVec4(0.2f, 0.5f, 1.0f, 1.0f) }, - { "Feign", kColorGray }, - }; - - float barW = 280.0f; - float barH = 36.0f; - float barX = (screenW - barW) / 2.0f; - float baseY = screenH - 160.0f; // Just above the cast bar slot - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoInputs; - - for (int i = 0; i < 3; ++i) { - const auto& t = gameHandler.getMirrorTimer(i); - if (!t.active || t.maxValue <= 0) continue; - - float frac = static_cast(t.value) / static_cast(t.maxValue); - frac = std::max(0.0f, std::min(1.0f, frac)); - - char winId[32]; - std::snprintf(winId, sizeof(winId), "##MirrorTimer%d", i); - ImGui::SetNextWindowPos(ImVec2(barX, baseY - i * (barH + 4.0f)), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); - - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.88f)); - if (ImGui::Begin(winId, nullptr, flags)) { - ImGui::PushStyleColor(ImGuiCol_PlotHistogram, kTimerInfo[i].color); - char overlay[48]; - float sec = static_cast(t.value) / 1000.0f; - std::snprintf(overlay, sizeof(overlay), "%s %.0fs", kTimerInfo[i].label, sec); - ImGui::ProgressBar(frac, ImVec2(-1, 20), overlay); - ImGui::PopStyleColor(); - } - ImGui::End(); - ImGui::PopStyleColor(); - ImGui::PopStyleVar(); - } -} - -// ============================================================ -// Cooldown Tracker — floating panel showing all active spell CDs -// ============================================================ - -// ============================================================ -// Quest Objective Tracker (right-side HUD) -// ============================================================ - -void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { - const auto& questLog = gameHandler.getQuestLog(); - if (questLog.empty()) return; - - auto* window = services_.window; - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - - constexpr float TRACKER_W = 220.0f; - 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 screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f; - - // Default position: top-right, below minimap + buff bar space. - // questTrackerRightOffset_ stores pixels from the right edge so the tracker - // stays anchored to the right side when the window is resized. - if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) { - questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned - questTrackerPos_.y = 320.0f; - questTrackerPosInit_ = true; - } - // Recompute X from right offset every frame (handles window resize) - questTrackerPos_.x = screenW - questTrackerRightOffset_; - - ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); - ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | - ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoBringToFrontOnFocus; - - ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); - - if (ImGui::Begin("##QuestTracker", nullptr, flags)) { - for (int i = 0; i < static_cast(toShow.size()); ++i) { - const auto& q = *toShow[i]; - - // Clickable quest title — opens quest log - ImGui::PushID(q.questId); - ImVec4 titleCol = q.complete ? colors::kWarmGold - : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); - ImGui::PushStyleColor(ImGuiCol_Text, titleCol); - if (ImGui::Selectable(q.title.c_str(), false, - ImGuiSelectableFlags_DontClosePopups, ImVec2(ImGui::GetContentRegionAvail().x, 0))) { - questLogScreen.openAndSelectQuest(q.questId); - } - if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) { - ImGui::SetTooltip("Click: open Quest Log | Right-click: tracking options"); - } - ImGui::PopStyleColor(); - - // Right-click context menu for quest tracker entry - if (ImGui::BeginPopupContextItem("##QTCtx")) { - ImGui::TextDisabled("%s", q.title.c_str()); - ImGui::Separator(); - if (ImGui::MenuItem("Open in Quest Log")) { - questLogScreen.openAndSelectQuest(q.questId); - } - bool tracked = gameHandler.isQuestTracked(q.questId); - if (tracked) { - if (ImGui::MenuItem("Stop Tracking")) { - gameHandler.setQuestTracked(q.questId, false); - } - } else { - if (ImGui::MenuItem("Track")) { - gameHandler.setQuestTracked(q.questId, true); - } - } - if (gameHandler.isInGroup() && !q.complete) { - if (ImGui::MenuItem("Share Quest")) { - gameHandler.shareQuestWithParty(q.questId); - } - } - if (!q.complete) { - ImGui::Separator(); - if (ImGui::MenuItem("Abandon Quest")) { - gameHandler.abandonQuest(q.questId); - gameHandler.setQuestTracked(q.questId, false); - } - } - ImGui::EndPopup(); - } - ImGui::PopID(); - - // Objectives line (condensed) - if (q.complete) { - ImGui::TextColored(colors::kActiveGreen, " (Complete)"); - } else { - // Kill counts — green when complete, gray when in progress - for (const auto& [entry, progress] : q.killCounts) { - bool objDone = (progress.first >= progress.second && progress.second > 0); - ImVec4 objColor = objDone ? kColorGreen - : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); - std::string name = gameHandler.getCachedCreatureName(entry); - if (name.empty()) { - const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); - if (goInfo && !goInfo->name.empty()) name = goInfo->name; - } - if (!name.empty()) { - ImGui::TextColored(objColor, - " %s: %u/%u", name.c_str(), - progress.first, progress.second); - } else { - ImGui::TextColored(objColor, - " %u/%u", progress.first, progress.second); - } - } - // Item counts — green when complete, gray when in progress - for (const auto& [itemId, count] : q.itemCounts) { - uint32_t required = 1; - auto reqIt = q.requiredItemCounts.find(itemId); - if (reqIt != q.requiredItemCounts.end()) required = reqIt->second; - bool objDone = (count >= required); - ImVec4 objColor = objDone ? kColorGreen - : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); - const auto* info = gameHandler.getItemInfo(itemId); - const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr; - - // Show small icon if available - uint32_t dispId = (info && info->displayInfoId) ? info->displayInfoId : 0; - VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; - if (iconTex) { - ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12)); - if (info && info->valid && ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - inventoryScreen.renderItemTooltip(*info); - ImGui::EndTooltip(); - } - ImGui::SameLine(0, 3); - ImGui::TextColored(objColor, - "%s: %u/%u", itemName ? itemName : "Item", count, required); - if (info && info->valid && ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - inventoryScreen.renderItemTooltip(*info); - ImGui::EndTooltip(); - } - } else if (itemName) { - ImGui::TextColored(objColor, - " %s: %u/%u", itemName, count, required); - if (info && info->valid && ImGui::IsItemHovered()) { - ImGui::BeginTooltip(); - inventoryScreen.renderItemTooltip(*info); - ImGui::EndTooltip(); - } - } else { - ImGui::TextColored(objColor, - " Item: %u/%u", count, required); - } - } - if (q.killCounts.empty() && q.itemCounts.empty() && !q.objectives.empty()) { - const std::string& obj = q.objectives; - if (obj.size() > 40) { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), - " %.37s...", obj.c_str()); - } else { - ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), - " %s", obj.c_str()); - } - } - } - - if (i < static_cast(toShow.size()) - 1) { - ImGui::Spacing(); - } - } - - // Capture position and size after drag/resize - ImVec2 newPos = ImGui::GetWindowPos(); - ImVec2 newSize = ImGui::GetWindowSize(); - bool changed = false; - - // Clamp within screen - newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x); - newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); - - if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || - std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { - questTrackerPos_ = newPos; - // Update right offset so resizes keep the new position anchored - questTrackerRightOffset_ = screenW - newPos.x; - changed = true; - } - if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f || - std::abs(newSize.y - questTrackerSize_.y) > 0.5f) { - questTrackerSize_ = newSize; - changed = true; - } - if (changed) saveSettings(); - } - ImGui::End(); - - ImGui::PopStyleVar(2); - ImGui::PopStyleColor(); -} - -// ============================================================ -// Nameplates — world-space health bars projected to screen -// ============================================================ - -void GameScreen::renderNameplates(game::GameHandler& gameHandler) { - if (gameHandler.getState() != game::WorldState::IN_WORLD) return; - - // Reset mouseover each frame; we'll set it below when the cursor is over a nameplate - gameHandler.setMouseoverGuid(0); - - auto* appRenderer = services_.renderer; - if (!appRenderer) return; - rendering::Camera* camera = appRenderer->getCamera(); - if (!camera) return; - - auto* window = services_.window; - if (!window) return; - const float screenW = static_cast(window->getWidth()); - const float screenH = static_cast(window->getHeight()); - - const glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); - const glm::vec3 camPos = camera->getPosition(); - const uint64_t playerGuid = gameHandler.getPlayerGuid(); - const uint64_t targetGuid = gameHandler.getTargetGuid(); - - // Build set of creature entries that are kill objectives in active (incomplete) quests. - std::unordered_set questKillEntries; - { - const auto& questLog = gameHandler.getQuestLog(); - const auto& trackedIds = gameHandler.getTrackedQuestIds(); - for (const auto& q : questLog) { - if (q.complete || q.questId == 0) continue; - // Only highlight for tracked quests (or all if nothing tracked). - if (!trackedIds.empty() && !trackedIds.count(q.questId)) continue; - for (const auto& obj : q.killObjectives) { - if (obj.npcOrGoId > 0 && obj.required > 0) { - // Check if not already completed. - auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); - if (it == q.killCounts.end() || it->second.first < it->second.second) { - questKillEntries.insert(static_cast(obj.npcOrGoId)); - } - } - } - } - } - - ImDrawList* drawList = ImGui::GetBackgroundDrawList(); - - for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) { - if (!entityPtr || guid == playerGuid) continue; - - if (!entityPtr->isUnit()) continue; - auto* unit = static_cast(entityPtr.get()); - if (unit->getMaxHealth() == 0) continue; - - bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER); - bool isTarget = (guid == targetGuid); - - // Player nameplates use Shift+V toggle; NPC/enemy nameplates use V toggle - if (isPlayer && !settingsPanel_.showFriendlyNameplates_) continue; - if (!isPlayer && !showNameplates_) continue; - - // For corpses (dead units), only show a minimal grey nameplate if selected - bool isCorpse = (unit->getHealth() == 0); - if (isCorpse && !isTarget) continue; - - // Prefer the renderer's actual instance position so the nameplate tracks the - // rendered model exactly (avoids drift from the parallel entity interpolator). - glm::vec3 renderPos; - if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) { - renderPos = core::coords::canonicalToRender( - glm::vec3(unit->getX(), unit->getY(), unit->getZ())); - } - renderPos.z += 2.3f; - - // Cull distance: target or other players up to 40 units; NPC others up to 20 units - glm::vec3 nameDelta = renderPos - camPos; - float distSq = glm::dot(nameDelta, nameDelta); - float cullDist = (isTarget || isPlayer) ? 40.0f : 20.0f; - if (distSq > cullDist * cullDist) continue; - - // Project to clip space - glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); - if (clipPos.w <= 0.01f) continue; // Behind camera - - glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; - if (ndc.x < -1.2f || ndc.x > 1.2f || ndc.y < -1.2f || ndc.y > 1.2f) continue; - - // NDC → screen pixels. - // The camera bakes the Vulkan Y-flip into the projection matrix, so - // NDC y = -1 is the top of the screen and y = 1 is the bottom. - // Map directly: sy = (ndc.y + 1) / 2 * screenH (no extra inversion). - 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 cull range - float fadeSq = (cullDist - 5.0f) * (cullDist - 5.0f); - float dist = std::sqrt(distSq); - float alpha = distSq < fadeSq ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f; - auto A = [&](int v) { return static_cast(v * alpha); }; - - // Bar colour by hostility (grey for corpses) - ImU32 barColor, bgColor; - if (isCorpse) { - // Minimal grey bar for selected corpses (loot/skin targets) - barColor = IM_COL32(140, 140, 140, A(200)); - bgColor = IM_COL32(70, 70, 70, A(160)); - } else if (unit->isHostile()) { - // Check if mob is tapped by another player (grey nameplate) - uint32_t dynFlags = unit->getDynamicFlags(); - bool tappedByOther = (dynFlags & 0x0004) != 0 && (dynFlags & 0x0008) == 0; // TAPPED but not TAPPED_BY_ALL_THREAT_LIST - if (tappedByOther) { - barColor = IM_COL32(160, 160, 160, A(200)); - bgColor = IM_COL32(80, 80, 80, A(160)); - } else { - barColor = IM_COL32(220, 60, 60, A(200)); - bgColor = IM_COL32(100, 25, 25, A(160)); - } - } else if (isPlayer) { - // Player nameplates: use class color for easy identification - uint8_t cid = entityClassId(unit); - if (cid != 0) { - ImVec4 cv = classColorVec4(cid); - barColor = IM_COL32( - static_cast(cv.x * 255), - static_cast(cv.y * 255), - static_cast(cv.z * 255), A(210)); - bgColor = IM_COL32( - static_cast(cv.x * 80), - static_cast(cv.y * 80), - static_cast(cv.z * 80), A(160)); - } else { - barColor = IM_COL32(60, 200, 80, A(200)); - bgColor = IM_COL32(25, 100, 35, A(160)); - } - } else { - barColor = IM_COL32(60, 200, 80, A(200)); - bgColor = IM_COL32(25, 100, 35, A(160)); - } - // Check if this unit is targeting the local player (threat indicator) - bool isTargetingPlayer = false; - if (unit->isHostile() && !isCorpse) { - const auto& fields = entityPtr->getFields(); - auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); - if (loIt != fields.end() && loIt->second != 0) { - uint64_t unitTarget = loIt->second; - auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); - if (hiIt != fields.end()) - unitTarget |= (static_cast(hiIt->second) << 32); - isTargetingPlayer = (unitTarget == playerGuid); - } - } - // Creature rank for border styling (Elite=gold double border, Boss=red, Rare=silver) - int creatureRank = -1; - if (!isPlayer) creatureRank = gameHandler.getCreatureRank(unit->getEntry()); - - // Border: gold = currently selected, orange = targeting player, dark = default - ImU32 borderColor = isTarget - ? IM_COL32(255, 215, 0, A(255)) - : isTargetingPlayer - ? IM_COL32(255, 140, 0, A(220)) // orange = this mob is targeting you - : IM_COL32(20, 20, 20, A(180)); - - // Bar geometry - const float barW = 80.0f * settingsPanel_.nameplateScale_; - const float barH = 8.0f * settingsPanel_.nameplateScale_; - const float barX = sx - barW * 0.5f; - - // Guard against division by zero when maxHealth hasn't been populated yet - // (freshly spawned entity with default fields). 0/0 produces NaN which - // poisons all downstream geometry; +inf is clamped but still wasteful. - float healthPct = (unit->getMaxHealth() > 0) - ? std::clamp(static_cast(unit->getHealth()) / static_cast(unit->getMaxHealth()), 0.0f, 1.0f) - : 0.0f; - - drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f); - // For corpses, don't fill health bar (just show grey background) - if (!isCorpse) { - drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f); - } - drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f); - - // Elite/Boss/Rare decoration: extra outer border with rank-specific color - if (creatureRank == 1 || creatureRank == 2) { - // Elite / Rare Elite: gold double border - drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), - ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), - IM_COL32(255, 200, 50, A(200)), 3.0f); - } else if (creatureRank == 3) { - // Boss: red double border - drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), - ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), - IM_COL32(255, 40, 40, A(200)), 3.0f); - } else if (creatureRank == 4) { - // Rare: silver double border - drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), - ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), - IM_COL32(170, 200, 230, A(200)), 3.0f); - } - - // HP % text centered on health bar (non-corpse, non-full-health for readability) - if (!isCorpse && unit->getMaxHealth() > 0) { - int hpPct = static_cast(healthPct * 100.0f + 0.5f); - char hpBuf[8]; - snprintf(hpBuf, sizeof(hpBuf), "%d%%", hpPct); - ImVec2 hpTextSz = ImGui::CalcTextSize(hpBuf); - float hpTx = sx - hpTextSz.x * 0.5f; - float hpTy = sy + (barH - hpTextSz.y) * 0.5f; - drawList->AddText(ImVec2(hpTx + 1.0f, hpTy + 1.0f), IM_COL32(0, 0, 0, A(140)), hpBuf); - drawList->AddText(ImVec2(hpTx, hpTy), IM_COL32(255, 255, 255, A(200)), hpBuf); - } - - // Cast bar below health bar when unit is casting - float castBarBaseY = sy + barH + 2.0f; - float nameplateBottom = castBarBaseY; // tracks lowest drawn element for debuff dots - { - const auto* cs = gameHandler.getUnitCastState(guid); - if (cs && cs->casting && cs->timeTotal > 0.0f) { - float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f); - const float cbH = 6.0f * settingsPanel_.nameplateScale_; - - // Spell icon + name above the cast bar - const std::string& spellName = gameHandler.getSpellName(cs->spellId); - { - auto* castAm = services_.assetManager; - VkDescriptorSet castIcon = (cs->spellId && castAm) - ? getSpellIcon(cs->spellId, castAm) : VK_NULL_HANDLE; - float iconSz = cbH + 8.0f; - if (castIcon) { - // Draw icon to the left of the cast bar - float iconX = barX - iconSz - 2.0f; - float iconY = castBarBaseY; - drawList->AddImage((ImTextureID)(uintptr_t)castIcon, - ImVec2(iconX, iconY), - ImVec2(iconX + iconSz, iconY + iconSz)); - drawList->AddRect(ImVec2(iconX - 1.0f, iconY - 1.0f), - ImVec2(iconX + iconSz + 1.0f, iconY + iconSz + 1.0f), - IM_COL32(0, 0, 0, A(180)), 1.0f); - } - if (!spellName.empty()) { - ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); - float snX = sx - snSz.x * 0.5f; - float snY = castBarBaseY; - drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); - drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); - castBarBaseY += snSz.y + 2.0f; - } - } - - // Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete - ImU32 cbBg = IM_COL32(30, 25, 40, A(180)); - ImU32 cbFill; - if (castPct > 0.8f && unit->isHostile()) { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); - cbFill = cs->interruptible - ? IM_COL32(static_cast(40 * pulse), static_cast(220 * pulse), static_cast(40 * pulse), A(220)) // green pulse - : IM_COL32(static_cast(255 * pulse), static_cast(30 * pulse), static_cast(30 * pulse), A(220)); // red pulse - } else { - cbFill = cs->interruptible - ? IM_COL32(50, 190, 50, A(200)) // green = interruptible - : IM_COL32(190, 40, 40, A(200)); // red = uninterruptible - } - drawList->AddRectFilled(ImVec2(barX, castBarBaseY), - ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f); - drawList->AddRectFilled(ImVec2(barX, castBarBaseY), - ImVec2(barX + barW * castPct, castBarBaseY + cbH), cbFill, 2.0f); - drawList->AddRect (ImVec2(barX - 1.0f, castBarBaseY - 1.0f), - ImVec2(barX + barW + 1.0f, castBarBaseY + cbH + 1.0f), - IM_COL32(20, 10, 40, A(200)), 2.0f); - - // Time remaining text - char timeBuf[12]; - snprintf(timeBuf, sizeof(timeBuf), "%.1fs", cs->timeRemaining); - ImVec2 timeSz = ImGui::CalcTextSize(timeBuf); - float timeX = sx - timeSz.x * 0.5f; - float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f; - drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf); - drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf); - nameplateBottom = castBarBaseY + cbH + 2.0f; - } - } - - // Debuff dot indicators: small colored squares below the nameplate showing - // player-applied auras on the current hostile target. - // Colors: Magic=blue, Curse=purple, Disease=yellow, Poison=green, Other=grey - if (isTarget && unit->isHostile() && !isCorpse) { - const auto& auras = gameHandler.getTargetAuras(); - const uint64_t pguid = gameHandler.getPlayerGuid(); - const float dotSize = 6.0f * settingsPanel_.nameplateScale_; - const float dotGap = 2.0f; - float dotX = barX; - for (const auto& aura : auras) { - if (aura.isEmpty() || aura.casterGuid != pguid) continue; - uint8_t dispelType = gameHandler.getSpellDispelType(aura.spellId); - ImU32 dotCol; - switch (dispelType) { - case 1: dotCol = IM_COL32( 64, 128, 255, A(210)); break; // Magic - blue - case 2: dotCol = IM_COL32(160, 32, 240, A(210)); break; // Curse - purple - case 3: dotCol = IM_COL32(180, 140, 40, A(210)); break; // Disease - yellow-brown - case 4: dotCol = IM_COL32( 50, 200, 50, A(210)); break; // Poison - green - default: dotCol = IM_COL32(170, 170, 170, A(170)); break; // Other - grey - } - drawList->AddRectFilled(ImVec2(dotX, nameplateBottom), - ImVec2(dotX + dotSize, nameplateBottom + dotSize), dotCol, 1.0f); - drawList->AddRect (ImVec2(dotX - 1.0f, nameplateBottom - 1.0f), - ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), - IM_COL32(0, 0, 0, A(150)), 1.0f); - - // Duration clock-sweep overlay (like target frame auras) - uint64_t nowMs = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now().time_since_epoch()).count()); - int32_t remainMs = aura.getRemainingMs(nowMs); - if (aura.maxDurationMs > 0 && remainMs > 0) { - float pct = 1.0f - static_cast(remainMs) / static_cast(aura.maxDurationMs); - pct = std::clamp(pct, 0.0f, 1.0f); - float cx = dotX + dotSize * 0.5f; - float cy = nameplateBottom + dotSize * 0.5f; - float r = dotSize * 0.5f; - float startAngle = -IM_PI * 0.5f; - float endAngle = startAngle + pct * IM_PI * 2.0f; - ImVec2 center(cx, cy); - const int segments = 12; - for (int seg = 0; seg < segments; seg++) { - float a0 = startAngle + (endAngle - startAngle) * seg / segments; - float a1 = startAngle + (endAngle - startAngle) * (seg + 1) / segments; - drawList->AddTriangleFilled( - center, - ImVec2(cx + r * std::cos(a0), cy + r * std::sin(a0)), - ImVec2(cx + r * std::cos(a1), cy + r * std::sin(a1)), - IM_COL32(0, 0, 0, A(100))); - } - } - - // Stack count on dot (upper-left corner) - if (aura.charges > 1) { - char stackBuf[8]; - snprintf(stackBuf, sizeof(stackBuf), "%d", aura.charges); - drawList->AddText(ImVec2(dotX + 1.0f, nameplateBottom), IM_COL32(0, 0, 0, A(200)), stackBuf); - drawList->AddText(ImVec2(dotX, nameplateBottom - 1.0f), IM_COL32(255, 255, 255, A(240)), stackBuf); - } - - // Duration text below dot - if (remainMs > 0) { - char durBuf[8]; - if (remainMs >= 60000) - snprintf(durBuf, sizeof(durBuf), "%dm", remainMs / 60000); - else - snprintf(durBuf, sizeof(durBuf), "%d", remainMs / 1000); - ImVec2 durSz = ImGui::CalcTextSize(durBuf); - float durX = dotX + (dotSize - durSz.x) * 0.5f; - float durY = nameplateBottom + dotSize + 1.0f; - drawList->AddText(ImVec2(durX + 1.0f, durY + 1.0f), IM_COL32(0, 0, 0, A(180)), durBuf); - // Color: red if < 5s, yellow if < 15s, white otherwise - ImU32 durCol = remainMs < 5000 ? IM_COL32(255, 60, 60, A(240)) - : remainMs < 15000 ? IM_COL32(255, 200, 60, A(240)) - : IM_COL32(230, 230, 230, A(220)); - drawList->AddText(ImVec2(durX, durY), durCol, durBuf); - } - - // Spell name + duration tooltip on hover - { - ImVec2 mouse = ImGui::GetMousePos(); - if (mouse.x >= dotX && mouse.x < dotX + dotSize && - mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) { - const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId); - if (!dotSpellName.empty()) { - if (remainMs > 0) { - int secs = remainMs / 1000; - int mins = secs / 60; - secs %= 60; - char tipBuf[128]; - if (mins > 0) - snprintf(tipBuf, sizeof(tipBuf), "%s (%dm %ds)", dotSpellName.c_str(), mins, secs); - else - snprintf(tipBuf, sizeof(tipBuf), "%s (%ds)", dotSpellName.c_str(), secs); - ImGui::SetTooltip("%s", tipBuf); - } else { - ImGui::SetTooltip("%s", dotSpellName.c_str()); - } - } - } - } - - dotX += dotSize + dotGap; - if (dotX + dotSize > barX + barW) break; - } - } - - // Name + level label above health bar - uint32_t level = unit->getLevel(); - const std::string& unitName = unit->getName(); - char labelBuf[96]; - if (isPlayer) { - // Player nameplates: show name only (no level clutter). - // Fall back to level as placeholder while the name query is pending. - if (!unitName.empty()) - snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); - else { - // Name query may be pending; request it now to ensure it gets resolved - gameHandler.queryPlayerName(unit->getGuid()); - if (level > 0) - snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level); - else - snprintf(labelBuf, sizeof(labelBuf), "Player"); - } - } else if (level > 0) { - uint32_t playerLevel = gameHandler.getPlayerLevel(); - // Show skull for units more than 10 levels above the player - if (playerLevel > 0 && level > playerLevel + 10) - snprintf(labelBuf, sizeof(labelBuf), "?? %s", unitName.c_str()); - else - snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unitName.c_str()); - } else { - snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); - } - ImVec2 textSize = ImGui::CalcTextSize(labelBuf); - float nameX = sx - textSize.x * 0.5f; - float nameY = sy - barH - 12.0f; - // Name color: players get WoW class colors; NPCs use hostility (red/yellow) - ImU32 nameColor; - if (isPlayer) { - // Class color with cyan fallback for unknown class - uint8_t cid = entityClassId(unit); - ImVec4 cc = (cid != 0) ? classColorVec4(cid) : ImVec4(0.31f, 0.78f, 1.0f, 1.0f); - nameColor = IM_COL32(static_cast(cc.x*255), static_cast(cc.y*255), - static_cast(cc.z*255), A(230)); - } else { - nameColor = unit->isHostile() - ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC - : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC - } - // Sub-label below the name: guild tag for players, subtitle for NPCs - std::string subLabel; - if (isPlayer) { - uint32_t guildId = gameHandler.getEntityGuildId(guid); - if (guildId != 0) { - const std::string& gn = gameHandler.lookupGuildName(guildId); - if (!gn.empty()) subLabel = "<" + gn + ">"; - } - } else { - // NPC subtitle (e.g. "", "") - std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); - if (!sub.empty()) subLabel = "<" + sub + ">"; - } - if (!subLabel.empty()) nameY -= 10.0f; // shift name up for sub-label line - - drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); - drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); - - // Sub-label below the name (WoW-style or in lighter color) - if (!subLabel.empty()) { - ImVec2 subSz = ImGui::CalcTextSize(subLabel.c_str()); - float subX = sx - subSz.x * 0.5f; - float subY = nameY + textSize.y + 1.0f; - drawList->AddText(ImVec2(subX + 1.0f, subY + 1.0f), IM_COL32(0, 0, 0, A(120)), subLabel.c_str()); - drawList->AddText(ImVec2(subX, subY), IM_COL32(180, 180, 180, A(200)), subLabel.c_str()); - } - - // Group leader crown to the right of the name on player nameplates - if (isPlayer && gameHandler.isInGroup() && - gameHandler.getPartyData().leaderGuid == guid) { - float crownX = nameX + textSize.x + 3.0f; - const char* crownSym = "\xe2\x99\x9b"; // ♛ - drawList->AddText(ImVec2(crownX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), crownSym); - drawList->AddText(ImVec2(crownX, nameY), IM_COL32(255, 215, 0, A(240)), crownSym); - } - - // Raid mark (if any) to the left of the name - { - static constexpr 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); - } - - // Quest kill objective indicator: small yellow sword icon to the right of the name - float questIconX = nameX + textSize.x + 4.0f; - if (!isPlayer && questKillEntries.count(unit->getEntry())) { - const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8) - drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); - drawList->AddText(ImVec2(questIconX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); - questIconX += ImGui::CalcTextSize("\xe2\x9a\x94").x + 2.0f; - } - - // Quest giver indicator: "!" for available quests, "?" for completable/incomplete - if (!isPlayer) { - using QGS = game::QuestGiverStatus; - QGS qgs = gameHandler.getQuestGiverStatus(guid); - const char* qSym = nullptr; - ImU32 qCol = IM_COL32(255, 210, 0, A(255)); - if (qgs == QGS::AVAILABLE) { - qSym = "!"; - } else if (qgs == QGS::AVAILABLE_LOW) { - qSym = "!"; - qCol = IM_COL32(160, 160, 160, A(220)); - } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { - qSym = "?"; - } else if (qgs == QGS::INCOMPLETE) { - qSym = "?"; - qCol = IM_COL32(160, 160, 160, A(220)); - } - if (qSym) { - drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), qSym); - drawList->AddText(ImVec2(questIconX, nameY), qCol, qSym); - } - } - } - - // Click to target / right-click context: detect clicks inside the nameplate region. - // Use the wider of name text or health bar for the horizontal hit area so short - // names like "Wolf" don't produce a tiny clickable strip narrower than the bar. - if (!ImGui::GetIO().WantCaptureMouse) { - ImVec2 mouse = ImGui::GetIO().MousePos; - float hitLeft = std::min(nameX, barX) - 2.0f; - float hitRight = std::max(nameX + textSize.x, barX + barW) + 2.0f; - float ny0 = nameY - 1.0f; - float ny1 = sy + barH + 2.0f; - float nx0 = hitLeft; - float nx1 = hitRight; - if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { - // Track mouseover for [target=mouseover] macro conditionals - gameHandler.setMouseoverGuid(guid); - // Hover tooltip: name, level/class, guild - ImGui::BeginTooltip(); - ImGui::TextUnformatted(unitName.c_str()); - if (isPlayer) { - uint8_t cid = entityClassId(unit); - ImGui::Text("Level %u %s", level, classNameStr(cid)); - } else if (level > 0) { - ImGui::Text("Level %u", level); - } - if (!subLabel.empty()) ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", subLabel.c_str()); - ImGui::EndTooltip(); - if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { - gameHandler.setTarget(guid); - } else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { - nameplateCtxGuid_ = guid; - nameplateCtxPos_ = mouse; - ImGui::OpenPopup("##NameplateCtx"); - } - } - } - } - - // Render nameplate context popup (uses a tiny overlay window as host) - if (nameplateCtxGuid_ != 0) { - ImGui::SetNextWindowPos(nameplateCtxPos_, ImGuiCond_Appearing); - ImGui::SetNextWindowSize(ImVec2(0, 0), ImGuiCond_Always); - ImGuiWindowFlags ctxHostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoFocusOnAppearing | - ImGuiWindowFlags_AlwaysAutoResize; - if (ImGui::Begin("##NameplateCtxHost", nullptr, ctxHostFlags)) { - if (ImGui::BeginPopup("##NameplateCtx")) { - auto entityPtr = gameHandler.getEntityManager().getEntity(nameplateCtxGuid_); - std::string ctxName = entityPtr ? getEntityName(entityPtr) : ""; - if (!ctxName.empty()) { - ImGui::TextDisabled("%s", ctxName.c_str()); - ImGui::Separator(); - } - if (ImGui::MenuItem("Target")) - gameHandler.setTarget(nameplateCtxGuid_); - if (ImGui::MenuItem("Set Focus")) - gameHandler.setFocus(nameplateCtxGuid_); - bool isPlayer = entityPtr && entityPtr->getType() == game::ObjectType::PLAYER; - if (isPlayer && !ctxName.empty()) { - ImGui::Separator(); - if (ImGui::MenuItem("Whisper")) { - chatPanel_.setWhisperTarget(ctxName); - } - if (ImGui::MenuItem("Invite to Group")) - gameHandler.inviteToGroup(ctxName); - if (ImGui::MenuItem("Trade")) - gameHandler.initiateTrade(nameplateCtxGuid_); - if (ImGui::MenuItem("Duel")) - gameHandler.proposeDuel(nameplateCtxGuid_); - if (ImGui::MenuItem("Inspect")) { - gameHandler.setTarget(nameplateCtxGuid_); - gameHandler.inspectTarget(); - socialPanel_.showInspectWindow_ = true; - } - ImGui::Separator(); - if (ImGui::MenuItem("Add Friend")) - gameHandler.addFriend(ctxName); - if (ImGui::MenuItem("Ignore")) - gameHandler.addIgnore(ctxName); - } - ImGui::EndPopup(); - } else { - nameplateCtxGuid_ = 0; - } - } - ImGui::End(); - } -} - -// ============================================================ -// Durability Warning (equipment damage indicator) -// ============================================================ - -void GameScreen::takeScreenshot(game::GameHandler& /*gameHandler*/) { - auto* renderer = services_.renderer; - if (!renderer) return; - - // Build path: ~/.wowee/screenshots/WoWee_YYYYMMDD_HHMMSS.png - const char* home = std::getenv("HOME"); - if (!home) home = std::getenv("USERPROFILE"); - if (!home) home = "/tmp"; - std::string dir = std::string(home) + "/.wowee/screenshots"; - - auto now = std::chrono::system_clock::now(); - auto tt = std::chrono::system_clock::to_time_t(now); - std::tm tm{}; -#ifdef _WIN32 - localtime_s(&tm, &tt); -#else - localtime_r(&tt, &tm); -#endif - - char filename[128]; - std::snprintf(filename, sizeof(filename), - "WoWee_%04d%02d%02d_%02d%02d%02d.png", - tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, - tm.tm_hour, tm.tm_min, tm.tm_sec); - - std::string path = dir + "/" + filename; - - if (renderer->captureScreenshot(path)) { - game::MessageChatData sysMsg; - sysMsg.type = game::ChatType::SYSTEM; - sysMsg.language = game::ChatLanguage::UNIVERSAL; - sysMsg.message = "Screenshot saved: " + path; - services_.gameHandler->addLocalChatMessage(sysMsg); - } -} - -void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) { - if (gameHandler.getPlayerGuid() == 0) return; - - const auto& inv = gameHandler.getInventory(); - - // Scan all equipment slots (skip bag slots which have no durability) - float minDurPct = 1.0f; - bool hasBroken = false; - - for (int i = static_cast(game::EquipSlot::HEAD); - i < static_cast(game::EquipSlot::BAG1); ++i) { - const auto& slot = inv.getEquipSlot(static_cast(i)); - if (slot.empty() || slot.item.maxDurability == 0) continue; - if (slot.item.curDurability == 0) { - hasBroken = true; - } - float pct = static_cast(slot.item.curDurability) / - static_cast(slot.item.maxDurability); - if (pct < minDurPct) minDurPct = pct; - } - - // Only show warning below 20% - if (minDurPct >= 0.2f && !hasBroken) return; - - ImGuiIO& io = ImGui::GetIO(); - const float screenW = io.DisplaySize.x; - const float screenH = io.DisplaySize.y; - - // Position: just above the XP bar / action bar area (bottom-center) - const float warningW = 220.0f; - const float warningH = 26.0f; - const float posX = (screenW - warningW) * 0.5f; - const float posY = screenH - 140.0f; // above action bar - - ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(warningW, warningH), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.75f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6, 4)); - ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); - ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(0, 0)); - - ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | - ImGuiWindowFlags_NoBringToFrontOnFocus; - - if (ImGui::Begin("##durability_warn", nullptr, flags)) { - if (hasBroken) { - ImGui::TextColored(ImVec4(1.0f, 0.15f, 0.15f, 1.0f), - "\xef\x94\x9b Gear broken! Visit a repair NPC"); - } else { - int pctInt = static_cast(minDurPct * 100.0f); - ImGui::TextColored(colors::kSymbolGold, - "\xef\x94\x9b Low durability: %d%%", pctInt); - } - if (ImGui::IsWindowHovered()) - ImGui::SetTooltip("Your equipment is damaged. Visit any blacksmith or repair NPC."); - } - ImGui::End(); - ImGui::PopStyleVar(3); -} - -// ============================================================ -// UI Error Frame (WoW-style center-bottom error overlay) -// ============================================================ - -void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaTime) { - // Age out old entries - for (auto& e : uiErrors_) e.age += deltaTime; - uiErrors_.erase( - std::remove_if(uiErrors_.begin(), uiErrors_.end(), - [](const UIErrorEntry& e) { return e.age >= kUIErrorLifetime; }), - uiErrors_.end()); - - if (uiErrors_.empty()) return; - - auto* window = services_.window; - float screenW = window ? static_cast(window->getWidth()) : 1280.0f; - float screenH = window ? static_cast(window->getHeight()) : 720.0f; - - // Fixed invisible overlay - ImGui::SetNextWindowPos(ImVec2(0, 0)); - ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | - ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar; - ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); - if (ImGui::Begin("##UIErrors", nullptr, flags)) { - // Render messages stacked above the action bar (~200px from bottom) - // The newest message is on top; older ones fade below it. - const float baseY = screenH - 200.0f; - const float lineH = 20.0f; - const int count = static_cast(uiErrors_.size()); - - ImDrawList* draw = ImGui::GetWindowDrawList(); - for (int i = count - 1; i >= 0; --i) { - const auto& e = uiErrors_[i]; - float alpha = 1.0f - (e.age / kUIErrorLifetime); - alpha = std::max(0.0f, std::min(1.0f, alpha)); - - // Fade fast in the last 0.5 s - if (e.age > kUIErrorLifetime - 0.5f) - alpha *= (kUIErrorLifetime - e.age) / 0.5f; - - uint8_t a8 = static_cast(alpha * 255.0f); - ImU32 textCol = IM_COL32(255, 50, 50, a8); - ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast(alpha * 180)); - - const char* txt = e.text.c_str(); - ImVec2 sz = ImGui::CalcTextSize(txt); - float x = std::round((screenW - sz.x) * 0.5f); - float y = std::round(baseY - (count - 1 - i) * lineH); - - // Drop shadow - draw->AddText(ImVec2(x + 1, y + 1), shadowCol, txt); - draw->AddText(ImVec2(x, y), textCol, txt); - } - } - ImGui::End(); - ImGui::PopStyleVar(); -} - -void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) { - const auto& statuses = gameHandler.getNpcQuestStatuses(); - if (statuses.empty()) return; - - auto* renderer = services_.renderer; - auto* camera = renderer ? renderer->getCamera() : nullptr; - auto* window = services_.window; - if (!camera || !window) return; - - float screenW = static_cast(window->getWidth()); - float screenH = static_cast(window->getHeight()); - glm::mat4 viewProj = camera->getViewProjectionMatrix(); - auto* drawList = ImGui::GetForegroundDrawList(); - - for (const auto& [guid, status] : statuses) { - // Only show markers for available (!) and reward/completable (?) - const char* marker = nullptr; - ImU32 color = IM_COL32(255, 210, 0, 255); // yellow - if (status == game::QuestGiverStatus::AVAILABLE) { - marker = "!"; - } else if (status == game::QuestGiverStatus::AVAILABLE_LOW) { - marker = "!"; - color = IM_COL32(160, 160, 160, 255); // gray - } else if (status == game::QuestGiverStatus::REWARD || - status == game::QuestGiverStatus::REWARD_REP) { - marker = "?"; - } else if (status == game::QuestGiverStatus::INCOMPLETE) { - marker = "?"; - color = IM_COL32(160, 160, 160, 255); // gray - } else { - continue; - } - - // Get entity position (canonical coords) - auto entity = gameHandler.getEntityManager().getEntity(guid); - if (!entity) continue; - - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); - glm::vec3 renderPos = core::coords::canonicalToRender(canonical); - - // Get model height for offset - float heightOffset = 3.0f; - glm::vec3 boundsCenter; - float boundsRadius = 0.0f; - if (core::Application::getInstance().getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) { - heightOffset = boundsRadius * 2.0f + 1.0f; - } - renderPos.z += heightOffset; - - // Project to screen - glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); - if (clipPos.w <= 0.0f) continue; - - glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); - float sx = (ndc.x + 1.0f) * 0.5f * screenW; - float sy = (1.0f - ndc.y) * 0.5f * screenH; - - // Skip if off-screen - if (sx < -50 || sx > screenW + 50 || sy < -50 || sy > screenH + 50) continue; - - // Scale text size based on distance - float dist = clipPos.w; - float fontSize = std::clamp(800.0f / dist, 14.0f, 48.0f); - - // Draw outlined text: 4 shadow copies then main text - ImFont* font = ImGui::GetFont(); - ImU32 outlineColor = IM_COL32(0, 0, 0, 220); - float off = std::max(1.0f, fontSize * 0.06f); - ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, marker); - float tx = sx - textSize.x * 0.5f; - float ty = sy - textSize.y * 0.5f; - - drawList->AddText(font, fontSize, ImVec2(tx - off, ty), outlineColor, marker); - drawList->AddText(font, fontSize, ImVec2(tx + off, ty), outlineColor, marker); - drawList->AddText(font, fontSize, ImVec2(tx, ty - off), outlineColor, marker); - drawList->AddText(font, fontSize, ImVec2(tx, ty + off), outlineColor, marker); - drawList->AddText(font, fontSize, ImVec2(tx, ty), color, marker); - } -} - -void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { - const auto& statuses = gameHandler.getNpcQuestStatuses(); - auto* renderer = services_.renderer; - auto* camera = renderer ? renderer->getCamera() : nullptr; - auto* minimap = renderer ? renderer->getMinimap() : nullptr; - auto* window = services_.window; - if (!camera || !minimap || !window) return; - - float screenW = static_cast(window->getWidth()); - - // Minimap parameters (matching minimap.cpp) - float mapSize = 200.0f; - float margin = 10.0f; - float mapRadius = mapSize * 0.5f; - float centerX = screenW - margin - mapRadius; - float centerY = margin + mapRadius; - float viewRadius = minimap->getViewRadius(); - - // Use the exact same minimap center as Renderer::renderWorld() to keep markers anchored. - glm::vec3 playerRender = camera->getPosition(); - if (renderer->getCharacterInstanceId() != 0) { - playerRender = renderer->getCharacterPosition(); - } - - // Camera bearing for minimap rotation - float bearing = 0.0f; - float cosB = 1.0f; - float sinB = 0.0f; - if (minimap->isRotateWithCamera()) { - glm::vec3 fwd = camera->getForward(); - // Render space: +X=West, +Y=North. Camera fwd=(cos(yaw),sin(yaw)). - // Clockwise bearing from North: atan2(fwd.y, -fwd.x). - bearing = std::atan2(fwd.y, -fwd.x); - cosB = std::cos(bearing); - sinB = std::sin(bearing); - } - - auto* drawList = ImGui::GetForegroundDrawList(); - - auto projectToMinimap = [&](const glm::vec3& worldRenderPos, float& sx, float& sy) -> bool { - float dx = worldRenderPos.x - playerRender.x; - float dy = worldRenderPos.y - playerRender.y; - - // Exact inverse of minimap display shader: - // shader: mapUV = playerUV + vec2(-rotated.x, rotated.y) * zoom * 2 - // where rotated = R(bearing) * center, center in [-0.5, 0.5] - // Inverse: center = R^-1(bearing) * (-deltaUV.x, deltaUV.y) / (zoom*2) - // With deltaUV.x ∝ +dx (render +X=west=larger U) and deltaUV.y ∝ -dy (V increases south): - float rx = -(dx * cosB + dy * sinB); - float ry = dx * sinB - dy * cosB; - - // Scale to minimap pixels - float px = rx / viewRadius * mapRadius; - float py = ry / viewRadius * mapRadius; - - float distFromCenter = std::sqrt(px * px + py * py); - if (distFromCenter > mapRadius - 3.0f) { - return false; - } - - sx = centerX + px; - sy = centerY + py; - return true; - }; - - // Build sets of entries that are incomplete objectives for tracked quests. - // minimapQuestEntries: NPC creature entries (npcOrGoId > 0) - // minimapQuestGoEntries: game object entries (npcOrGoId < 0, stored as abs value) - std::unordered_set minimapQuestEntries; - std::unordered_set minimapQuestGoEntries; - { - const auto& ql = gameHandler.getQuestLog(); - const auto& tq = gameHandler.getTrackedQuestIds(); - for (const auto& q : ql) { - if (q.complete || q.questId == 0) continue; - if (!tq.empty() && !tq.count(q.questId)) continue; - for (const auto& obj : q.killObjectives) { - if (obj.required == 0) continue; - if (obj.npcOrGoId > 0) { - auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); - if (it == q.killCounts.end() || it->second.first < it->second.second) - minimapQuestEntries.insert(static_cast(obj.npcOrGoId)); - } else if (obj.npcOrGoId < 0) { - uint32_t goEntry = static_cast(-obj.npcOrGoId); - auto it = q.killCounts.find(goEntry); - if (it == q.killCounts.end() || it->second.first < it->second.second) - minimapQuestGoEntries.insert(goEntry); - } - } - } - } - - // Optional base nearby NPC dots (independent of quest status packets). - if (settingsPanel_.minimapNpcDots_) { - ImVec2 mouse = ImGui::GetMousePos(); - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() != game::ObjectType::UNIT) continue; - - auto unit = std::static_pointer_cast(entity); - if (!unit || unit->getHealth() == 0) continue; - - glm::vec3 npcRender = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(npcRender, sx, sy)) continue; - - bool isQuestTarget = minimapQuestEntries.count(unit->getEntry()) != 0; - if (isQuestTarget) { - // Quest kill objective: larger gold dot with dark outline - drawList->AddCircleFilled(ImVec2(sx, sy), 3.5f, IM_COL32(255, 210, 30, 240)); - drawList->AddCircle(ImVec2(sx, sy), 3.5f, IM_COL32(80, 50, 0, 180), 0, 1.0f); - // Tooltip on hover showing unit name - float mdx = mouse.x - sx, mdy = mouse.y - sy; - if (mdx * mdx + mdy * mdy < 64.0f) { - const std::string& nm = unit->getName(); - if (!nm.empty()) ImGui::SetTooltip("%s (quest)", nm.c_str()); - } - } else { - ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210); - drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot); - } - } - } - - // Nearby other-player dots — shown when NPC dots are enabled. - // Party members are already drawn as squares above; other players get a small circle. - if (settingsPanel_.minimapNpcDots_) { - const uint64_t selfGuid = gameHandler.getPlayerGuid(); - const auto& partyData = gameHandler.getPartyData(); - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; - if (entity->getGuid() == selfGuid) continue; // skip self (already drawn as arrow) - - // Skip party members (already drawn as squares above) - bool isPartyMember = false; - for (const auto& m : partyData.members) { - if (m.guid == guid) { isPartyMember = true; break; } - } - if (isPartyMember) continue; - - glm::vec3 pRender = core::coords::canonicalToRender( - glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(pRender, sx, sy)) continue; - - // Blue dot for other nearby players - drawList->AddCircleFilled(ImVec2(sx, sy), 2.0f, IM_COL32(80, 160, 255, 220)); - } - } - - // Lootable corpse dots: small yellow-green diamonds on dead, lootable units. - // Shown whenever NPC dots are enabled (or always, since they're always useful). - { - constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() != game::ObjectType::UNIT) continue; - auto unit = std::static_pointer_cast(entity); - if (!unit) continue; - // Must be dead (health == 0) and marked lootable - if (unit->getHealth() != 0) continue; - if (!(unit->getDynamicFlags() & UNIT_DYNFLAG_LOOTABLE)) continue; - - glm::vec3 npcRender = core::coords::canonicalToRender( - glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(npcRender, sx, sy)) continue; - - // Draw a small diamond (rotated square) in light yellow-green - const float dr = 3.5f; - ImVec2 top (sx, sy - dr); - ImVec2 right(sx + dr, sy ); - ImVec2 bot (sx, sy + dr); - ImVec2 left (sx - dr, sy ); - drawList->AddQuadFilled(top, right, bot, left, IM_COL32(180, 230, 80, 230)); - drawList->AddQuad (top, right, bot, left, IM_COL32(60, 80, 20, 200), 1.0f); - - // Tooltip on hover - if (ImGui::IsMouseHoveringRect(ImVec2(sx - dr, sy - dr), ImVec2(sx + dr, sy + dr))) { - const std::string& nm = unit->getName(); - ImGui::BeginTooltip(); - ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.3f, 1.0f), "%s", - nm.empty() ? "Lootable corpse" : nm.c_str()); - ImGui::EndTooltip(); - } - } - } - - // Interactable game object dots (chests, resource nodes) when NPC dots are enabled. - // Shown as small orange triangles to distinguish from unit dots and loot corpses. - if (settingsPanel_.minimapNpcDots_) { - ImVec2 mouse = ImGui::GetMousePos(); - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() != game::ObjectType::GAMEOBJECT) continue; - - // Only show objects that are likely interactive (chests/nodes: type 3; - // also show type 0=Door when open, but filter by dynamic-flag ACTIVATED). - // For simplicity, show all game objects that have a non-empty cached name. - auto go = std::static_pointer_cast(entity); - if (!go) continue; - - // Only show if we have name data (avoids cluttering with unknown objects) - const auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry()); - if (!goInfo || !goInfo->isValid()) continue; - // Skip transport objects (boats/zeppelins): type 15 = MO_TRANSPORT, 11 = TRANSPORT - if (goInfo->type == 11 || goInfo->type == 15) continue; - - glm::vec3 goRender = core::coords::canonicalToRender( - glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(goRender, sx, sy)) continue; - - // Triangle size and color: bright cyan for quest objectives, amber for others - bool isQuestGO = minimapQuestGoEntries.count(go->getEntry()) != 0; - const float ts = isQuestGO ? 4.5f : 3.5f; - ImVec2 goTip (sx, sy - ts); - ImVec2 goLeft (sx - ts, sy + ts * 0.6f); - ImVec2 goRight(sx + ts, sy + ts * 0.6f); - if (isQuestGO) { - drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(50, 230, 255, 240)); - drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(0, 60, 80, 200), 1.5f); - } else { - drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(255, 185, 30, 220)); - drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(100, 60, 0, 180), 1.0f); - } - - // Tooltip on hover - float mdx = mouse.x - sx, mdy = mouse.y - sy; - if (mdx * mdx + mdy * mdy < 64.0f) { - if (isQuestGO) - ImGui::SetTooltip("%s (quest)", goInfo->name.c_str()); - else - ImGui::SetTooltip("%s", goInfo->name.c_str()); - } - } - } - - // Party member dots on minimap — small colored squares with name tooltip on hover - if (gameHandler.isInGroup()) { - const auto& partyData = gameHandler.getPartyData(); - ImVec2 mouse = ImGui::GetMousePos(); - for (const auto& member : partyData.members) { - if (!member.hasPartyStats) continue; - bool isOnline = (member.onlineStatus & 0x0001) != 0; - bool isDead = (member.onlineStatus & 0x0020) != 0; - bool isGhost = (member.onlineStatus & 0x0010) != 0; - if (!isOnline) continue; - if (member.posX == 0 && member.posY == 0) continue; - - // Party stat positions: posY = canonical X (north), posX = canonical Y (west) - glm::vec3 memberRender = core::coords::canonicalToRender( - glm::vec3(static_cast(member.posY), - static_cast(member.posX), 0.0f)); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(memberRender, sx, sy)) continue; - - // Determine dot color: class color > leader gold > light blue - ImU32 dotCol; - if (isDead || isGhost) { - dotCol = IM_COL32(140, 140, 140, 200); // gray for dead - } else { - auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); - uint8_t cid = entityClassId(mEnt.get()); - if (cid != 0) { - ImVec4 cv = classColorVec4(cid); - dotCol = IM_COL32( - static_cast(cv.x * 255), - static_cast(cv.y * 255), - static_cast(cv.z * 255), 230); - } else if (member.guid == partyData.leaderGuid) { - dotCol = IM_COL32(255, 210, 0, 230); // gold for leader - } else { - dotCol = IM_COL32(100, 180, 255, 230); // blue for others - } - } - - // Draw a small square (WoW-style party member dot) - const float hs = 3.5f; - drawList->AddRectFilled(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), dotCol, 1.0f); - drawList->AddRect(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), - IM_COL32(0, 0, 0, 180), 1.0f, 0, 1.0f); - - // Name tooltip on hover - float mdx = mouse.x - sx, mdy = mouse.y - sy; - if (mdx * mdx + mdy * mdy < 64.0f && !member.name.empty()) { - ImGui::SetTooltip("%s", member.name.c_str()); - } - } - } - - for (const auto& [guid, status] : statuses) { - ImU32 dotColor; - const char* marker = nullptr; - if (status == game::QuestGiverStatus::AVAILABLE) { - dotColor = IM_COL32(255, 210, 0, 255); - marker = "!"; - } else if (status == game::QuestGiverStatus::AVAILABLE_LOW) { - dotColor = IM_COL32(160, 160, 160, 255); - marker = "!"; - } else if (status == game::QuestGiverStatus::REWARD || - status == game::QuestGiverStatus::REWARD_REP) { - dotColor = IM_COL32(255, 210, 0, 255); - marker = "?"; - } else if (status == game::QuestGiverStatus::INCOMPLETE) { - dotColor = IM_COL32(160, 160, 160, 255); - marker = "?"; - } else { - continue; - } - - auto entity = gameHandler.getEntityManager().getEntity(guid); - if (!entity) continue; - - glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); - glm::vec3 npcRender = core::coords::canonicalToRender(canonical); - - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(npcRender, sx, sy)) continue; - - // Draw dot with marker text - drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, dotColor); - ImFont* font = ImGui::GetFont(); - ImVec2 textSize = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, marker); - drawList->AddText(font, 11.0f, - ImVec2(sx - textSize.x * 0.5f, sy - textSize.y * 0.5f), - IM_COL32(0, 0, 0, 255), marker); - - // Show NPC name and quest status on hover - { - ImVec2 mouse = ImGui::GetMousePos(); - float mdx = mouse.x - sx, mdy = mouse.y - sy; - if (mdx * mdx + mdy * mdy < 64.0f) { - std::string npcName; - if (entity->getType() == game::ObjectType::UNIT) { - auto npcUnit = std::static_pointer_cast(entity); - npcName = npcUnit->getName(); - } - if (!npcName.empty()) { - bool hasQuest = (status == game::QuestGiverStatus::AVAILABLE || - status == game::QuestGiverStatus::AVAILABLE_LOW); - ImGui::SetTooltip("%s\n%s", npcName.c_str(), - hasQuest ? "Has a quest for you" : "Quest ready to turn in"); - } - } - } - } - - // Quest kill objective markers — highlight live NPCs matching active quest kill objectives - { - // Build map of NPC entry → (quest title, current, required) for tooltips - struct KillInfo { std::string questTitle; uint32_t current = 0; uint32_t required = 0; }; - std::unordered_map killInfoMap; - const auto& trackedIds = gameHandler.getTrackedQuestIds(); - for (const auto& quest : gameHandler.getQuestLog()) { - if (quest.complete) continue; - if (!trackedIds.empty() && !trackedIds.count(quest.questId)) continue; - for (const auto& obj : quest.killObjectives) { - if (obj.npcOrGoId <= 0 || obj.required == 0) continue; - uint32_t npcEntry = static_cast(obj.npcOrGoId); - auto it = quest.killCounts.find(npcEntry); - uint32_t current = (it != quest.killCounts.end()) ? it->second.first : 0; - if (current < obj.required) { - killInfoMap[npcEntry] = { quest.title, current, obj.required }; - } - } - } - - if (!killInfoMap.empty()) { - ImVec2 mouse = ImGui::GetMousePos(); - for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { - if (!entity || entity->getType() != game::ObjectType::UNIT) continue; - auto unit = std::static_pointer_cast(entity); - if (!unit || unit->getHealth() == 0) continue; - auto infoIt = killInfoMap.find(unit->getEntry()); - if (infoIt == killInfoMap.end()) continue; - - glm::vec3 unitRender = core::coords::canonicalToRender( - glm::vec3(entity->getX(), entity->getY(), entity->getZ())); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(unitRender, sx, sy)) continue; - - // Gold circle with a dark "x" mark — indicates a quest kill target - drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, IM_COL32(255, 185, 0, 240)); - drawList->AddCircle(ImVec2(sx, sy), 5.5f, IM_COL32(0, 0, 0, 180), 12, 1.0f); - drawList->AddLine(ImVec2(sx - 2.5f, sy - 2.5f), ImVec2(sx + 2.5f, sy + 2.5f), - IM_COL32(20, 20, 20, 230), 1.2f); - drawList->AddLine(ImVec2(sx + 2.5f, sy - 2.5f), ImVec2(sx - 2.5f, sy + 2.5f), - IM_COL32(20, 20, 20, 230), 1.2f); - - // Tooltip on hover - float mdx = mouse.x - sx, mdy = mouse.y - sy; - if (mdx * mdx + mdy * mdy < 64.0f) { - const auto& ki = infoIt->second; - const std::string& npcName = unit->getName(); - if (!npcName.empty()) { - ImGui::SetTooltip("%s\n%s: %u/%u", - npcName.c_str(), - ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), - ki.current, ki.required); - } else { - ImGui::SetTooltip("%s: %u/%u", - ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), - ki.current, ki.required); - } - } - } - } - } - - // Gossip POI markers (quest / NPC navigation targets) - for (const auto& poi : gameHandler.getGossipPois()) { - // Convert WoW canonical coords to render coords for minimap projection - glm::vec3 poiRender = core::coords::canonicalToRender(glm::vec3(poi.x, poi.y, 0.0f)); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(poiRender, sx, sy)) continue; - - // Draw as a cyan diamond with tooltip on hover - const float d = 5.0f; - ImVec2 pts[4] = { - { sx, sy - d }, - { sx + d, sy }, - { sx, sy + d }, - { sx - d, sy }, - }; - drawList->AddConvexPolyFilled(pts, 4, IM_COL32(0, 210, 255, 220)); - drawList->AddPolyline(pts, 4, IM_COL32(255, 255, 255, 160), true, 1.0f); - - // Show name label if cursor is within ~8px - ImVec2 cursorPos = ImGui::GetMousePos(); - float dx = cursorPos.x - sx, dy = cursorPos.y - sy; - if (!poi.name.empty() && (dx * dx + dy * dy) < 64.0f) { - ImGui::SetTooltip("%s", poi.name.c_str()); - } - } - - // Minimap pings from party members - for (const auto& ping : gameHandler.getMinimapPings()) { - glm::vec3 pingRender = core::coords::canonicalToRender(glm::vec3(ping.wowX, ping.wowY, 0.0f)); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(pingRender, sx, sy)) continue; - - float t = ping.age / game::GameHandler::MinimapPing::LIFETIME; - float alpha = 1.0f - t; - float pulse = 1.0f + 1.5f * t; // expands outward as it fades - - ImU32 col = IM_COL32(255, 220, 0, static_cast(alpha * 200)); - ImU32 col2 = IM_COL32(255, 150, 0, static_cast(alpha * 100)); - float r1 = 4.0f * pulse; - float r2 = 8.0f * pulse; - drawList->AddCircle(ImVec2(sx, sy), r1, col, 16, 2.0f); - drawList->AddCircle(ImVec2(sx, sy), r2, col2, 16, 1.0f); - 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; - { - auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); - uint8_t cid = entityClassId(mEnt.get()); - dotColor = (cid != 0) - ? classColorU32(cid, 235) - : (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); - - // Raid mark: tiny symbol drawn above the dot - { - static constexpr struct { const char* sym; ImU32 col; } kMMMarks[] = { - { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, - { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, - { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, - { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, - { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, - { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, - { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, - { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, - }; - uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); - if (pmk < game::GameHandler::kRaidMarkCount) { - ImFont* mmFont = ImGui::GetFont(); - ImVec2 msz = mmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kMMMarks[pmk].sym); - drawList->AddText(mmFont, 9.0f, - ImVec2(sx - msz.x * 0.5f, sy - 4.0f - msz.y), - kMMMarks[pmk].col, kMMMarks[pmk].sym); - } - } - - ImVec2 cursorPos = ImGui::GetMousePos(); - float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy; - if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) { - uint8_t pmk2 = gameHandler.getEntityRaidMark(member.guid); - if (pmk2 < game::GameHandler::kRaidMarkCount) { - static constexpr const char* kMarkNames[] = { - "Star", "Circle", "Diamond", "Triangle", - "Moon", "Square", "Cross", "Skull" - }; - ImGui::SetTooltip("%s {%s}", member.name.c_str(), kMarkNames[pmk2]); - } else { - ImGui::SetTooltip("%s", member.name.c_str()); - } - } - } - } - - // BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) - { - const auto& bgPositions = gameHandler.getBgPlayerPositions(); - if (!bgPositions.empty()) { - ImVec2 mouse = ImGui::GetMousePos(); - // group 0 = typically ally-held flag / first list; group 1 = enemy - static const ImU32 kBgGroupColors[2] = { - IM_COL32( 80, 180, 255, 240), // group 0: blue (alliance) - IM_COL32(220, 50, 50, 240), // group 1: red (horde) - }; - for (const auto& bp : bgPositions) { - // Packet coords: wowX=canonical X (north), wowY=canonical Y (west) - glm::vec3 bpRender = core::coords::canonicalToRender(glm::vec3(bp.wowX, bp.wowY, 0.0f)); - float sx = 0.0f, sy = 0.0f; - if (!projectToMinimap(bpRender, sx, sy)) continue; - - ImU32 col = kBgGroupColors[bp.group & 1]; - - // Draw a flag-like diamond icon - const float r = 5.0f; - ImVec2 top (sx, sy - r); - ImVec2 right(sx + r, sy ); - ImVec2 bot (sx, sy + r); - ImVec2 left (sx - r, sy ); - drawList->AddQuadFilled(top, right, bot, left, col); - drawList->AddQuad(top, right, bot, left, IM_COL32(255, 255, 255, 180), 1.0f); - - float mdx = mouse.x - sx, mdy = mouse.y - sy; - if (mdx * mdx + mdy * mdy < 64.0f) { - // Show entity name if available, otherwise guid - auto ent = gameHandler.getEntityManager().getEntity(bp.guid); - if (ent) { - std::string nm; - if (ent->getType() == game::ObjectType::PLAYER) { - auto pl = std::static_pointer_cast(ent); - nm = pl ? pl->getName() : ""; - } - if (!nm.empty()) - ImGui::SetTooltip("Flag carrier: %s", nm.c_str()); - else - ImGui::SetTooltip("Flag carrier"); - } else { - ImGui::SetTooltip("Flag carrier"); - } - } - } - } - } - - // Corpse direction indicator — shown when player is a ghost - if (gameHandler.isPlayerGhost()) { - float corpseCanX = 0.0f, corpseCanY = 0.0f; - if (gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY)) { - glm::vec3 corpseRender = core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)); - float csx = 0.0f, csy = 0.0f; - bool onMap = projectToMinimap(corpseRender, csx, csy); - - if (onMap) { - // Draw a small skull-like X marker at the corpse position - const float r = 5.0f; - drawList->AddCircleFilled(ImVec2(csx, csy), r + 1.0f, IM_COL32(0, 0, 0, 140), 12); - drawList->AddCircle(ImVec2(csx, csy), r + 1.0f, IM_COL32(200, 200, 220, 220), 12, 1.5f); - // Draw an X in the circle - drawList->AddLine(ImVec2(csx - 3.0f, csy - 3.0f), ImVec2(csx + 3.0f, csy + 3.0f), - IM_COL32(180, 180, 220, 255), 1.5f); - drawList->AddLine(ImVec2(csx + 3.0f, csy - 3.0f), ImVec2(csx - 3.0f, csy + 3.0f), - IM_COL32(180, 180, 220, 255), 1.5f); - // Tooltip on hover - ImVec2 mouse = ImGui::GetMousePos(); - float mdx = mouse.x - csx, mdy = mouse.y - csy; - if (mdx * mdx + mdy * mdy < 64.0f) { - float dist = gameHandler.getCorpseDistance(); - if (dist >= 0.0f) - ImGui::SetTooltip("Your corpse (%.0f yd)", dist); - else - ImGui::SetTooltip("Your corpse"); - } - } else { - // Corpse is outside minimap — draw an edge arrow pointing toward it - float dx = corpseRender.x - playerRender.x; - float dy = corpseRender.y - playerRender.y; - // Rotate delta into minimap frame (same as projectToMinimap) - float rx = -(dx * cosB + dy * sinB); - float ry = dx * sinB - dy * cosB; - float len = std::sqrt(rx * rx + ry * ry); - if (len > 0.001f) { - float nx = rx / len; - float ny = ry / len; - // Place arrow at the minimap edge - float edgeR = mapRadius - 7.0f; - float ax = centerX + nx * edgeR; - float ay = centerY + ny * edgeR; - // Arrow pointing outward (toward corpse) - float arrowLen = 6.0f; - float arrowW = 3.5f; - ImVec2 tip(ax + nx * arrowLen, ay + ny * arrowLen); - ImVec2 left(ax - ny * arrowW - nx * arrowLen * 0.4f, - ay + nx * arrowW - ny * arrowLen * 0.4f); - ImVec2 right(ax + ny * arrowW - nx * arrowLen * 0.4f, - ay - nx * arrowW - ny * arrowLen * 0.4f); - drawList->AddTriangleFilled(tip, left, right, IM_COL32(180, 180, 240, 230)); - drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f); - // Tooltip on hover - ImVec2 mouse = ImGui::GetMousePos(); - float mdx = mouse.x - ax, mdy = mouse.y - ay; - if (mdx * mdx + mdy * mdy < 100.0f) { - float dist = gameHandler.getCorpseDistance(); - if (dist >= 0.0f) - ImGui::SetTooltip("Your corpse (%.0f yd)", dist); - else - ImGui::SetTooltip("Your corpse"); - } - } - } - } - } - - // Player position arrow at minimap center, pointing in camera facing direction. - // On a rotating minimap the map already turns so forward = screen-up; on a fixed - // minimap we rotate the arrow to match the player's compass heading. - { - // Compute screen-space facing direction for the arrow. - // bearing = clockwise angle from screen-north (0 = facing north/up). - float arrowAngle = 0.0f; // 0 = pointing up (north) - if (!minimap->isRotateWithCamera()) { - // Fixed minimap: arrow must show actual facing relative to north. - glm::vec3 fwd = camera->getForward(); - // +render_y = north = screen-up, +render_x = west = screen-left. - // bearing from north clockwise: atan2(-fwd.x_west, fwd.y_north) - // => sin=east component, cos=north component - // In render coords west=+x, east=-x, so sin(bearing)=east=-fwd.x - arrowAngle = std::atan2(-fwd.x, fwd.y); // clockwise from north in screen space - } - // Screen direction the arrow tip points toward - float nx = std::sin(arrowAngle); // screen +X = east - float ny = -std::cos(arrowAngle); // screen -Y = north - - // Draw a chevron-style arrow: tip, two base corners, and a notch at the back - const float tipLen = 8.0f; // tip forward distance - const float baseW = 5.0f; // half-width at base - const float notchIn = 3.0f; // how far back the center notch sits - // Perpendicular direction (rotated 90°) - float px = ny; // perpendicular x - float py = -nx; // perpendicular y - - ImVec2 tip (centerX + nx * tipLen, centerY + ny * tipLen); - ImVec2 baseL(centerX - nx * baseW + px * baseW, centerY - ny * baseW + py * baseW); - ImVec2 baseR(centerX - nx * baseW - px * baseW, centerY - ny * baseW - py * baseW); - ImVec2 notch(centerX - nx * (baseW - notchIn), centerY - ny * (baseW - notchIn)); - - // Fill: bright white with slight gold tint, dark outline for readability - drawList->AddTriangleFilled(tip, baseL, notch, IM_COL32(255, 248, 200, 245)); - drawList->AddTriangleFilled(tip, notch, baseR, IM_COL32(255, 248, 200, 245)); - drawList->AddTriangle(tip, baseL, notch, IM_COL32(60, 40, 0, 200), 1.2f); - drawList->AddTriangle(tip, notch, baseR, IM_COL32(60, 40, 0, 200), 1.2f); - } - - // Scroll wheel over minimap → zoom in/out - { - float wheel = ImGui::GetIO().MouseWheel; - if (wheel != 0.0f) { - ImVec2 mouse = ImGui::GetMousePos(); - float mdx = mouse.x - centerX; - float mdy = mouse.y - centerY; - if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) { - if (wheel > 0.0f) - minimap->zoomIn(); - else - minimap->zoomOut(); - } - } - } - - // Ctrl+click on minimap → send minimap ping to party - if (ImGui::IsMouseClicked(0) && ImGui::GetIO().KeyCtrl) { - ImVec2 mouse = ImGui::GetMousePos(); - float mdx = mouse.x - centerX; - float mdy = mouse.y - centerY; - float distSq = mdx * mdx + mdy * mdy; - if (distSq <= mapRadius * mapRadius) { - // Invert projectToMinimap: px=mdx, py=mdy → rx=px*viewRadius/mapRadius - float rx = mdx * viewRadius / mapRadius; - float ry = mdy * viewRadius / mapRadius; - // rx/ry are in rotated frame; unrotate to get world dx/dy - // rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB - // Solving: dx = -(rx*cosB - ry*sinB), dy = -(rx*sinB + ry*cosB) - float wdx = -(rx * cosB - ry * sinB); - float wdy = -(rx * sinB + ry * cosB); - // playerRender is in render coords; add delta to get render position then convert to canonical - glm::vec3 clickRender = playerRender + glm::vec3(wdx, wdy, 0.0f); - glm::vec3 clickCanon = core::coords::renderToCanonical(clickRender); - gameHandler.sendMinimapPing(clickCanon.x, clickCanon.y); - } - } - - // Persistent coordinate display below the minimap - { - glm::vec3 playerCanon = core::coords::renderToCanonical(playerRender); - char coordBuf[32]; - std::snprintf(coordBuf, sizeof(coordBuf), "%.1f, %.1f", playerCanon.x, playerCanon.y); - - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize(); - ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, coordBuf); - - float tx = centerX - textSz.x * 0.5f; - float ty = centerY + mapRadius + 3.0f; - - // Semi-transparent dark background pill - float pad = 3.0f; - drawList->AddRectFilled( - ImVec2(tx - pad, ty - pad), - ImVec2(tx + textSz.x + pad, ty + textSz.y + pad), - IM_COL32(0, 0, 0, 140), 4.0f); - // Coordinate text in warm yellow - drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); - } - - // Local time clock — displayed just below the coordinate label - { - auto now = std::chrono::system_clock::now(); - std::time_t tt = std::chrono::system_clock::to_time_t(now); - std::tm tmLocal{}; -#if defined(_WIN32) - localtime_s(&tmLocal, &tt); -#else - localtime_r(&tt, &tmLocal); -#endif - char clockBuf[16]; - std::snprintf(clockBuf, sizeof(clockBuf), "%02d:%02d", - tmLocal.tm_hour, tmLocal.tm_min); - - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize() * 0.9f; // slightly smaller than coords - ImVec2 clockSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, clockBuf); - - float tx = centerX - clockSz.x * 0.5f; - // Position below the coordinate line (+fontSize of coord + 2px gap) - float coordLineH = ImGui::GetFontSize(); - float ty = centerY + mapRadius + 3.0f + coordLineH + 2.0f; - - float pad = 2.0f; - drawList->AddRectFilled( - ImVec2(tx - pad, ty - pad), - ImVec2(tx + clockSz.x + pad, ty + clockSz.y + pad), - IM_COL32(0, 0, 0, 120), 3.0f); - drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(200, 200, 220, 220), clockBuf); - } - - // Zone name display — drawn inside the top edge of the minimap circle - { - auto* zmRenderer = renderer ? renderer->getZoneManager() : nullptr; - uint32_t zoneId = gameHandler.getWorldStateZoneId(); - const game::ZoneInfo* zi = (zmRenderer && zoneId != 0) ? zmRenderer->getZoneInfo(zoneId) : nullptr; - if (zi && !zi->name.empty()) { - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize(); - ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, zi->name.c_str()); - float tx = centerX - ts.x * 0.5f; - float ty = centerY - mapRadius + 4.0f; // just inside top edge of the circle - float pad = 2.0f; - drawList->AddRectFilled( - ImVec2(tx - pad, ty - pad), - ImVec2(tx + ts.x + pad, ty + ts.y + pad), - IM_COL32(0, 0, 0, 160), 2.0f); - drawList->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), - IM_COL32(0, 0, 0, 180), zi->name.c_str()); - drawList->AddText(font, fontSize, ImVec2(tx, ty), - IM_COL32(255, 230, 150, 220), zi->name.c_str()); - } - } - - // Instance difficulty indicator — just below zone name, inside minimap top edge - if (gameHandler.isInInstance()) { - static constexpr const char* kDiffLabels[] = {"Normal", "Heroic", "25 Normal", "25 Heroic"}; - uint32_t diff = gameHandler.getInstanceDifficulty(); - const char* label = (diff < 4) ? kDiffLabels[diff] : "Unknown"; - - ImFont* font = ImGui::GetFont(); - float fontSize = ImGui::GetFontSize() * 0.85f; - ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, label); - float tx = centerX - ts.x * 0.5f; - // Position below zone name: top edge + zone font size + small gap - float ty = centerY - mapRadius + 4.0f + ImGui::GetFontSize() + 2.0f; - float pad = 2.0f; - - // Color-code: heroic=orange, normal=light gray - ImU32 bgCol = gameHandler.isInstanceHeroic() ? IM_COL32(120, 60, 0, 180) : IM_COL32(0, 0, 0, 160); - ImU32 textCol = gameHandler.isInstanceHeroic() ? IM_COL32(255, 180, 50, 255) : IM_COL32(200, 200, 200, 220); - - drawList->AddRectFilled( - ImVec2(tx - pad, ty - pad), - ImVec2(tx + ts.x + pad, ty + ts.y + pad), - bgCol, 2.0f); - drawList->AddText(font, fontSize, ImVec2(tx, ty), textCol, label); - } - - // Hover tooltip and right-click context menu - { - ImVec2 mouse = ImGui::GetMousePos(); - float mdx = mouse.x - centerX; - float mdy = mouse.y - centerY; - bool overMinimap = (mdx * mdx + mdy * mdy <= mapRadius * mapRadius); - - if (overMinimap) { - ImGui::BeginTooltip(); - // Compute the world coordinate under the mouse cursor - // Inverse of projectToMinimap: pixel offset → world offset in render space → canonical - float rxW = mdx / mapRadius * viewRadius; - float ryW = mdy / mapRadius * viewRadius; - // Un-rotate: [dx, dy] = R^-1 * [rxW, ryW] - // where R applied: rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB - float hoverDx = -cosB * rxW + sinB * ryW; - float hoverDy = -sinB * rxW - cosB * ryW; - glm::vec3 hoverRender(playerRender.x + hoverDx, playerRender.y + hoverDy, playerRender.z); - glm::vec3 hoverCanon = core::coords::renderToCanonical(hoverRender); - ImGui::TextColored(ImVec4(0.9f, 0.85f, 0.5f, 1.0f), "%.1f, %.1f", hoverCanon.x, hoverCanon.y); - ImGui::TextColored(colors::kMediumGray, "Ctrl+click to ping"); - ImGui::EndTooltip(); - - if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { - ImGui::OpenPopup("##minimapContextMenu"); - } - } - - if (ImGui::BeginPopup("##minimapContextMenu")) { - ImGui::TextColored(ui::colors::kTooltipGold, "Minimap"); - ImGui::Separator(); - - // Zoom controls - if (ImGui::MenuItem("Zoom In")) { - minimap->zoomIn(); - } - if (ImGui::MenuItem("Zoom Out")) { - minimap->zoomOut(); - } - - ImGui::Separator(); - - // Toggle options with checkmarks - bool rotWithCam = minimap->isRotateWithCamera(); - if (ImGui::MenuItem("Rotate with Camera", nullptr, rotWithCam)) { - minimap->setRotateWithCamera(!rotWithCam); - } - - bool squareShape = minimap->isSquareShape(); - if (ImGui::MenuItem("Square Shape", nullptr, squareShape)) { - minimap->setSquareShape(!squareShape); - } - - bool npcDots = settingsPanel_.minimapNpcDots_; - if (ImGui::MenuItem("Show NPC Dots", nullptr, npcDots)) { - settingsPanel_.minimapNpcDots_ = !settingsPanel_.minimapNpcDots_; - } - - ImGui::EndPopup(); - } - } - - auto applyMuteState = [&]() { - auto* ac = services_.audioCoordinator; - float masterScale = settingsPanel_.soundMuted_ ? 0.0f : static_cast(settingsPanel_.pendingMasterVolume) / 100.0f; - audio::AudioEngine::instance().setMasterVolume(masterScale); - if (!ac) return; - if (auto* music = ac->getMusicManager()) { - music->setVolume(settingsPanel_.pendingMusicVolume); - } - if (auto* ambient = ac->getAmbientSoundManager()) { - ambient->setVolumeScale(settingsPanel_.pendingAmbientVolume / 100.0f); - } - if (auto* ui = ac->getUiSoundManager()) { - ui->setVolumeScale(settingsPanel_.pendingUiVolume / 100.0f); - } - if (auto* combat = ac->getCombatSoundManager()) { - combat->setVolumeScale(settingsPanel_.pendingCombatVolume / 100.0f); - } - if (auto* spell = ac->getSpellSoundManager()) { - spell->setVolumeScale(settingsPanel_.pendingSpellVolume / 100.0f); - } - if (auto* movement = ac->getMovementSoundManager()) { - movement->setVolumeScale(settingsPanel_.pendingMovementVolume / 100.0f); - } - if (auto* footstep = ac->getFootstepManager()) { - footstep->setVolumeScale(settingsPanel_.pendingFootstepVolume / 100.0f); - } - if (auto* npcVoice = ac->getNpcVoiceManager()) { - npcVoice->setVolumeScale(settingsPanel_.pendingNpcVoiceVolume / 100.0f); - } - if (auto* mount = ac->getMountSoundManager()) { - mount->setVolumeScale(settingsPanel_.pendingMountVolume / 100.0f); - } - if (auto* activity = ac->getActivitySoundManager()) { - activity->setVolumeScale(settingsPanel_.pendingActivityVolume / 100.0f); - } - }; - - // Zone name label above the minimap (centered, WoW-style) - // Prefer the server-reported zone/area name (from SMSG_INIT_WORLD_STATES) so sub-zones - // like Ironforge or Wailing Caverns display correctly; fall back to renderer zone name. - { - std::string wsZoneName; - uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); - if (wsZoneId != 0) - wsZoneName = gameHandler.getWhoAreaName(wsZoneId); - const std::string& rendererZoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; - const std::string& zoneName = !wsZoneName.empty() ? wsZoneName : rendererZoneName; - if (!zoneName.empty()) { - auto* fgDl = ImGui::GetForegroundDrawList(); - float zoneTextY = centerY - mapRadius - 16.0f; - ImFont* font = ImGui::GetFont(); - - // Weather icon appended to zone name when active - uint32_t wType = gameHandler.getWeatherType(); - float wIntensity = gameHandler.getWeatherIntensity(); - const char* weatherIcon = nullptr; - ImU32 weatherColor = IM_COL32(255, 255, 255, 200); - if (wType == 1 && wIntensity > 0.05f) { // Rain - weatherIcon = " \xe2\x9b\x86"; // U+26C6 ⛆ - weatherColor = IM_COL32(140, 180, 240, 220); - } else if (wType == 2 && wIntensity > 0.05f) { // Snow - weatherIcon = " \xe2\x9d\x84"; // U+2744 ❄ - weatherColor = IM_COL32(210, 230, 255, 220); - } else if (wType == 3 && wIntensity > 0.05f) { // Storm/Fog - weatherIcon = " \xe2\x98\x81"; // U+2601 ☁ - weatherColor = IM_COL32(160, 160, 190, 220); - } - - std::string displayName = zoneName; - // Build combined string if weather active - std::string fullLabel = weatherIcon ? (zoneName + weatherIcon) : zoneName; - ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, fullLabel.c_str()); - float tzx = centerX - tsz.x * 0.5f; - - // Shadow pass - fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f), - IM_COL32(0, 0, 0, 180), zoneName.c_str()); - // Zone name in gold - fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY), - IM_COL32(255, 220, 120, 230), zoneName.c_str()); - // Weather symbol in its own color appended after - if (weatherIcon) { - ImVec2 nameSz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); - fgDl->AddText(font, 12.0f, ImVec2(tzx + nameSz.x, zoneTextY), weatherColor, weatherIcon); - } - } - } - - // 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); - ImGuiWindowFlags muteFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoBackground; - if (ImGui::Begin("##MinimapMute", nullptr, muteFlags)) { - ImDrawList* draw = ImGui::GetWindowDrawList(); - ImVec2 p = ImGui::GetCursorScreenPos(); - ImVec2 size(20.0f, 20.0f); - if (ImGui::InvisibleButton("##MinimapMuteButton", size)) { - settingsPanel_.soundMuted_ = !settingsPanel_.soundMuted_; - if (settingsPanel_.soundMuted_) { - settingsPanel_.preMuteVolume_ = audio::AudioEngine::instance().getMasterVolume(); - } - applyMuteState(); - saveSettings(); - } - bool hovered = ImGui::IsItemHovered(); - ImU32 bg = settingsPanel_.soundMuted_ ? IM_COL32(135, 42, 42, 230) : IM_COL32(38, 38, 38, 210); - if (hovered) bg = settingsPanel_.soundMuted_ ? IM_COL32(160, 58, 58, 230) : IM_COL32(65, 65, 65, 220); - ImU32 fg = IM_COL32(255, 255, 255, 245); - draw->AddRectFilled(p, ImVec2(p.x + size.x, p.y + size.y), bg, 4.0f); - draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), ImVec2(p.x + size.x - 0.5f, p.y + size.y - 0.5f), - IM_COL32(255, 255, 255, 42), 4.0f); - draw->AddRectFilled(ImVec2(p.x + 4.0f, p.y + 8.0f), ImVec2(p.x + 7.0f, p.y + 12.0f), fg, 1.0f); - draw->AddTriangleFilled(ImVec2(p.x + 7.0f, p.y + 7.0f), - ImVec2(p.x + 7.0f, p.y + 13.0f), - ImVec2(p.x + 11.8f, p.y + 10.0f), fg); - if (settingsPanel_.soundMuted_) { - draw->AddLine(ImVec2(p.x + 13.5f, p.y + 6.2f), ImVec2(p.x + 17.2f, p.y + 13.8f), fg, 1.8f); - draw->AddLine(ImVec2(p.x + 17.2f, p.y + 6.2f), ImVec2(p.x + 13.5f, p.y + 13.8f), fg, 1.8f); - } else { - draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 3.6f, -0.7f, 0.7f, 12); - draw->PathStroke(fg, 0, 1.4f); - draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 5.5f, -0.7f, 0.7f, 12); - draw->PathStroke(fg, 0, 1.2f); - } - if (hovered) ImGui::SetTooltip(settingsPanel_.soundMuted_ ? "Unmute" : "Mute"); - } - ImGui::End(); - - // Friends button at top-left of minimap - { - const auto& contacts = gameHandler.getContacts(); - int onlineCount = 0; - for (const auto& c : contacts) - if (c.isFriend() && c.isOnline()) ++onlineCount; - - ImGui::SetNextWindowPos(ImVec2(centerX - mapRadius + 4.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always); - ImGuiWindowFlags friendsBtnFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoBackground; - if (ImGui::Begin("##MinimapFriendsBtn", nullptr, friendsBtnFlags)) { - ImDrawList* draw = ImGui::GetWindowDrawList(); - ImVec2 p = ImGui::GetCursorScreenPos(); - ImVec2 sz(20.0f, 20.0f); - if (ImGui::InvisibleButton("##FriendsBtnInv", sz)) { - socialPanel_.showSocialFrame_ = !socialPanel_.showSocialFrame_; - } - bool hovered = ImGui::IsItemHovered(); - ImU32 bg = socialPanel_.showSocialFrame_ - ? IM_COL32(42, 100, 42, 230) - : IM_COL32(38, 38, 38, 210); - if (hovered) bg = socialPanel_.showSocialFrame_ ? IM_COL32(58, 130, 58, 230) : IM_COL32(65, 65, 65, 220); - draw->AddRectFilled(p, ImVec2(p.x + sz.x, p.y + sz.y), bg, 4.0f); - draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), - ImVec2(p.x + sz.x - 0.5f, p.y + sz.y - 0.5f), - IM_COL32(255, 255, 255, 42), 4.0f); - // Simple smiley-face dots as "social" icon - ImU32 fg = IM_COL32(255, 255, 255, 245); - draw->AddCircle(ImVec2(p.x + 10.0f, p.y + 10.0f), 6.5f, fg, 16, 1.2f); - draw->AddCircleFilled(ImVec2(p.x + 7.5f, p.y + 8.0f), 1.2f, fg); - draw->AddCircleFilled(ImVec2(p.x + 12.5f, p.y + 8.0f), 1.2f, fg); - draw->PathArcTo(ImVec2(p.x + 10.0f, p.y + 11.5f), 3.0f, 0.2f, 2.9f, 8); - draw->PathStroke(fg, 0, 1.2f); - // Small green dot if friends online - if (onlineCount > 0) { - draw->AddCircleFilled(ImVec2(p.x + sz.x - 3.5f, p.y + 3.5f), - 3.5f, IM_COL32(50, 220, 50, 255)); - } - if (hovered) { - if (onlineCount > 0) - ImGui::SetTooltip("Friends (%d online)", onlineCount); - else - ImGui::SetTooltip("Friends"); - } - } - ImGui::End(); - } - - // Zoom buttons at the bottom edge of the minimap - ImGui::SetNextWindowPos(ImVec2(centerX - 22, centerY + mapRadius - 30), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(44, 24), ImGuiCond_Always); - ImGuiWindowFlags zoomFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoBackground; - if (ImGui::Begin("##MinimapZoom", nullptr, zoomFlags)) { - ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2)); - ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2, 0)); - if (ImGui::SmallButton("-")) { - if (minimap) minimap->zoomOut(); - } - ImGui::SameLine(); - if (ImGui::SmallButton("+")) { - if (minimap) minimap->zoomIn(); - } - ImGui::PopStyleVar(2); - } - ImGui::End(); - - // Clock display at bottom-right of minimap (local time) - { - auto now = std::chrono::system_clock::now(); - auto tt = std::chrono::system_clock::to_time_t(now); - std::tm tmBuf{}; -#ifdef _WIN32 - localtime_s(&tmBuf, &tt); -#else - localtime_r(&tt, &tmBuf); -#endif - char clockText[16]; - std::snprintf(clockText, sizeof(clockText), "%d:%02d %s", - (tmBuf.tm_hour % 12 == 0) ? 12 : tmBuf.tm_hour % 12, - tmBuf.tm_min, - tmBuf.tm_hour >= 12 ? "PM" : "AM"); - ImVec2 clockSz = ImGui::CalcTextSize(clockText); - float clockW = clockSz.x + 10.0f; - float clockH = clockSz.y + 6.0f; - ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - clockW - 2.0f, - centerY + mapRadius - clockH - 2.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(clockW, clockH), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.45f); - ImGuiWindowFlags clockFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoInputs; - if (ImGui::Begin("##MinimapClock", nullptr, clockFlags)) { - ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.8f, 0.85f), "%s", clockText); - } - ImGui::End(); - } - - // Indicators below the minimap (stacked: new mail, then BG queue, then latency) - float indicatorX = centerX - mapRadius; - float nextIndicatorY = centerY + mapRadius + 4.0f; - const float indicatorW = mapRadius * 2.0f; - constexpr float kIndicatorH = 22.0f; - ImGuiWindowFlags indicatorFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | - ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs; - - // "New Mail" indicator - if (gameHandler.hasNewMail()) { - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - if (ImGui::Begin("##NewMailIndicator", nullptr, indicatorFlags)) { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); - ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!"); - } - ImGui::End(); - nextIndicatorY += kIndicatorH; - } - - // Unspent talent points indicator - { - uint8_t unspent = gameHandler.getUnspentTalentPoints(); - if (unspent > 0) { - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - if (ImGui::Begin("##TalentIndicator", nullptr, indicatorFlags)) { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.5f); - char talentBuf[40]; - snprintf(talentBuf, sizeof(talentBuf), "! %u Talent Point%s Available", - static_cast(unspent), unspent == 1 ? "" : "s"); - ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f * pulse, pulse), "%s", talentBuf); - } - ImGui::End(); - nextIndicatorY += kIndicatorH; - } - } - - // BG queue status indicator (when in queue but not yet invited) - for (const auto& slot : gameHandler.getBgQueues()) { - if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only - - std::string bgName; - if (slot.arenaType > 0) { - bgName = std::to_string(slot.arenaType) + "v" + std::to_string(slot.arenaType) + " Arena"; - } else { - switch (slot.bgTypeId) { - case 1: bgName = "AV"; break; - case 2: bgName = "WSG"; break; - case 3: bgName = "AB"; break; - case 7: bgName = "EotS"; break; - case 9: bgName = "SotA"; break; - case 11: bgName = "IoC"; break; - default: bgName = "BG"; break; - } - } - - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) { - float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.5f); - if (slot.avgWaitTimeSec > 0) { - int avgMin = static_cast(slot.avgWaitTimeSec) / 60; - int avgSec = static_cast(slot.avgWaitTimeSec) % 60; - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), - "Queue: %s (~%d:%02d)", bgName.c_str(), avgMin, avgSec); - } else { - ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), - "In Queue: %s", bgName.c_str()); - } - } - ImGui::End(); - nextIndicatorY += kIndicatorH; - break; // Show at most one queue slot indicator - } - - // LFG queue indicator — shown when Dungeon Finder queue is active (Queued or RoleCheck) - { - using LfgState = game::GameHandler::LfgState; - LfgState lfgSt = gameHandler.getLfgState(); - if (lfgSt == LfgState::Queued || lfgSt == LfgState::RoleCheck) { - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - if (ImGui::Begin("##LfgQueueIndicator", nullptr, indicatorFlags)) { - if (lfgSt == LfgState::RoleCheck) { - float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); - ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, pulse), "LFG: Role Check..."); - } else { - uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); - int qMin = static_cast(qMs / 60000); - int qSec = static_cast((qMs % 60000) / 1000); - float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.2f); - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, pulse), - "LFG: %d:%02d", qMin, qSec); - } - } - ImGui::End(); - nextIndicatorY += kIndicatorH; - } - } - - // Calendar pending invites indicator (WotLK only) - { - auto* expReg = services_.expansionRegistry; - bool isWotLK = expReg && expReg->getActive() && expReg->getActive()->id == "wotlk"; - if (isWotLK) { - uint32_t calPending = gameHandler.getCalendarPendingInvites(); - if (calPending > 0) { - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - if (ImGui::Begin("##CalendarIndicator", nullptr, indicatorFlags)) { - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.0f); - char calBuf[48]; - snprintf(calBuf, sizeof(calBuf), "Calendar: %u Invite%s", - calPending, calPending == 1 ? "" : "s"); - ImGui::TextColored(ImVec4(0.6f, 0.5f, 1.0f, pulse), "%s", calBuf); - } - ImGui::End(); - nextIndicatorY += kIndicatorH; - } - } - } - - // Taxi flight indicator — shown while on a flight path - if (gameHandler.isOnTaxiFlight()) { - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - if (ImGui::Begin("##TaxiIndicator", nullptr, indicatorFlags)) { - const std::string& dest = gameHandler.getTaxiDestName(); - float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 1.0f); - if (dest.empty()) { - ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "\xe2\x9c\x88 In Flight"); - } else { - char buf[64]; - snprintf(buf, sizeof(buf), "\xe2\x9c\x88 \xe2\x86\x92 %s", dest.c_str()); - ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "%s", buf); - } - } - ImGui::End(); - nextIndicatorY += kIndicatorH; - } - - // Latency + FPS indicator — centered at top of screen - uint32_t latMs = gameHandler.getLatencyMs(); - if (settingsPanel_.showLatencyMeter_ && gameHandler.getState() == game::WorldState::IN_WORLD) { - float currentFps = ImGui::GetIO().Framerate; - ImVec4 latColor; - if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); - else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); - else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f); - else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f); - - ImVec4 fpsColor; - if (currentFps >= 60.0f) fpsColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); - else if (currentFps >= 30.0f) fpsColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); - else fpsColor = ImVec4(1.0f, 0.3f, 0.3f, 0.9f); - - char infoText[64]; - if (latMs > 0) - snprintf(infoText, sizeof(infoText), "%.0f fps | %u ms", currentFps, latMs); - else - snprintf(infoText, sizeof(infoText), "%.0f fps", currentFps); - - ImVec2 textSize = ImGui::CalcTextSize(infoText); - float latW = textSize.x + 16.0f; - float latH = textSize.y + 8.0f; - ImGuiIO& lio = ImGui::GetIO(); - float latX = (lio.DisplaySize.x - latW) * 0.5f; - ImGui::SetNextWindowPos(ImVec2(latX, 4.0f), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always); - ImGui::SetNextWindowBgAlpha(0.45f); - if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { - // Color the FPS and latency portions differently - ImGui::TextColored(fpsColor, "%.0f fps", currentFps); - if (latMs > 0) { - ImGui::SameLine(0, 4); - ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 0.7f), "|"); - ImGui::SameLine(0, 4); - ImGui::TextColored(latColor, "%u ms", latMs); - } - } - ImGui::End(); - } - - // Low durability warning — shown when any equipped item has < 20% durability - if (gameHandler.getState() == game::WorldState::IN_WORLD) { - const auto& inv = gameHandler.getInventory(); - float lowestDurPct = 1.0f; - for (int i = 0; i < game::Inventory::NUM_EQUIP_SLOTS; ++i) { - const auto& slot = inv.getEquipSlot(static_cast(i)); - if (slot.empty()) continue; - const auto& it = slot.item; - if (it.maxDurability > 0) { - float pct = static_cast(it.curDurability) / static_cast(it.maxDurability); - if (pct < lowestDurPct) lowestDurPct = pct; - } - } - if (lowestDurPct < 0.20f) { - bool critical = (lowestDurPct < 0.05f); - float pulse = critical - ? (0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 4.0f)) - : 1.0f; - ImVec4 durWarnColor = critical - ? ImVec4(1.0f, 0.2f, 0.2f, pulse) - : ImVec4(1.0f, 0.65f, 0.1f, 0.9f); - const char* durWarnText = critical ? "Item breaking!" : "Low durability"; - - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - if (ImGui::Begin("##DurabilityIndicator", nullptr, indicatorFlags)) { - ImGui::TextColored(durWarnColor, "%s", durWarnText); - } - ImGui::End(); - nextIndicatorY += kIndicatorH; - } - } - - // Local time clock — always visible below minimap indicators - { - auto now = std::chrono::system_clock::now(); - std::time_t tt = std::chrono::system_clock::to_time_t(now); - struct tm tmBuf; -#ifdef _WIN32 - localtime_s(&tmBuf, &tt); -#else - localtime_r(&tt, &tmBuf); -#endif - char clockStr[16]; - snprintf(clockStr, sizeof(clockStr), "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min); - - ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); - ImGuiWindowFlags clockFlags = indicatorFlags & ~ImGuiWindowFlags_NoInputs; - if (ImGui::Begin("##ClockIndicator", nullptr, clockFlags)) { - ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.85f, 0.75f), "%s", clockStr); - if (ImGui::IsItemHovered()) { - char fullTime[32]; - snprintf(fullTime, sizeof(fullTime), "%02d:%02d:%02d (local)", - tmBuf.tm_hour, tmBuf.tm_min, tmBuf.tm_sec); - ImGui::SetTooltip("%s", fullTime); - } - } - ImGui::End(); - } -} - -void GameScreen::saveSettings() { - std::string path = SettingsPanel::getSettingsPath(); - std::filesystem::path dir = std::filesystem::path(path).parent_path(); - std::error_code ec; - std::filesystem::create_directories(dir, ec); - - std::ofstream out(path); - if (!out.is_open()) { - LOG_WARNING("Could not save settings to ", path); - return; - } - - // Interface - out << "ui_opacity=" << settingsPanel_.pendingUiOpacity << "\n"; - out << "minimap_rotate=" << (settingsPanel_.pendingMinimapRotate ? 1 : 0) << "\n"; - out << "minimap_square=" << (settingsPanel_.pendingMinimapSquare ? 1 : 0) << "\n"; - out << "minimap_npc_dots=" << (settingsPanel_.pendingMinimapNpcDots ? 1 : 0) << "\n"; - out << "show_latency_meter=" << (settingsPanel_.pendingShowLatencyMeter ? 1 : 0) << "\n"; - out << "show_dps_meter=" << (settingsPanel_.showDPSMeter_ ? 1 : 0) << "\n"; - out << "show_cooldown_tracker=" << (settingsPanel_.showCooldownTracker_ ? 1 : 0) << "\n"; - out << "separate_bags=" << (settingsPanel_.pendingSeparateBags ? 1 : 0) << "\n"; - out << "show_keyring=" << (settingsPanel_.pendingShowKeyring ? 1 : 0) << "\n"; - out << "action_bar_scale=" << settingsPanel_.pendingActionBarScale << "\n"; - out << "nameplate_scale=" << settingsPanel_.nameplateScale_ << "\n"; - out << "show_friendly_nameplates=" << (settingsPanel_.showFriendlyNameplates_ ? 1 : 0) << "\n"; - out << "show_action_bar2=" << (settingsPanel_.pendingShowActionBar2 ? 1 : 0) << "\n"; - out << "action_bar2_offset_x=" << settingsPanel_.pendingActionBar2OffsetX << "\n"; - out << "action_bar2_offset_y=" << settingsPanel_.pendingActionBar2OffsetY << "\n"; - out << "show_right_bar=" << (settingsPanel_.pendingShowRightBar ? 1 : 0) << "\n"; - out << "show_left_bar=" << (settingsPanel_.pendingShowLeftBar ? 1 : 0) << "\n"; - out << "right_bar_offset_y=" << settingsPanel_.pendingRightBarOffsetY << "\n"; - out << "left_bar_offset_y=" << settingsPanel_.pendingLeftBarOffsetY << "\n"; - out << "damage_flash=" << (settingsPanel_.damageFlashEnabled_ ? 1 : 0) << "\n"; - out << "low_health_vignette=" << (settingsPanel_.lowHealthVignetteEnabled_ ? 1 : 0) << "\n"; - - // Audio - out << "sound_muted=" << (settingsPanel_.soundMuted_ ? 1 : 0) << "\n"; - out << "use_original_soundtrack=" << (settingsPanel_.pendingUseOriginalSoundtrack ? 1 : 0) << "\n"; - out << "master_volume=" << settingsPanel_.pendingMasterVolume << "\n"; - out << "music_volume=" << settingsPanel_.pendingMusicVolume << "\n"; - out << "ambient_volume=" << settingsPanel_.pendingAmbientVolume << "\n"; - out << "ui_volume=" << settingsPanel_.pendingUiVolume << "\n"; - out << "combat_volume=" << settingsPanel_.pendingCombatVolume << "\n"; - out << "spell_volume=" << settingsPanel_.pendingSpellVolume << "\n"; - out << "movement_volume=" << settingsPanel_.pendingMovementVolume << "\n"; - out << "footstep_volume=" << settingsPanel_.pendingFootstepVolume << "\n"; - out << "npc_voice_volume=" << settingsPanel_.pendingNpcVoiceVolume << "\n"; - out << "mount_volume=" << settingsPanel_.pendingMountVolume << "\n"; - out << "activity_volume=" << settingsPanel_.pendingActivityVolume << "\n"; - - // Gameplay - out << "auto_loot=" << (settingsPanel_.pendingAutoLoot ? 1 : 0) << "\n"; - out << "auto_sell_grey=" << (settingsPanel_.pendingAutoSellGrey ? 1 : 0) << "\n"; - out << "auto_repair=" << (settingsPanel_.pendingAutoRepair ? 1 : 0) << "\n"; - out << "graphics_preset=" << static_cast(settingsPanel_.currentGraphicsPreset) << "\n"; - out << "ground_clutter_density=" << settingsPanel_.pendingGroundClutterDensity << "\n"; - out << "shadows=" << (settingsPanel_.pendingShadows ? 1 : 0) << "\n"; - out << "shadow_distance=" << settingsPanel_.pendingShadowDistance << "\n"; - out << "brightness=" << settingsPanel_.pendingBrightness << "\n"; - out << "water_refraction=" << (settingsPanel_.pendingWaterRefraction ? 1 : 0) << "\n"; - out << "antialiasing=" << settingsPanel_.pendingAntiAliasing << "\n"; - out << "fxaa=" << (settingsPanel_.pendingFXAA ? 1 : 0) << "\n"; - out << "normal_mapping=" << (settingsPanel_.pendingNormalMapping ? 1 : 0) << "\n"; - out << "normal_map_strength=" << settingsPanel_.pendingNormalMapStrength << "\n"; - out << "pom=" << (settingsPanel_.pendingPOM ? 1 : 0) << "\n"; - out << "pom_quality=" << settingsPanel_.pendingPOMQuality << "\n"; - out << "upscaling_mode=" << settingsPanel_.pendingUpscalingMode << "\n"; - out << "fsr=" << (settingsPanel_.pendingFSR ? 1 : 0) << "\n"; - out << "fsr_quality=" << settingsPanel_.pendingFSRQuality << "\n"; - out << "fsr_sharpness=" << settingsPanel_.pendingFSRSharpness << "\n"; - out << "fsr2_jitter_sign=" << settingsPanel_.pendingFSR2JitterSign << "\n"; - out << "fsr2_mv_scale_x=" << settingsPanel_.pendingFSR2MotionVecScaleX << "\n"; - out << "fsr2_mv_scale_y=" << settingsPanel_.pendingFSR2MotionVecScaleY << "\n"; - out << "amd_fsr3_framegen=" << (settingsPanel_.pendingAMDFramegen ? 1 : 0) << "\n"; - - // Controls - out << "mouse_sensitivity=" << settingsPanel_.pendingMouseSensitivity << "\n"; - out << "invert_mouse=" << (settingsPanel_.pendingInvertMouse ? 1 : 0) << "\n"; - out << "extended_zoom=" << (settingsPanel_.pendingExtendedZoom ? 1 : 0) << "\n"; - out << "camera_stiffness=" << settingsPanel_.pendingCameraStiffness << "\n"; - out << "camera_pivot_height=" << settingsPanel_.pendingPivotHeight << "\n"; - out << "fov=" << settingsPanel_.pendingFov << "\n"; - - // Quest tracker position/size - out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n"; - out << "quest_tracker_y=" << questTrackerPos_.y << "\n"; - out << "quest_tracker_w=" << questTrackerSize_.x << "\n"; - out << "quest_tracker_h=" << questTrackerSize_.y << "\n"; - - // Chat - out << "chat_active_tab=" << chatPanel_.activeChatTab << "\n"; - out << "chat_timestamps=" << (chatPanel_.chatShowTimestamps ? 1 : 0) << "\n"; - out << "chat_font_size=" << chatPanel_.chatFontSize << "\n"; - out << "chat_autojoin_general=" << (chatPanel_.chatAutoJoinGeneral ? 1 : 0) << "\n"; - out << "chat_autojoin_trade=" << (chatPanel_.chatAutoJoinTrade ? 1 : 0) << "\n"; - out << "chat_autojoin_localdefense=" << (chatPanel_.chatAutoJoinLocalDefense ? 1 : 0) << "\n"; - out << "chat_autojoin_lfg=" << (chatPanel_.chatAutoJoinLFG ? 1 : 0) << "\n"; - out << "chat_autojoin_local=" << (chatPanel_.chatAutoJoinLocal ? 1 : 0) << "\n"; - - out.close(); - - // Save keybindings to the same config file (appends [Keybindings] section) - KeybindingManager::getInstance().saveToConfigFile(path); - - LOG_INFO("Settings saved to ", path); -} - -void GameScreen::loadSettings() { - std::string path = SettingsPanel::getSettingsPath(); - std::ifstream in(path); - if (!in.is_open()) return; - - std::string line; - while (std::getline(in, line)) { - size_t eq = line.find('='); - if (eq == std::string::npos) continue; - std::string key = line.substr(0, eq); - std::string val = line.substr(eq + 1); - - try { - // Interface - if (key == "ui_opacity") { - int v = std::stoi(val); - if (v >= 20 && v <= 100) { - settingsPanel_.pendingUiOpacity = v; - settingsPanel_.uiOpacity_ = static_cast(v) / 100.0f; - } - } else if (key == "minimap_rotate") { - // Ignore persisted rotate state; keep north-up. - settingsPanel_.minimapRotate_ = false; - settingsPanel_.pendingMinimapRotate = false; - } else if (key == "minimap_square") { - int v = std::stoi(val); - settingsPanel_.minimapSquare_ = (v != 0); - settingsPanel_.pendingMinimapSquare = settingsPanel_.minimapSquare_; - } else if (key == "minimap_npc_dots") { - int v = std::stoi(val); - settingsPanel_.minimapNpcDots_ = (v != 0); - settingsPanel_.pendingMinimapNpcDots = settingsPanel_.minimapNpcDots_; - } else if (key == "show_latency_meter") { - settingsPanel_.showLatencyMeter_ = (std::stoi(val) != 0); - settingsPanel_.pendingShowLatencyMeter = settingsPanel_.showLatencyMeter_; - } else if (key == "show_dps_meter") { - settingsPanel_.showDPSMeter_ = (std::stoi(val) != 0); - } else if (key == "show_cooldown_tracker") { - settingsPanel_.showCooldownTracker_ = (std::stoi(val) != 0); - } else if (key == "separate_bags") { - settingsPanel_.pendingSeparateBags = (std::stoi(val) != 0); - inventoryScreen.setSeparateBags(settingsPanel_.pendingSeparateBags); - } else if (key == "show_keyring") { - settingsPanel_.pendingShowKeyring = (std::stoi(val) != 0); - inventoryScreen.setShowKeyring(settingsPanel_.pendingShowKeyring); - } else if (key == "action_bar_scale") { - settingsPanel_.pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); - } else if (key == "nameplate_scale") { - settingsPanel_.nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f); - } else if (key == "show_friendly_nameplates") { - settingsPanel_.showFriendlyNameplates_ = (std::stoi(val) != 0); - } else if (key == "show_action_bar2") { - settingsPanel_.pendingShowActionBar2 = (std::stoi(val) != 0); - } else if (key == "action_bar2_offset_x") { - settingsPanel_.pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f); - } else if (key == "action_bar2_offset_y") { - settingsPanel_.pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); - } else if (key == "show_right_bar") { - settingsPanel_.pendingShowRightBar = (std::stoi(val) != 0); - } else if (key == "show_left_bar") { - settingsPanel_.pendingShowLeftBar = (std::stoi(val) != 0); - } else if (key == "right_bar_offset_y") { - settingsPanel_.pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); - } else if (key == "left_bar_offset_y") { - settingsPanel_.pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); - } else if (key == "damage_flash") { - settingsPanel_.damageFlashEnabled_ = (std::stoi(val) != 0); - } else if (key == "low_health_vignette") { - settingsPanel_.lowHealthVignetteEnabled_ = (std::stoi(val) != 0); - } - // Audio - else if (key == "sound_muted") { - settingsPanel_.soundMuted_ = (std::stoi(val) != 0); - if (settingsPanel_.soundMuted_) { - // Apply mute on load; settingsPanel_.preMuteVolume_ will be set when AudioEngine is available - audio::AudioEngine::instance().setMasterVolume(0.0f); - } - } - else if (key == "use_original_soundtrack") settingsPanel_.pendingUseOriginalSoundtrack = (std::stoi(val) != 0); - else if (key == "master_volume") settingsPanel_.pendingMasterVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "music_volume") settingsPanel_.pendingMusicVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "ambient_volume") settingsPanel_.pendingAmbientVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "ui_volume") settingsPanel_.pendingUiVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "combat_volume") settingsPanel_.pendingCombatVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "spell_volume") settingsPanel_.pendingSpellVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "movement_volume") settingsPanel_.pendingMovementVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "footstep_volume") settingsPanel_.pendingFootstepVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "npc_voice_volume") settingsPanel_.pendingNpcVoiceVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "mount_volume") settingsPanel_.pendingMountVolume = std::clamp(std::stoi(val), 0, 100); - else if (key == "activity_volume") settingsPanel_.pendingActivityVolume = std::clamp(std::stoi(val), 0, 100); - // Gameplay - else if (key == "auto_loot") settingsPanel_.pendingAutoLoot = (std::stoi(val) != 0); - else if (key == "auto_sell_grey") settingsPanel_.pendingAutoSellGrey = (std::stoi(val) != 0); - else if (key == "auto_repair") settingsPanel_.pendingAutoRepair = (std::stoi(val) != 0); - else if (key == "graphics_preset") { - int presetVal = std::clamp(std::stoi(val), 0, 4); - settingsPanel_.currentGraphicsPreset = static_cast(presetVal); - settingsPanel_.pendingGraphicsPreset = settingsPanel_.currentGraphicsPreset; - } - else if (key == "ground_clutter_density") settingsPanel_.pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); - else if (key == "shadows") settingsPanel_.pendingShadows = (std::stoi(val) != 0); - else if (key == "shadow_distance") settingsPanel_.pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f); - else if (key == "brightness") { - settingsPanel_.pendingBrightness = std::clamp(std::stoi(val), 0, 100); - if (auto* r = services_.renderer) - r->setBrightness(static_cast(settingsPanel_.pendingBrightness) / 50.0f); - } - else if (key == "water_refraction") settingsPanel_.pendingWaterRefraction = (std::stoi(val) != 0); - else if (key == "antialiasing") settingsPanel_.pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); - else if (key == "fxaa") settingsPanel_.pendingFXAA = (std::stoi(val) != 0); - else if (key == "normal_mapping") settingsPanel_.pendingNormalMapping = (std::stoi(val) != 0); - else if (key == "normal_map_strength") settingsPanel_.pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); - else if (key == "pom") settingsPanel_.pendingPOM = (std::stoi(val) != 0); - else if (key == "pom_quality") settingsPanel_.pendingPOMQuality = std::clamp(std::stoi(val), 0, 2); - else if (key == "upscaling_mode") { - settingsPanel_.pendingUpscalingMode = std::clamp(std::stoi(val), 0, 2); - settingsPanel_.pendingFSR = (settingsPanel_.pendingUpscalingMode == 1); - } else if (key == "fsr") { - settingsPanel_.pendingFSR = (std::stoi(val) != 0); - // Backward compatibility: old configs only had fsr=0/1. - if (settingsPanel_.pendingUpscalingMode == 0 && settingsPanel_.pendingFSR) settingsPanel_.pendingUpscalingMode = 1; - } - else if (key == "fsr_quality") settingsPanel_.pendingFSRQuality = std::clamp(std::stoi(val), 0, 3); - else if (key == "fsr_sharpness") settingsPanel_.pendingFSRSharpness = std::clamp(std::stof(val), 0.0f, 2.0f); - else if (key == "fsr2_jitter_sign") settingsPanel_.pendingFSR2JitterSign = std::clamp(std::stof(val), -2.0f, 2.0f); - else if (key == "fsr2_mv_scale_x") settingsPanel_.pendingFSR2MotionVecScaleX = std::clamp(std::stof(val), -2.0f, 2.0f); - else if (key == "fsr2_mv_scale_y") settingsPanel_.pendingFSR2MotionVecScaleY = std::clamp(std::stof(val), -2.0f, 2.0f); - else if (key == "amd_fsr3_framegen") settingsPanel_.pendingAMDFramegen = (std::stoi(val) != 0); - // Controls - else if (key == "mouse_sensitivity") settingsPanel_.pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); - else if (key == "invert_mouse") settingsPanel_.pendingInvertMouse = (std::stoi(val) != 0); - else if (key == "extended_zoom") settingsPanel_.pendingExtendedZoom = (std::stoi(val) != 0); - else if (key == "camera_stiffness") settingsPanel_.pendingCameraStiffness = std::clamp(std::stof(val), 5.0f, 100.0f); - else if (key == "camera_pivot_height") settingsPanel_.pendingPivotHeight = std::clamp(std::stof(val), 0.0f, 3.0f); - else if (key == "fov") { - settingsPanel_.pendingFov = std::clamp(std::stof(val), 45.0f, 110.0f); - if (auto* renderer = services_.renderer) { - if (auto* camera = renderer->getCamera()) camera->setFov(settingsPanel_.pendingFov); - } - } - // Quest tracker position/size - else if (key == "quest_tracker_x") { - // Legacy: ignore absolute X (right_offset supersedes it) - (void)val; - } - else if (key == "quest_tracker_right_offset") { - questTrackerRightOffset_ = std::stof(val); - questTrackerPosInit_ = true; - } - else if (key == "quest_tracker_y") { - questTrackerPos_.y = std::stof(val); - questTrackerPosInit_ = true; - } - else if (key == "quest_tracker_w") { - questTrackerSize_.x = std::max(100.0f, std::stof(val)); - } - else if (key == "quest_tracker_h") { - questTrackerSize_.y = std::max(60.0f, std::stof(val)); - } - // Chat - else if (key == "chat_active_tab") chatPanel_.activeChatTab = std::clamp(std::stoi(val), 0, 3); - else if (key == "chat_timestamps") chatPanel_.chatShowTimestamps = (std::stoi(val) != 0); - else if (key == "chat_font_size") chatPanel_.chatFontSize = std::clamp(std::stoi(val), 0, 2); - else if (key == "chat_autojoin_general") chatPanel_.chatAutoJoinGeneral = (std::stoi(val) != 0); - else if (key == "chat_autojoin_trade") chatPanel_.chatAutoJoinTrade = (std::stoi(val) != 0); - else if (key == "chat_autojoin_localdefense") chatPanel_.chatAutoJoinLocalDefense = (std::stoi(val) != 0); - else if (key == "chat_autojoin_lfg") chatPanel_.chatAutoJoinLFG = (std::stoi(val) != 0); - else if (key == "chat_autojoin_local") chatPanel_.chatAutoJoinLocal = (std::stoi(val) != 0); - } catch (...) {} - } - - // Load keybindings from the same config file - KeybindingManager::getInstance().loadFromConfigFile(path); - - LOG_INFO("Settings loaded from ", path); -} - -// ============================================================ -// Mail Window -// ============================================================ - - - -// ============================================================ -// Bank Window -// ============================================================ - - -// ============================================================ -// Guild Bank Window -// ============================================================ - - -// ============================================================ -// Auction House Window -// ============================================================ - - - -// --------------------------------------------------------------------------- -// Screen-space weather overlay (rain / snow / storm) -// --------------------------------------------------------------------------- -void GameScreen::renderWeatherOverlay(game::GameHandler& gameHandler) { - uint32_t wType = gameHandler.getWeatherType(); - float intensity = gameHandler.getWeatherIntensity(); - if (wType == 0 || intensity < 0.05f) return; - - const ImGuiIO& io = ImGui::GetIO(); - float sw = io.DisplaySize.x; - float sh = io.DisplaySize.y; - if (sw <= 0.0f || sh <= 0.0f) return; - - // Seeded RNG for weather particle positions — replaces std::rand() which - // shares global state and has modulo bias. - static std::mt19937 wxRng(std::random_device{}()); - auto wxRandInt = [](int maxExcl) { - return std::uniform_int_distribution(0, std::max(0, maxExcl - 1))(wxRng); - }; - - ImDrawList* dl = ImGui::GetForegroundDrawList(); - const float dt = std::min(io.DeltaTime, 0.05f); // cap delta at 50ms to avoid teleporting particles - - if (wType == 1 || wType == 3) { - // ── Rain / Storm ───────────────────────────────────────────────────── - constexpr int MAX_DROPS = 300; - struct RainState { - float x[MAX_DROPS], y[MAX_DROPS]; - bool initialized = false; - uint32_t lastType = 0; - float lastW = 0.0f, lastH = 0.0f; - }; - static RainState rs; - - // Re-seed if weather type or screen size changed - if (!rs.initialized || rs.lastType != wType || - rs.lastW != sw || rs.lastH != sh) { - for (int i = 0; i < MAX_DROPS; ++i) { - rs.x[i] = static_cast(wxRandInt(static_cast(sw) + 200)) - 100.0f; - rs.y[i] = static_cast(wxRandInt(static_cast(sh))); - } - rs.initialized = true; - rs.lastType = wType; - rs.lastW = sw; - rs.lastH = sh; - } - - const float fallSpeed = (wType == 3) ? 680.0f : 440.0f; - const float windSpeed = (wType == 3) ? 110.0f : 65.0f; - const int numDrops = static_cast(MAX_DROPS * std::min(1.0f, intensity)); - const float alpha = std::min(1.0f, 0.28f + intensity * 0.38f); - const uint8_t alphaU8 = static_cast(alpha * 255.0f); - const ImU32 dropCol = IM_COL32(175, 195, 225, alphaU8); - const float dropLen = 7.0f + intensity * 7.0f; - // Normalised wind direction for the trail endpoint - const float invSpeed = 1.0f / std::sqrt(fallSpeed * fallSpeed + windSpeed * windSpeed); - const float trailDx = -windSpeed * invSpeed * dropLen; - const float trailDy = -fallSpeed * invSpeed * dropLen; - - for (int i = 0; i < numDrops; ++i) { - rs.x[i] += windSpeed * dt; - rs.y[i] += fallSpeed * dt; - if (rs.y[i] > sh + 10.0f) { - rs.y[i] = -10.0f; - rs.x[i] = static_cast(wxRandInt(static_cast(sw) + 200)) - 100.0f; - } - if (rs.x[i] > sw + 100.0f) rs.x[i] -= sw + 200.0f; - dl->AddLine(ImVec2(rs.x[i], rs.y[i]), - ImVec2(rs.x[i] + trailDx, rs.y[i] + trailDy), - dropCol, 1.0f); - } - - // Storm: dark fog-vignette at screen edges - if (wType == 3) { - const float vigAlpha = std::min(1.0f, 0.12f + intensity * 0.18f); - const ImU32 vigCol = IM_COL32(60, 65, 80, static_cast(vigAlpha * 255.0f)); - const float vigW = sw * 0.22f; - const float vigH = sh * 0.22f; - dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(vigW, sh), vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol); - dl->AddRectFilledMultiColor(ImVec2(sw-vigW, 0), ImVec2(sw, sh), IM_COL32_BLACK_TRANS, vigCol, vigCol, IM_COL32_BLACK_TRANS); - dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(sw, vigH), vigCol, vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS); - dl->AddRectFilledMultiColor(ImVec2(0, sh-vigH),ImVec2(sw, sh), IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol, vigCol); - } - - } else if (wType == 2) { - // ── Snow ───────────────────────────────────────────────────────────── - constexpr int MAX_FLAKES = 120; - struct SnowState { - float x[MAX_FLAKES], y[MAX_FLAKES], phase[MAX_FLAKES]; - bool initialized = false; - float lastW = 0.0f, lastH = 0.0f; - }; - static SnowState ss; - - if (!ss.initialized || ss.lastW != sw || ss.lastH != sh) { - for (int i = 0; i < MAX_FLAKES; ++i) { - ss.x[i] = static_cast(wxRandInt(static_cast(sw))); - ss.y[i] = static_cast(wxRandInt(static_cast(sh))); - ss.phase[i] = static_cast(wxRandInt(628)) * 0.01f; - } - ss.initialized = true; - ss.lastW = sw; - ss.lastH = sh; - } - - const float fallSpeed = 45.0f + intensity * 45.0f; - const int numFlakes = static_cast(MAX_FLAKES * std::min(1.0f, intensity)); - const float alpha = std::min(1.0f, 0.5f + intensity * 0.3f); - const uint8_t alphaU8 = static_cast(alpha * 255.0f); - const float radius = 1.5f + intensity * 1.5f; - const float time = static_cast(ImGui::GetTime()); - - for (int i = 0; i < numFlakes; ++i) { - float sway = std::sin(time * 0.7f + ss.phase[i]) * 18.0f; - ss.x[i] += sway * dt; - ss.y[i] += fallSpeed * dt; - ss.phase[i] += dt * 0.25f; - if (ss.y[i] > sh + 5.0f) { - ss.y[i] = -5.0f; - ss.x[i] = static_cast(wxRandInt(static_cast(sw))); - } - if (ss.x[i] < -5.0f) ss.x[i] += sw + 10.0f; - if (ss.x[i] > sw + 5.0f) ss.x[i] -= sw + 10.0f; - // Two-tone: bright centre dot + transparent outer ring for depth - dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius, IM_COL32(220, 235, 255, alphaU8)); - dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius * 0.45f, IM_COL32(245, 250, 255, std::min(255, alphaU8 + 30))); - } - } -} - -// --------------------------------------------------------------------------- -// Dungeon Finder window (toggle with hotkey or bag-bar button) -// --------------------------------------------------------------------------- -// ============================================================ -// Instance Lockouts -// ============================================================ - - - - -// ─── Threat Window ──────────────────────────────────────────────────────────── -// ─── BG Scoreboard ──────────────────────────────────────────────────────────── - - - - - }} // namespace wowee::ui diff --git a/src/ui/game_screen_frames.cpp b/src/ui/game_screen_frames.cpp new file mode 100644 index 00000000..6ecca46f --- /dev/null +++ b/src/ui/game_screen_frames.cpp @@ -0,0 +1,2432 @@ +#include "ui/game_screen.hpp" +#include "ui/ui_colors.hpp" +#include "ui/ui_helpers.hpp" +#include "rendering/vk_context.hpp" +#include "core/application.hpp" +#include "core/appearance_composer.hpp" +#include "addons/addon_manager.hpp" +#include "core/coordinates.hpp" +#include "core/input.hpp" +#include "rendering/renderer.hpp" +#include "rendering/post_process_pipeline.hpp" +#include "rendering/animation_controller.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/terrain_manager.hpp" +#include "rendering/minimap.hpp" +#include "rendering/world_map.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/camera.hpp" +#include "rendering/camera_controller.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/audio_engine.hpp" +#include "audio/music_manager.hpp" +#include "game/zone_manager.hpp" +#include "audio/footstep_manager.hpp" +#include "audio/activity_sound_manager.hpp" +#include "audio/mount_sound_manager.hpp" +#include "audio/npc_voice_manager.hpp" +#include "audio/ambient_sound_manager.hpp" +#include "audio/ui_sound_manager.hpp" +#include "audio/combat_sound_manager.hpp" +#include "audio/spell_sound_manager.hpp" +#include "audio/movement_sound_manager.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" + +#include "game/expansion_profile.hpp" +#include "game/character.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + using namespace wowee::ui::colors; + using namespace wowee::ui::helpers; + constexpr auto& kColorRed = kRed; + constexpr auto& kColorGreen = kGreen; + constexpr auto& kColorBrightGreen= kBrightGreen; + constexpr auto& kColorYellow = kYellow; + constexpr auto& kColorGray = kGray; + constexpr auto& kColorDarkGray = kDarkGray; + + // Abbreviated month names (indexed 0-11) + constexpr const char* kMonthAbbrev[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + + // Common ImGui window flags for popup dialogs + const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + + bool raySphereIntersect(const wowee::rendering::Ray& ray, const glm::vec3& center, float radius, float& tOut) { + glm::vec3 oc = ray.origin - center; + float b = glm::dot(oc, ray.direction); + float c = glm::dot(oc, oc) - radius * radius; + float discriminant = b * b - c; + if (discriminant < 0.0f) return false; + float t = -b - std::sqrt(discriminant); + if (t < 0.0f) t = -b + std::sqrt(discriminant); + if (t < 0.0f) return false; + tOut = t; + return true; + } + + std::string getEntityName(const std::shared_ptr& entity) { + if (entity->getType() == wowee::game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) return player->getName(); + } else if (entity->getType() == wowee::game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (!unit->getName().empty()) return unit->getName(); + } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (!go->getName().empty()) return go->getName(); + } + return "Unknown"; + } + +} + +namespace wowee { namespace ui { + +void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { + bool isDead = gameHandler.isPlayerDead(); + ImGui::SetNextWindowPos(ImVec2(10.0f, 30.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(250.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); + const bool inCombatConfirmed = gameHandler.isInCombat(); + const bool attackIntentOnly = gameHandler.hasAutoAttackIntent() && !inCombatConfirmed; + ImVec4 playerBorder = isDead + ? kColorDarkGray + : (inCombatConfirmed + ? colors::kBrightRed + : (attackIntentOnly + ? ImVec4(1.0f, 0.7f, 0.2f, 1.0f) + : ImVec4(0.4f, 0.4f, 0.4f, 1.0f))); + ImGui::PushStyleColor(ImGuiCol_Border, playerBorder); + + if (ImGui::Begin("##PlayerFrame", nullptr, flags)) { + // Use selected character info if available, otherwise defaults + std::string playerName = "Adventurer"; + uint32_t playerLevel = 1; + uint32_t playerHp = 100; + uint32_t playerMaxHp = 100; + + const auto& characters = gameHandler.getCharacters(); + uint64_t activeGuid = gameHandler.getActiveCharacterGuid(); + const game::Character* activeChar = nullptr; + for (const auto& c : characters) { + if (c.guid == activeGuid) { activeChar = &c; break; } + } + if (!activeChar && !characters.empty()) activeChar = &characters[0]; + if (activeChar) { + const auto& ch = *activeChar; + playerName = ch.name; + // Use live server level if available, otherwise character struct + playerLevel = gameHandler.getPlayerLevel(); + if (playerLevel == 0) playerLevel = ch.level; + playerMaxHp = 20 + playerLevel * 10; + playerHp = playerMaxHp; + } + + // Derive class color via shared helper + ImVec4 classColor = activeChar + ? classColorVec4(static_cast(activeChar->characterClass)) + : kColorBrightGreen; + + // Name in class color — clickable for self-target, right-click for menu + ImGui::PushStyleColor(ImGuiCol_Text, classColor); + if (ImGui::Selectable(playerName.c_str(), false, 0, ImVec2(0, 0))) { + gameHandler.setTarget(gameHandler.getPlayerGuid()); + } + if (ImGui::BeginPopupContextItem("PlayerSelfCtx")) { + ImGui::TextDisabled("%s", playerName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Open Character")) { + inventoryScreen.setCharacterOpen(true); + } + if (ImGui::MenuItem("Toggle PvP")) { + gameHandler.togglePvp(); + } + ImGui::Separator(); + bool afk = gameHandler.isAfk(); + bool dnd = gameHandler.isDnd(); + if (ImGui::MenuItem(afk ? "Cancel AFK" : "Set AFK")) { + gameHandler.toggleAfk(); + } + if (ImGui::MenuItem(dnd ? "Cancel DND" : "Set DND")) { + gameHandler.toggleDnd(); + } + if (gameHandler.isInGroup()) { + ImGui::Separator(); + if (ImGui::MenuItem("Leave Group")) { + gameHandler.leaveGroup(); + } + } + ImGui::EndPopup(); + } + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled("Lv %u", playerLevel); + if (isDead) { + ImGui::SameLine(); + ImGui::TextColored(colors::kDarkRed, "DEAD"); + } + // Group leader crown on self frame when you lead the party/raid + if (gameHandler.isInGroup() && + gameHandler.getPartyData().leaderGuid == gameHandler.getPlayerGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are the group leader"); + } + if (gameHandler.isAfk()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.3f, 1.0f), ""); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Away from keyboard — /afk to cancel"); + } else if (gameHandler.isDnd()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.9f, 0.5f, 0.2f, 1.0f), ""); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Do not disturb — /dnd to cancel"); + } + if (auto* ren = services_.renderer) { + if (auto* cam = ren->getCameraController()) { + if (cam->isAutoRunning()) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.9f, 1.0f, 1.0f), "[Auto-Run]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Auto-running — press ` or NumLock to stop"); + } + } + } + if (inCombatConfirmed && !isDead) { + float combatPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.2f * combatPulse, 0.2f * combatPulse, 1.0f), "[Combat]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("You are in combat"); + } + + // Active title — shown in gold below the name/level line + { + int32_t titleBit = gameHandler.getChosenTitleBit(); + if (titleBit >= 0) { + const std::string titleText = gameHandler.getFormattedTitle( + static_cast(titleBit)); + if (!titleText.empty()) { + ImGui::TextColored(ImVec4(1.0f, 0.84f, 0.0f, 0.9f), "%s", titleText.c_str()); + } + } + } + + // Try to get real HP/mana from the player entity + auto playerEntity = gameHandler.getEntityManager().getEntity(gameHandler.getPlayerGuid()); + if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + if (unit->getMaxHealth() > 0) { + playerHp = unit->getHealth(); + playerMaxHp = unit->getMaxHealth(); + } + } + + // Health bar — color transitions green→yellow→red as HP drops + float pct = static_cast(playerHp) / static_cast(playerMaxHp); + ImVec4 hpColor; + if (isDead) { + hpColor = kColorDarkGray; + } else if (pct > 0.5f) { + hpColor = colors::kHealthGreen; // green + } else if (pct > 0.2f) { + float t = (pct - 0.2f) / 0.3f; // 0 at 20%, 1 at 50% + hpColor = ImVec4(0.9f - 0.7f * t, 0.4f + 0.4f * t, 0.0f, 1.0f); // orange→yellow + } else { + // Critical — pulse red when < 20% + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.5f); + hpColor = ImVec4(0.9f * pulse, 0.05f, 0.05f, 1.0f); // pulsing red + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hpColor); + char overlay[64]; + snprintf(overlay, sizeof(overlay), "%u / %u", playerHp, playerMaxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay); + ImGui::PopStyleColor(); + + // Mana/Power bar + if (playerEntity && (playerEntity->getType() == game::ObjectType::PLAYER || playerEntity->getType() == game::ObjectType::UNIT)) { + auto unit = std::static_pointer_cast(playerEntity); + uint8_t powerType = unit->getPowerType(); + uint32_t power = unit->getPower(); + uint32_t maxPower = unit->getMaxPower(); + // Rage (1), Focus (2), Energy (3), and Runic Power (6) always cap at 100. + // Show bar even if server hasn't sent UNIT_FIELD_MAXPOWER1 yet. + if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3 || powerType == 6)) maxPower = 100; + if (maxPower > 0) { + float mpPct = static_cast(power) / static_cast(maxPower); + ImVec4 powerColor; + switch (powerType) { + case 0: { + // Mana: pulse desaturated blue when critically low (< 20%) + if (mpPct < 0.2f) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + powerColor = ImVec4(0.1f, 0.1f, 0.8f * pulse, 1.0f); + } else { + powerColor = colors::kManaBlue; + } + break; + } + case 1: powerColor = colors::kDarkRed; break; // Rage (red) + case 2: powerColor = colors::kOrange; break; // Focus (orange) + case 3: powerColor = colors::kEnergyYellow; break; // Energy (yellow) + case 4: powerColor = colors::kHappinessGreen; break; // Happiness (green) + case 6: powerColor = colors::kRunicRed; break; // Runic Power (crimson) + case 7: powerColor = colors::kSoulShardPurple; break; // Soul Shards (purple) + default: powerColor = colors::kManaBlue; break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); + char mpOverlay[64]; + snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", power, maxPower); + ImGui::ProgressBar(mpPct, ImVec2(-1, 18), mpOverlay); + ImGui::PopStyleColor(); + } + } + + // Death Knight rune bar (class 6) — 6 colored squares with fill fraction + if (gameHandler.getPlayerClass() == 6) { + const auto& runes = gameHandler.getPlayerRunes(); + float dt = ImGui::GetIO().DeltaTime; + + ImGui::Spacing(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float totalW = ImGui::GetContentRegionAvail().x; + float spacing = 3.0f; + float squareW = (totalW - spacing * 5.0f) / 6.0f; + float squareH = 14.0f; + ImDrawList* dl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < 6; i++) { + // Client-side prediction: advance fill over ~10s cooldown + runeClientFill_[i] = runes[i].ready ? 1.0f + : std::min(runeClientFill_[i] + dt / 10.0f, runes[i].readyFraction + 0.02f); + runeClientFill_[i] = std::clamp(runeClientFill_[i], 0.0f, runes[i].ready ? 1.0f : 0.97f); + + float x0 = cursor.x + i * (squareW + spacing); + float y0 = cursor.y; + float x1 = x0 + squareW; + float y1 = y0 + squareH; + + // Background (dark) + dl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), + IM_COL32(30, 30, 30, 200), 2.0f); + + // Fill color by rune type + ImVec4 fc; + switch (runes[i].type) { + case game::GameHandler::RuneType::Blood: fc = ImVec4(0.85f, 0.12f, 0.12f, 1.0f); break; + case game::GameHandler::RuneType::Unholy: fc = ImVec4(0.20f, 0.72f, 0.20f, 1.0f); break; + case game::GameHandler::RuneType::Frost: fc = ImVec4(0.30f, 0.55f, 0.90f, 1.0f); break; + case game::GameHandler::RuneType::Death: fc = ImVec4(0.55f, 0.20f, 0.70f, 1.0f); break; + default: fc = ImVec4(0.6f, 0.6f, 0.6f, 1.0f); break; + } + float fillX = x0 + (x1 - x0) * runeClientFill_[i]; + dl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), + ImGui::ColorConvertFloat4ToU32(fc), 2.0f); + + // Border + ImU32 borderCol = runes[i].ready + ? IM_COL32(220, 220, 220, 180) + : IM_COL32(100, 100, 100, 160); + dl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); + } + ImGui::Dummy(ImVec2(totalW, squareH)); + } + + // Combo point display — Rogue (4) and Druid (11) in Cat Form + { + uint8_t cls = gameHandler.getPlayerClass(); + const bool isRogue = (cls == 4); + const bool isDruid = (cls == 11); + if (isRogue || isDruid) { + uint8_t cp = gameHandler.getComboPoints(); + if (cp > 0 || isRogue) { // always show for rogue; only when non-zero for druid + ImGui::Spacing(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float totalW = ImGui::GetContentRegionAvail().x; + constexpr int MAX_CP = 5; + constexpr float DOT_R = 7.0f; + constexpr float SPACING = 4.0f; + float totalDotsW = MAX_CP * (DOT_R * 2.0f) + (MAX_CP - 1) * SPACING; + float startX = cursor.x + (totalW - totalDotsW) * 0.5f; + float cy = cursor.y + DOT_R; + ImDrawList* dl = ImGui::GetWindowDrawList(); + for (int i = 0; i < MAX_CP; ++i) { + float cx = startX + i * (DOT_R * 2.0f + SPACING) + DOT_R; + ImU32 col = (i < static_cast(cp)) + ? IM_COL32(255, 210, 0, 240) // bright gold — active + : IM_COL32(60, 60, 60, 160); // dark — empty + dl->AddCircleFilled(ImVec2(cx, cy), DOT_R, col); + dl->AddCircle(ImVec2(cx, cy), DOT_R, IM_COL32(160, 140, 0, 180), 0, 1.5f); + } + ImGui::Dummy(ImVec2(totalW, DOT_R * 2.0f)); + } + } + } + + // Shaman totem bar (class 7) — 4 slots: Earth, Fire, Water, Air + if (gameHandler.getPlayerClass() == 7) { + static constexpr ImVec4 kTotemColors[] = { + ImVec4(0.80f, 0.55f, 0.25f, 1.0f), // Earth — brown + ImVec4(1.00f, 0.35f, 0.10f, 1.0f), // Fire — orange-red + ImVec4(0.20f, 0.55f, 0.90f, 1.0f), // Water — blue + ImVec4(0.70f, 0.90f, 1.00f, 1.0f), // Air — pale sky + }; + static constexpr const char* kTotemNames[] = { "Earth", "Fire", "Water", "Air" }; + + ImGui::Spacing(); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + float totalW = ImGui::GetContentRegionAvail().x; + float spacing = 3.0f; + float slotW = (totalW - spacing * 3.0f) / 4.0f; + float slotH = 14.0f; + ImDrawList* tdl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; i++) { + const auto& ts = gameHandler.getTotemSlot(i); + float x0 = cursor.x + i * (slotW + spacing); + float y0 = cursor.y; + float x1 = x0 + slotW; + float y1 = y0 + slotH; + + // Background + tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(x1, y1), IM_COL32(20, 20, 20, 200), 2.0f); + + if (ts.active()) { + float rem = ts.remainingMs(); + float frac = rem / static_cast(ts.durationMs); + float fillX = x0 + (x1 - x0) * frac; + tdl->AddRectFilled(ImVec2(x0, y0), ImVec2(fillX, y1), + ImGui::ColorConvertFloat4ToU32(kTotemColors[i]), 2.0f); + // Remaining seconds label + char secBuf[16]; + snprintf(secBuf, sizeof(secBuf), "%.0f", rem / 1000.0f); + ImVec2 tsz = ImGui::CalcTextSize(secBuf); + float lx = x0 + (slotW - tsz.x) * 0.5f; + float ly = y0 + (slotH - tsz.y) * 0.5f; + tdl->AddText(ImVec2(lx + 1, ly + 1), IM_COL32(0, 0, 0, 180), secBuf); + tdl->AddText(ImVec2(lx, ly), IM_COL32(255, 255, 255, 230), secBuf); + } else { + // Inactive — show element letter + const char* letter = kTotemNames[i]; + char single[2] = { letter[0], '\0' }; + ImVec2 tsz = ImGui::CalcTextSize(single); + float lx = x0 + (slotW - tsz.x) * 0.5f; + float ly = y0 + (slotH - tsz.y) * 0.5f; + tdl->AddText(ImVec2(lx, ly), IM_COL32(80, 80, 80, 200), single); + } + + // Border + ImU32 borderCol = ts.active() + ? ImGui::ColorConvertFloat4ToU32(kTotemColors[i]) + : IM_COL32(60, 60, 60, 160); + tdl->AddRect(ImVec2(x0, y0), ImVec2(x1, y1), borderCol, 2.0f); + + // Tooltip on hover + ImGui::SetCursorScreenPos(ImVec2(x0, y0)); + char totemBtnId[16]; snprintf(totemBtnId, sizeof(totemBtnId), "##totem%d", i); + ImGui::InvisibleButton(totemBtnId, ImVec2(slotW, slotH)); + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + if (ts.active()) { + const std::string& spellNm = gameHandler.getSpellName(ts.spellId); + ImGui::TextColored(ImVec4(kTotemColors[i].x, kTotemColors[i].y, + kTotemColors[i].z, 1.0f), + "%s Totem", kTotemNames[i]); + if (!spellNm.empty()) ImGui::Text("%s", spellNm.c_str()); + ImGui::Text("%.1fs remaining", ts.remainingMs() / 1000.0f); + } else { + ImGui::TextDisabled("%s Totem (empty)", kTotemNames[i]); + } + ImGui::EndTooltip(); + } + } + ImGui::SetCursorScreenPos(ImVec2(cursor.x, cursor.y + slotH + 2.0f)); + } + } + + // Melee swing timer — shown when player is auto-attacking + if (gameHandler.isAutoAttacking()) { + const uint64_t lastSwingMs = gameHandler.getLastMeleeSwingMs(); + if (lastSwingMs > 0) { + // Determine weapon speed from the equipped main-hand weapon + uint32_t weaponDelayMs = 2000; // Default: 2.0s unarmed + const auto& mainSlot = gameHandler.getInventory().getEquipSlot(game::EquipSlot::MAIN_HAND); + if (!mainSlot.empty() && mainSlot.item.itemId != 0) { + const auto* info = gameHandler.getItemInfo(mainSlot.item.itemId); + if (info && info->delayMs > 0) { + weaponDelayMs = info->delayMs; + } + } + + // Compute elapsed since last swing + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count()); + uint64_t elapsedMs = (nowMs >= lastSwingMs) ? (nowMs - lastSwingMs) : 0; + + // Clamp to weapon delay (cap at 1.0 so the bar fills but doesn't exceed) + float pct = std::min(static_cast(elapsedMs) / static_cast(weaponDelayMs), 1.0f); + + // Light silver-orange color indicating auto-attack readiness + ImVec4 swingColor = (pct >= 0.95f) + ? ImVec4(1.0f, 0.75f, 0.15f, 1.0f) // gold when ready to swing + : ImVec4(0.65f, 0.55f, 0.40f, 1.0f); // muted brown-orange while filling + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, swingColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.12f, 0.08f, 0.8f)); + char swingLabel[24]; + float remainSec = std::max(0.0f, (weaponDelayMs - static_cast(elapsedMs)) / 1000.0f); + if (pct >= 0.98f) + snprintf(swingLabel, sizeof(swingLabel), "Swing!"); + else + snprintf(swingLabel, sizeof(swingLabel), "%.1fs", remainSec); + ImGui::ProgressBar(pct, ImVec2(-1.0f, 8.0f), swingLabel); + ImGui::PopStyleColor(2); + } + } + + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +void GameScreen::renderPetFrame(game::GameHandler& gameHandler) { + uint64_t petGuid = gameHandler.getPetGuid(); + if (petGuid == 0) return; + + auto petEntity = gameHandler.getEntityManager().getEntity(petGuid); + if (!petEntity) return; + auto* petUnit = petEntity->isUnit() ? static_cast(petEntity.get()) : nullptr; + if (!petUnit) return; + + // Position below player frame. If in a group, push below party frames + // (party frame at y=120, each member ~50px, up to 4 members → max ~320px + y=120 = ~440). + // When not grouped, the player frame ends at ~110px so y=125 is fine. + const int partyMemberCount = gameHandler.isInGroup() + ? static_cast(gameHandler.getPartyData().members.size()) : 0; + float petY = (partyMemberCount > 0) + ? 120.0f + partyMemberCount * 52.0f + 8.0f + : 125.0f; + ImGui::SetNextWindowPos(ImVec2(10.0f, petY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(200.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.1f, 0.08f, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.2f, 0.6f, 0.2f, 1.0f)); + + if (ImGui::Begin("##PetFrame", nullptr, flags)) { + const std::string& petName = petUnit->getName(); + uint32_t petLevel = petUnit->getLevel(); + + // Name + level on one row — clicking the pet name targets it + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.4f, 0.9f, 0.4f, 1.0f)); + char petLabel[96]; + snprintf(petLabel, sizeof(petLabel), "%s", + petName.empty() ? "Pet" : petName.c_str()); + if (ImGui::Selectable(petLabel, false, 0, ImVec2(0, 0))) { + gameHandler.setTarget(petGuid); + } + // Right-click context menu on pet name + if (ImGui::BeginPopupContextItem("PetNameCtx")) { + ImGui::TextDisabled("%s", petLabel); + ImGui::Separator(); + if (ImGui::MenuItem("Target Pet")) { + gameHandler.setTarget(petGuid); + } + if (ImGui::MenuItem("Rename Pet")) { + ImGui::CloseCurrentPopup(); + petRenameOpen_ = true; + petRenameBuf_[0] = '\0'; + } + if (ImGui::MenuItem("Dismiss Pet")) { + gameHandler.dismissPet(); + } + ImGui::EndPopup(); + } + // Pet rename modal (opened via context menu) + if (petRenameOpen_) { + ImGui::OpenPopup("Rename Pet###PetRename"); + petRenameOpen_ = false; + } + if (ImGui::BeginPopupModal("Rename Pet###PetRename", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoCollapse)) { + ImGui::Text("Enter new pet name (max 12 characters):"); + ImGui::SetNextItemWidth(180.0f); + bool submitted = ImGui::InputText("##PetRenameInput", petRenameBuf_, sizeof(petRenameBuf_), + ImGuiInputTextFlags_EnterReturnsTrue); + ImGui::SameLine(); + if (ImGui::Button("OK") || submitted) { + std::string newName(petRenameBuf_); + if (!newName.empty() && newName.size() <= 12) { + gameHandler.renamePet(newName); + } + ImGui::CloseCurrentPopup(); + } + ImGui::SameLine(); + if (ImGui::Button("Cancel")) { + ImGui::CloseCurrentPopup(); + } + ImGui::EndPopup(); + } + ImGui::PopStyleColor(); + if (petLevel > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("Lv %u", petLevel); + } + + // Health bar + uint32_t hp = petUnit->getHealth(); + uint32_t maxHp = petUnit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImVec4 petHpColor = pct > 0.5f ? colors::kHealthGreen + : pct > 0.2f ? ImVec4(0.9f, 0.6f, 0.0f, 1.0f) + : ImVec4(0.9f, 0.15f, 0.15f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, petHpColor); + char hpText[32]; + snprintf(hpText, sizeof(hpText), "%u/%u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), hpText); + ImGui::PopStyleColor(); + } + + // Power/mana bar (hunters' pets use focus) + uint8_t powerType = petUnit->getPowerType(); + uint32_t power = petUnit->getPower(); + uint32_t maxPower = petUnit->getMaxPower(); + if (maxPower == 0 && (powerType == 1 || powerType == 2 || powerType == 3)) maxPower = 100; + if (maxPower > 0) { + float mpPct = static_cast(power) / static_cast(maxPower); + ImVec4 powerColor; + switch (powerType) { + case 0: powerColor = colors::kManaBlue; break; // Mana + case 1: powerColor = colors::kDarkRed; break; // Rage + case 2: powerColor = colors::kOrange; break; // Focus (hunter pets) + case 3: powerColor = colors::kEnergyYellow; break; // Energy + default: powerColor = colors::kManaBlue; break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, powerColor); + char mpText[32]; + snprintf(mpText, sizeof(mpText), "%u/%u", power, maxPower); + ImGui::ProgressBar(mpPct, ImVec2(-1, 14), mpText); + ImGui::PopStyleColor(); + } + + // Happiness bar — hunter pets store happiness as power type 4 + { + uint32_t happiness = petUnit->getPowerByType(4); + uint32_t maxHappiness = petUnit->getMaxPowerByType(4); + if (maxHappiness > 0 && happiness > 0) { + float hapPct = static_cast(happiness) / static_cast(maxHappiness); + // Tier: < 33% = Unhappy (red), < 67% = Content (yellow), >= 67% = Happy (green) + ImVec4 hapColor = hapPct >= 0.667f ? ImVec4(0.2f, 0.85f, 0.2f, 1.0f) + : hapPct >= 0.333f ? ImVec4(0.9f, 0.75f, 0.1f, 1.0f) + : ImVec4(0.85f, 0.2f, 0.2f, 1.0f); + const char* hapLabel = hapPct >= 0.667f ? "Happy" : hapPct >= 0.333f ? "Content" : "Unhappy"; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, hapColor); + ImGui::ProgressBar(hapPct, ImVec2(-1, 8), hapLabel); + ImGui::PopStyleColor(); + } + } + + // Pet cast bar + if (auto* pcs = gameHandler.getUnitCastState(petGuid)) { + float castPct = (pcs->timeTotal > 0.0f) + ? (pcs->timeTotal - pcs->timeRemaining) / pcs->timeTotal : 0.0f; + // Orange color to distinguish from health/power bars + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.85f, 0.55f, 0.1f, 1.0f)); + char petCastLabel[48]; + const std::string& spellNm = gameHandler.getSpellName(pcs->spellId); + if (!spellNm.empty()) + snprintf(petCastLabel, sizeof(petCastLabel), "%s (%.1fs)", spellNm.c_str(), pcs->timeRemaining); + else + snprintf(petCastLabel, sizeof(petCastLabel), "Casting... (%.1fs)", pcs->timeRemaining); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), petCastLabel); + ImGui::PopStyleColor(); + } + + // Stance row: Passive / Defensive / Aggressive — with Dismiss right-aligned + { + static constexpr const char* kReactLabels[] = { "Psv", "Def", "Agg" }; + static constexpr const char* kReactTooltips[] = { "Passive", "Defensive", "Aggressive" }; + static constexpr ImVec4 kReactColors[] = { + colors::kLightBlue, // passive — blue + ImVec4(0.3f, 0.85f, 0.3f, 1.0f), // defensive — green + colors::kHostileRed,// aggressive — red + }; + static constexpr ImVec4 kReactDimColors[] = { + ImVec4(0.15f, 0.2f, 0.4f, 0.8f), + ImVec4(0.1f, 0.3f, 0.1f, 0.8f), + ImVec4(0.4f, 0.1f, 0.1f, 0.8f), + }; + uint8_t curReact = gameHandler.getPetReact(); // 0=passive,1=defensive,2=aggressive + + // Find each react-type slot in the action bar by known built-in IDs: + // 1=Passive, 4=Defensive, 6=Aggressive (WoW wire protocol) + static const uint32_t kReactActionIds[] = { 1u, 4u, 6u }; + uint32_t reactSlotVals[3] = { 0, 0, 0 }; + const int slotTotal = game::GameHandler::PET_ACTION_BAR_SLOTS; + for (int i = 0; i < slotTotal; ++i) { + uint32_t sv = gameHandler.getPetActionSlot(i); + uint32_t aid = sv & 0x00FFFFFFu; + for (int r = 0; r < 3; ++r) { + if (aid == kReactActionIds[r]) { reactSlotVals[r] = sv; break; } + } + } + + for (int r = 0; r < 3; ++r) { + if (r > 0) ImGui::SameLine(0.0f, 3.0f); + bool active = (curReact == static_cast(r)); + ImVec4 btnCol = active ? kReactColors[r] : kReactDimColors[r]; + ImGui::PushID(r + 1000); + ImGui::PushStyleColor(ImGuiCol_Button, btnCol); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kReactColors[r]); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, kReactColors[r]); + if (ImGui::Button(kReactLabels[r], ImVec2(34.0f, 16.0f))) { + // Use server-provided slot value if available; fall back to raw ID + uint32_t action = (reactSlotVals[r] != 0) + ? reactSlotVals[r] + : kReactActionIds[r]; + gameHandler.sendPetAction(action, 0); + } + ImGui::PopStyleColor(3); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", kReactTooltips[r]); + ImGui::PopID(); + } + + // Dismiss button right-aligned on the same row + ImGui::SameLine(ImGui::GetContentRegionAvail().x - 58.0f); + if (ImGui::SmallButton("Dismiss")) { + gameHandler.dismissPet(); + } + } + + // Pet action bar — show up to 10 action slots from SMSG_PET_SPELLS + { + const int slotCount = game::GameHandler::PET_ACTION_BAR_SLOTS; + // Filter to non-zero slots; lay them out as small icon/text buttons. + // Raw slot value layout (WotLK 3.3.5): low 24 bits = spell/action ID, + // high byte = flag (0x80=autocast on, 0x40=can-autocast, 0x0C=type). + // Built-in commands: id=2 follow, id=3 stay/move, id=5 attack. + auto* assetMgr = services_.assetManager; + const float iconSz = 20.0f; + const float spacing = 2.0f; + ImGui::Separator(); + + int rendered = 0; + for (int i = 0; i < slotCount; ++i) { + uint32_t slotVal = gameHandler.getPetActionSlot(i); + if (slotVal == 0) continue; + + uint32_t actionId = slotVal & 0x00FFFFFFu; + // Use the authoritative autocast set from SMSG_PET_SPELLS spell list flags. + bool autocastOn = gameHandler.isPetSpellAutocast(actionId); + + // Cooldown tracking for pet spells (actionId > 6 are spell IDs) + float petCd = (actionId > 6) ? gameHandler.getSpellCooldown(actionId) : 0.0f; + bool petOnCd = (petCd > 0.0f); + + ImGui::PushID(i); + if (rendered > 0) ImGui::SameLine(0.0f, spacing); + + // Try to show spell icon; fall back to abbreviated text label. + VkDescriptorSet iconTex = VK_NULL_HANDLE; + const char* builtinLabel = nullptr; + if (actionId == 1) builtinLabel = "Psv"; + else if (actionId == 2) builtinLabel = "Fol"; + else if (actionId == 3) builtinLabel = "Sty"; + else if (actionId == 4) builtinLabel = "Def"; + else if (actionId == 5) builtinLabel = "Atk"; + else if (actionId == 6) builtinLabel = "Agg"; + else if (assetMgr) iconTex = getSpellIcon(actionId, assetMgr); + + // Dim when on cooldown; tint green when autocast is on + ImVec4 tint = petOnCd + ? ImVec4(0.35f, 0.35f, 0.35f, 0.7f) + : (autocastOn ? colors::kLightGreen : ui::colors::kWhite); + bool clicked = false; + if (iconTex) { + clicked = ImGui::ImageButton("##pa", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(iconSz, iconSz), + ImVec2(0,0), ImVec2(1,1), + ImVec4(0.1f,0.1f,0.1f,0.9f), tint); + } else { + char label[8]; + if (builtinLabel) { + snprintf(label, sizeof(label), "%s", builtinLabel); + } else { + // Show first 3 chars of spell name or spell ID. + std::string nm = gameHandler.getSpellName(actionId); + if (nm.empty()) snprintf(label, sizeof(label), "?%u", actionId % 100); + else snprintf(label, sizeof(label), "%.3s", nm.c_str()); + } + ImVec4 btnCol = petOnCd ? ImVec4(0.1f,0.1f,0.15f,0.9f) + : (autocastOn ? ImVec4(0.2f,0.5f,0.2f,0.9f) + : ImVec4(0.2f,0.2f,0.3f,0.9f)); + ImGui::PushStyleColor(ImGuiCol_Button, btnCol); + clicked = ImGui::Button(label, ImVec2(iconSz + 4.0f, iconSz)); + ImGui::PopStyleColor(); + } + + // Cooldown overlay: dark fill + time text centered on the button + if (petOnCd && !builtinLabel) { + ImVec2 bMin = ImGui::GetItemRectMin(); + ImVec2 bMax = ImGui::GetItemRectMax(); + auto* cdDL = ImGui::GetWindowDrawList(); + cdDL->AddRectFilled(bMin, bMax, IM_COL32(0, 0, 0, 140)); + char cdTxt[8]; + if (petCd >= 60.0f) + snprintf(cdTxt, sizeof(cdTxt), "%dm", static_cast(petCd / 60.0f)); + else if (petCd >= 1.0f) + snprintf(cdTxt, sizeof(cdTxt), "%d", static_cast(petCd)); + else + snprintf(cdTxt, sizeof(cdTxt), "%.1f", petCd); + ImVec2 tsz = ImGui::CalcTextSize(cdTxt); + float cx = (bMin.x + bMax.x) * 0.5f; + float cy = (bMin.y + bMax.y) * 0.5f; + cdDL->AddText(ImVec2(cx - tsz.x * 0.5f, cy - tsz.y * 0.5f), + IM_COL32(255, 255, 255, 230), cdTxt); + } + + if (clicked && !petOnCd) { + // Send pet action; use current target for spells. + uint64_t targetGuid = (actionId > 5) ? gameHandler.getTargetGuid() : 0u; + gameHandler.sendPetAction(slotVal, targetGuid); + } + // Right-click toggles autocast for castable pet spells (actionId > 6) + if (actionId > 6 && ImGui::IsItemClicked(ImGuiMouseButton_Right)) { + gameHandler.togglePetSpellAutocast(actionId); + } + + // Tooltip: rich spell info for pet spells, simple label for built-in commands + if (ImGui::IsItemHovered()) { + if (builtinLabel) { + const char* tip = nullptr; + if (actionId == 1) tip = "Passive"; + else if (actionId == 2) tip = "Follow"; + else if (actionId == 3) tip = "Stay"; + else if (actionId == 4) tip = "Defensive"; + else if (actionId == 5) tip = "Attack"; + else if (actionId == 6) tip = "Aggressive"; + if (tip) ImGui::SetTooltip("%s", tip); + } else if (actionId > 6) { + auto* spellAsset = services_.assetManager; + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(actionId, gameHandler, spellAsset); + if (!richOk) { + std::string nm = gameHandler.getSpellName(actionId); + if (nm.empty()) nm = "Spell #" + std::to_string(actionId); + ImGui::Text("%s", nm.c_str()); + } + ImGui::TextColored(autocastOn + ? kColorGreen + : kColorGray, + "Autocast: %s (right-click to toggle)", autocastOn ? "On" : "Off"); + if (petOnCd) { + if (petCd >= 60.0f) + ImGui::TextColored(kColorRed, + "Cooldown: %d min %d sec", + static_cast(petCd) / 60, static_cast(petCd) % 60); + else + ImGui::TextColored(kColorRed, + "Cooldown: %.1f sec", petCd); + } + ImGui::EndTooltip(); + } + } + + ImGui::PopID(); + ++rendered; + } + } + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + +// ============================================================ +// Totem Frame (Shaman — below pet frame / player frame) +// ============================================================ + +void GameScreen::renderTotemFrame(game::GameHandler& gameHandler) { + // Only show if at least one totem is active + bool anyActive = false; + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { + if (gameHandler.getTotemSlot(i).active()) { anyActive = true; break; } + } + if (!anyActive) return; + + static constexpr struct { const char* name; ImU32 color; } kTotemInfo[4] = { + { "Earth", IM_COL32(139, 90, 43, 255) }, // brown + { "Fire", IM_COL32(220, 80, 30, 255) }, // red-orange + { "Water", IM_COL32( 30,120, 220, 255) }, // blue + { "Air", IM_COL32(180,220, 255, 255) }, // light blue + }; + + // Position: below pet frame / player frame, left side + // Pet frame is at ~y=200 if active, player frame is at y=20; totem frame near y=300 + // We anchor relative to screen left edge like pet frame + ImGui::SetNextWindowPos(ImVec2(8.0f, 300.0f), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(130.0f, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize | + ImGuiWindowFlags_NoTitleBar; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.08f, 0.06f, 0.88f)); + + if (ImGui::Begin("##TotemFrame", nullptr, flags)) { + ImGui::TextColored(ImVec4(0.9f, 0.75f, 0.3f, 1.0f), "Totems"); + ImGui::Separator(); + + for (int i = 0; i < game::GameHandler::NUM_TOTEM_SLOTS; ++i) { + const auto& slot = gameHandler.getTotemSlot(i); + if (!slot.active()) continue; + + ImGui::PushID(i); + + // Colored element dot + ImVec2 dotPos = ImGui::GetCursorScreenPos(); + dotPos.x += 4.0f; dotPos.y += 6.0f; + ImGui::GetWindowDrawList()->AddCircleFilled( + ImVec2(dotPos.x + 4.0f, dotPos.y + 4.0f), 4.0f, kTotemInfo[i].color); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 14.0f); + + // Totem name or spell name + const std::string& spellName = gameHandler.getSpellName(slot.spellId); + const char* displayName = spellName.empty() ? kTotemInfo[i].name : spellName.c_str(); + ImGui::Text("%s", displayName); + + // Duration countdown bar + float remMs = slot.remainingMs(); + float totMs = static_cast(slot.durationMs); + float frac = (totMs > 0.0f) ? std::min(remMs / totMs, 1.0f) : 0.0f; + float remSec = remMs / 1000.0f; + + // Color bar with totem element tint + ImVec4 barCol( + static_cast((kTotemInfo[i].color >> IM_COL32_R_SHIFT) & 0xFF) / 255.0f, + static_cast((kTotemInfo[i].color >> IM_COL32_G_SHIFT) & 0xFF) / 255.0f, + static_cast((kTotemInfo[i].color >> IM_COL32_B_SHIFT) & 0xFF) / 255.0f, + 0.9f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, barCol); + char timeBuf[16]; + snprintf(timeBuf, sizeof(timeBuf), "%.0fs", remSec); + ImGui::ProgressBar(frac, ImVec2(-1, 8), timeBuf); + ImGui::PopStyleColor(); + + ImGui::PopID(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + +void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { + auto target = gameHandler.getTarget(); + if (!target) return; + + auto* window = services_.window; + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + float frameW = 250.0f; + float frameX = (screenW - frameW) / 2.0f; + + ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + // Determine hostility/level color for border and name (WoW-canonical) + ImVec4 hostileColor(0.7f, 0.7f, 0.7f, 1.0f); + if (target->getType() == game::ObjectType::PLAYER) { + hostileColor = kColorBrightGreen; + } else if (target->getType() == game::ObjectType::UNIT) { + auto u = std::static_pointer_cast(target); + if (u->getHealth() == 0 && u->getMaxHealth() > 0) { + hostileColor = kColorDarkGray; + } else if (u->isHostile()) { + // Check tapped-by-other: grey name for mobs tagged by someone else + uint32_t tgtDynFlags = u->getDynamicFlags(); + bool tgtTapped = (tgtDynFlags & 0x0004) != 0 && (tgtDynFlags & 0x0008) == 0; + if (tgtTapped) { + hostileColor = kColorGray; // Grey — tapped by other + } else { + // WoW level-based color for hostile mobs + uint32_t playerLv = gameHandler.getPlayerLevel(); + uint32_t mobLv = u->getLevel(); + if (mobLv == 0) { + // Level 0 = unknown/?? (e.g. high-level raid bosses) — always skull red + hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); + } else { + int32_t diff = static_cast(mobLv) - static_cast(playerLv); + if (game::GameHandler::killXp(playerLv, mobLv) == 0) { + hostileColor = kColorGray; // Grey - no XP + } else if (diff >= 10) { + hostileColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // Red - skull/very hard + } else if (diff >= 5) { + hostileColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); // Orange - hard + } else if (diff >= -2) { + hostileColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); // Yellow - even + } else { + hostileColor = kColorBrightGreen; // Green - easy + } + } + } // end tapped else + } else { + hostileColor = kColorBrightGreen; // Friendly + } + } + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.1f, 0.1f, 0.1f, 0.85f)); + const uint64_t targetGuid = target->getGuid(); + const bool confirmedCombatWithTarget = gameHandler.isInCombatWith(targetGuid); + const bool intentTowardTarget = + gameHandler.hasAutoAttackIntent() && + gameHandler.getAutoAttackTargetGuid() == targetGuid && + !confirmedCombatWithTarget; + ImVec4 borderColor = ImVec4(hostileColor.x * 0.8f, hostileColor.y * 0.8f, hostileColor.z * 0.8f, 1.0f); + if (confirmedCombatWithTarget) { + float t = ImGui::GetTime(); + float pulse = (std::fmod(t, 0.6f) < 0.3f) ? 1.0f : 0.0f; + borderColor = ImVec4(1.0f, 0.1f, 0.1f, pulse); + } else if (intentTowardTarget) { + borderColor = ImVec4(1.0f, 0.7f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_Border, borderColor); + + if (ImGui::Begin("##TargetFrame", nullptr, flags)) { + // Raid mark icon (Star/Circle/Diamond/Triangle/Moon/Square/Cross/Skull) + static constexpr 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 — Selectable so we can attach a right-click context menu + std::string name = getEntityName(target); + + // Player targets: use class color instead of the generic green + ImVec4 nameColor = hostileColor; + if (target->getType() == game::ObjectType::PLAYER) { + uint8_t cid = entityClassId(target.get()); + if (cid != 0) nameColor = classColorVec4(cid); + } + + ImGui::SameLine(0.0f, 0.0f); + ImGui::PushStyleColor(ImGuiCol_Text, nameColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + ImGui::Selectable(name.c_str(), false, ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(name.c_str()).x, 0)); + ImGui::PopStyleColor(4); + + // Right-click context menu on target frame + if (ImGui::BeginPopupContextItem("##TargetFrameCtx")) { + const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); + const uint64_t tGuid = target->getGuid(); + + ImGui::TextDisabled("%s", name.c_str()); + ImGui::Separator(); + + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(tGuid); + if (ImGui::MenuItem("Clear Target")) + gameHandler.clearTarget(); + if (isPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + chatPanel_.setWhisperTarget(name); + } + if (ImGui::MenuItem("Follow")) + gameHandler.followTarget(); + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(name); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(tGuid); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(tGuid); + if (ImGui::MenuItem("Inspect")) { + gameHandler.inspectTarget(); + socialPanel_.showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(name); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(name); + if (ImGui::MenuItem("Report Player")) + gameHandler.reportPlayer(tGuid, "Reported via UI"); + } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(tGuid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(tGuid, 0xFF); + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } + + // Group leader crown — golden ♛ when the targeted player is the party/raid leader + if (gameHandler.isInGroup() && target->getType() == game::ObjectType::PLAYER) { + if (gameHandler.getPartyData().leaderGuid == target->getGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); + } + } + + // Quest giver indicator — "!" for available quests, "?" for completable quests + { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(target->getGuid()); + if (qgs == QGS::AVAILABLE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(colors::kBrightGold, "!"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a quest available"); + } else if (qgs == QGS::AVAILABLE_LOW) { + ImGui::SameLine(0, 4); + ImGui::TextColored(kColorGray, "!"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Has a low-level quest available"); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + ImGui::SameLine(0, 4); + ImGui::TextColored(colors::kBrightGold, "?"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest ready to turn in"); + } else if (qgs == QGS::INCOMPLETE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(kColorGray, "?"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Quest incomplete"); + } + } + + // Creature subtitle (e.g. "", "Captain of the Guard") + if (target->getType() == game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(target); + const std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); + if (!sub.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", sub.c_str()); + } + } + + // Player guild name (e.g. "") — mirrors NPC subtitle styling + if (target->getType() == game::ObjectType::PLAYER) { + uint32_t guildId = gameHandler.getEntityGuildId(target->getGuid()); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); + } + } + } + + // Right-click context menu on the target name + if (ImGui::BeginPopupContextItem("##TargetNameCtx")) { + const bool isPlayer = (target->getType() == game::ObjectType::PLAYER); + const uint64_t tGuid = target->getGuid(); + + ImGui::TextDisabled("%s", name.c_str()); + ImGui::Separator(); + + if (ImGui::MenuItem("Set Focus")) { + gameHandler.setFocus(tGuid); + } + if (ImGui::MenuItem("Clear Target")) { + gameHandler.clearTarget(); + } + if (isPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + chatPanel_.setWhisperTarget(name); + } + if (ImGui::MenuItem("Follow")) { + gameHandler.followTarget(); + } + if (ImGui::MenuItem("Invite to Group")) { + gameHandler.inviteToGroup(name); + } + if (ImGui::MenuItem("Trade")) { + gameHandler.initiateTrade(tGuid); + } + if (ImGui::MenuItem("Duel")) { + gameHandler.proposeDuel(tGuid); + } + if (ImGui::MenuItem("Inspect")) { + gameHandler.inspectTarget(); + socialPanel_.showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) { + gameHandler.addFriend(name); + } + if (ImGui::MenuItem("Ignore")) { + gameHandler.addIgnore(name); + } + } + ImGui::Separator(); + if (ImGui::BeginMenu("Set Raid Mark")) { + for (int mi = 0; mi < 8; ++mi) { + if (ImGui::MenuItem(kRaidMarkNames[mi])) + gameHandler.setRaidMark(tGuid, static_cast(mi)); + } + ImGui::Separator(); + if (ImGui::MenuItem("Clear Mark")) + gameHandler.setRaidMark(tGuid, 0xFF); + ImGui::EndMenu(); + } + ImGui::EndPopup(); + } + + // Level (for units/players) — colored by difficulty + if (target->getType() == game::ObjectType::UNIT || target->getType() == game::ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(target); + ImGui::SameLine(); + // Level color matches the hostility/difficulty color + ImVec4 levelColor = hostileColor; + if (target->getType() == game::ObjectType::PLAYER) { + levelColor = ui::colors::kLightGray; + } + if (unit->getLevel() == 0) + ImGui::TextColored(levelColor, "Lv ??"); + else + ImGui::TextColored(levelColor, "Lv %u", unit->getLevel()); + // Classification badge: Elite / Rare Elite / Boss / Rare + if (target->getType() == game::ObjectType::UNIT) { + int rank = gameHandler.getCreatureRank(unit->getEntry()); + if (rank == 1) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "[Elite]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Elite — requires a group"); + } else if (rank == 2) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.8f, 0.4f, 1.0f, 1.0f), "[Rare Elite]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare Elite — uncommon spawn, group recommended"); + } else if (rank == 3) { + ImGui::SameLine(0, 4); + ImGui::TextColored(kColorRed, "[Boss]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Boss — raid / dungeon boss"); + } else if (rank == 4) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.5f, 0.9f, 1.0f, 1.0f), "[Rare]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Rare — uncommon spawn with better loot"); + } + } + // Creature type label (Beast, Humanoid, Demon, etc.) + if (target->getType() == game::ObjectType::UNIT) { + uint32_t ctype = gameHandler.getCreatureType(unit->getEntry()); + const char* ctypeName = nullptr; + switch (ctype) { + case 1: ctypeName = "Beast"; break; + case 2: ctypeName = "Dragonkin"; break; + case 3: ctypeName = "Demon"; break; + case 4: ctypeName = "Elemental"; break; + case 5: ctypeName = "Giant"; break; + case 6: ctypeName = "Undead"; break; + case 7: ctypeName = "Humanoid"; break; + case 8: ctypeName = "Critter"; break; + case 9: ctypeName = "Mechanical"; break; + case 11: ctypeName = "Totem"; break; + case 12: ctypeName = "Non-combat Pet"; break; + case 13: ctypeName = "Gas Cloud"; break; + default: break; + } + if (ctypeName) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", ctypeName); + } + } + if (confirmedCombatWithTarget) { + float cPulse = 0.75f + 0.25f * std::sin(static_cast(ImGui::GetTime()) * 4.0f); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.2f * cPulse, 0.2f * cPulse, 1.0f), "[Attacking]"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Engaged in combat with this target"); + } + + // Health bar + uint32_t hp = unit->getHealth(); + uint32_t maxHp = unit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? colors::kHealthGreen : + pct > 0.2f ? colors::kMidHealthYellow : + colors::kLowHealthRed); + + char overlay[64]; + snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 18), overlay); + ImGui::PopStyleColor(); + // Target power bar (mana/rage/energy) + uint8_t targetPowerType = unit->getPowerType(); + uint32_t targetPower = unit->getPower(); + uint32_t targetMaxPower = unit->getMaxPower(); + if (targetMaxPower == 0 && (targetPowerType == 1 || targetPowerType == 3)) targetMaxPower = 100; + if (targetMaxPower > 0) { + float mpPct = static_cast(targetPower) / static_cast(targetMaxPower); + ImVec4 targetPowerColor; + switch (targetPowerType) { + case 0: targetPowerColor = colors::kManaBlue; break; // Mana (blue) + case 1: targetPowerColor = colors::kDarkRed; break; // Rage (red) + case 2: targetPowerColor = colors::kOrange; break; // Focus (orange) + case 3: targetPowerColor = colors::kEnergyYellow; break; // Energy (yellow) + case 4: targetPowerColor = colors::kHappinessGreen; break; // Happiness (green) + case 6: targetPowerColor = colors::kRunicRed; break; // Runic Power (crimson) + case 7: targetPowerColor = colors::kSoulShardPurple; break; // Soul Shards (purple) + default: targetPowerColor = colors::kManaBlue; break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, targetPowerColor); + char mpOverlay[64]; + snprintf(mpOverlay, sizeof(mpOverlay), "%u / %u", targetPower, targetMaxPower); + ImGui::ProgressBar(mpPct, ImVec2(-1, 18), mpOverlay); + ImGui::PopStyleColor(); + } + } else { + ImGui::TextDisabled("No health data"); + } + } + + // Combo points — shown when the player has combo points on this target + { + uint8_t cp = gameHandler.getComboPoints(); + if (cp > 0 && gameHandler.getComboTarget() == target->getGuid()) { + const float dotSize = 12.0f; + const float dotSpacing = 4.0f; + const int maxCP = 5; + float totalW = maxCP * dotSize + (maxCP - 1) * dotSpacing; + float startX = (frameW - totalW) * 0.5f; + ImGui::SetCursorPosX(startX); + ImVec2 cursor = ImGui::GetCursorScreenPos(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + for (int ci = 0; ci < maxCP; ++ci) { + float cx = cursor.x + ci * (dotSize + dotSpacing) + dotSize * 0.5f; + float cy = cursor.y + dotSize * 0.5f; + if (ci < static_cast(cp)) { + // Lit: yellow for 1-4, red glow for 5 + ImU32 col = (cp >= 5) + ? IM_COL32(255, 50, 30, 255) + : IM_COL32(255, 210, 30, 255); + dl->AddCircleFilled(ImVec2(cx, cy), dotSize * 0.45f, col); + // Subtle glow + dl->AddCircle(ImVec2(cx, cy), dotSize * 0.5f, IM_COL32(255, 255, 200, 80), 0, 1.5f); + } else { + // Unlit: dark outline + dl->AddCircle(ImVec2(cx, cy), dotSize * 0.4f, IM_COL32(80, 80, 80, 180), 0, 1.5f); + } + } + ImGui::Dummy(ImVec2(totalW, dotSize + 2.0f)); + } + } + + // Target cast bar — shown when the target is casting + if (gameHandler.isTargetCasting()) { + float castPct = gameHandler.getTargetCastProgress(); + float castLeft = gameHandler.getTargetCastTimeRemaining(); + uint32_t tspell = gameHandler.getTargetCastSpellId(); + bool interruptible = gameHandler.isTargetCastInterruptible(); + const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; + // Color: interruptible = green (can Kick/CS), not interruptible = red, both pulse when >80% + ImVec4 castBarColor; + if (castPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + if (interruptible) + castBarColor = ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f); // green pulse + else + castBarColor = ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); // red pulse + } else { + castBarColor = interruptible ? colors::kCastGreen // green = can interrupt + : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); // red = uninterruptible + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, castBarColor); + char castLabel[72]; + if (!castName.empty()) + snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft); + else if (tspell != 0) + snprintf(castLabel, sizeof(castLabel), "Spell #%u (%.1fs)", tspell, castLeft); + else + snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft); + { + auto* tcastAsset = services_.assetManager; + VkDescriptorSet tIcon = (tspell != 0 && tcastAsset) + ? getSpellIcon(tspell, tcastAsset) : VK_NULL_HANDLE; + if (tIcon) { + ImGui::Image((ImTextureID)(uintptr_t)tIcon, ImVec2(14, 14)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + } else { + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + } + } + ImGui::PopStyleColor(); + } + + // Target-of-Target (ToT): show who the current target is targeting + { + uint64_t totGuid = 0; + const auto& tFields = target->getFields(); + auto itLo = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (itLo != tFields.end()) { + totGuid = itLo->second; + auto itHi = tFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (itHi != tFields.end()) + totGuid |= (static_cast(itHi->second) << 32); + } + if (totGuid != 0) { + auto totEnt = gameHandler.getEntityManager().getEntity(totGuid); + std::string totName; + ImVec4 totColor(0.7f, 0.7f, 0.7f, 1.0f); + if (totGuid == gameHandler.getPlayerGuid()) { + auto playerEnt = gameHandler.getEntityManager().getEntity(totGuid); + totName = playerEnt ? getEntityName(playerEnt) : "You"; + totColor = kColorBrightGreen; + } else if (totEnt) { + totName = getEntityName(totEnt); + uint8_t cid = entityClassId(totEnt.get()); + if (cid != 0) totColor = classColorVec4(cid); + } + if (!totName.empty()) { + ImGui::TextDisabled("▶"); + ImGui::SameLine(0, 2); + ImGui::TextColored(totColor, "%s", totName.c_str()); + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Target's target: %s\nClick to target", totName.c_str()); + } + if (ImGui::IsItemClicked()) { + gameHandler.setTarget(totGuid); + } + + // Compact health bar for the ToT — essential for healers tracking boss target + if (totEnt) { + auto totUnit = std::dynamic_pointer_cast(totEnt); + if (totUnit && totUnit->getMaxHealth() > 0) { + uint32_t totHp = totUnit->getHealth(); + uint32_t totMaxHp = totUnit->getMaxHealth(); + float totPct = static_cast(totHp) / static_cast(totMaxHp); + ImVec4 totBarColor = + totPct > 0.5f ? colors::kCastGreen : + totPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : + ImVec4(0.75f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, totBarColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + char totOverlay[32]; + snprintf(totOverlay, sizeof(totOverlay), "%u%%", + static_cast(totPct * 100.0f + 0.5f)); + ImGui::ProgressBar(totPct, ImVec2(-1, 10), totOverlay); + ImGui::PopStyleColor(2); + } + } + } + } + } + + // Distance + const auto& movement = gameHandler.getMovementInfo(); + float dx = target->getX() - movement.x; + float dy = target->getY() - movement.y; + float dz = target->getZ() - movement.z; + float distance = std::sqrt(dx*dx + dy*dy + dz*dz); + ImGui::TextDisabled("%.1f yd", distance); + + // Threat button (shown when in combat and threat data is available) + if (gameHandler.getTargetThreatList()) { + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.1f, 0.1f, 0.8f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 0.9f)); + if (ImGui::SmallButton("Threat")) combatUI_.showThreatWindow_ = !combatUI_.showThreatWindow_; + ImGui::PopStyleColor(2); + } + + // Target auras (buffs/debuffs) + const auto& targetAuras = gameHandler.getTargetAuras(); + int activeAuras = 0; + for (const auto& a : targetAuras) { + if (!a.isEmpty()) activeAuras++; + } + if (activeAuras > 0) { + auto* assetMgr = services_.assetManager; + constexpr float ICON_SIZE = 24.0f; + constexpr int ICONS_PER_ROW = 8; + + ImGui::Separator(); + + // Build sorted index list: debuffs before buffs, shorter duration first + uint64_t tNowSort = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + std::vector sortedIdx; + sortedIdx.reserve(targetAuras.size()); + for (size_t i = 0; i < targetAuras.size(); ++i) + if (!targetAuras[i].isEmpty()) sortedIdx.push_back(i); + std::sort(sortedIdx.begin(), sortedIdx.end(), [&](size_t a, size_t b) { + const auto& aa = targetAuras[a]; const auto& ab = targetAuras[b]; + bool aDebuff = (aa.flags & 0x80) != 0; + bool bDebuff = (ab.flags & 0x80) != 0; + if (aDebuff != bDebuff) return aDebuff > bDebuff; // debuffs first + int32_t ra = aa.getRemainingMs(tNowSort); + int32_t rb = ab.getRemainingMs(tNowSort); + // Permanent (-1) goes last; shorter remaining goes first + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + int shown = 0; + for (size_t si = 0; si < sortedIdx.size() && shown < 16; ++si) { + size_t i = sortedIdx[si]; + const auto& aura = targetAuras[i]; + if (aura.isEmpty()) continue; + + if (shown > 0 && shown % ICONS_PER_ROW != 0) ImGui::SameLine(); + + ImGui::PushID(static_cast(10000 + i)); + + bool isBuff = (aura.flags & 0x80) == 0; + ImVec4 auraBorderColor; + if (isBuff) { + auraBorderColor = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + // Debuff: color by dispel type, matching player buff bar convention + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: auraBorderColor = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; // magic: blue + case 2: auraBorderColor = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; // curse: purple + case 3: auraBorderColor = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; // disease: brown + case 4: auraBorderColor = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; // poison: green + default: auraBorderColor = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; // other: red + } + } + + VkDescriptorSet iconTex = VK_NULL_HANDLE; + if (assetMgr) { + iconTex = getSpellIcon(aura.spellId, assetMgr); + } + + if (iconTex) { + ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##taura", + (ImTextureID)(uintptr_t)iconTex, + ImVec2(ICON_SIZE - 2, ICON_SIZE - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, auraBorderColor); + const std::string& tAuraName = gameHandler.getSpellName(aura.spellId); + char label[32]; + if (!tAuraName.empty()) + snprintf(label, sizeof(label), "%.6s", tAuraName.c_str()); + else + snprintf(label, sizeof(label), "%u", aura.spellId); + ImGui::Button(label, ImVec2(ICON_SIZE, ICON_SIZE)); + ImGui::PopStyleColor(); + } + + // Compute remaining once for overlay + tooltip + uint64_t tNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t tRemainMs = aura.getRemainingMs(tNowMs); + + // Clock-sweep overlay (elapsed = dark area, WoW style) + if (tRemainMs > 0 && aura.maxDurationMs > 0) { + ImVec2 tIconMin = ImGui::GetItemRectMin(); + ImVec2 tIconMax = ImGui::GetItemRectMax(); + float tcx = (tIconMin.x + tIconMax.x) * 0.5f; + float tcy = (tIconMin.y + tIconMax.y) * 0.5f; + float tR = (tIconMax.x - tIconMin.x) * 0.5f; + float tTot = static_cast(aura.maxDurationMs); + float tFrac = std::clamp( + 1.0f - static_cast(tRemainMs) / tTot, 0.0f, 1.0f); + if (tFrac > 0.005f) { + constexpr int TSEGS = 24; + float tSa = -IM_PI * 0.5f; + float tEa = tSa + tFrac * 2.0f * IM_PI; + ImVec2 tPts[TSEGS + 2]; + tPts[0] = ImVec2(tcx, tcy); + for (int s = 0; s <= TSEGS; ++s) { + float a = tSa + (tEa - tSa) * s / static_cast(TSEGS); + tPts[s + 1] = ImVec2(tcx + std::cos(a) * tR, + tcy + std::sin(a) * tR); + } + ImGui::GetWindowDrawList()->AddConvexPolyFilled( + tPts, TSEGS + 2, IM_COL32(0, 0, 0, 145)); + } + } + + // Duration countdown overlay + if (tRemainMs > 0) { + ImVec2 iconMin = ImGui::GetItemRectMin(); + ImVec2 iconMax = ImGui::GetItemRectMax(); + char timeStr[12]; + int secs = (tRemainMs + 999) / 1000; + if (secs >= 3600) + snprintf(timeStr, sizeof(timeStr), "%dh", secs / 3600); + else if (secs >= 60) + snprintf(timeStr, sizeof(timeStr), "%d:%02d", secs / 60, secs % 60); + else + snprintf(timeStr, sizeof(timeStr), "%d", secs); + ImVec2 textSize = ImGui::CalcTextSize(timeStr); + float cx = iconMin.x + (iconMax.x - iconMin.x - textSize.x) * 0.5f; + float cy = iconMax.y - textSize.y - 1.0f; + // Color by urgency (matches player buff bar) + ImU32 tTimerColor; + if (tRemainMs < 10000) { + float pulse = 0.7f + 0.3f * std::sin( + static_cast(ImGui::GetTime()) * 6.0f); + tTimerColor = IM_COL32( + static_cast(255 * pulse), + static_cast(80 * pulse), + static_cast(60 * pulse), 255); + } else if (tRemainMs < 30000) { + tTimerColor = IM_COL32(255, 165, 0, 255); + } else { + tTimerColor = IM_COL32(255, 255, 255, 255); + } + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), + IM_COL32(0, 0, 0, 200), timeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), + tTimerColor, timeStr); + } + + // Stack / charge count — upper-left corner + if (aura.charges > 1) { + ImVec2 iconMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 3, iconMin.y + 3), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(iconMin.x + 2, iconMin.y + 2), + IM_COL32(255, 220, 50, 255), chargeStr); + } + + // Tooltip: rich spell info + remaining duration + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip(aura.spellId, gameHandler, assetMgr); + if (!richOk) { + std::string name = spellbookScreen.lookupSpellName(aura.spellId, assetMgr); + if (name.empty()) name = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", name.c_str()); + } + renderAuraRemaining(tRemainMs); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + shown++; + } + } + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); + + // ---- Target-of-Target (ToT) mini frame ---- + // Read target's current target from UNIT_FIELD_TARGET_LO/HI update fields + if (target) { + const auto& fields = target->getFields(); + uint64_t totGuid = 0; + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt != fields.end()) { + totGuid = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + totGuid |= (static_cast(hiIt->second) << 32); + } + + if (totGuid != 0) { + auto totEntity = gameHandler.getEntityManager().getEntity(totGuid); + if (totEntity) { + // Position ToT frame just below and right-aligned with the target frame + float totW = 160.0f; + float totX = (screenW - totW) / 2.0f + (frameW - totW); + ImGui::SetNextWindowPos(ImVec2(totX, 30.0f + 130.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(totW, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 3.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.08f, 0.80f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.4f, 0.4f, 0.4f, 0.7f)); + + if (ImGui::Begin("##ToTFrame", nullptr, + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoScrollbar)) { + std::string totName = getEntityName(totEntity); + // Class color for players; gray for NPCs + ImVec4 totNameColor = colors::kSilver; + if (totEntity->getType() == game::ObjectType::PLAYER) { + uint8_t cid = entityClassId(totEntity.get()); + if (cid != 0) totNameColor = classColorVec4(cid); + } + // Selectable so we can attach a right-click context menu + ImGui::PushStyleColor(ImGuiCol_Text, totNameColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + if (ImGui::Selectable(totName.c_str(), false, + ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(totName.c_str()).x, 0))) { + gameHandler.setTarget(totGuid); + } + ImGui::PopStyleColor(4); + + if (ImGui::BeginPopupContextItem("##ToTCtx")) { + ImGui::TextDisabled("%s", totName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(totGuid); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(totGuid); + ImGui::EndPopup(); + } + + if (totEntity->getType() == game::ObjectType::UNIT || + totEntity->getType() == game::ObjectType::PLAYER) { + auto totUnit = std::static_pointer_cast(totEntity); + if (totUnit->getLevel() > 0) { + ImGui::SameLine(); + ImGui::TextDisabled("Lv%u", totUnit->getLevel()); + } + uint32_t hp = totUnit->getHealth(); + uint32_t maxHp = totUnit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? colors::kFriendlyGreen : + pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) : + colors::kDangerRed); + ImGui::ProgressBar(pct, ImVec2(-1, 10), ""); + ImGui::PopStyleColor(); + } + + // ToT cast bar — green if interruptible, red if not; pulses near completion + if (auto* totCs = gameHandler.getUnitCastState(totGuid)) { + float totCastPct = (totCs->timeTotal > 0.0f) + ? (totCs->timeTotal - totCs->timeRemaining) / totCs->timeTotal : 0.0f; + ImVec4 tcColor; + if (totCastPct > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + tcColor = totCs->interruptible + ? ImVec4(0.2f * pulse, 0.9f * pulse, 0.2f * pulse, 1.0f) + : ImVec4(1.0f * pulse, 0.1f * pulse, 0.1f * pulse, 1.0f); + } else { + tcColor = totCs->interruptible + ? colors::kCastGreen + : ImVec4(0.85f, 0.15f, 0.15f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, tcColor); + char tcLabel[48]; + const std::string& tcName = gameHandler.getSpellName(totCs->spellId); + if (!tcName.empty()) + snprintf(tcLabel, sizeof(tcLabel), "%s (%.1fs)", tcName.c_str(), totCs->timeRemaining); + else + snprintf(tcLabel, sizeof(tcLabel), "Casting... (%.1fs)", totCs->timeRemaining); + ImGui::ProgressBar(totCastPct, ImVec2(-1, 8), tcLabel); + ImGui::PopStyleColor(); + } + + // ToT aura row — compact icons, debuffs first + { + const std::vector* totAuras = nullptr; + if (totGuid == gameHandler.getPlayerGuid()) + totAuras = &gameHandler.getPlayerAuras(); + else if (totGuid == gameHandler.getTargetGuid()) + totAuras = &gameHandler.getTargetAuras(); + else + totAuras = gameHandler.getUnitAuras(totGuid); + + if (totAuras) { + int totActive = 0; + for (const auto& a : *totAuras) if (!a.isEmpty()) totActive++; + if (totActive > 0) { + auto* totAsset = services_.assetManager; + constexpr float TA_ICON = 16.0f; + constexpr int TA_PER_ROW = 8; + + ImGui::Separator(); + + uint64_t taNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + std::vector taIdx; + taIdx.reserve(totAuras->size()); + for (size_t i = 0; i < totAuras->size(); ++i) + if (!(*totAuras)[i].isEmpty()) taIdx.push_back(i); + std::sort(taIdx.begin(), taIdx.end(), [&](size_t a, size_t b) { + bool aD = ((*totAuras)[a].flags & 0x80) != 0; + bool bD = ((*totAuras)[b].flags & 0x80) != 0; + if (aD != bD) return aD > bD; + int32_t ra = (*totAuras)[a].getRemainingMs(taNowMs); + int32_t rb = (*totAuras)[b].getRemainingMs(taNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int taShown = 0; + for (size_t si = 0; si < taIdx.size() && taShown < 16; ++si) { + const auto& aura = (*totAuras)[taIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + + if (taShown > 0 && taShown % TA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(taIdx[si]) + 5000); + + ImVec4 borderCol; + if (isBuff) { + borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; + } + } + + VkDescriptorSet taIcon = (totAsset) + ? getSpellIcon(aura.spellId, totAsset) : VK_NULL_HANDLE; + if (taIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##taura", + (ImTextureID)(uintptr_t)taIcon, + ImVec2(TA_ICON - 2, TA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId % 10000); + ImGui::Button(lab, ImVec2(TA_ICON, TA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t taRemain = aura.getRemainingMs(taNowMs); + if (taRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + fmtDurationCompact(ts, sizeof(ts), (taRemain + 999) / 1000); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, totAsset); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, totAsset); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + renderAuraRemaining(taRemain); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + taShown++; + } + ImGui::PopStyleVar(); + } + } + } + } + } + ImGui::End(); + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); + } + } + } +} + +void GameScreen::renderFocusFrame(game::GameHandler& gameHandler) { + auto focus = gameHandler.getFocus(); + if (!focus) return; + + auto* window = services_.window; + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + // Position: right side of screen, mirroring the target frame on the opposite side + float frameW = 200.0f; + float frameX = screenW - frameW - 10.0f; + + ImGui::SetNextWindowPos(ImVec2(frameX, 30.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_AlwaysAutoResize; + + // Determine color based on relation (same logic as target frame) + ImVec4 focusColor(0.7f, 0.7f, 0.7f, 1.0f); + if (focus->getType() == game::ObjectType::PLAYER) { + // Use class color for player focus targets + uint8_t cid = entityClassId(focus.get()); + focusColor = (cid != 0) ? classColorVec4(cid) : kColorBrightGreen; + } else if (focus->getType() == game::ObjectType::UNIT) { + auto u = std::static_pointer_cast(focus); + if (u->getHealth() == 0 && u->getMaxHealth() > 0) { + focusColor = kColorDarkGray; + } else if (u->isHostile()) { + // Tapped-by-other: grey focus frame name + uint32_t focDynFlags = u->getDynamicFlags(); + bool focTapped = (focDynFlags & 0x0004) != 0 && (focDynFlags & 0x0008) == 0; + if (focTapped) { + focusColor = kColorGray; + } else { + uint32_t playerLv = gameHandler.getPlayerLevel(); + uint32_t mobLv = u->getLevel(); + if (mobLv == 0) { + focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); // ?? level = skull red + } else { + int32_t diff = static_cast(mobLv) - static_cast(playerLv); + if (game::GameHandler::killXp(playerLv, mobLv) == 0) + focusColor = kColorGray; + else if (diff >= 10) + focusColor = ImVec4(1.0f, 0.1f, 0.1f, 1.0f); + else if (diff >= 5) + focusColor = ImVec4(1.0f, 0.5f, 0.1f, 1.0f); + else if (diff >= -2) + focusColor = ImVec4(1.0f, 1.0f, 0.1f, 1.0f); + else + focusColor = kColorBrightGreen; + } + } // end tapped else + } else { + focusColor = kColorBrightGreen; + } + } + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.08f, 0.15f, 0.85f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 0.9f, 0.8f)); // Blue tint = focus + + if (ImGui::Begin("##FocusFrame", nullptr, flags)) { + // "Focus" label + ImGui::TextDisabled("[Focus]"); + ImGui::SameLine(); + + // Raid mark icon (star, circle, diamond, …) preceding the name + { + static constexpr struct { const char* sym; ImU32 col; } kFocusMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 204, 0, 255) }, // 0 Star (yellow) + { "\xe2\x97\x8f", IM_COL32(255, 103, 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 fmark = gameHandler.getEntityRaidMark(focus->getGuid()); + if (fmark < game::GameHandler::kRaidMarkCount) { + ImGui::GetWindowDrawList()->AddText( + ImGui::GetCursorScreenPos(), + kFocusMarks[fmark].col, kFocusMarks[fmark].sym); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 18.0f); + } + } + + std::string focusName = getEntityName(focus); + ImGui::PushStyleColor(ImGuiCol_Text, focusColor); + ImGui::PushStyleColor(ImGuiCol_Header, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_HeaderHovered, ImVec4(1,1,1,0.08f)); + ImGui::PushStyleColor(ImGuiCol_HeaderActive, ImVec4(1,1,1,0.12f)); + ImGui::Selectable(focusName.c_str(), false, ImGuiSelectableFlags_DontClosePopups, + ImVec2(ImGui::CalcTextSize(focusName.c_str()).x, 0)); + ImGui::PopStyleColor(4); + + // Right-click context menu on focus frame + if (ImGui::BeginPopupContextItem("##FocusFrameCtx")) { + ImGui::TextDisabled("%s", focusName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(focus->getGuid()); + if (ImGui::MenuItem("Clear Focus")) + gameHandler.clearFocus(); + if (focus->getType() == game::ObjectType::PLAYER) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + chatPanel_.setWhisperTarget(focusName); + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(focusName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(focus->getGuid()); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(focus->getGuid()); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(focus->getGuid()); + gameHandler.inspectTarget(); + socialPanel_.showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(focusName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(focusName); + } + ImGui::EndPopup(); + } + + // Group leader crown — golden ♛ when the focused player is the party/raid leader + if (gameHandler.isInGroup() && focus->getType() == game::ObjectType::PLAYER) { + if (gameHandler.getPartyData().leaderGuid == focus->getGuid()) { + ImGui::SameLine(0, 4); + ImGui::TextColored(colors::kSymbolGold, "\xe2\x99\x9b"); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("Group Leader"); + } + } + + // Quest giver indicator and classification badge for NPC focus targets + if (focus->getType() == game::ObjectType::UNIT) { + auto focusUnit = std::static_pointer_cast(focus); + + // Quest indicator: ! / ? + { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(focus->getGuid()); + if (qgs == QGS::AVAILABLE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(colors::kBrightGold, "!"); + } else if (qgs == QGS::AVAILABLE_LOW) { + ImGui::SameLine(0, 4); + ImGui::TextColored(kColorGray, "!"); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + ImGui::SameLine(0, 4); + ImGui::TextColored(colors::kBrightGold, "?"); + } else if (qgs == QGS::INCOMPLETE) { + ImGui::SameLine(0, 4); + ImGui::TextColored(kColorGray, "?"); + } + } + + // Classification badge + int fRank = gameHandler.getCreatureRank(focusUnit->getEntry()); + if (fRank == 1) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(1.0f,0.8f,0.2f,1.0f), "[Elite]"); } + else if (fRank == 2) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.8f,0.4f,1.0f,1.0f), "[Rare Elite]"); } + else if (fRank == 3) { ImGui::SameLine(0,4); ImGui::TextColored(colors::kRed, "[Boss]"); } + else if (fRank == 4) { ImGui::SameLine(0,4); ImGui::TextColored(ImVec4(0.5f,0.9f,1.0f,1.0f), "[Rare]"); } + + // Creature type + { + uint32_t fctype = gameHandler.getCreatureType(focusUnit->getEntry()); + const char* fctName = nullptr; + switch (fctype) { + case 1: fctName="Beast"; break; case 2: fctName="Dragonkin"; break; + case 3: fctName="Demon"; break; case 4: fctName="Elemental"; break; + case 5: fctName="Giant"; break; case 6: fctName="Undead"; break; + case 7: fctName="Humanoid"; break; case 8: fctName="Critter"; break; + case 9: fctName="Mechanical"; break; case 11: fctName="Totem"; break; + case 12: fctName="Non-combat Pet"; break; case 13: fctName="Gas Cloud"; break; + default: break; + } + if (fctName) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.6f, 0.6f, 0.6f, 0.9f), "(%s)", fctName); + } + } + + // Creature subtitle + const std::string fSub = gameHandler.getCachedCreatureSubName(focusUnit->getEntry()); + if (!fSub.empty()) + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", fSub.c_str()); + } + + // Player guild name on focus frame + if (focus->getType() == game::ObjectType::PLAYER) { + uint32_t guildId = gameHandler.getEntityGuildId(focus->getGuid()); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) { + ImGui::TextColored(ImVec4(0.7f, 0.7f, 0.7f, 0.9f), "<%s>", gn.c_str()); + } + } + } + + if (ImGui::BeginPopupContextItem("##FocusNameCtx")) { + const bool focusIsPlayer = (focus->getType() == game::ObjectType::PLAYER); + const uint64_t fGuid = focus->getGuid(); + ImGui::TextDisabled("%s", focusName.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(fGuid); + if (ImGui::MenuItem("Clear Focus")) + gameHandler.clearFocus(); + if (focusIsPlayer) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + chatPanel_.setWhisperTarget(focusName); + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(focusName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(fGuid); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(fGuid); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(fGuid); + gameHandler.inspectTarget(); + socialPanel_.showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(focusName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(focusName); + } + ImGui::EndPopup(); + } + + if (focus->getType() == game::ObjectType::UNIT || + focus->getType() == game::ObjectType::PLAYER) { + auto unit = std::static_pointer_cast(focus); + + // Level + health on same row + ImGui::SameLine(); + if (unit->getLevel() == 0) + ImGui::TextDisabled("Lv ??"); + else + ImGui::TextDisabled("Lv %u", unit->getLevel()); + + uint32_t hp = unit->getHealth(); + uint32_t maxHp = unit->getMaxHealth(); + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? colors::kFriendlyGreen : + pct > 0.2f ? ImVec4(0.7f, 0.7f, 0.2f, 1.0f) : + colors::kDangerRed); + char overlay[32]; + snprintf(overlay, sizeof(overlay), "%u / %u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), overlay); + ImGui::PopStyleColor(); + + // Power bar + uint8_t pType = unit->getPowerType(); + uint32_t pwr = unit->getPower(); + uint32_t maxPwr = unit->getMaxPower(); + if (maxPwr == 0 && (pType == 1 || pType == 3)) maxPwr = 100; + if (maxPwr > 0) { + float mpPct = static_cast(pwr) / static_cast(maxPwr); + ImVec4 pwrColor; + switch (pType) { + case 0: pwrColor = colors::kManaBlue; break; + case 1: pwrColor = colors::kDarkRed; break; + case 3: pwrColor = colors::kEnergyYellow; break; + case 6: pwrColor = colors::kRunicRed; break; + default: pwrColor = colors::kManaBlue; break; + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, pwrColor); + ImGui::ProgressBar(mpPct, ImVec2(-1, 10), ""); + ImGui::PopStyleColor(); + } + } + + // Focus cast bar + const auto* focusCast = gameHandler.getUnitCastState(focus->getGuid()); + if (focusCast) { + float total = focusCast->timeTotal > 0.f ? focusCast->timeTotal : 1.f; + float rem = focusCast->timeRemaining; + float prog = std::clamp(1.0f - rem / total, 0.f, 1.f); + const std::string& spName = gameHandler.getSpellName(focusCast->spellId); + // Pulse orange when > 80% complete — interrupt window closing + ImVec4 focusCastColor; + if (prog > 0.8f) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + focusCastColor = ImVec4(1.0f * pulse, 0.5f * pulse, 0.0f, 1.0f); + } else { + focusCastColor = ImVec4(0.9f, 0.3f, 0.2f, 1.0f); + } + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, focusCastColor); + char castBuf[64]; + if (!spName.empty()) + snprintf(castBuf, sizeof(castBuf), "%s (%.1fs)", spName.c_str(), rem); + else + snprintf(castBuf, sizeof(castBuf), "Casting... (%.1fs)", rem); + { + auto* fcAsset = services_.assetManager; + VkDescriptorSet fcIcon = (focusCast->spellId != 0 && fcAsset) + ? getSpellIcon(focusCast->spellId, fcAsset) : VK_NULL_HANDLE; + if (fcIcon) { + ImGui::Image((ImTextureID)(uintptr_t)fcIcon, ImVec2(12, 12)); + ImGui::SameLine(0, 2); + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + } else { + ImGui::ProgressBar(prog, ImVec2(-1, 12), castBuf); + } + } + ImGui::PopStyleColor(); + } + } + + // Focus auras — buffs first, then debuffs, up to 8 icons wide + { + const std::vector* focusAuras = + (focus->getGuid() == gameHandler.getTargetGuid()) + ? &gameHandler.getTargetAuras() + : gameHandler.getUnitAuras(focus->getGuid()); + + if (focusAuras) { + int activeCount = 0; + for (const auto& a : *focusAuras) if (!a.isEmpty()) activeCount++; + if (activeCount > 0) { + auto* focusAsset = services_.assetManager; + constexpr float FA_ICON = 20.0f; + constexpr int FA_PER_ROW = 10; + + ImGui::Separator(); + + uint64_t faNowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + // Sort: debuffs first (so hostile-caster info is prominent), then buffs + std::vector faIdx; + faIdx.reserve(focusAuras->size()); + for (size_t i = 0; i < focusAuras->size(); ++i) + if (!(*focusAuras)[i].isEmpty()) faIdx.push_back(i); + std::sort(faIdx.begin(), faIdx.end(), [&](size_t a, size_t b) { + bool aD = ((*focusAuras)[a].flags & 0x80) != 0; + bool bD = ((*focusAuras)[b].flags & 0x80) != 0; + if (aD != bD) return aD > bD; // debuffs first + int32_t ra = (*focusAuras)[a].getRemainingMs(faNowMs); + int32_t rb = (*focusAuras)[b].getRemainingMs(faNowMs); + if (ra < 0 && rb < 0) return false; + if (ra < 0) return false; + if (rb < 0) return true; + return ra < rb; + }); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2.0f, 2.0f)); + int faShown = 0; + for (size_t si = 0; si < faIdx.size() && faShown < 20; ++si) { + const auto& aura = (*focusAuras)[faIdx[si]]; + bool isBuff = (aura.flags & 0x80) == 0; + + if (faShown > 0 && faShown % FA_PER_ROW != 0) ImGui::SameLine(); + ImGui::PushID(static_cast(faIdx[si]) + 3000); + + ImVec4 borderCol; + if (isBuff) { + borderCol = ImVec4(0.2f, 0.8f, 0.2f, 0.9f); + } else { + uint8_t dt = gameHandler.getSpellDispelType(aura.spellId); + switch (dt) { + case 1: borderCol = ImVec4(0.15f, 0.50f, 1.00f, 0.9f); break; + case 2: borderCol = ImVec4(0.70f, 0.20f, 0.90f, 0.9f); break; + case 3: borderCol = ImVec4(0.55f, 0.30f, 0.10f, 0.9f); break; + case 4: borderCol = ImVec4(0.10f, 0.70f, 0.10f, 0.9f); break; + default: borderCol = ImVec4(0.80f, 0.20f, 0.20f, 0.9f); break; + } + } + + VkDescriptorSet faIcon = (focusAsset) + ? getSpellIcon(aura.spellId, focusAsset) : VK_NULL_HANDLE; + if (faIcon) { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(1, 1)); + ImGui::ImageButton("##faura", + (ImTextureID)(uintptr_t)faIcon, + ImVec2(FA_ICON - 2, FA_ICON - 2)); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Button, borderCol); + char lab[8]; + snprintf(lab, sizeof(lab), "%u", aura.spellId); + ImGui::Button(lab, ImVec2(FA_ICON, FA_ICON)); + ImGui::PopStyleColor(); + } + + // Duration overlay + int32_t faRemain = aura.getRemainingMs(faNowMs); + if (faRemain > 0) { + ImVec2 imin = ImGui::GetItemRectMin(); + ImVec2 imax = ImGui::GetItemRectMax(); + char ts[12]; + fmtDurationCompact(ts, sizeof(ts), (faRemain + 999) / 1000); + ImVec2 tsz = ImGui::CalcTextSize(ts); + float cx = imin.x + (imax.x - imin.x - tsz.x) * 0.5f; + float cy = imax.y - tsz.y - 1.0f; + ImGui::GetWindowDrawList()->AddText(ImVec2(cx + 1, cy + 1), IM_COL32(0, 0, 0, 180), ts); + ImGui::GetWindowDrawList()->AddText(ImVec2(cx, cy), IM_COL32(255, 255, 255, 220), ts); + } + + // Stack / charge count — upper-left corner (parity with target frame) + if (aura.charges > 1) { + ImVec2 faMin = ImGui::GetItemRectMin(); + char chargeStr[8]; + snprintf(chargeStr, sizeof(chargeStr), "%u", static_cast(aura.charges)); + ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 3, faMin.y + 3), + IM_COL32(0, 0, 0, 200), chargeStr); + ImGui::GetWindowDrawList()->AddText(ImVec2(faMin.x + 2, faMin.y + 2), + IM_COL32(255, 220, 50, 255), chargeStr); + } + + // Tooltip + if (ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + bool richOk = spellbookScreen.renderSpellInfoTooltip( + aura.spellId, gameHandler, focusAsset); + if (!richOk) { + std::string nm = spellbookScreen.lookupSpellName(aura.spellId, focusAsset); + if (nm.empty()) nm = "Spell #" + std::to_string(aura.spellId); + ImGui::Text("%s", nm.c_str()); + } + renderAuraRemaining(faRemain); + ImGui::EndTooltip(); + } + + ImGui::PopID(); + faShown++; + } + ImGui::PopStyleVar(); + } + } + } + + // Target-of-Focus: who the focus target is currently targeting + { + uint64_t fofGuid = 0; + const auto& fFields = focus->getFields(); + auto fItLo = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (fItLo != fFields.end()) { + fofGuid = fItLo->second; + auto fItHi = fFields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (fItHi != fFields.end()) + fofGuid |= (static_cast(fItHi->second) << 32); + } + if (fofGuid != 0) { + auto fofEnt = gameHandler.getEntityManager().getEntity(fofGuid); + std::string fofName; + ImVec4 fofColor(0.7f, 0.7f, 0.7f, 1.0f); + if (fofGuid == gameHandler.getPlayerGuid()) { + fofName = "You"; + fofColor = kColorBrightGreen; + } else if (fofEnt) { + fofName = getEntityName(fofEnt); + uint8_t fcid = entityClassId(fofEnt.get()); + if (fcid != 0) fofColor = classColorVec4(fcid); + } + if (!fofName.empty()) { + ImGui::TextDisabled("▶"); + ImGui::SameLine(0, 2); + ImGui::TextColored(fofColor, "%s", fofName.c_str()); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Focus's target: %s\nClick to target", fofName.c_str()); + if (ImGui::IsItemClicked()) + gameHandler.setTarget(fofGuid); + + // Compact health bar for target-of-focus + if (fofEnt) { + auto fofUnit = std::dynamic_pointer_cast(fofEnt); + if (fofUnit && fofUnit->getMaxHealth() > 0) { + float fofPct = static_cast(fofUnit->getHealth()) / + static_cast(fofUnit->getMaxHealth()); + ImVec4 fofBarColor = + fofPct > 0.5f ? colors::kCastGreen : + fofPct > 0.2f ? ImVec4(0.75f, 0.75f, 0.2f, 1.0f) : + ImVec4(0.75f, 0.2f, 0.2f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, fofBarColor); + ImGui::PushStyleColor(ImGuiCol_FrameBg, ImVec4(0.15f, 0.15f, 0.15f, 0.8f)); + char fofOverlay[32]; + snprintf(fofOverlay, sizeof(fofOverlay), "%u%%", + static_cast(fofPct * 100.0f + 0.5f)); + ImGui::ProgressBar(fofPct, ImVec2(-1, 10), fofOverlay); + ImGui::PopStyleColor(2); + } + } + } + } + } + + // Distance to focus target + { + const auto& mv = gameHandler.getMovementInfo(); + float fdx = focus->getX() - mv.x; + float fdy = focus->getY() - mv.y; + float fdz = focus->getZ() - mv.z; + float fdist = std::sqrt(fdx * fdx + fdy * fdy + fdz * fdz); + ImGui::TextDisabled("%.1f yd", fdist); + } + + // Clicking the focus frame targets it + if (ImGui::IsWindowHovered() && ImGui::IsMouseClicked(0)) { + gameHandler.setTarget(focus->getGuid()); + } + } + ImGui::End(); + + ImGui::PopStyleColor(2); + ImGui::PopStyleVar(); +} + + +}} // namespace wowee::ui diff --git a/src/ui/game_screen_hud.cpp b/src/ui/game_screen_hud.cpp new file mode 100644 index 00000000..c725326f --- /dev/null +++ b/src/ui/game_screen_hud.cpp @@ -0,0 +1,1719 @@ +#include "ui/game_screen.hpp" +#include "ui/ui_colors.hpp" +#include "ui/ui_helpers.hpp" +#include "rendering/vk_context.hpp" +#include "core/application.hpp" +#include "core/appearance_composer.hpp" +#include "addons/addon_manager.hpp" +#include "core/coordinates.hpp" +#include "core/input.hpp" +#include "rendering/renderer.hpp" +#include "rendering/post_process_pipeline.hpp" +#include "rendering/animation_controller.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/terrain_manager.hpp" +#include "rendering/minimap.hpp" +#include "rendering/world_map.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/camera.hpp" +#include "rendering/camera_controller.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/audio_engine.hpp" +#include "audio/music_manager.hpp" +#include "game/zone_manager.hpp" +#include "audio/footstep_manager.hpp" +#include "audio/activity_sound_manager.hpp" +#include "audio/mount_sound_manager.hpp" +#include "audio/npc_voice_manager.hpp" +#include "audio/ambient_sound_manager.hpp" +#include "audio/ui_sound_manager.hpp" +#include "audio/combat_sound_manager.hpp" +#include "audio/spell_sound_manager.hpp" +#include "audio/movement_sound_manager.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" + +#include "game/expansion_profile.hpp" +#include "game/character.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + using namespace wowee::ui::colors; + using namespace wowee::ui::helpers; + constexpr auto& kColorRed = kRed; + constexpr auto& kColorGreen = kGreen; + constexpr auto& kColorBrightGreen= kBrightGreen; + constexpr auto& kColorYellow = kYellow; + constexpr auto& kColorGray = kGray; + constexpr auto& kColorDarkGray = kDarkGray; + + // Abbreviated month names (indexed 0-11) + constexpr const char* kMonthAbbrev[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + + // Common ImGui window flags for popup dialogs + const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + + bool raySphereIntersect(const wowee::rendering::Ray& ray, const glm::vec3& center, float radius, float& tOut) { + glm::vec3 oc = ray.origin - center; + float b = glm::dot(oc, ray.direction); + float c = glm::dot(oc, oc) - radius * radius; + float discriminant = b * b - c; + if (discriminant < 0.0f) return false; + float t = -b - std::sqrt(discriminant); + if (t < 0.0f) t = -b + std::sqrt(discriminant); + if (t < 0.0f) return false; + tOut = t; + return true; + } + + std::string getEntityName(const std::shared_ptr& entity) { + if (entity->getType() == wowee::game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) return player->getName(); + } else if (entity->getType() == wowee::game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (!unit->getName().empty()) return unit->getName(); + } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (!go->getName().empty()) return go->getName(); + } + return "Unknown"; + } + +} + +namespace wowee { namespace ui { + +void GameScreen::updateCharacterGeosets(game::Inventory& inventory) { + auto& app = core::Application::getInstance(); + auto* renderer = app.getRenderer(); + if (!renderer) return; + + uint32_t instanceId = renderer->getCharacterInstanceId(); + if (instanceId == 0) return; + + auto* charRenderer = renderer->getCharacterRenderer(); + if (!charRenderer) return; + + auto* assetManager = app.getAssetManager(); + + // Load ItemDisplayInfo.dbc for geosetGroup lookup + std::shared_ptr displayInfoDbc; + if (assetManager) { + displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); + } + + // Helper: get geosetGroup field for an equipped item's displayInfoId + // DBC binary fields: 7=geosetGroup_1, 8=geosetGroup_2, 9=geosetGroup_3 + auto getGeosetGroup = [&](uint32_t displayInfoId, int groupField) -> uint32_t { + if (!displayInfoDbc || displayInfoId == 0) return 0; + int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); + if (recIdx < 0) return 0; + return displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); + }; + + // Helper: find first equipped item matching inventoryType, return its displayInfoId + auto findEquippedDisplayId = [&](std::initializer_list types) -> uint32_t { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& slot = inventory.getEquipSlot(static_cast(s)); + if (!slot.empty()) { + for (uint8_t t : types) { + if (slot.item.inventoryType == t) + return slot.item.displayInfoId; + } + } + } + return 0; + }; + + // Helper: check if any equipment slot has the given inventoryType + auto hasEquippedType = [&](std::initializer_list types) -> bool { + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& slot = inventory.getEquipSlot(static_cast(s)); + if (!slot.empty()) { + for (uint8_t t : types) { + if (slot.item.inventoryType == t) return true; + } + } + } + return false; + }; + + // Base geosets always present (group 0: IDs 0-99, some models use up to 27) + std::unordered_set geosets; + for (uint16_t i = 0; i <= 99; i++) { + geosets.insert(i); + } + // Hair/facial geosets must match the active character's appearance, otherwise + // we end up forcing a default hair mesh (often perceived as "wrong hair"). + { + uint8_t hairStyleId = 0; + uint8_t facialId = 0; + if (auto* gh = app.getGameHandler()) { + if (const auto* ch = gh->getActiveCharacter()) { + hairStyleId = static_cast((ch->appearanceBytes >> 16) & 0xFF); + facialId = ch->facialFeatures; + } + } + geosets.insert(static_cast(100 + hairStyleId + 1)); // Group 1 hair + geosets.insert(static_cast(200 + facialId + 1)); // Group 2 facial + } + geosets.insert(702); // Ears: visible (default) + geosets.insert(2002); // Bare feet mesh (group 20 = CG_FEET, always on) + + // CharGeosets mapping (verified via vertex bounding boxes): + // Group 4 (401+) = GLOVES (forearm area, Z~1.1-1.4) + // Group 5 (501+) = BOOTS (shin area, Z~0.1-0.6) + // Group 8 (801+) = WRISTBANDS/SLEEVES (controlled by chest armor) + // Group 9 (901+) = KNEEPADS + // Group 13 (1301+) = TROUSERS/PANTS + // Group 15 (1501+) = CAPE/CLOAK + // Group 20 (2002) = FEET + + // Gloves: inventoryType 10 → group 4 (forearms) + // 401=bare forearms, 402+=glove styles covering forearm + { + uint32_t did = findEquippedDisplayId({10}); + uint32_t gg = getGeosetGroup(did, 0); + geosets.insert(static_cast(gg > 0 ? 401 + gg : 401)); + } + + // Boots: inventoryType 8 → group 5 (shins/lower legs) + // 501=narrow bare shin, 502=wider (matches thigh width better). Use 502 as bare default. + // When boots equipped, gg selects boot style: 501+gg (gg=1→502, gg=2→503, etc.) + { + uint32_t did = findEquippedDisplayId({8}); + uint32_t gg = getGeosetGroup(did, 0); + geosets.insert(static_cast(gg > 0 ? 501 + gg : 502)); + } + + // Chest/Shirt: inventoryType 4 (shirt), 5 (chest), 20 (robe) + // Controls group 8 (wristbands/sleeve length): 801=bare wrists, 802+=sleeve styles + // Also controls group 13 (trousers) via GeosetGroup[2] for robes + { + uint32_t did = findEquippedDisplayId({4, 5, 20}); + uint32_t gg = getGeosetGroup(did, 0); + geosets.insert(static_cast(gg > 0 ? 801 + gg : 801)); + // Robe kilt: GeosetGroup[2] > 0 → show kilt legs (1302+) + uint32_t gg3 = getGeosetGroup(did, 2); + if (gg3 > 0) { + geosets.insert(static_cast(1301 + gg3)); + } + } + + // Kneepads: group 9 (always default 902) + geosets.insert(902); + + // Legs/Pants: inventoryType 7 → group 13 (trousers/thighs) + // 1301=bare legs, 1302+=pant/kilt styles + { + uint32_t did = findEquippedDisplayId({7}); + uint32_t gg = getGeosetGroup(did, 0); + // Only add if robe hasn't already set a kilt geoset + if (geosets.count(1302) == 0 && geosets.count(1303) == 0) { + geosets.insert(static_cast(gg > 0 ? 1301 + gg : 1301)); + } + } + + // Back/Cloak: inventoryType 16 → group 15 + geosets.insert(hasEquippedType({16}) ? 1502 : 1501); + + // Tabard: inventoryType 19 → group 12 + if (hasEquippedType({19})) { + geosets.insert(1201); + } + + charRenderer->setActiveGeosets(instanceId, geosets); +} + +void GameScreen::updateCharacterTextures(game::Inventory& inventory) { + auto& app = core::Application::getInstance(); + auto* renderer = app.getRenderer(); + if (!renderer) return; + + auto* charRenderer = renderer->getCharacterRenderer(); + if (!charRenderer) return; + + auto* assetManager = app.getAssetManager(); + if (!assetManager) return; + + const auto& bodySkinPath = app.getBodySkinPath(); + const auto& underwearPaths = app.getUnderwearPaths(); + uint32_t skinSlot = app.getSkinTextureSlotIndex(); + + if (bodySkinPath.empty()) return; + + // Component directory names indexed by region + static constexpr const char* componentDirs[] = { + "ArmUpperTexture", // 0 + "ArmLowerTexture", // 1 + "HandTexture", // 2 + "TorsoUpperTexture", // 3 + "TorsoLowerTexture", // 4 + "LegUpperTexture", // 5 + "LegLowerTexture", // 6 + "FootTexture", // 7 + }; + + // Load ItemDisplayInfo.dbc + auto displayInfoDbc = assetManager->loadDBC("ItemDisplayInfo.dbc"); + if (!displayInfoDbc) return; + const auto* idiL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + uint32_t texRegionFields[8]; + pipeline::getItemDisplayInfoTextureFields(*displayInfoDbc, idiL, texRegionFields); + + // Collect equipment texture regions from all equipped items + std::vector> regionLayers; + + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& slot = inventory.getEquipSlot(static_cast(s)); + if (slot.empty() || slot.item.displayInfoId == 0) continue; + + int32_t recIdx = displayInfoDbc->findRecordById(slot.item.displayInfoId); + if (recIdx < 0) continue; + + for (int region = 0; region < 8; region++) { + std::string texName = displayInfoDbc->getString( + static_cast(recIdx), texRegionFields[region]); + if (texName.empty()) continue; + + // Actual MPQ files have a gender suffix: _M (male), _F (female), _U (unisex) + // Try gender-specific first, then unisex fallback + std::string base = "Item\\TextureComponents\\" + + std::string(componentDirs[region]) + "\\" + texName; + // Determine gender suffix from active character + bool isFemale = false; + if (auto* gh = app.getGameHandler()) { + if (auto* ch = gh->getActiveCharacter()) { + isFemale = (ch->gender == game::Gender::FEMALE) || + (ch->gender == game::Gender::NONBINARY && ch->useFemaleModel); + } + } + std::string genderPath = base + (isFemale ? "_F.blp" : "_M.blp"); + std::string unisexPath = base + "_U.blp"; + std::string fullPath; + if (assetManager->fileExists(genderPath)) { + fullPath = genderPath; + } else if (assetManager->fileExists(unisexPath)) { + fullPath = unisexPath; + } else { + // Last resort: try without suffix + fullPath = base + ".blp"; + } + regionLayers.emplace_back(region, fullPath); + } + } + + // Re-composite: base skin + underwear + equipment regions + // Clear composite cache first to prevent stale textures from being reused + charRenderer->clearCompositeCache(); + // Use per-instance texture override (not model-level) to avoid deleting cached composites. + uint32_t instanceId = renderer->getCharacterInstanceId(); + auto* newTex = charRenderer->compositeWithRegions(bodySkinPath, underwearPaths, regionLayers); + if (newTex != nullptr && instanceId != 0) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(skinSlot), newTex); + } + + // Cloak cape texture — separate from skin atlas, uses texture slot type-2 (Object Skin) + uint32_t cloakSlot = app.getCloakTextureSlotIndex(); + if (cloakSlot > 0 && instanceId != 0) { + // Find equipped cloak (inventoryType 16) + uint32_t cloakDisplayId = 0; + for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { + const auto& slot = inventory.getEquipSlot(static_cast(s)); + if (!slot.empty() && slot.item.inventoryType == 16 && slot.item.displayInfoId != 0) { + cloakDisplayId = slot.item.displayInfoId; + break; + } + } + + if (cloakDisplayId > 0) { + int32_t recIdx = displayInfoDbc->findRecordById(cloakDisplayId); + if (recIdx >= 0) { + // DBC field 3 = modelTexture_1 (cape texture name) + const auto* dispL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("ItemDisplayInfo") : nullptr; + std::string capeName = displayInfoDbc->getString(static_cast(recIdx), dispL ? (*dispL)["LeftModelTexture"] : 3); + if (!capeName.empty()) { + std::string capePath = "Item\\ObjectComponents\\Cape\\" + capeName + ".blp"; + auto* capeTex = charRenderer->loadTexture(capePath); + if (capeTex != nullptr) { + charRenderer->setTextureSlotOverride(instanceId, static_cast(cloakSlot), capeTex); + LOG_INFO("Cloak texture applied: ", capePath); + } + } + } + } else { + // No cloak equipped — clear override so model's default (white) shows + charRenderer->clearTextureSlotOverride(instanceId, static_cast(cloakSlot)); + } + } +} + +// ============================================================ +// World Map +// ============================================================ + +void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { + if (!showWorldMap_) return; + + auto& app = core::Application::getInstance(); + auto* renderer = app.getRenderer(); + if (!renderer) return; + + auto* wm = renderer->getWorldMap(); + if (!wm) return; + + // Keep map name in sync with minimap's map name + auto* minimap = renderer->getMinimap(); + if (minimap) { + wm->setMapName(minimap->getMapName()); + } + wm->setServerExplorationMask( + gameHandler.getPlayerExploredZoneMasks(), + gameHandler.hasPlayerExploredZoneMasks()); + + // Party member dots on world map + { + std::vector dots; + if (gameHandler.isInGroup()) { + const auto& partyData = gameHandler.getPartyData(); + for (const auto& member : partyData.members) { + if (!member.isOnline || !member.hasPartyStats) continue; + if (member.posX == 0 && member.posY == 0) continue; + // posY → canonical X (north), posX → canonical Y (west) + float wowX = static_cast(member.posY); + float wowY = static_cast(member.posX); + glm::vec3 rpos = core::coords::canonicalToRender(glm::vec3(wowX, wowY, 0.0f)); + auto ent = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(ent.get()); + ImU32 col = (cid != 0) + ? classColorU32(cid, 230) + : (member.guid == partyData.leaderGuid + ? IM_COL32(255, 210, 0, 230) + : IM_COL32(100, 180, 255, 230)); + dots.push_back({ rpos, col, member.name }); + } + } + wm->setPartyDots(std::move(dots)); + } + + // Taxi node markers on world map + { + std::vector taxiNodes; + const auto& nodes = gameHandler.getTaxiNodes(); + taxiNodes.reserve(nodes.size()); + for (const auto& [id, node] : nodes) { + rendering::WorldMapTaxiNode wtn; + wtn.id = node.id; + wtn.mapId = node.mapId; + wtn.wowX = node.x; + wtn.wowY = node.y; + wtn.wowZ = node.z; + wtn.name = node.name; + wtn.known = gameHandler.isKnownTaxiNode(id); + taxiNodes.push_back(std::move(wtn)); + } + wm->setTaxiNodes(std::move(taxiNodes)); + } + + // Quest POI markers on world map (from SMSG_QUEST_POI_QUERY_RESPONSE / gossip POIs) + { + std::vector qpois; + for (const auto& poi : gameHandler.getGossipPois()) { + rendering::WorldMap::QuestPoi qp; + qp.wowX = poi.x; + qp.wowY = poi.y; + qp.name = poi.name; + qpois.push_back(std::move(qp)); + } + wm->setQuestPois(std::move(qpois)); + } + + // Corpse marker: show skull X on world map when ghost with unclaimed corpse + { + float corpseCanX = 0.0f, corpseCanY = 0.0f; + bool ghostWithCorpse = gameHandler.isPlayerGhost() && + gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY); + glm::vec3 corpseRender = ghostWithCorpse + ? core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)) + : glm::vec3{}; + wm->setCorpsePos(ghostWithCorpse, corpseRender); + } + + glm::vec3 playerPos = renderer->getCharacterPosition(); + float playerYaw = renderer->getCharacterYaw(); + auto* window = app.getWindow(); + int screenW = window ? window->getWidth() : 1280; + int screenH = window ? window->getHeight() : 720; + wm->render(playerPos, screenW, screenH, playerYaw); + + // Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay). + if (!wm->isOpen()) showWorldMap_ = false; +} + +// ============================================================ +// Action Bar +// ============================================================ + +VkDescriptorSet GameScreen::getSpellIcon(uint32_t spellId, pipeline::AssetManager* am) { + if (spellId == 0 || !am) return VK_NULL_HANDLE; + + // Check cache first + auto cit = spellIconCache_.find(spellId); + if (cit != spellIconCache_.end()) return cit->second; + + // Lazy-load SpellIcon.dbc and Spell.dbc icon IDs + if (!spellIconDbLoaded_) { + spellIconDbLoaded_ = true; + + // Load SpellIcon.dbc: field 0 = ID, field 1 = icon path + auto iconDbc = am->loadDBC("SpellIcon.dbc"); + const auto* iconL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("SpellIcon") : nullptr; + if (iconDbc && iconDbc->isLoaded()) { + for (uint32_t i = 0; i < iconDbc->getRecordCount(); i++) { + uint32_t id = iconDbc->getUInt32(i, iconL ? (*iconL)["ID"] : 0); + std::string path = iconDbc->getString(i, iconL ? (*iconL)["Path"] : 1); + if (!path.empty() && id > 0) { + spellIconPaths_[id] = path; + } + } + } + + // Load Spell.dbc: SpellIconID field + auto spellDbc = am->loadDBC("Spell.dbc"); + const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + if (spellDbc && spellDbc->isLoaded()) { + uint32_t fieldCount = spellDbc->getFieldCount(); + // Helper to load icons for a given field layout + auto tryLoadIcons = [&](uint32_t idField, uint32_t iconField) { + spellIconIds_.clear(); + if (iconField >= fieldCount) return; + for (uint32_t i = 0; i < spellDbc->getRecordCount(); i++) { + uint32_t id = spellDbc->getUInt32(i, idField); + uint32_t iconId = spellDbc->getUInt32(i, iconField); + if (id > 0 && iconId > 0) { + spellIconIds_[id] = iconId; + } + } + }; + + // Use expansion-aware layout if available AND the DBC field count + // matches the expansion's expected format. Classic=173, TBC=216, + // WotLK=234 fields. When Classic is active but the base WotLK DBC + // is loaded (234 fields), field 117 is NOT IconID — we must use + // the WotLK field 133 instead. + uint32_t iconField = 133; // WotLK default + uint32_t idField = 0; + if (spellL) { + uint32_t layoutIcon = (*spellL)["IconID"]; + // Only trust the expansion layout if the DBC has a compatible + // field count (within ~20 of the layout's icon field). + if (layoutIcon < fieldCount && fieldCount <= layoutIcon + 20) { + iconField = layoutIcon; + idField = (*spellL)["ID"]; + } + } + tryLoadIcons(idField, iconField); + } + } + + // Rate-limit GPU uploads per frame to prevent stalls when many icons are uncached + // (e.g., first login, after loading screen, or many new auras appearing at once). + static int gsLoadsThisFrame = 0; + static int gsLastImGuiFrame = -1; + int gsCurFrame = ImGui::GetFrameCount(); + if (gsCurFrame != gsLastImGuiFrame) { gsLoadsThisFrame = 0; gsLastImGuiFrame = gsCurFrame; } + if (gsLoadsThisFrame >= 4) return VK_NULL_HANDLE; // defer — do NOT cache null here + + // Look up spellId -> SpellIconID -> icon path + auto iit = spellIconIds_.find(spellId); + if (iit == spellIconIds_.end()) { + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } + + auto pit = spellIconPaths_.find(iit->second); + if (pit == spellIconPaths_.end()) { + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } + + // Path from DBC has no extension — append .blp + std::string iconPath = pit->second + ".blp"; + auto blpData = am->readFile(iconPath); + if (blpData.empty()) { + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } + + auto image = pipeline::BLPLoader::load(blpData); + if (!image.isValid()) { + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } + + // Upload to Vulkan via VkContext + auto* window = services_.window; + auto* vkCtx = window ? window->getVkContext() : nullptr; + if (!vkCtx) { + spellIconCache_[spellId] = VK_NULL_HANDLE; + return VK_NULL_HANDLE; + } + + ++gsLoadsThisFrame; + VkDescriptorSet ds = vkCtx->uploadImGuiTexture(image.data.data(), image.width, image.height); + spellIconCache_[spellId] = ds; + return ds; +} + +// ============================================================ +// Mirror Timers (breath / fatigue / feign death) +// ============================================================ + +void GameScreen::renderMirrorTimers(game::GameHandler& gameHandler) { + ImVec2 displaySize = ImGui::GetIO().DisplaySize; + float screenW = displaySize.x > 0.0f ? displaySize.x : 1280.0f; + float screenH = displaySize.y > 0.0f ? displaySize.y : 720.0f; + + static constexpr struct { const char* label; ImVec4 color; } kTimerInfo[3] = { + { "Fatigue", ImVec4(0.8f, 0.4f, 0.1f, 1.0f) }, + { "Breath", ImVec4(0.2f, 0.5f, 1.0f, 1.0f) }, + { "Feign", kColorGray }, + }; + + float barW = 280.0f; + float barH = 36.0f; + float barX = (screenW - barW) / 2.0f; + float baseY = screenH - 160.0f; // Just above the cast bar slot + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoInputs; + + for (int i = 0; i < 3; ++i) { + const auto& t = gameHandler.getMirrorTimer(i); + if (!t.active || t.maxValue <= 0) continue; + + float frac = static_cast(t.value) / static_cast(t.maxValue); + frac = std::max(0.0f, std::min(1.0f, frac)); + + char winId[32]; + std::snprintf(winId, sizeof(winId), "##MirrorTimer%d", i); + ImGui::SetNextWindowPos(ImVec2(barX, baseY - i * (barH + 4.0f)), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(barW, barH), ImGuiCond_Always); + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.05f, 0.05f, 0.05f, 0.88f)); + if (ImGui::Begin(winId, nullptr, flags)) { + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, kTimerInfo[i].color); + char overlay[48]; + float sec = static_cast(t.value) / 1000.0f; + std::snprintf(overlay, sizeof(overlay), "%s %.0fs", kTimerInfo[i].label, sec); + ImGui::ProgressBar(frac, ImVec2(-1, 20), overlay); + ImGui::PopStyleColor(); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); + } +} + +// ============================================================ +// Cooldown Tracker — floating panel showing all active spell CDs +// ============================================================ + +// ============================================================ +// Quest Objective Tracker (right-side HUD) +// ============================================================ + +void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { + const auto& questLog = gameHandler.getQuestLog(); + if (questLog.empty()) return; + + auto* window = services_.window; + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + constexpr float TRACKER_W = 220.0f; + 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 screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f; + + // Default position: top-right, below minimap + buff bar space. + // questTrackerRightOffset_ stores pixels from the right edge so the tracker + // stays anchored to the right side when the window is resized. + if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) { + questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned + questTrackerPos_.y = 320.0f; + questTrackerPosInit_ = true; + } + // Recompute X from right offset every frame (handles window resize) + questTrackerPos_.x = screenW - questTrackerRightOffset_; + + ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); + ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(4.0f, 2.0f)); + + if (ImGui::Begin("##QuestTracker", nullptr, flags)) { + for (int i = 0; i < static_cast(toShow.size()); ++i) { + const auto& q = *toShow[i]; + + // Clickable quest title — opens quest log + ImGui::PushID(q.questId); + ImVec4 titleCol = q.complete ? colors::kWarmGold + : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Text, titleCol); + if (ImGui::Selectable(q.title.c_str(), false, + ImGuiSelectableFlags_DontClosePopups, ImVec2(ImGui::GetContentRegionAvail().x, 0))) { + questLogScreen.openAndSelectQuest(q.questId); + } + if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) { + ImGui::SetTooltip("Click: open Quest Log | Right-click: tracking options"); + } + ImGui::PopStyleColor(); + + // Right-click context menu for quest tracker entry + if (ImGui::BeginPopupContextItem("##QTCtx")) { + ImGui::TextDisabled("%s", q.title.c_str()); + ImGui::Separator(); + if (ImGui::MenuItem("Open in Quest Log")) { + questLogScreen.openAndSelectQuest(q.questId); + } + bool tracked = gameHandler.isQuestTracked(q.questId); + if (tracked) { + if (ImGui::MenuItem("Stop Tracking")) { + gameHandler.setQuestTracked(q.questId, false); + } + } else { + if (ImGui::MenuItem("Track")) { + gameHandler.setQuestTracked(q.questId, true); + } + } + if (gameHandler.isInGroup() && !q.complete) { + if (ImGui::MenuItem("Share Quest")) { + gameHandler.shareQuestWithParty(q.questId); + } + } + if (!q.complete) { + ImGui::Separator(); + if (ImGui::MenuItem("Abandon Quest")) { + gameHandler.abandonQuest(q.questId); + gameHandler.setQuestTracked(q.questId, false); + } + } + ImGui::EndPopup(); + } + ImGui::PopID(); + + // Objectives line (condensed) + if (q.complete) { + ImGui::TextColored(colors::kActiveGreen, " (Complete)"); + } else { + // Kill counts — green when complete, gray when in progress + for (const auto& [entry, progress] : q.killCounts) { + bool objDone = (progress.first >= progress.second && progress.second > 0); + ImVec4 objColor = objDone ? kColorGreen + : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); + std::string name = gameHandler.getCachedCreatureName(entry); + if (name.empty()) { + const auto* goInfo = gameHandler.getCachedGameObjectInfo(entry); + if (goInfo && !goInfo->name.empty()) name = goInfo->name; + } + if (!name.empty()) { + ImGui::TextColored(objColor, + " %s: %u/%u", name.c_str(), + progress.first, progress.second); + } else { + ImGui::TextColored(objColor, + " %u/%u", progress.first, progress.second); + } + } + // Item counts — green when complete, gray when in progress + for (const auto& [itemId, count] : q.itemCounts) { + uint32_t required = 1; + auto reqIt = q.requiredItemCounts.find(itemId); + if (reqIt != q.requiredItemCounts.end()) required = reqIt->second; + bool objDone = (count >= required); + ImVec4 objColor = objDone ? kColorGreen + : ImVec4(0.75f, 0.75f, 0.75f, 1.0f); + const auto* info = gameHandler.getItemInfo(itemId); + const char* itemName = (info && !info->name.empty()) ? info->name.c_str() : nullptr; + + // Show small icon if available + uint32_t dispId = (info && info->displayInfoId) ? info->displayInfoId : 0; + VkDescriptorSet iconTex = dispId ? inventoryScreen.getItemIcon(dispId) : VK_NULL_HANDLE; + if (iconTex) { + ImGui::Image((ImTextureID)(uintptr_t)iconTex, ImVec2(12, 12)); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } + ImGui::SameLine(0, 3); + ImGui::TextColored(objColor, + "%s: %u/%u", itemName ? itemName : "Item", count, required); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } + } else if (itemName) { + ImGui::TextColored(objColor, + " %s: %u/%u", itemName, count, required); + if (info && info->valid && ImGui::IsItemHovered()) { + ImGui::BeginTooltip(); + inventoryScreen.renderItemTooltip(*info); + ImGui::EndTooltip(); + } + } else { + ImGui::TextColored(objColor, + " Item: %u/%u", count, required); + } + } + if (q.killCounts.empty() && q.itemCounts.empty() && !q.objectives.empty()) { + const std::string& obj = q.objectives; + if (obj.size() > 40) { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %.37s...", obj.c_str()); + } else { + ImGui::TextColored(ImVec4(0.75f, 0.75f, 0.75f, 1.0f), + " %s", obj.c_str()); + } + } + } + + if (i < static_cast(toShow.size()) - 1) { + ImGui::Spacing(); + } + } + + // Capture position and size after drag/resize + ImVec2 newPos = ImGui::GetWindowPos(); + ImVec2 newSize = ImGui::GetWindowSize(); + bool changed = false; + + // Clamp within screen + newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x); + newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); + + if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || + std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { + questTrackerPos_ = newPos; + // Update right offset so resizes keep the new position anchored + questTrackerRightOffset_ = screenW - newPos.x; + changed = true; + } + if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f || + std::abs(newSize.y - questTrackerSize_.y) > 0.5f) { + questTrackerSize_ = newSize; + changed = true; + } + if (changed) saveSettings(); + } + ImGui::End(); + + ImGui::PopStyleVar(2); + ImGui::PopStyleColor(); +} + +// ============================================================ +// Nameplates — world-space health bars projected to screen +// ============================================================ + +void GameScreen::renderNameplates(game::GameHandler& gameHandler) { + if (gameHandler.getState() != game::WorldState::IN_WORLD) return; + + // Reset mouseover each frame; we'll set it below when the cursor is over a nameplate + gameHandler.setMouseoverGuid(0); + + auto* appRenderer = services_.renderer; + if (!appRenderer) return; + rendering::Camera* camera = appRenderer->getCamera(); + if (!camera) return; + + auto* window = services_.window; + if (!window) return; + const float screenW = static_cast(window->getWidth()); + const float screenH = static_cast(window->getHeight()); + + const glm::mat4 viewProj = camera->getProjectionMatrix() * camera->getViewMatrix(); + const glm::vec3 camPos = camera->getPosition(); + const uint64_t playerGuid = gameHandler.getPlayerGuid(); + const uint64_t targetGuid = gameHandler.getTargetGuid(); + + // Build set of creature entries that are kill objectives in active (incomplete) quests. + std::unordered_set questKillEntries; + { + const auto& questLog = gameHandler.getQuestLog(); + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + for (const auto& q : questLog) { + if (q.complete || q.questId == 0) continue; + // Only highlight for tracked quests (or all if nothing tracked). + if (!trackedIds.empty() && !trackedIds.count(q.questId)) continue; + for (const auto& obj : q.killObjectives) { + if (obj.npcOrGoId > 0 && obj.required > 0) { + // Check if not already completed. + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) { + questKillEntries.insert(static_cast(obj.npcOrGoId)); + } + } + } + } + } + + ImDrawList* drawList = ImGui::GetBackgroundDrawList(); + + for (const auto& [guid, entityPtr] : gameHandler.getEntityManager().getEntities()) { + if (!entityPtr || guid == playerGuid) continue; + + if (!entityPtr->isUnit()) continue; + auto* unit = static_cast(entityPtr.get()); + if (unit->getMaxHealth() == 0) continue; + + bool isPlayer = (entityPtr->getType() == game::ObjectType::PLAYER); + bool isTarget = (guid == targetGuid); + + // Player nameplates use Shift+V toggle; NPC/enemy nameplates use V toggle + if (isPlayer && !settingsPanel_.showFriendlyNameplates_) continue; + if (!isPlayer && !showNameplates_) continue; + + // For corpses (dead units), only show a minimal grey nameplate if selected + bool isCorpse = (unit->getHealth() == 0); + if (isCorpse && !isTarget) continue; + + // Prefer the renderer's actual instance position so the nameplate tracks the + // rendered model exactly (avoids drift from the parallel entity interpolator). + glm::vec3 renderPos; + if (!core::Application::getInstance().getRenderPositionForGuid(guid, renderPos)) { + renderPos = core::coords::canonicalToRender( + glm::vec3(unit->getX(), unit->getY(), unit->getZ())); + } + renderPos.z += 2.3f; + + // Cull distance: target or other players up to 40 units; NPC others up to 20 units + glm::vec3 nameDelta = renderPos - camPos; + float distSq = glm::dot(nameDelta, nameDelta); + float cullDist = (isTarget || isPlayer) ? 40.0f : 20.0f; + if (distSq > cullDist * cullDist) continue; + + // Project to clip space + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w <= 0.01f) continue; // Behind camera + + glm::vec3 ndc = glm::vec3(clipPos) / clipPos.w; + if (ndc.x < -1.2f || ndc.x > 1.2f || ndc.y < -1.2f || ndc.y > 1.2f) continue; + + // NDC → screen pixels. + // The camera bakes the Vulkan Y-flip into the projection matrix, so + // NDC y = -1 is the top of the screen and y = 1 is the bottom. + // Map directly: sy = (ndc.y + 1) / 2 * screenH (no extra inversion). + 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 cull range + float fadeSq = (cullDist - 5.0f) * (cullDist - 5.0f); + float dist = std::sqrt(distSq); + float alpha = distSq < fadeSq ? 1.0f : 1.0f - (dist - (cullDist - 5.0f)) / 5.0f; + auto A = [&](int v) { return static_cast(v * alpha); }; + + // Bar colour by hostility (grey for corpses) + ImU32 barColor, bgColor; + if (isCorpse) { + // Minimal grey bar for selected corpses (loot/skin targets) + barColor = IM_COL32(140, 140, 140, A(200)); + bgColor = IM_COL32(70, 70, 70, A(160)); + } else if (unit->isHostile()) { + // Check if mob is tapped by another player (grey nameplate) + uint32_t dynFlags = unit->getDynamicFlags(); + bool tappedByOther = (dynFlags & 0x0004) != 0 && (dynFlags & 0x0008) == 0; // TAPPED but not TAPPED_BY_ALL_THREAT_LIST + if (tappedByOther) { + barColor = IM_COL32(160, 160, 160, A(200)); + bgColor = IM_COL32(80, 80, 80, A(160)); + } else { + barColor = IM_COL32(220, 60, 60, A(200)); + bgColor = IM_COL32(100, 25, 25, A(160)); + } + } else if (isPlayer) { + // Player nameplates: use class color for easy identification + uint8_t cid = entityClassId(unit); + if (cid != 0) { + ImVec4 cv = classColorVec4(cid); + barColor = IM_COL32( + static_cast(cv.x * 255), + static_cast(cv.y * 255), + static_cast(cv.z * 255), A(210)); + bgColor = IM_COL32( + static_cast(cv.x * 80), + static_cast(cv.y * 80), + static_cast(cv.z * 80), A(160)); + } else { + barColor = IM_COL32(60, 200, 80, A(200)); + bgColor = IM_COL32(25, 100, 35, A(160)); + } + } else { + barColor = IM_COL32(60, 200, 80, A(200)); + bgColor = IM_COL32(25, 100, 35, A(160)); + } + // Check if this unit is targeting the local player (threat indicator) + bool isTargetingPlayer = false; + if (unit->isHostile() && !isCorpse) { + const auto& fields = entityPtr->getFields(); + auto loIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_LO)); + if (loIt != fields.end() && loIt->second != 0) { + uint64_t unitTarget = loIt->second; + auto hiIt = fields.find(game::fieldIndex(game::UF::UNIT_FIELD_TARGET_HI)); + if (hiIt != fields.end()) + unitTarget |= (static_cast(hiIt->second) << 32); + isTargetingPlayer = (unitTarget == playerGuid); + } + } + // Creature rank for border styling (Elite=gold double border, Boss=red, Rare=silver) + int creatureRank = -1; + if (!isPlayer) creatureRank = gameHandler.getCreatureRank(unit->getEntry()); + + // Border: gold = currently selected, orange = targeting player, dark = default + ImU32 borderColor = isTarget + ? IM_COL32(255, 215, 0, A(255)) + : isTargetingPlayer + ? IM_COL32(255, 140, 0, A(220)) // orange = this mob is targeting you + : IM_COL32(20, 20, 20, A(180)); + + // Bar geometry + const float barW = 80.0f * settingsPanel_.nameplateScale_; + const float barH = 8.0f * settingsPanel_.nameplateScale_; + const float barX = sx - barW * 0.5f; + + // Guard against division by zero when maxHealth hasn't been populated yet + // (freshly spawned entity with default fields). 0/0 produces NaN which + // poisons all downstream geometry; +inf is clamped but still wasteful. + float healthPct = (unit->getMaxHealth() > 0) + ? std::clamp(static_cast(unit->getHealth()) / static_cast(unit->getMaxHealth()), 0.0f, 1.0f) + : 0.0f; + + drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW, sy + barH), bgColor, 2.0f); + // For corpses, don't fill health bar (just show grey background) + if (!isCorpse) { + drawList->AddRectFilled(ImVec2(barX, sy), ImVec2(barX + barW * healthPct, sy + barH), barColor, 2.0f); + } + drawList->AddRect (ImVec2(barX - 1.0f, sy - 1.0f), ImVec2(barX + barW + 1.0f, sy + barH + 1.0f), borderColor, 2.0f); + + // Elite/Boss/Rare decoration: extra outer border with rank-specific color + if (creatureRank == 1 || creatureRank == 2) { + // Elite / Rare Elite: gold double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(255, 200, 50, A(200)), 3.0f); + } else if (creatureRank == 3) { + // Boss: red double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(255, 40, 40, A(200)), 3.0f); + } else if (creatureRank == 4) { + // Rare: silver double border + drawList->AddRect(ImVec2(barX - 3.0f, sy - 3.0f), + ImVec2(barX + barW + 3.0f, sy + barH + 3.0f), + IM_COL32(170, 200, 230, A(200)), 3.0f); + } + + // HP % text centered on health bar (non-corpse, non-full-health for readability) + if (!isCorpse && unit->getMaxHealth() > 0) { + int hpPct = static_cast(healthPct * 100.0f + 0.5f); + char hpBuf[8]; + snprintf(hpBuf, sizeof(hpBuf), "%d%%", hpPct); + ImVec2 hpTextSz = ImGui::CalcTextSize(hpBuf); + float hpTx = sx - hpTextSz.x * 0.5f; + float hpTy = sy + (barH - hpTextSz.y) * 0.5f; + drawList->AddText(ImVec2(hpTx + 1.0f, hpTy + 1.0f), IM_COL32(0, 0, 0, A(140)), hpBuf); + drawList->AddText(ImVec2(hpTx, hpTy), IM_COL32(255, 255, 255, A(200)), hpBuf); + } + + // Cast bar below health bar when unit is casting + float castBarBaseY = sy + barH + 2.0f; + float nameplateBottom = castBarBaseY; // tracks lowest drawn element for debuff dots + { + const auto* cs = gameHandler.getUnitCastState(guid); + if (cs && cs->casting && cs->timeTotal > 0.0f) { + float castPct = std::clamp((cs->timeTotal - cs->timeRemaining) / cs->timeTotal, 0.0f, 1.0f); + const float cbH = 6.0f * settingsPanel_.nameplateScale_; + + // Spell icon + name above the cast bar + const std::string& spellName = gameHandler.getSpellName(cs->spellId); + { + auto* castAm = services_.assetManager; + VkDescriptorSet castIcon = (cs->spellId && castAm) + ? getSpellIcon(cs->spellId, castAm) : VK_NULL_HANDLE; + float iconSz = cbH + 8.0f; + if (castIcon) { + // Draw icon to the left of the cast bar + float iconX = barX - iconSz - 2.0f; + float iconY = castBarBaseY; + drawList->AddImage((ImTextureID)(uintptr_t)castIcon, + ImVec2(iconX, iconY), + ImVec2(iconX + iconSz, iconY + iconSz)); + drawList->AddRect(ImVec2(iconX - 1.0f, iconY - 1.0f), + ImVec2(iconX + iconSz + 1.0f, iconY + iconSz + 1.0f), + IM_COL32(0, 0, 0, A(180)), 1.0f); + } + if (!spellName.empty()) { + ImVec2 snSz = ImGui::CalcTextSize(spellName.c_str()); + float snX = sx - snSz.x * 0.5f; + float snY = castBarBaseY; + drawList->AddText(ImVec2(snX + 1.0f, snY + 1.0f), IM_COL32(0, 0, 0, A(140)), spellName.c_str()); + drawList->AddText(ImVec2(snX, snY), IM_COL32(255, 210, 100, A(220)), spellName.c_str()); + castBarBaseY += snSz.y + 2.0f; + } + } + + // Cast bar: green = interruptible, red = uninterruptible; both pulse when >80% complete + ImU32 cbBg = IM_COL32(30, 25, 40, A(180)); + ImU32 cbFill; + if (castPct > 0.8f && unit->isHostile()) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 8.0f); + cbFill = cs->interruptible + ? IM_COL32(static_cast(40 * pulse), static_cast(220 * pulse), static_cast(40 * pulse), A(220)) // green pulse + : IM_COL32(static_cast(255 * pulse), static_cast(30 * pulse), static_cast(30 * pulse), A(220)); // red pulse + } else { + cbFill = cs->interruptible + ? IM_COL32(50, 190, 50, A(200)) // green = interruptible + : IM_COL32(190, 40, 40, A(200)); // red = uninterruptible + } + drawList->AddRectFilled(ImVec2(barX, castBarBaseY), + ImVec2(barX + barW, castBarBaseY + cbH), cbBg, 2.0f); + drawList->AddRectFilled(ImVec2(barX, castBarBaseY), + ImVec2(barX + barW * castPct, castBarBaseY + cbH), cbFill, 2.0f); + drawList->AddRect (ImVec2(barX - 1.0f, castBarBaseY - 1.0f), + ImVec2(barX + barW + 1.0f, castBarBaseY + cbH + 1.0f), + IM_COL32(20, 10, 40, A(200)), 2.0f); + + // Time remaining text + char timeBuf[12]; + snprintf(timeBuf, sizeof(timeBuf), "%.1fs", cs->timeRemaining); + ImVec2 timeSz = ImGui::CalcTextSize(timeBuf); + float timeX = sx - timeSz.x * 0.5f; + float timeY = castBarBaseY + (cbH - timeSz.y) * 0.5f; + drawList->AddText(ImVec2(timeX + 1.0f, timeY + 1.0f), IM_COL32(0, 0, 0, A(140)), timeBuf); + drawList->AddText(ImVec2(timeX, timeY), IM_COL32(220, 200, 255, A(220)), timeBuf); + nameplateBottom = castBarBaseY + cbH + 2.0f; + } + } + + // Debuff dot indicators: small colored squares below the nameplate showing + // player-applied auras on the current hostile target. + // Colors: Magic=blue, Curse=purple, Disease=yellow, Poison=green, Other=grey + if (isTarget && unit->isHostile() && !isCorpse) { + const auto& auras = gameHandler.getTargetAuras(); + const uint64_t pguid = gameHandler.getPlayerGuid(); + const float dotSize = 6.0f * settingsPanel_.nameplateScale_; + const float dotGap = 2.0f; + float dotX = barX; + for (const auto& aura : auras) { + if (aura.isEmpty() || aura.casterGuid != pguid) continue; + uint8_t dispelType = gameHandler.getSpellDispelType(aura.spellId); + ImU32 dotCol; + switch (dispelType) { + case 1: dotCol = IM_COL32( 64, 128, 255, A(210)); break; // Magic - blue + case 2: dotCol = IM_COL32(160, 32, 240, A(210)); break; // Curse - purple + case 3: dotCol = IM_COL32(180, 140, 40, A(210)); break; // Disease - yellow-brown + case 4: dotCol = IM_COL32( 50, 200, 50, A(210)); break; // Poison - green + default: dotCol = IM_COL32(170, 170, 170, A(170)); break; // Other - grey + } + drawList->AddRectFilled(ImVec2(dotX, nameplateBottom), + ImVec2(dotX + dotSize, nameplateBottom + dotSize), dotCol, 1.0f); + drawList->AddRect (ImVec2(dotX - 1.0f, nameplateBottom - 1.0f), + ImVec2(dotX + dotSize + 1.0f, nameplateBottom + dotSize + 1.0f), + IM_COL32(0, 0, 0, A(150)), 1.0f); + + // Duration clock-sweep overlay (like target frame auras) + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + int32_t remainMs = aura.getRemainingMs(nowMs); + if (aura.maxDurationMs > 0 && remainMs > 0) { + float pct = 1.0f - static_cast(remainMs) / static_cast(aura.maxDurationMs); + pct = std::clamp(pct, 0.0f, 1.0f); + float cx = dotX + dotSize * 0.5f; + float cy = nameplateBottom + dotSize * 0.5f; + float r = dotSize * 0.5f; + float startAngle = -IM_PI * 0.5f; + float endAngle = startAngle + pct * IM_PI * 2.0f; + ImVec2 center(cx, cy); + const int segments = 12; + for (int seg = 0; seg < segments; seg++) { + float a0 = startAngle + (endAngle - startAngle) * seg / segments; + float a1 = startAngle + (endAngle - startAngle) * (seg + 1) / segments; + drawList->AddTriangleFilled( + center, + ImVec2(cx + r * std::cos(a0), cy + r * std::sin(a0)), + ImVec2(cx + r * std::cos(a1), cy + r * std::sin(a1)), + IM_COL32(0, 0, 0, A(100))); + } + } + + // Stack count on dot (upper-left corner) + if (aura.charges > 1) { + char stackBuf[8]; + snprintf(stackBuf, sizeof(stackBuf), "%d", aura.charges); + drawList->AddText(ImVec2(dotX + 1.0f, nameplateBottom), IM_COL32(0, 0, 0, A(200)), stackBuf); + drawList->AddText(ImVec2(dotX, nameplateBottom - 1.0f), IM_COL32(255, 255, 255, A(240)), stackBuf); + } + + // Duration text below dot + if (remainMs > 0) { + char durBuf[8]; + if (remainMs >= 60000) + snprintf(durBuf, sizeof(durBuf), "%dm", remainMs / 60000); + else + snprintf(durBuf, sizeof(durBuf), "%d", remainMs / 1000); + ImVec2 durSz = ImGui::CalcTextSize(durBuf); + float durX = dotX + (dotSize - durSz.x) * 0.5f; + float durY = nameplateBottom + dotSize + 1.0f; + drawList->AddText(ImVec2(durX + 1.0f, durY + 1.0f), IM_COL32(0, 0, 0, A(180)), durBuf); + // Color: red if < 5s, yellow if < 15s, white otherwise + ImU32 durCol = remainMs < 5000 ? IM_COL32(255, 60, 60, A(240)) + : remainMs < 15000 ? IM_COL32(255, 200, 60, A(240)) + : IM_COL32(230, 230, 230, A(220)); + drawList->AddText(ImVec2(durX, durY), durCol, durBuf); + } + + // Spell name + duration tooltip on hover + { + ImVec2 mouse = ImGui::GetMousePos(); + if (mouse.x >= dotX && mouse.x < dotX + dotSize && + mouse.y >= nameplateBottom && mouse.y < nameplateBottom + dotSize) { + const std::string& dotSpellName = gameHandler.getSpellName(aura.spellId); + if (!dotSpellName.empty()) { + if (remainMs > 0) { + int secs = remainMs / 1000; + int mins = secs / 60; + secs %= 60; + char tipBuf[128]; + if (mins > 0) + snprintf(tipBuf, sizeof(tipBuf), "%s (%dm %ds)", dotSpellName.c_str(), mins, secs); + else + snprintf(tipBuf, sizeof(tipBuf), "%s (%ds)", dotSpellName.c_str(), secs); + ImGui::SetTooltip("%s", tipBuf); + } else { + ImGui::SetTooltip("%s", dotSpellName.c_str()); + } + } + } + } + + dotX += dotSize + dotGap; + if (dotX + dotSize > barX + barW) break; + } + } + + // Name + level label above health bar + uint32_t level = unit->getLevel(); + const std::string& unitName = unit->getName(); + char labelBuf[96]; + if (isPlayer) { + // Player nameplates: show name only (no level clutter). + // Fall back to level as placeholder while the name query is pending. + if (!unitName.empty()) + snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); + else { + // Name query may be pending; request it now to ensure it gets resolved + gameHandler.queryPlayerName(unit->getGuid()); + if (level > 0) + snprintf(labelBuf, sizeof(labelBuf), "Player (%u)", level); + else + snprintf(labelBuf, sizeof(labelBuf), "Player"); + } + } else if (level > 0) { + uint32_t playerLevel = gameHandler.getPlayerLevel(); + // Show skull for units more than 10 levels above the player + if (playerLevel > 0 && level > playerLevel + 10) + snprintf(labelBuf, sizeof(labelBuf), "?? %s", unitName.c_str()); + else + snprintf(labelBuf, sizeof(labelBuf), "%u %s", level, unitName.c_str()); + } else { + snprintf(labelBuf, sizeof(labelBuf), "%s", unitName.c_str()); + } + ImVec2 textSize = ImGui::CalcTextSize(labelBuf); + float nameX = sx - textSize.x * 0.5f; + float nameY = sy - barH - 12.0f; + // Name color: players get WoW class colors; NPCs use hostility (red/yellow) + ImU32 nameColor; + if (isPlayer) { + // Class color with cyan fallback for unknown class + uint8_t cid = entityClassId(unit); + ImVec4 cc = (cid != 0) ? classColorVec4(cid) : ImVec4(0.31f, 0.78f, 1.0f, 1.0f); + nameColor = IM_COL32(static_cast(cc.x*255), static_cast(cc.y*255), + static_cast(cc.z*255), A(230)); + } else { + nameColor = unit->isHostile() + ? IM_COL32(220, 80, 80, A(230)) // red — hostile NPC + : IM_COL32(240, 200, 100, A(230)); // yellow — friendly NPC + } + // Sub-label below the name: guild tag for players, subtitle for NPCs + std::string subLabel; + if (isPlayer) { + uint32_t guildId = gameHandler.getEntityGuildId(guid); + if (guildId != 0) { + const std::string& gn = gameHandler.lookupGuildName(guildId); + if (!gn.empty()) subLabel = "<" + gn + ">"; + } + } else { + // NPC subtitle (e.g. "", "") + std::string sub = gameHandler.getCachedCreatureSubName(unit->getEntry()); + if (!sub.empty()) subLabel = "<" + sub + ">"; + } + if (!subLabel.empty()) nameY -= 10.0f; // shift name up for sub-label line + + drawList->AddText(ImVec2(nameX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), labelBuf); + drawList->AddText(ImVec2(nameX, nameY), nameColor, labelBuf); + + // Sub-label below the name (WoW-style or in lighter color) + if (!subLabel.empty()) { + ImVec2 subSz = ImGui::CalcTextSize(subLabel.c_str()); + float subX = sx - subSz.x * 0.5f; + float subY = nameY + textSize.y + 1.0f; + drawList->AddText(ImVec2(subX + 1.0f, subY + 1.0f), IM_COL32(0, 0, 0, A(120)), subLabel.c_str()); + drawList->AddText(ImVec2(subX, subY), IM_COL32(180, 180, 180, A(200)), subLabel.c_str()); + } + + // Group leader crown to the right of the name on player nameplates + if (isPlayer && gameHandler.isInGroup() && + gameHandler.getPartyData().leaderGuid == guid) { + float crownX = nameX + textSize.x + 3.0f; + const char* crownSym = "\xe2\x99\x9b"; // ♛ + drawList->AddText(ImVec2(crownX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), crownSym); + drawList->AddText(ImVec2(crownX, nameY), IM_COL32(255, 215, 0, A(240)), crownSym); + } + + // Raid mark (if any) to the left of the name + { + static constexpr 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); + } + + // Quest kill objective indicator: small yellow sword icon to the right of the name + float questIconX = nameX + textSize.x + 4.0f; + if (!isPlayer && questKillEntries.count(unit->getEntry())) { + const char* objSym = "\xe2\x9a\x94"; // ⚔ crossed swords (UTF-8) + drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), objSym); + drawList->AddText(ImVec2(questIconX, nameY), IM_COL32(255, 220, 0, A(230)), objSym); + questIconX += ImGui::CalcTextSize("\xe2\x9a\x94").x + 2.0f; + } + + // Quest giver indicator: "!" for available quests, "?" for completable/incomplete + if (!isPlayer) { + using QGS = game::QuestGiverStatus; + QGS qgs = gameHandler.getQuestGiverStatus(guid); + const char* qSym = nullptr; + ImU32 qCol = IM_COL32(255, 210, 0, A(255)); + if (qgs == QGS::AVAILABLE) { + qSym = "!"; + } else if (qgs == QGS::AVAILABLE_LOW) { + qSym = "!"; + qCol = IM_COL32(160, 160, 160, A(220)); + } else if (qgs == QGS::REWARD || qgs == QGS::REWARD_REP) { + qSym = "?"; + } else if (qgs == QGS::INCOMPLETE) { + qSym = "?"; + qCol = IM_COL32(160, 160, 160, A(220)); + } + if (qSym) { + drawList->AddText(ImVec2(questIconX + 1.0f, nameY + 1.0f), IM_COL32(0, 0, 0, A(160)), qSym); + drawList->AddText(ImVec2(questIconX, nameY), qCol, qSym); + } + } + } + + // Click to target / right-click context: detect clicks inside the nameplate region. + // Use the wider of name text or health bar for the horizontal hit area so short + // names like "Wolf" don't produce a tiny clickable strip narrower than the bar. + if (!ImGui::GetIO().WantCaptureMouse) { + ImVec2 mouse = ImGui::GetIO().MousePos; + float hitLeft = std::min(nameX, barX) - 2.0f; + float hitRight = std::max(nameX + textSize.x, barX + barW) + 2.0f; + float ny0 = nameY - 1.0f; + float ny1 = sy + barH + 2.0f; + float nx0 = hitLeft; + float nx1 = hitRight; + if (mouse.x >= nx0 && mouse.x <= nx1 && mouse.y >= ny0 && mouse.y <= ny1) { + // Track mouseover for [target=mouseover] macro conditionals + gameHandler.setMouseoverGuid(guid); + // Hover tooltip: name, level/class, guild + ImGui::BeginTooltip(); + ImGui::TextUnformatted(unitName.c_str()); + if (isPlayer) { + uint8_t cid = entityClassId(unit); + ImGui::Text("Level %u %s", level, classNameStr(cid)); + } else if (level > 0) { + ImGui::Text("Level %u", level); + } + if (!subLabel.empty()) ImGui::TextColored(ImVec4(0.7f,0.7f,0.7f,1.0f), "%s", subLabel.c_str()); + ImGui::EndTooltip(); + if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + gameHandler.setTarget(guid); + } else if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + nameplateCtxGuid_ = guid; + nameplateCtxPos_ = mouse; + ImGui::OpenPopup("##NameplateCtx"); + } + } + } + } + + // Render nameplate context popup (uses a tiny overlay window as host) + if (nameplateCtxGuid_ != 0) { + ImGui::SetNextWindowPos(nameplateCtxPos_, ImGuiCond_Appearing); + ImGui::SetNextWindowSize(ImVec2(0, 0), ImGuiCond_Always); + ImGuiWindowFlags ctxHostFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoFocusOnAppearing | + ImGuiWindowFlags_AlwaysAutoResize; + if (ImGui::Begin("##NameplateCtxHost", nullptr, ctxHostFlags)) { + if (ImGui::BeginPopup("##NameplateCtx")) { + auto entityPtr = gameHandler.getEntityManager().getEntity(nameplateCtxGuid_); + std::string ctxName = entityPtr ? getEntityName(entityPtr) : ""; + if (!ctxName.empty()) { + ImGui::TextDisabled("%s", ctxName.c_str()); + ImGui::Separator(); + } + if (ImGui::MenuItem("Target")) + gameHandler.setTarget(nameplateCtxGuid_); + if (ImGui::MenuItem("Set Focus")) + gameHandler.setFocus(nameplateCtxGuid_); + bool isPlayer = entityPtr && entityPtr->getType() == game::ObjectType::PLAYER; + if (isPlayer && !ctxName.empty()) { + ImGui::Separator(); + if (ImGui::MenuItem("Whisper")) { + chatPanel_.setWhisperTarget(ctxName); + } + if (ImGui::MenuItem("Invite to Group")) + gameHandler.inviteToGroup(ctxName); + if (ImGui::MenuItem("Trade")) + gameHandler.initiateTrade(nameplateCtxGuid_); + if (ImGui::MenuItem("Duel")) + gameHandler.proposeDuel(nameplateCtxGuid_); + if (ImGui::MenuItem("Inspect")) { + gameHandler.setTarget(nameplateCtxGuid_); + gameHandler.inspectTarget(); + socialPanel_.showInspectWindow_ = true; + } + ImGui::Separator(); + if (ImGui::MenuItem("Add Friend")) + gameHandler.addFriend(ctxName); + if (ImGui::MenuItem("Ignore")) + gameHandler.addIgnore(ctxName); + } + ImGui::EndPopup(); + } else { + nameplateCtxGuid_ = 0; + } + } + ImGui::End(); + } +} + +// ============================================================ +// Durability Warning (equipment damage indicator) +// ============================================================ + +void GameScreen::takeScreenshot(game::GameHandler& /*gameHandler*/) { + auto* renderer = services_.renderer; + if (!renderer) return; + + // Build path: ~/.wowee/screenshots/WoWee_YYYYMMDD_HHMMSS.png + const char* home = std::getenv("HOME"); + if (!home) home = std::getenv("USERPROFILE"); + if (!home) home = "/tmp"; + std::string dir = std::string(home) + "/.wowee/screenshots"; + + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tm{}; +#ifdef _WIN32 + localtime_s(&tm, &tt); +#else + localtime_r(&tt, &tm); +#endif + + char filename[128]; + std::snprintf(filename, sizeof(filename), + "WoWee_%04d%02d%02d_%02d%02d%02d.png", + tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday, + tm.tm_hour, tm.tm_min, tm.tm_sec); + + std::string path = dir + "/" + filename; + + if (renderer->captureScreenshot(path)) { + game::MessageChatData sysMsg; + sysMsg.type = game::ChatType::SYSTEM; + sysMsg.language = game::ChatLanguage::UNIVERSAL; + sysMsg.message = "Screenshot saved: " + path; + services_.gameHandler->addLocalChatMessage(sysMsg); + } +} + +void GameScreen::renderDurabilityWarning(game::GameHandler& gameHandler) { + if (gameHandler.getPlayerGuid() == 0) return; + + const auto& inv = gameHandler.getInventory(); + + // Scan all equipment slots (skip bag slots which have no durability) + float minDurPct = 1.0f; + bool hasBroken = false; + + for (int i = static_cast(game::EquipSlot::HEAD); + i < static_cast(game::EquipSlot::BAG1); ++i) { + const auto& slot = inv.getEquipSlot(static_cast(i)); + if (slot.empty() || slot.item.maxDurability == 0) continue; + if (slot.item.curDurability == 0) { + hasBroken = true; + } + float pct = static_cast(slot.item.curDurability) / + static_cast(slot.item.maxDurability); + if (pct < minDurPct) minDurPct = pct; + } + + // Only show warning below 20% + if (minDurPct >= 0.2f && !hasBroken) return; + + ImGuiIO& io = ImGui::GetIO(); + const float screenW = io.DisplaySize.x; + const float screenH = io.DisplaySize.y; + + // Position: just above the XP bar / action bar area (bottom-center) + const float warningW = 220.0f; + const float warningH = 26.0f; + const float posX = (screenW - warningW) * 0.5f; + const float posY = screenH - 140.0f; // above action bar + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(warningW, warningH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6, 4)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowMinSize, ImVec2(0, 0)); + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoInputs | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoSavedSettings | + ImGuiWindowFlags_NoBringToFrontOnFocus; + + if (ImGui::Begin("##durability_warn", nullptr, flags)) { + if (hasBroken) { + ImGui::TextColored(ImVec4(1.0f, 0.15f, 0.15f, 1.0f), + "\xef\x94\x9b Gear broken! Visit a repair NPC"); + } else { + int pctInt = static_cast(minDurPct * 100.0f); + ImGui::TextColored(colors::kSymbolGold, + "\xef\x94\x9b Low durability: %d%%", pctInt); + } + if (ImGui::IsWindowHovered()) + ImGui::SetTooltip("Your equipment is damaged. Visit any blacksmith or repair NPC."); + } + ImGui::End(); + ImGui::PopStyleVar(3); +} + +// ============================================================ +// UI Error Frame (WoW-style center-bottom error overlay) +// ============================================================ + +void GameScreen::renderUIErrors(game::GameHandler& /*gameHandler*/, float deltaTime) { + // Age out old entries + for (auto& e : uiErrors_) e.age += deltaTime; + uiErrors_.erase( + std::remove_if(uiErrors_.begin(), uiErrors_.end(), + [](const UIErrorEntry& e) { return e.age >= kUIErrorLifetime; }), + uiErrors_.end()); + + if (uiErrors_.empty()) return; + + auto* window = services_.window; + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + // Fixed invisible overlay + ImGui::SetNextWindowPos(ImVec2(0, 0)); + ImGui::SetNextWindowSize(ImVec2(screenW, screenH)); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_NoNav | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + if (ImGui::Begin("##UIErrors", nullptr, flags)) { + // Render messages stacked above the action bar (~200px from bottom) + // The newest message is on top; older ones fade below it. + const float baseY = screenH - 200.0f; + const float lineH = 20.0f; + const int count = static_cast(uiErrors_.size()); + + ImDrawList* draw = ImGui::GetWindowDrawList(); + for (int i = count - 1; i >= 0; --i) { + const auto& e = uiErrors_[i]; + float alpha = 1.0f - (e.age / kUIErrorLifetime); + alpha = std::max(0.0f, std::min(1.0f, alpha)); + + // Fade fast in the last 0.5 s + if (e.age > kUIErrorLifetime - 0.5f) + alpha *= (kUIErrorLifetime - e.age) / 0.5f; + + uint8_t a8 = static_cast(alpha * 255.0f); + ImU32 textCol = IM_COL32(255, 50, 50, a8); + ImU32 shadowCol= IM_COL32( 0, 0, 0, static_cast(alpha * 180)); + + const char* txt = e.text.c_str(); + ImVec2 sz = ImGui::CalcTextSize(txt); + float x = std::round((screenW - sz.x) * 0.5f); + float y = std::round(baseY - (count - 1 - i) * lineH); + + // Drop shadow + draw->AddText(ImVec2(x + 1, y + 1), shadowCol, txt); + draw->AddText(ImVec2(x, y), textCol, txt); + } + } + ImGui::End(); + ImGui::PopStyleVar(); +} + +void GameScreen::renderQuestMarkers(game::GameHandler& gameHandler) { + const auto& statuses = gameHandler.getNpcQuestStatuses(); + if (statuses.empty()) return; + + auto* renderer = services_.renderer; + auto* camera = renderer ? renderer->getCamera() : nullptr; + auto* window = services_.window; + if (!camera || !window) return; + + float screenW = static_cast(window->getWidth()); + float screenH = static_cast(window->getHeight()); + glm::mat4 viewProj = camera->getViewProjectionMatrix(); + auto* drawList = ImGui::GetForegroundDrawList(); + + for (const auto& [guid, status] : statuses) { + // Only show markers for available (!) and reward/completable (?) + const char* marker = nullptr; + ImU32 color = IM_COL32(255, 210, 0, 255); // yellow + if (status == game::QuestGiverStatus::AVAILABLE) { + marker = "!"; + } else if (status == game::QuestGiverStatus::AVAILABLE_LOW) { + marker = "!"; + color = IM_COL32(160, 160, 160, 255); // gray + } else if (status == game::QuestGiverStatus::REWARD || + status == game::QuestGiverStatus::REWARD_REP) { + marker = "?"; + } else if (status == game::QuestGiverStatus::INCOMPLETE) { + marker = "?"; + color = IM_COL32(160, 160, 160, 255); // gray + } else { + continue; + } + + // Get entity position (canonical coords) + auto entity = gameHandler.getEntityManager().getEntity(guid); + if (!entity) continue; + + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + glm::vec3 renderPos = core::coords::canonicalToRender(canonical); + + // Get model height for offset + float heightOffset = 3.0f; + glm::vec3 boundsCenter; + float boundsRadius = 0.0f; + if (core::Application::getInstance().getRenderBoundsForGuid(guid, boundsCenter, boundsRadius)) { + heightOffset = boundsRadius * 2.0f + 1.0f; + } + renderPos.z += heightOffset; + + // Project to screen + glm::vec4 clipPos = viewProj * glm::vec4(renderPos, 1.0f); + if (clipPos.w <= 0.0f) continue; + + glm::vec2 ndc(clipPos.x / clipPos.w, clipPos.y / clipPos.w); + float sx = (ndc.x + 1.0f) * 0.5f * screenW; + float sy = (1.0f - ndc.y) * 0.5f * screenH; + + // Skip if off-screen + if (sx < -50 || sx > screenW + 50 || sy < -50 || sy > screenH + 50) continue; + + // Scale text size based on distance + float dist = clipPos.w; + float fontSize = std::clamp(800.0f / dist, 14.0f, 48.0f); + + // Draw outlined text: 4 shadow copies then main text + ImFont* font = ImGui::GetFont(); + ImU32 outlineColor = IM_COL32(0, 0, 0, 220); + float off = std::max(1.0f, fontSize * 0.06f); + ImVec2 textSize = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, marker); + float tx = sx - textSize.x * 0.5f; + float ty = sy - textSize.y * 0.5f; + + drawList->AddText(font, fontSize, ImVec2(tx - off, ty), outlineColor, marker); + drawList->AddText(font, fontSize, ImVec2(tx + off, ty), outlineColor, marker); + drawList->AddText(font, fontSize, ImVec2(tx, ty - off), outlineColor, marker); + drawList->AddText(font, fontSize, ImVec2(tx, ty + off), outlineColor, marker); + drawList->AddText(font, fontSize, ImVec2(tx, ty), color, marker); + } +} + + +}} // namespace wowee::ui diff --git a/src/ui/game_screen_minimap.cpp b/src/ui/game_screen_minimap.cpp new file mode 100644 index 00000000..b3fbd6e9 --- /dev/null +++ b/src/ui/game_screen_minimap.cpp @@ -0,0 +1,1918 @@ +#include "ui/game_screen.hpp" +#include "ui/ui_colors.hpp" +#include "ui/ui_helpers.hpp" +#include "rendering/vk_context.hpp" +#include "core/application.hpp" +#include "core/appearance_composer.hpp" +#include "addons/addon_manager.hpp" +#include "core/coordinates.hpp" +#include "core/input.hpp" +#include "rendering/renderer.hpp" +#include "rendering/post_process_pipeline.hpp" +#include "rendering/animation_controller.hpp" +#include "rendering/wmo_renderer.hpp" +#include "rendering/terrain_manager.hpp" +#include "rendering/minimap.hpp" +#include "rendering/world_map.hpp" +#include "rendering/character_renderer.hpp" +#include "rendering/camera.hpp" +#include "rendering/camera_controller.hpp" +#include "audio/audio_coordinator.hpp" +#include "audio/audio_engine.hpp" +#include "audio/music_manager.hpp" +#include "game/zone_manager.hpp" +#include "audio/footstep_manager.hpp" +#include "audio/activity_sound_manager.hpp" +#include "audio/mount_sound_manager.hpp" +#include "audio/npc_voice_manager.hpp" +#include "audio/ambient_sound_manager.hpp" +#include "audio/ui_sound_manager.hpp" +#include "audio/combat_sound_manager.hpp" +#include "audio/spell_sound_manager.hpp" +#include "audio/movement_sound_manager.hpp" +#include "pipeline/asset_manager.hpp" +#include "pipeline/dbc_loader.hpp" +#include "pipeline/dbc_layout.hpp" + +#include "game/expansion_profile.hpp" +#include "game/character.hpp" +#include "core/logger.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +namespace { + using namespace wowee::ui::colors; + using namespace wowee::ui::helpers; + constexpr auto& kColorRed = kRed; + constexpr auto& kColorGreen = kGreen; + constexpr auto& kColorBrightGreen= kBrightGreen; + constexpr auto& kColorYellow = kYellow; + constexpr auto& kColorGray = kGray; + constexpr auto& kColorDarkGray = kDarkGray; + + // Abbreviated month names (indexed 0-11) + constexpr const char* kMonthAbbrev[12] = { + "Jan","Feb","Mar","Apr","May","Jun", + "Jul","Aug","Sep","Oct","Nov","Dec" + }; + + // Common ImGui window flags for popup dialogs + const ImGuiWindowFlags kDialogFlags = ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoResize; + + bool raySphereIntersect(const wowee::rendering::Ray& ray, const glm::vec3& center, float radius, float& tOut) { + glm::vec3 oc = ray.origin - center; + float b = glm::dot(oc, ray.direction); + float c = glm::dot(oc, oc) - radius * radius; + float discriminant = b * b - c; + if (discriminant < 0.0f) return false; + float t = -b - std::sqrt(discriminant); + if (t < 0.0f) t = -b + std::sqrt(discriminant); + if (t < 0.0f) return false; + tOut = t; + return true; + } + + std::string getEntityName(const std::shared_ptr& entity) { + if (entity->getType() == wowee::game::ObjectType::PLAYER) { + auto player = std::static_pointer_cast(entity); + if (!player->getName().empty()) return player->getName(); + } else if (entity->getType() == wowee::game::ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (!unit->getName().empty()) return unit->getName(); + } else if (entity->getType() == wowee::game::ObjectType::GAMEOBJECT) { + auto go = std::static_pointer_cast(entity); + if (!go->getName().empty()) return go->getName(); + } + return "Unknown"; + } + +} + +namespace wowee { namespace ui { + +void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { + const auto& statuses = gameHandler.getNpcQuestStatuses(); + auto* renderer = services_.renderer; + auto* camera = renderer ? renderer->getCamera() : nullptr; + auto* minimap = renderer ? renderer->getMinimap() : nullptr; + auto* window = services_.window; + if (!camera || !minimap || !window) return; + + float screenW = static_cast(window->getWidth()); + + // Minimap parameters (matching minimap.cpp) + float mapSize = 200.0f; + float margin = 10.0f; + float mapRadius = mapSize * 0.5f; + float centerX = screenW - margin - mapRadius; + float centerY = margin + mapRadius; + float viewRadius = minimap->getViewRadius(); + + // Use the exact same minimap center as Renderer::renderWorld() to keep markers anchored. + glm::vec3 playerRender = camera->getPosition(); + if (renderer->getCharacterInstanceId() != 0) { + playerRender = renderer->getCharacterPosition(); + } + + // Camera bearing for minimap rotation + float bearing = 0.0f; + float cosB = 1.0f; + float sinB = 0.0f; + if (minimap->isRotateWithCamera()) { + glm::vec3 fwd = camera->getForward(); + // Render space: +X=West, +Y=North. Camera fwd=(cos(yaw),sin(yaw)). + // Clockwise bearing from North: atan2(fwd.y, -fwd.x). + bearing = std::atan2(fwd.y, -fwd.x); + cosB = std::cos(bearing); + sinB = std::sin(bearing); + } + + auto* drawList = ImGui::GetForegroundDrawList(); + + auto projectToMinimap = [&](const glm::vec3& worldRenderPos, float& sx, float& sy) -> bool { + float dx = worldRenderPos.x - playerRender.x; + float dy = worldRenderPos.y - playerRender.y; + + // Exact inverse of minimap display shader: + // shader: mapUV = playerUV + vec2(-rotated.x, rotated.y) * zoom * 2 + // where rotated = R(bearing) * center, center in [-0.5, 0.5] + // Inverse: center = R^-1(bearing) * (-deltaUV.x, deltaUV.y) / (zoom*2) + // With deltaUV.x ∝ +dx (render +X=west=larger U) and deltaUV.y ∝ -dy (V increases south): + float rx = -(dx * cosB + dy * sinB); + float ry = dx * sinB - dy * cosB; + + // Scale to minimap pixels + float px = rx / viewRadius * mapRadius; + float py = ry / viewRadius * mapRadius; + + float distFromCenter = std::sqrt(px * px + py * py); + if (distFromCenter > mapRadius - 3.0f) { + return false; + } + + sx = centerX + px; + sy = centerY + py; + return true; + }; + + // Build sets of entries that are incomplete objectives for tracked quests. + // minimapQuestEntries: NPC creature entries (npcOrGoId > 0) + // minimapQuestGoEntries: game object entries (npcOrGoId < 0, stored as abs value) + std::unordered_set minimapQuestEntries; + std::unordered_set minimapQuestGoEntries; + { + const auto& ql = gameHandler.getQuestLog(); + const auto& tq = gameHandler.getTrackedQuestIds(); + for (const auto& q : ql) { + if (q.complete || q.questId == 0) continue; + if (!tq.empty() && !tq.count(q.questId)) continue; + for (const auto& obj : q.killObjectives) { + if (obj.required == 0) continue; + if (obj.npcOrGoId > 0) { + auto it = q.killCounts.find(static_cast(obj.npcOrGoId)); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestEntries.insert(static_cast(obj.npcOrGoId)); + } else if (obj.npcOrGoId < 0) { + uint32_t goEntry = static_cast(-obj.npcOrGoId); + auto it = q.killCounts.find(goEntry); + if (it == q.killCounts.end() || it->second.first < it->second.second) + minimapQuestGoEntries.insert(goEntry); + } + } + } + } + + // Optional base nearby NPC dots (independent of quest status packets). + if (settingsPanel_.minimapNpcDots_) { + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + + auto unit = std::static_pointer_cast(entity); + if (!unit || unit->getHealth() == 0) continue; + + glm::vec3 npcRender = core::coords::canonicalToRender(glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(npcRender, sx, sy)) continue; + + bool isQuestTarget = minimapQuestEntries.count(unit->getEntry()) != 0; + if (isQuestTarget) { + // Quest kill objective: larger gold dot with dark outline + drawList->AddCircleFilled(ImVec2(sx, sy), 3.5f, IM_COL32(255, 210, 30, 240)); + drawList->AddCircle(ImVec2(sx, sy), 3.5f, IM_COL32(80, 50, 0, 180), 0, 1.0f); + // Tooltip on hover showing unit name + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + const std::string& nm = unit->getName(); + if (!nm.empty()) ImGui::SetTooltip("%s (quest)", nm.c_str()); + } + } else { + ImU32 baseDot = unit->isHostile() ? IM_COL32(220, 70, 70, 220) : IM_COL32(245, 245, 245, 210); + drawList->AddCircleFilled(ImVec2(sx, sy), 1.0f, baseDot); + } + } + } + + // Nearby other-player dots — shown when NPC dots are enabled. + // Party members are already drawn as squares above; other players get a small circle. + if (settingsPanel_.minimapNpcDots_) { + const uint64_t selfGuid = gameHandler.getPlayerGuid(); + const auto& partyData = gameHandler.getPartyData(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::PLAYER) continue; + if (entity->getGuid() == selfGuid) continue; // skip self (already drawn as arrow) + + // Skip party members (already drawn as squares above) + bool isPartyMember = false; + for (const auto& m : partyData.members) { + if (m.guid == guid) { isPartyMember = true; break; } + } + if (isPartyMember) continue; + + glm::vec3 pRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(pRender, sx, sy)) continue; + + // Blue dot for other nearby players + drawList->AddCircleFilled(ImVec2(sx, sy), 2.0f, IM_COL32(80, 160, 255, 220)); + } + } + + // Lootable corpse dots: small yellow-green diamonds on dead, lootable units. + // Shown whenever NPC dots are enabled (or always, since they're always useful). + { + constexpr uint32_t UNIT_DYNFLAG_LOOTABLE = 0x0001; + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (!unit) continue; + // Must be dead (health == 0) and marked lootable + if (unit->getHealth() != 0) continue; + if (!(unit->getDynamicFlags() & UNIT_DYNFLAG_LOOTABLE)) continue; + + glm::vec3 npcRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(npcRender, sx, sy)) continue; + + // Draw a small diamond (rotated square) in light yellow-green + const float dr = 3.5f; + ImVec2 top (sx, sy - dr); + ImVec2 right(sx + dr, sy ); + ImVec2 bot (sx, sy + dr); + ImVec2 left (sx - dr, sy ); + drawList->AddQuadFilled(top, right, bot, left, IM_COL32(180, 230, 80, 230)); + drawList->AddQuad (top, right, bot, left, IM_COL32(60, 80, 20, 200), 1.0f); + + // Tooltip on hover + if (ImGui::IsMouseHoveringRect(ImVec2(sx - dr, sy - dr), ImVec2(sx + dr, sy + dr))) { + const std::string& nm = unit->getName(); + ImGui::BeginTooltip(); + ImGui::TextColored(ImVec4(0.7f, 0.9f, 0.3f, 1.0f), "%s", + nm.empty() ? "Lootable corpse" : nm.c_str()); + ImGui::EndTooltip(); + } + } + } + + // Interactable game object dots (chests, resource nodes) when NPC dots are enabled. + // Shown as small orange triangles to distinguish from unit dots and loot corpses. + if (settingsPanel_.minimapNpcDots_) { + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::GAMEOBJECT) continue; + + // Only show objects that are likely interactive (chests/nodes: type 3; + // also show type 0=Door when open, but filter by dynamic-flag ACTIVATED). + // For simplicity, show all game objects that have a non-empty cached name. + auto go = std::static_pointer_cast(entity); + if (!go) continue; + + // Only show if we have name data (avoids cluttering with unknown objects) + const auto* goInfo = gameHandler.getCachedGameObjectInfo(go->getEntry()); + if (!goInfo || !goInfo->isValid()) continue; + // Skip transport objects (boats/zeppelins): type 15 = MO_TRANSPORT, 11 = TRANSPORT + if (goInfo->type == 11 || goInfo->type == 15) continue; + + glm::vec3 goRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(goRender, sx, sy)) continue; + + // Triangle size and color: bright cyan for quest objectives, amber for others + bool isQuestGO = minimapQuestGoEntries.count(go->getEntry()) != 0; + const float ts = isQuestGO ? 4.5f : 3.5f; + ImVec2 goTip (sx, sy - ts); + ImVec2 goLeft (sx - ts, sy + ts * 0.6f); + ImVec2 goRight(sx + ts, sy + ts * 0.6f); + if (isQuestGO) { + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(50, 230, 255, 240)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(0, 60, 80, 200), 1.5f); + } else { + drawList->AddTriangleFilled(goTip, goLeft, goRight, IM_COL32(255, 185, 30, 220)); + drawList->AddTriangle(goTip, goLeft, goRight, IM_COL32(100, 60, 0, 180), 1.0f); + } + + // Tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + if (isQuestGO) + ImGui::SetTooltip("%s (quest)", goInfo->name.c_str()); + else + ImGui::SetTooltip("%s", goInfo->name.c_str()); + } + } + } + + // Party member dots on minimap — small colored squares with name tooltip on hover + if (gameHandler.isInGroup()) { + const auto& partyData = gameHandler.getPartyData(); + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& member : partyData.members) { + if (!member.hasPartyStats) continue; + bool isOnline = (member.onlineStatus & 0x0001) != 0; + bool isDead = (member.onlineStatus & 0x0020) != 0; + bool isGhost = (member.onlineStatus & 0x0010) != 0; + if (!isOnline) continue; + if (member.posX == 0 && member.posY == 0) continue; + + // Party stat positions: posY = canonical X (north), posX = canonical Y (west) + glm::vec3 memberRender = core::coords::canonicalToRender( + glm::vec3(static_cast(member.posY), + static_cast(member.posX), 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(memberRender, sx, sy)) continue; + + // Determine dot color: class color > leader gold > light blue + ImU32 dotCol; + if (isDead || isGhost) { + dotCol = IM_COL32(140, 140, 140, 200); // gray for dead + } else { + auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(mEnt.get()); + if (cid != 0) { + ImVec4 cv = classColorVec4(cid); + dotCol = IM_COL32( + static_cast(cv.x * 255), + static_cast(cv.y * 255), + static_cast(cv.z * 255), 230); + } else if (member.guid == partyData.leaderGuid) { + dotCol = IM_COL32(255, 210, 0, 230); // gold for leader + } else { + dotCol = IM_COL32(100, 180, 255, 230); // blue for others + } + } + + // Draw a small square (WoW-style party member dot) + const float hs = 3.5f; + drawList->AddRectFilled(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), dotCol, 1.0f); + drawList->AddRect(ImVec2(sx - hs, sy - hs), ImVec2(sx + hs, sy + hs), + IM_COL32(0, 0, 0, 180), 1.0f, 0, 1.0f); + + // Name tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f && !member.name.empty()) { + ImGui::SetTooltip("%s", member.name.c_str()); + } + } + } + + for (const auto& [guid, status] : statuses) { + ImU32 dotColor; + const char* marker = nullptr; + if (status == game::QuestGiverStatus::AVAILABLE) { + dotColor = IM_COL32(255, 210, 0, 255); + marker = "!"; + } else if (status == game::QuestGiverStatus::AVAILABLE_LOW) { + dotColor = IM_COL32(160, 160, 160, 255); + marker = "!"; + } else if (status == game::QuestGiverStatus::REWARD || + status == game::QuestGiverStatus::REWARD_REP) { + dotColor = IM_COL32(255, 210, 0, 255); + marker = "?"; + } else if (status == game::QuestGiverStatus::INCOMPLETE) { + dotColor = IM_COL32(160, 160, 160, 255); + marker = "?"; + } else { + continue; + } + + auto entity = gameHandler.getEntityManager().getEntity(guid); + if (!entity) continue; + + glm::vec3 canonical(entity->getX(), entity->getY(), entity->getZ()); + glm::vec3 npcRender = core::coords::canonicalToRender(canonical); + + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(npcRender, sx, sy)) continue; + + // Draw dot with marker text + drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, dotColor); + ImFont* font = ImGui::GetFont(); + ImVec2 textSize = font->CalcTextSizeA(11.0f, FLT_MAX, 0.0f, marker); + drawList->AddText(font, 11.0f, + ImVec2(sx - textSize.x * 0.5f, sy - textSize.y * 0.5f), + IM_COL32(0, 0, 0, 255), marker); + + // Show NPC name and quest status on hover + { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + std::string npcName; + if (entity->getType() == game::ObjectType::UNIT) { + auto npcUnit = std::static_pointer_cast(entity); + npcName = npcUnit->getName(); + } + if (!npcName.empty()) { + bool hasQuest = (status == game::QuestGiverStatus::AVAILABLE || + status == game::QuestGiverStatus::AVAILABLE_LOW); + ImGui::SetTooltip("%s\n%s", npcName.c_str(), + hasQuest ? "Has a quest for you" : "Quest ready to turn in"); + } + } + } + } + + // Quest kill objective markers — highlight live NPCs matching active quest kill objectives + { + // Build map of NPC entry → (quest title, current, required) for tooltips + struct KillInfo { std::string questTitle; uint32_t current = 0; uint32_t required = 0; }; + std::unordered_map killInfoMap; + const auto& trackedIds = gameHandler.getTrackedQuestIds(); + for (const auto& quest : gameHandler.getQuestLog()) { + if (quest.complete) continue; + if (!trackedIds.empty() && !trackedIds.count(quest.questId)) continue; + for (const auto& obj : quest.killObjectives) { + if (obj.npcOrGoId <= 0 || obj.required == 0) continue; + uint32_t npcEntry = static_cast(obj.npcOrGoId); + auto it = quest.killCounts.find(npcEntry); + uint32_t current = (it != quest.killCounts.end()) ? it->second.first : 0; + if (current < obj.required) { + killInfoMap[npcEntry] = { quest.title, current, obj.required }; + } + } + } + + if (!killInfoMap.empty()) { + ImVec2 mouse = ImGui::GetMousePos(); + for (const auto& [guid, entity] : gameHandler.getEntityManager().getEntities()) { + if (!entity || entity->getType() != game::ObjectType::UNIT) continue; + auto unit = std::static_pointer_cast(entity); + if (!unit || unit->getHealth() == 0) continue; + auto infoIt = killInfoMap.find(unit->getEntry()); + if (infoIt == killInfoMap.end()) continue; + + glm::vec3 unitRender = core::coords::canonicalToRender( + glm::vec3(entity->getX(), entity->getY(), entity->getZ())); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(unitRender, sx, sy)) continue; + + // Gold circle with a dark "x" mark — indicates a quest kill target + drawList->AddCircleFilled(ImVec2(sx, sy), 5.0f, IM_COL32(255, 185, 0, 240)); + drawList->AddCircle(ImVec2(sx, sy), 5.5f, IM_COL32(0, 0, 0, 180), 12, 1.0f); + drawList->AddLine(ImVec2(sx - 2.5f, sy - 2.5f), ImVec2(sx + 2.5f, sy + 2.5f), + IM_COL32(20, 20, 20, 230), 1.2f); + drawList->AddLine(ImVec2(sx + 2.5f, sy - 2.5f), ImVec2(sx - 2.5f, sy + 2.5f), + IM_COL32(20, 20, 20, 230), 1.2f); + + // Tooltip on hover + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + const auto& ki = infoIt->second; + const std::string& npcName = unit->getName(); + if (!npcName.empty()) { + ImGui::SetTooltip("%s\n%s: %u/%u", + npcName.c_str(), + ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), + ki.current, ki.required); + } else { + ImGui::SetTooltip("%s: %u/%u", + ki.questTitle.empty() ? "Quest" : ki.questTitle.c_str(), + ki.current, ki.required); + } + } + } + } + } + + // Gossip POI markers (quest / NPC navigation targets) + for (const auto& poi : gameHandler.getGossipPois()) { + // Convert WoW canonical coords to render coords for minimap projection + glm::vec3 poiRender = core::coords::canonicalToRender(glm::vec3(poi.x, poi.y, 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(poiRender, sx, sy)) continue; + + // Draw as a cyan diamond with tooltip on hover + const float d = 5.0f; + ImVec2 pts[4] = { + { sx, sy - d }, + { sx + d, sy }, + { sx, sy + d }, + { sx - d, sy }, + }; + drawList->AddConvexPolyFilled(pts, 4, IM_COL32(0, 210, 255, 220)); + drawList->AddPolyline(pts, 4, IM_COL32(255, 255, 255, 160), true, 1.0f); + + // Show name label if cursor is within ~8px + ImVec2 cursorPos = ImGui::GetMousePos(); + float dx = cursorPos.x - sx, dy = cursorPos.y - sy; + if (!poi.name.empty() && (dx * dx + dy * dy) < 64.0f) { + ImGui::SetTooltip("%s", poi.name.c_str()); + } + } + + // Minimap pings from party members + for (const auto& ping : gameHandler.getMinimapPings()) { + glm::vec3 pingRender = core::coords::canonicalToRender(glm::vec3(ping.wowX, ping.wowY, 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(pingRender, sx, sy)) continue; + + float t = ping.age / game::GameHandler::MinimapPing::LIFETIME; + float alpha = 1.0f - t; + float pulse = 1.0f + 1.5f * t; // expands outward as it fades + + ImU32 col = IM_COL32(255, 220, 0, static_cast(alpha * 200)); + ImU32 col2 = IM_COL32(255, 150, 0, static_cast(alpha * 100)); + float r1 = 4.0f * pulse; + float r2 = 8.0f * pulse; + drawList->AddCircle(ImVec2(sx, sy), r1, col, 16, 2.0f); + drawList->AddCircle(ImVec2(sx, sy), r2, col2, 16, 1.0f); + 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; + { + auto mEnt = gameHandler.getEntityManager().getEntity(member.guid); + uint8_t cid = entityClassId(mEnt.get()); + dotColor = (cid != 0) + ? classColorU32(cid, 235) + : (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); + + // Raid mark: tiny symbol drawn above the dot + { + static constexpr struct { const char* sym; ImU32 col; } kMMMarks[] = { + { "\xe2\x98\x85", IM_COL32(255, 220, 50, 255) }, + { "\xe2\x97\x8f", IM_COL32(255, 140, 0, 255) }, + { "\xe2\x97\x86", IM_COL32(160, 32, 240, 255) }, + { "\xe2\x96\xb2", IM_COL32( 50, 200, 50, 255) }, + { "\xe2\x97\x8c", IM_COL32( 80, 160, 255, 255) }, + { "\xe2\x96\xa0", IM_COL32( 50, 200, 220, 255) }, + { "\xe2\x9c\x9d", IM_COL32(255, 80, 80, 255) }, + { "\xe2\x98\xa0", IM_COL32(255, 255, 255, 255) }, + }; + uint8_t pmk = gameHandler.getEntityRaidMark(member.guid); + if (pmk < game::GameHandler::kRaidMarkCount) { + ImFont* mmFont = ImGui::GetFont(); + ImVec2 msz = mmFont->CalcTextSizeA(9.0f, FLT_MAX, 0.0f, kMMMarks[pmk].sym); + drawList->AddText(mmFont, 9.0f, + ImVec2(sx - msz.x * 0.5f, sy - 4.0f - msz.y), + kMMMarks[pmk].col, kMMMarks[pmk].sym); + } + } + + ImVec2 cursorPos = ImGui::GetMousePos(); + float mdx = cursorPos.x - sx, mdy = cursorPos.y - sy; + if (!member.name.empty() && (mdx * mdx + mdy * mdy) < 64.0f) { + uint8_t pmk2 = gameHandler.getEntityRaidMark(member.guid); + if (pmk2 < game::GameHandler::kRaidMarkCount) { + static constexpr const char* kMarkNames[] = { + "Star", "Circle", "Diamond", "Triangle", + "Moon", "Square", "Cross", "Skull" + }; + ImGui::SetTooltip("%s {%s}", member.name.c_str(), kMarkNames[pmk2]); + } else { + ImGui::SetTooltip("%s", member.name.c_str()); + } + } + } + } + + // BG flag carrier / important player positions (MSG_BATTLEGROUND_PLAYER_POSITIONS) + { + const auto& bgPositions = gameHandler.getBgPlayerPositions(); + if (!bgPositions.empty()) { + ImVec2 mouse = ImGui::GetMousePos(); + // group 0 = typically ally-held flag / first list; group 1 = enemy + static const ImU32 kBgGroupColors[2] = { + IM_COL32( 80, 180, 255, 240), // group 0: blue (alliance) + IM_COL32(220, 50, 50, 240), // group 1: red (horde) + }; + for (const auto& bp : bgPositions) { + // Packet coords: wowX=canonical X (north), wowY=canonical Y (west) + glm::vec3 bpRender = core::coords::canonicalToRender(glm::vec3(bp.wowX, bp.wowY, 0.0f)); + float sx = 0.0f, sy = 0.0f; + if (!projectToMinimap(bpRender, sx, sy)) continue; + + ImU32 col = kBgGroupColors[bp.group & 1]; + + // Draw a flag-like diamond icon + const float r = 5.0f; + ImVec2 top (sx, sy - r); + ImVec2 right(sx + r, sy ); + ImVec2 bot (sx, sy + r); + ImVec2 left (sx - r, sy ); + drawList->AddQuadFilled(top, right, bot, left, col); + drawList->AddQuad(top, right, bot, left, IM_COL32(255, 255, 255, 180), 1.0f); + + float mdx = mouse.x - sx, mdy = mouse.y - sy; + if (mdx * mdx + mdy * mdy < 64.0f) { + // Show entity name if available, otherwise guid + auto ent = gameHandler.getEntityManager().getEntity(bp.guid); + if (ent) { + std::string nm; + if (ent->getType() == game::ObjectType::PLAYER) { + auto pl = std::static_pointer_cast(ent); + nm = pl ? pl->getName() : ""; + } + if (!nm.empty()) + ImGui::SetTooltip("Flag carrier: %s", nm.c_str()); + else + ImGui::SetTooltip("Flag carrier"); + } else { + ImGui::SetTooltip("Flag carrier"); + } + } + } + } + } + + // Corpse direction indicator — shown when player is a ghost + if (gameHandler.isPlayerGhost()) { + float corpseCanX = 0.0f, corpseCanY = 0.0f; + if (gameHandler.getCorpseCanonicalPos(corpseCanX, corpseCanY)) { + glm::vec3 corpseRender = core::coords::canonicalToRender(glm::vec3(corpseCanX, corpseCanY, 0.0f)); + float csx = 0.0f, csy = 0.0f; + bool onMap = projectToMinimap(corpseRender, csx, csy); + + if (onMap) { + // Draw a small skull-like X marker at the corpse position + const float r = 5.0f; + drawList->AddCircleFilled(ImVec2(csx, csy), r + 1.0f, IM_COL32(0, 0, 0, 140), 12); + drawList->AddCircle(ImVec2(csx, csy), r + 1.0f, IM_COL32(200, 200, 220, 220), 12, 1.5f); + // Draw an X in the circle + drawList->AddLine(ImVec2(csx - 3.0f, csy - 3.0f), ImVec2(csx + 3.0f, csy + 3.0f), + IM_COL32(180, 180, 220, 255), 1.5f); + drawList->AddLine(ImVec2(csx + 3.0f, csy - 3.0f), ImVec2(csx - 3.0f, csy + 3.0f), + IM_COL32(180, 180, 220, 255), 1.5f); + // Tooltip on hover + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - csx, mdy = mouse.y - csy; + if (mdx * mdx + mdy * mdy < 64.0f) { + float dist = gameHandler.getCorpseDistance(); + if (dist >= 0.0f) + ImGui::SetTooltip("Your corpse (%.0f yd)", dist); + else + ImGui::SetTooltip("Your corpse"); + } + } else { + // Corpse is outside minimap — draw an edge arrow pointing toward it + float dx = corpseRender.x - playerRender.x; + float dy = corpseRender.y - playerRender.y; + // Rotate delta into minimap frame (same as projectToMinimap) + float rx = -(dx * cosB + dy * sinB); + float ry = dx * sinB - dy * cosB; + float len = std::sqrt(rx * rx + ry * ry); + if (len > 0.001f) { + float nx = rx / len; + float ny = ry / len; + // Place arrow at the minimap edge + float edgeR = mapRadius - 7.0f; + float ax = centerX + nx * edgeR; + float ay = centerY + ny * edgeR; + // Arrow pointing outward (toward corpse) + float arrowLen = 6.0f; + float arrowW = 3.5f; + ImVec2 tip(ax + nx * arrowLen, ay + ny * arrowLen); + ImVec2 left(ax - ny * arrowW - nx * arrowLen * 0.4f, + ay + nx * arrowW - ny * arrowLen * 0.4f); + ImVec2 right(ax + ny * arrowW - nx * arrowLen * 0.4f, + ay - nx * arrowW - ny * arrowLen * 0.4f); + drawList->AddTriangleFilled(tip, left, right, IM_COL32(180, 180, 240, 230)); + drawList->AddTriangle(tip, left, right, IM_COL32(0, 0, 0, 180), 1.0f); + // Tooltip on hover + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - ax, mdy = mouse.y - ay; + if (mdx * mdx + mdy * mdy < 100.0f) { + float dist = gameHandler.getCorpseDistance(); + if (dist >= 0.0f) + ImGui::SetTooltip("Your corpse (%.0f yd)", dist); + else + ImGui::SetTooltip("Your corpse"); + } + } + } + } + } + + // Player position arrow at minimap center, pointing in camera facing direction. + // On a rotating minimap the map already turns so forward = screen-up; on a fixed + // minimap we rotate the arrow to match the player's compass heading. + { + // Compute screen-space facing direction for the arrow. + // bearing = clockwise angle from screen-north (0 = facing north/up). + float arrowAngle = 0.0f; // 0 = pointing up (north) + if (!minimap->isRotateWithCamera()) { + // Fixed minimap: arrow must show actual facing relative to north. + glm::vec3 fwd = camera->getForward(); + // +render_y = north = screen-up, +render_x = west = screen-left. + // bearing from north clockwise: atan2(-fwd.x_west, fwd.y_north) + // => sin=east component, cos=north component + // In render coords west=+x, east=-x, so sin(bearing)=east=-fwd.x + arrowAngle = std::atan2(-fwd.x, fwd.y); // clockwise from north in screen space + } + // Screen direction the arrow tip points toward + float nx = std::sin(arrowAngle); // screen +X = east + float ny = -std::cos(arrowAngle); // screen -Y = north + + // Draw a chevron-style arrow: tip, two base corners, and a notch at the back + const float tipLen = 8.0f; // tip forward distance + const float baseW = 5.0f; // half-width at base + const float notchIn = 3.0f; // how far back the center notch sits + // Perpendicular direction (rotated 90°) + float px = ny; // perpendicular x + float py = -nx; // perpendicular y + + ImVec2 tip (centerX + nx * tipLen, centerY + ny * tipLen); + ImVec2 baseL(centerX - nx * baseW + px * baseW, centerY - ny * baseW + py * baseW); + ImVec2 baseR(centerX - nx * baseW - px * baseW, centerY - ny * baseW - py * baseW); + ImVec2 notch(centerX - nx * (baseW - notchIn), centerY - ny * (baseW - notchIn)); + + // Fill: bright white with slight gold tint, dark outline for readability + drawList->AddTriangleFilled(tip, baseL, notch, IM_COL32(255, 248, 200, 245)); + drawList->AddTriangleFilled(tip, notch, baseR, IM_COL32(255, 248, 200, 245)); + drawList->AddTriangle(tip, baseL, notch, IM_COL32(60, 40, 0, 200), 1.2f); + drawList->AddTriangle(tip, notch, baseR, IM_COL32(60, 40, 0, 200), 1.2f); + } + + // Scroll wheel over minimap → zoom in/out + { + float wheel = ImGui::GetIO().MouseWheel; + if (wheel != 0.0f) { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + if (mdx * mdx + mdy * mdy <= mapRadius * mapRadius) { + if (wheel > 0.0f) + minimap->zoomIn(); + else + minimap->zoomOut(); + } + } + } + + // Ctrl+click on minimap → send minimap ping to party + if (ImGui::IsMouseClicked(0) && ImGui::GetIO().KeyCtrl) { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + float distSq = mdx * mdx + mdy * mdy; + if (distSq <= mapRadius * mapRadius) { + // Invert projectToMinimap: px=mdx, py=mdy → rx=px*viewRadius/mapRadius + float rx = mdx * viewRadius / mapRadius; + float ry = mdy * viewRadius / mapRadius; + // rx/ry are in rotated frame; unrotate to get world dx/dy + // rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB + // Solving: dx = -(rx*cosB - ry*sinB), dy = -(rx*sinB + ry*cosB) + float wdx = -(rx * cosB - ry * sinB); + float wdy = -(rx * sinB + ry * cosB); + // playerRender is in render coords; add delta to get render position then convert to canonical + glm::vec3 clickRender = playerRender + glm::vec3(wdx, wdy, 0.0f); + glm::vec3 clickCanon = core::coords::renderToCanonical(clickRender); + gameHandler.sendMinimapPing(clickCanon.x, clickCanon.y); + } + } + + // Persistent coordinate display below the minimap + { + glm::vec3 playerCanon = core::coords::renderToCanonical(playerRender); + char coordBuf[32]; + std::snprintf(coordBuf, sizeof(coordBuf), "%.1f, %.1f", playerCanon.x, playerCanon.y); + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 textSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, coordBuf); + + float tx = centerX - textSz.x * 0.5f; + float ty = centerY + mapRadius + 3.0f; + + // Semi-transparent dark background pill + float pad = 3.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + textSz.x + pad, ty + textSz.y + pad), + IM_COL32(0, 0, 0, 140), 4.0f); + // Coordinate text in warm yellow + drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(230, 220, 140, 255), coordBuf); + } + + // Local time clock — displayed just below the coordinate label + { + auto now = std::chrono::system_clock::now(); + std::time_t tt = std::chrono::system_clock::to_time_t(now); + std::tm tmLocal{}; +#if defined(_WIN32) + localtime_s(&tmLocal, &tt); +#else + localtime_r(&tt, &tmLocal); +#endif + char clockBuf[16]; + std::snprintf(clockBuf, sizeof(clockBuf), "%02d:%02d", + tmLocal.tm_hour, tmLocal.tm_min); + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize() * 0.9f; // slightly smaller than coords + ImVec2 clockSz = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, clockBuf); + + float tx = centerX - clockSz.x * 0.5f; + // Position below the coordinate line (+fontSize of coord + 2px gap) + float coordLineH = ImGui::GetFontSize(); + float ty = centerY + mapRadius + 3.0f + coordLineH + 2.0f; + + float pad = 2.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + clockSz.x + pad, ty + clockSz.y + pad), + IM_COL32(0, 0, 0, 120), 3.0f); + drawList->AddText(font, fontSize, ImVec2(tx, ty), IM_COL32(200, 200, 220, 220), clockBuf); + } + + // Zone name display — drawn inside the top edge of the minimap circle + { + auto* zmRenderer = renderer ? renderer->getZoneManager() : nullptr; + uint32_t zoneId = gameHandler.getWorldStateZoneId(); + const game::ZoneInfo* zi = (zmRenderer && zoneId != 0) ? zmRenderer->getZoneInfo(zoneId) : nullptr; + if (zi && !zi->name.empty()) { + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize(); + ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, zi->name.c_str()); + float tx = centerX - ts.x * 0.5f; + float ty = centerY - mapRadius + 4.0f; // just inside top edge of the circle + float pad = 2.0f; + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + ts.x + pad, ty + ts.y + pad), + IM_COL32(0, 0, 0, 160), 2.0f); + drawList->AddText(font, fontSize, ImVec2(tx + 1.0f, ty + 1.0f), + IM_COL32(0, 0, 0, 180), zi->name.c_str()); + drawList->AddText(font, fontSize, ImVec2(tx, ty), + IM_COL32(255, 230, 150, 220), zi->name.c_str()); + } + } + + // Instance difficulty indicator — just below zone name, inside minimap top edge + if (gameHandler.isInInstance()) { + static constexpr const char* kDiffLabels[] = {"Normal", "Heroic", "25 Normal", "25 Heroic"}; + uint32_t diff = gameHandler.getInstanceDifficulty(); + const char* label = (diff < 4) ? kDiffLabels[diff] : "Unknown"; + + ImFont* font = ImGui::GetFont(); + float fontSize = ImGui::GetFontSize() * 0.85f; + ImVec2 ts = font->CalcTextSizeA(fontSize, FLT_MAX, 0.0f, label); + float tx = centerX - ts.x * 0.5f; + // Position below zone name: top edge + zone font size + small gap + float ty = centerY - mapRadius + 4.0f + ImGui::GetFontSize() + 2.0f; + float pad = 2.0f; + + // Color-code: heroic=orange, normal=light gray + ImU32 bgCol = gameHandler.isInstanceHeroic() ? IM_COL32(120, 60, 0, 180) : IM_COL32(0, 0, 0, 160); + ImU32 textCol = gameHandler.isInstanceHeroic() ? IM_COL32(255, 180, 50, 255) : IM_COL32(200, 200, 200, 220); + + drawList->AddRectFilled( + ImVec2(tx - pad, ty - pad), + ImVec2(tx + ts.x + pad, ty + ts.y + pad), + bgCol, 2.0f); + drawList->AddText(font, fontSize, ImVec2(tx, ty), textCol, label); + } + + // Hover tooltip and right-click context menu + { + ImVec2 mouse = ImGui::GetMousePos(); + float mdx = mouse.x - centerX; + float mdy = mouse.y - centerY; + bool overMinimap = (mdx * mdx + mdy * mdy <= mapRadius * mapRadius); + + if (overMinimap) { + ImGui::BeginTooltip(); + // Compute the world coordinate under the mouse cursor + // Inverse of projectToMinimap: pixel offset → world offset in render space → canonical + float rxW = mdx / mapRadius * viewRadius; + float ryW = mdy / mapRadius * viewRadius; + // Un-rotate: [dx, dy] = R^-1 * [rxW, ryW] + // where R applied: rx = -(dx*cosB + dy*sinB), ry = dx*sinB - dy*cosB + float hoverDx = -cosB * rxW + sinB * ryW; + float hoverDy = -sinB * rxW - cosB * ryW; + glm::vec3 hoverRender(playerRender.x + hoverDx, playerRender.y + hoverDy, playerRender.z); + glm::vec3 hoverCanon = core::coords::renderToCanonical(hoverRender); + ImGui::TextColored(ImVec4(0.9f, 0.85f, 0.5f, 1.0f), "%.1f, %.1f", hoverCanon.x, hoverCanon.y); + ImGui::TextColored(colors::kMediumGray, "Ctrl+click to ping"); + ImGui::EndTooltip(); + + if (ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + ImGui::OpenPopup("##minimapContextMenu"); + } + } + + if (ImGui::BeginPopup("##minimapContextMenu")) { + ImGui::TextColored(ui::colors::kTooltipGold, "Minimap"); + ImGui::Separator(); + + // Zoom controls + if (ImGui::MenuItem("Zoom In")) { + minimap->zoomIn(); + } + if (ImGui::MenuItem("Zoom Out")) { + minimap->zoomOut(); + } + + ImGui::Separator(); + + // Toggle options with checkmarks + bool rotWithCam = minimap->isRotateWithCamera(); + if (ImGui::MenuItem("Rotate with Camera", nullptr, rotWithCam)) { + minimap->setRotateWithCamera(!rotWithCam); + } + + bool squareShape = minimap->isSquareShape(); + if (ImGui::MenuItem("Square Shape", nullptr, squareShape)) { + minimap->setSquareShape(!squareShape); + } + + bool npcDots = settingsPanel_.minimapNpcDots_; + if (ImGui::MenuItem("Show NPC Dots", nullptr, npcDots)) { + settingsPanel_.minimapNpcDots_ = !settingsPanel_.minimapNpcDots_; + } + + ImGui::EndPopup(); + } + } + + auto applyMuteState = [&]() { + auto* ac = services_.audioCoordinator; + float masterScale = settingsPanel_.soundMuted_ ? 0.0f : static_cast(settingsPanel_.pendingMasterVolume) / 100.0f; + audio::AudioEngine::instance().setMasterVolume(masterScale); + if (!ac) return; + if (auto* music = ac->getMusicManager()) { + music->setVolume(settingsPanel_.pendingMusicVolume); + } + if (auto* ambient = ac->getAmbientSoundManager()) { + ambient->setVolumeScale(settingsPanel_.pendingAmbientVolume / 100.0f); + } + if (auto* ui = ac->getUiSoundManager()) { + ui->setVolumeScale(settingsPanel_.pendingUiVolume / 100.0f); + } + if (auto* combat = ac->getCombatSoundManager()) { + combat->setVolumeScale(settingsPanel_.pendingCombatVolume / 100.0f); + } + if (auto* spell = ac->getSpellSoundManager()) { + spell->setVolumeScale(settingsPanel_.pendingSpellVolume / 100.0f); + } + if (auto* movement = ac->getMovementSoundManager()) { + movement->setVolumeScale(settingsPanel_.pendingMovementVolume / 100.0f); + } + if (auto* footstep = ac->getFootstepManager()) { + footstep->setVolumeScale(settingsPanel_.pendingFootstepVolume / 100.0f); + } + if (auto* npcVoice = ac->getNpcVoiceManager()) { + npcVoice->setVolumeScale(settingsPanel_.pendingNpcVoiceVolume / 100.0f); + } + if (auto* mount = ac->getMountSoundManager()) { + mount->setVolumeScale(settingsPanel_.pendingMountVolume / 100.0f); + } + if (auto* activity = ac->getActivitySoundManager()) { + activity->setVolumeScale(settingsPanel_.pendingActivityVolume / 100.0f); + } + }; + + // Zone name label above the minimap (centered, WoW-style) + // Prefer the server-reported zone/area name (from SMSG_INIT_WORLD_STATES) so sub-zones + // like Ironforge or Wailing Caverns display correctly; fall back to renderer zone name. + { + std::string wsZoneName; + uint32_t wsZoneId = gameHandler.getWorldStateZoneId(); + if (wsZoneId != 0) + wsZoneName = gameHandler.getWhoAreaName(wsZoneId); + const std::string& rendererZoneName = renderer ? renderer->getCurrentZoneName() : std::string{}; + const std::string& zoneName = !wsZoneName.empty() ? wsZoneName : rendererZoneName; + if (!zoneName.empty()) { + auto* fgDl = ImGui::GetForegroundDrawList(); + float zoneTextY = centerY - mapRadius - 16.0f; + ImFont* font = ImGui::GetFont(); + + // Weather icon appended to zone name when active + uint32_t wType = gameHandler.getWeatherType(); + float wIntensity = gameHandler.getWeatherIntensity(); + const char* weatherIcon = nullptr; + ImU32 weatherColor = IM_COL32(255, 255, 255, 200); + if (wType == 1 && wIntensity > 0.05f) { // Rain + weatherIcon = " \xe2\x9b\x86"; // U+26C6 ⛆ + weatherColor = IM_COL32(140, 180, 240, 220); + } else if (wType == 2 && wIntensity > 0.05f) { // Snow + weatherIcon = " \xe2\x9d\x84"; // U+2744 ❄ + weatherColor = IM_COL32(210, 230, 255, 220); + } else if (wType == 3 && wIntensity > 0.05f) { // Storm/Fog + weatherIcon = " \xe2\x98\x81"; // U+2601 ☁ + weatherColor = IM_COL32(160, 160, 190, 220); + } + + std::string displayName = zoneName; + // Build combined string if weather active + std::string fullLabel = weatherIcon ? (zoneName + weatherIcon) : zoneName; + ImVec2 tsz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, fullLabel.c_str()); + float tzx = centerX - tsz.x * 0.5f; + + // Shadow pass + fgDl->AddText(font, 12.0f, ImVec2(tzx + 1.0f, zoneTextY + 1.0f), + IM_COL32(0, 0, 0, 180), zoneName.c_str()); + // Zone name in gold + fgDl->AddText(font, 12.0f, ImVec2(tzx, zoneTextY), + IM_COL32(255, 220, 120, 230), zoneName.c_str()); + // Weather symbol in its own color appended after + if (weatherIcon) { + ImVec2 nameSz = font->CalcTextSizeA(12.0f, FLT_MAX, 0.0f, zoneName.c_str()); + fgDl->AddText(font, 12.0f, ImVec2(tzx + nameSz.x, zoneTextY), weatherColor, weatherIcon); + } + } + } + + // 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); + ImGuiWindowFlags muteFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##MinimapMute", nullptr, muteFlags)) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImVec2 p = ImGui::GetCursorScreenPos(); + ImVec2 size(20.0f, 20.0f); + if (ImGui::InvisibleButton("##MinimapMuteButton", size)) { + settingsPanel_.soundMuted_ = !settingsPanel_.soundMuted_; + if (settingsPanel_.soundMuted_) { + settingsPanel_.preMuteVolume_ = audio::AudioEngine::instance().getMasterVolume(); + } + applyMuteState(); + saveSettings(); + } + bool hovered = ImGui::IsItemHovered(); + ImU32 bg = settingsPanel_.soundMuted_ ? IM_COL32(135, 42, 42, 230) : IM_COL32(38, 38, 38, 210); + if (hovered) bg = settingsPanel_.soundMuted_ ? IM_COL32(160, 58, 58, 230) : IM_COL32(65, 65, 65, 220); + ImU32 fg = IM_COL32(255, 255, 255, 245); + draw->AddRectFilled(p, ImVec2(p.x + size.x, p.y + size.y), bg, 4.0f); + draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), ImVec2(p.x + size.x - 0.5f, p.y + size.y - 0.5f), + IM_COL32(255, 255, 255, 42), 4.0f); + draw->AddRectFilled(ImVec2(p.x + 4.0f, p.y + 8.0f), ImVec2(p.x + 7.0f, p.y + 12.0f), fg, 1.0f); + draw->AddTriangleFilled(ImVec2(p.x + 7.0f, p.y + 7.0f), + ImVec2(p.x + 7.0f, p.y + 13.0f), + ImVec2(p.x + 11.8f, p.y + 10.0f), fg); + if (settingsPanel_.soundMuted_) { + draw->AddLine(ImVec2(p.x + 13.5f, p.y + 6.2f), ImVec2(p.x + 17.2f, p.y + 13.8f), fg, 1.8f); + draw->AddLine(ImVec2(p.x + 17.2f, p.y + 6.2f), ImVec2(p.x + 13.5f, p.y + 13.8f), fg, 1.8f); + } else { + draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 3.6f, -0.7f, 0.7f, 12); + draw->PathStroke(fg, 0, 1.4f); + draw->PathArcTo(ImVec2(p.x + 11.8f, p.y + 10.0f), 5.5f, -0.7f, 0.7f, 12); + draw->PathStroke(fg, 0, 1.2f); + } + if (hovered) ImGui::SetTooltip(settingsPanel_.soundMuted_ ? "Unmute" : "Mute"); + } + ImGui::End(); + + // Friends button at top-left of minimap + { + const auto& contacts = gameHandler.getContacts(); + int onlineCount = 0; + for (const auto& c : contacts) + if (c.isFriend() && c.isOnline()) ++onlineCount; + + ImGui::SetNextWindowPos(ImVec2(centerX - mapRadius + 4.0f, centerY - mapRadius + 4.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(22.0f, 22.0f), ImGuiCond_Always); + ImGuiWindowFlags friendsBtnFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##MinimapFriendsBtn", nullptr, friendsBtnFlags)) { + ImDrawList* draw = ImGui::GetWindowDrawList(); + ImVec2 p = ImGui::GetCursorScreenPos(); + ImVec2 sz(20.0f, 20.0f); + if (ImGui::InvisibleButton("##FriendsBtnInv", sz)) { + socialPanel_.showSocialFrame_ = !socialPanel_.showSocialFrame_; + } + bool hovered = ImGui::IsItemHovered(); + ImU32 bg = socialPanel_.showSocialFrame_ + ? IM_COL32(42, 100, 42, 230) + : IM_COL32(38, 38, 38, 210); + if (hovered) bg = socialPanel_.showSocialFrame_ ? IM_COL32(58, 130, 58, 230) : IM_COL32(65, 65, 65, 220); + draw->AddRectFilled(p, ImVec2(p.x + sz.x, p.y + sz.y), bg, 4.0f); + draw->AddRect(ImVec2(p.x + 0.5f, p.y + 0.5f), + ImVec2(p.x + sz.x - 0.5f, p.y + sz.y - 0.5f), + IM_COL32(255, 255, 255, 42), 4.0f); + // Simple smiley-face dots as "social" icon + ImU32 fg = IM_COL32(255, 255, 255, 245); + draw->AddCircle(ImVec2(p.x + 10.0f, p.y + 10.0f), 6.5f, fg, 16, 1.2f); + draw->AddCircleFilled(ImVec2(p.x + 7.5f, p.y + 8.0f), 1.2f, fg); + draw->AddCircleFilled(ImVec2(p.x + 12.5f, p.y + 8.0f), 1.2f, fg); + draw->PathArcTo(ImVec2(p.x + 10.0f, p.y + 11.5f), 3.0f, 0.2f, 2.9f, 8); + draw->PathStroke(fg, 0, 1.2f); + // Small green dot if friends online + if (onlineCount > 0) { + draw->AddCircleFilled(ImVec2(p.x + sz.x - 3.5f, p.y + 3.5f), + 3.5f, IM_COL32(50, 220, 50, 255)); + } + if (hovered) { + if (onlineCount > 0) + ImGui::SetTooltip("Friends (%d online)", onlineCount); + else + ImGui::SetTooltip("Friends"); + } + } + ImGui::End(); + } + + // Zoom buttons at the bottom edge of the minimap + ImGui::SetNextWindowPos(ImVec2(centerX - 22, centerY + mapRadius - 30), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(44, 24), ImGuiCond_Always); + ImGuiWindowFlags zoomFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground; + if (ImGui::Begin("##MinimapZoom", nullptr, zoomFlags)) { + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(2, 2)); + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(2, 0)); + if (ImGui::SmallButton("-")) { + if (minimap) minimap->zoomOut(); + } + ImGui::SameLine(); + if (ImGui::SmallButton("+")) { + if (minimap) minimap->zoomIn(); + } + ImGui::PopStyleVar(2); + } + ImGui::End(); + + // Clock display at bottom-right of minimap (local time) + { + auto now = std::chrono::system_clock::now(); + auto tt = std::chrono::system_clock::to_time_t(now); + std::tm tmBuf{}; +#ifdef _WIN32 + localtime_s(&tmBuf, &tt); +#else + localtime_r(&tt, &tmBuf); +#endif + char clockText[16]; + std::snprintf(clockText, sizeof(clockText), "%d:%02d %s", + (tmBuf.tm_hour % 12 == 0) ? 12 : tmBuf.tm_hour % 12, + tmBuf.tm_min, + tmBuf.tm_hour >= 12 ? "PM" : "AM"); + ImVec2 clockSz = ImGui::CalcTextSize(clockText); + float clockW = clockSz.x + 10.0f; + float clockH = clockSz.y + 6.0f; + ImGui::SetNextWindowPos(ImVec2(centerX + mapRadius - clockW - 2.0f, + centerY + mapRadius - clockH - 2.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(clockW, clockH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.45f); + ImGuiWindowFlags clockFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoInputs; + if (ImGui::Begin("##MinimapClock", nullptr, clockFlags)) { + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.8f, 0.85f), "%s", clockText); + } + ImGui::End(); + } + + // Indicators below the minimap (stacked: new mail, then BG queue, then latency) + float indicatorX = centerX - mapRadius; + float nextIndicatorY = centerY + mapRadius + 4.0f; + const float indicatorW = mapRadius * 2.0f; + constexpr float kIndicatorH = 22.0f; + ImGuiWindowFlags indicatorFlags = ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoInputs; + + // "New Mail" indicator + if (gameHandler.hasNewMail()) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##NewMailIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, pulse), "New Mail!"); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + + // Unspent talent points indicator + { + uint8_t unspent = gameHandler.getUnspentTalentPoints(); + if (unspent > 0) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##TalentIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.5f); + char talentBuf[40]; + snprintf(talentBuf, sizeof(talentBuf), "! %u Talent Point%s Available", + static_cast(unspent), unspent == 1 ? "" : "s"); + ImGui::TextColored(ImVec4(0.3f, 1.0f, 0.3f * pulse, pulse), "%s", talentBuf); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + + // BG queue status indicator (when in queue but not yet invited) + for (const auto& slot : gameHandler.getBgQueues()) { + if (slot.statusId != 1) continue; // STATUS_WAIT_QUEUE only + + std::string bgName; + if (slot.arenaType > 0) { + bgName = std::to_string(slot.arenaType) + "v" + std::to_string(slot.arenaType) + " Arena"; + } else { + switch (slot.bgTypeId) { + case 1: bgName = "AV"; break; + case 2: bgName = "WSG"; break; + case 3: bgName = "AB"; break; + case 7: bgName = "EotS"; break; + case 9: bgName = "SotA"; break; + case 11: bgName = "IoC"; break; + default: bgName = "BG"; break; + } + } + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##BgQueueIndicator", nullptr, indicatorFlags)) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.5f); + if (slot.avgWaitTimeSec > 0) { + int avgMin = static_cast(slot.avgWaitTimeSec) / 60; + int avgSec = static_cast(slot.avgWaitTimeSec) % 60; + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "Queue: %s (~%d:%02d)", bgName.c_str(), avgMin, avgSec); + } else { + ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, pulse), + "In Queue: %s", bgName.c_str()); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + break; // Show at most one queue slot indicator + } + + // LFG queue indicator — shown when Dungeon Finder queue is active (Queued or RoleCheck) + { + using LfgState = game::GameHandler::LfgState; + LfgState lfgSt = gameHandler.getLfgState(); + if (lfgSt == LfgState::Queued || lfgSt == LfgState::RoleCheck) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##LfgQueueIndicator", nullptr, indicatorFlags)) { + if (lfgSt == LfgState::RoleCheck) { + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 3.0f); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, pulse), "LFG: Role Check..."); + } else { + uint32_t qMs = gameHandler.getLfgTimeInQueueMs(); + int qMin = static_cast(qMs / 60000); + int qSec = static_cast((qMs % 60000) / 1000); + float pulse = 0.6f + 0.4f * std::sin(static_cast(ImGui::GetTime()) * 1.2f); + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, pulse), + "LFG: %d:%02d", qMin, qSec); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + + // Calendar pending invites indicator (WotLK only) + { + auto* expReg = services_.expansionRegistry; + bool isWotLK = expReg && expReg->getActive() && expReg->getActive()->id == "wotlk"; + if (isWotLK) { + uint32_t calPending = gameHandler.getCalendarPendingInvites(); + if (calPending > 0) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##CalendarIndicator", nullptr, indicatorFlags)) { + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 2.0f); + char calBuf[48]; + snprintf(calBuf, sizeof(calBuf), "Calendar: %u Invite%s", + calPending, calPending == 1 ? "" : "s"); + ImGui::TextColored(ImVec4(0.6f, 0.5f, 1.0f, pulse), "%s", calBuf); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + } + + // Taxi flight indicator — shown while on a flight path + if (gameHandler.isOnTaxiFlight()) { + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##TaxiIndicator", nullptr, indicatorFlags)) { + const std::string& dest = gameHandler.getTaxiDestName(); + float pulse = 0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 1.0f); + if (dest.empty()) { + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "\xe2\x9c\x88 In Flight"); + } else { + char buf[64]; + snprintf(buf, sizeof(buf), "\xe2\x9c\x88 \xe2\x86\x92 %s", dest.c_str()); + ImGui::TextColored(ImVec4(0.6f, 0.85f, 1.0f, pulse), "%s", buf); + } + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + + // Latency + FPS indicator — centered at top of screen + uint32_t latMs = gameHandler.getLatencyMs(); + if (settingsPanel_.showLatencyMeter_ && gameHandler.getState() == game::WorldState::IN_WORLD) { + float currentFps = ImGui::GetIO().Framerate; + ImVec4 latColor; + if (latMs < 100) latColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); + else if (latMs < 250) latColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); + else if (latMs < 500) latColor = ImVec4(1.0f, 0.6f, 0.1f, 0.9f); + else latColor = ImVec4(1.0f, 0.2f, 0.2f, 0.9f); + + ImVec4 fpsColor; + if (currentFps >= 60.0f) fpsColor = ImVec4(0.3f, 1.0f, 0.3f, 0.9f); + else if (currentFps >= 30.0f) fpsColor = ImVec4(1.0f, 1.0f, 0.3f, 0.9f); + else fpsColor = ImVec4(1.0f, 0.3f, 0.3f, 0.9f); + + char infoText[64]; + if (latMs > 0) + snprintf(infoText, sizeof(infoText), "%.0f fps | %u ms", currentFps, latMs); + else + snprintf(infoText, sizeof(infoText), "%.0f fps", currentFps); + + ImVec2 textSize = ImGui::CalcTextSize(infoText); + float latW = textSize.x + 16.0f; + float latH = textSize.y + 8.0f; + ImGuiIO& lio = ImGui::GetIO(); + float latX = (lio.DisplaySize.x - latW) * 0.5f; + ImGui::SetNextWindowPos(ImVec2(latX, 4.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(latW, latH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.45f); + if (ImGui::Begin("##LatencyIndicator", nullptr, indicatorFlags)) { + // Color the FPS and latency portions differently + ImGui::TextColored(fpsColor, "%.0f fps", currentFps); + if (latMs > 0) { + ImGui::SameLine(0, 4); + ImGui::TextColored(ImVec4(0.5f, 0.5f, 0.5f, 0.7f), "|"); + ImGui::SameLine(0, 4); + ImGui::TextColored(latColor, "%u ms", latMs); + } + } + ImGui::End(); + } + + // Low durability warning — shown when any equipped item has < 20% durability + if (gameHandler.getState() == game::WorldState::IN_WORLD) { + const auto& inv = gameHandler.getInventory(); + float lowestDurPct = 1.0f; + for (int i = 0; i < game::Inventory::NUM_EQUIP_SLOTS; ++i) { + const auto& slot = inv.getEquipSlot(static_cast(i)); + if (slot.empty()) continue; + const auto& it = slot.item; + if (it.maxDurability > 0) { + float pct = static_cast(it.curDurability) / static_cast(it.maxDurability); + if (pct < lowestDurPct) lowestDurPct = pct; + } + } + if (lowestDurPct < 0.20f) { + bool critical = (lowestDurPct < 0.05f); + float pulse = critical + ? (0.7f + 0.3f * std::sin(static_cast(ImGui::GetTime()) * 4.0f)) + : 1.0f; + ImVec4 durWarnColor = critical + ? ImVec4(1.0f, 0.2f, 0.2f, pulse) + : ImVec4(1.0f, 0.65f, 0.1f, 0.9f); + const char* durWarnText = critical ? "Item breaking!" : "Low durability"; + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + if (ImGui::Begin("##DurabilityIndicator", nullptr, indicatorFlags)) { + ImGui::TextColored(durWarnColor, "%s", durWarnText); + } + ImGui::End(); + nextIndicatorY += kIndicatorH; + } + } + + // Local time clock — always visible below minimap indicators + { + auto now = std::chrono::system_clock::now(); + std::time_t tt = std::chrono::system_clock::to_time_t(now); + struct tm tmBuf; +#ifdef _WIN32 + localtime_s(&tmBuf, &tt); +#else + localtime_r(&tt, &tmBuf); +#endif + char clockStr[16]; + snprintf(clockStr, sizeof(clockStr), "%02d:%02d", tmBuf.tm_hour, tmBuf.tm_min); + + ImGui::SetNextWindowPos(ImVec2(indicatorX, nextIndicatorY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(indicatorW, kIndicatorH), ImGuiCond_Always); + ImGuiWindowFlags clockFlags = indicatorFlags & ~ImGuiWindowFlags_NoInputs; + if (ImGui::Begin("##ClockIndicator", nullptr, clockFlags)) { + ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.85f, 0.75f), "%s", clockStr); + if (ImGui::IsItemHovered()) { + char fullTime[32]; + snprintf(fullTime, sizeof(fullTime), "%02d:%02d:%02d (local)", + tmBuf.tm_hour, tmBuf.tm_min, tmBuf.tm_sec); + ImGui::SetTooltip("%s", fullTime); + } + } + ImGui::End(); + } +} + +void GameScreen::saveSettings() { + std::string path = SettingsPanel::getSettingsPath(); + std::filesystem::path dir = std::filesystem::path(path).parent_path(); + std::error_code ec; + std::filesystem::create_directories(dir, ec); + + std::ofstream out(path); + if (!out.is_open()) { + LOG_WARNING("Could not save settings to ", path); + return; + } + + // Interface + out << "ui_opacity=" << settingsPanel_.pendingUiOpacity << "\n"; + out << "minimap_rotate=" << (settingsPanel_.pendingMinimapRotate ? 1 : 0) << "\n"; + out << "minimap_square=" << (settingsPanel_.pendingMinimapSquare ? 1 : 0) << "\n"; + out << "minimap_npc_dots=" << (settingsPanel_.pendingMinimapNpcDots ? 1 : 0) << "\n"; + out << "show_latency_meter=" << (settingsPanel_.pendingShowLatencyMeter ? 1 : 0) << "\n"; + out << "show_dps_meter=" << (settingsPanel_.showDPSMeter_ ? 1 : 0) << "\n"; + out << "show_cooldown_tracker=" << (settingsPanel_.showCooldownTracker_ ? 1 : 0) << "\n"; + out << "separate_bags=" << (settingsPanel_.pendingSeparateBags ? 1 : 0) << "\n"; + out << "show_keyring=" << (settingsPanel_.pendingShowKeyring ? 1 : 0) << "\n"; + out << "action_bar_scale=" << settingsPanel_.pendingActionBarScale << "\n"; + out << "nameplate_scale=" << settingsPanel_.nameplateScale_ << "\n"; + out << "show_friendly_nameplates=" << (settingsPanel_.showFriendlyNameplates_ ? 1 : 0) << "\n"; + out << "show_action_bar2=" << (settingsPanel_.pendingShowActionBar2 ? 1 : 0) << "\n"; + out << "action_bar2_offset_x=" << settingsPanel_.pendingActionBar2OffsetX << "\n"; + out << "action_bar2_offset_y=" << settingsPanel_.pendingActionBar2OffsetY << "\n"; + out << "show_right_bar=" << (settingsPanel_.pendingShowRightBar ? 1 : 0) << "\n"; + out << "show_left_bar=" << (settingsPanel_.pendingShowLeftBar ? 1 : 0) << "\n"; + out << "right_bar_offset_y=" << settingsPanel_.pendingRightBarOffsetY << "\n"; + out << "left_bar_offset_y=" << settingsPanel_.pendingLeftBarOffsetY << "\n"; + out << "damage_flash=" << (settingsPanel_.damageFlashEnabled_ ? 1 : 0) << "\n"; + out << "low_health_vignette=" << (settingsPanel_.lowHealthVignetteEnabled_ ? 1 : 0) << "\n"; + + // Audio + out << "sound_muted=" << (settingsPanel_.soundMuted_ ? 1 : 0) << "\n"; + out << "use_original_soundtrack=" << (settingsPanel_.pendingUseOriginalSoundtrack ? 1 : 0) << "\n"; + out << "master_volume=" << settingsPanel_.pendingMasterVolume << "\n"; + out << "music_volume=" << settingsPanel_.pendingMusicVolume << "\n"; + out << "ambient_volume=" << settingsPanel_.pendingAmbientVolume << "\n"; + out << "ui_volume=" << settingsPanel_.pendingUiVolume << "\n"; + out << "combat_volume=" << settingsPanel_.pendingCombatVolume << "\n"; + out << "spell_volume=" << settingsPanel_.pendingSpellVolume << "\n"; + out << "movement_volume=" << settingsPanel_.pendingMovementVolume << "\n"; + out << "footstep_volume=" << settingsPanel_.pendingFootstepVolume << "\n"; + out << "npc_voice_volume=" << settingsPanel_.pendingNpcVoiceVolume << "\n"; + out << "mount_volume=" << settingsPanel_.pendingMountVolume << "\n"; + out << "activity_volume=" << settingsPanel_.pendingActivityVolume << "\n"; + + // Gameplay + out << "auto_loot=" << (settingsPanel_.pendingAutoLoot ? 1 : 0) << "\n"; + out << "auto_sell_grey=" << (settingsPanel_.pendingAutoSellGrey ? 1 : 0) << "\n"; + out << "auto_repair=" << (settingsPanel_.pendingAutoRepair ? 1 : 0) << "\n"; + out << "graphics_preset=" << static_cast(settingsPanel_.currentGraphicsPreset) << "\n"; + out << "ground_clutter_density=" << settingsPanel_.pendingGroundClutterDensity << "\n"; + out << "shadows=" << (settingsPanel_.pendingShadows ? 1 : 0) << "\n"; + out << "shadow_distance=" << settingsPanel_.pendingShadowDistance << "\n"; + out << "brightness=" << settingsPanel_.pendingBrightness << "\n"; + out << "water_refraction=" << (settingsPanel_.pendingWaterRefraction ? 1 : 0) << "\n"; + out << "antialiasing=" << settingsPanel_.pendingAntiAliasing << "\n"; + out << "fxaa=" << (settingsPanel_.pendingFXAA ? 1 : 0) << "\n"; + out << "normal_mapping=" << (settingsPanel_.pendingNormalMapping ? 1 : 0) << "\n"; + out << "normal_map_strength=" << settingsPanel_.pendingNormalMapStrength << "\n"; + out << "pom=" << (settingsPanel_.pendingPOM ? 1 : 0) << "\n"; + out << "pom_quality=" << settingsPanel_.pendingPOMQuality << "\n"; + out << "upscaling_mode=" << settingsPanel_.pendingUpscalingMode << "\n"; + out << "fsr=" << (settingsPanel_.pendingFSR ? 1 : 0) << "\n"; + out << "fsr_quality=" << settingsPanel_.pendingFSRQuality << "\n"; + out << "fsr_sharpness=" << settingsPanel_.pendingFSRSharpness << "\n"; + out << "fsr2_jitter_sign=" << settingsPanel_.pendingFSR2JitterSign << "\n"; + out << "fsr2_mv_scale_x=" << settingsPanel_.pendingFSR2MotionVecScaleX << "\n"; + out << "fsr2_mv_scale_y=" << settingsPanel_.pendingFSR2MotionVecScaleY << "\n"; + out << "amd_fsr3_framegen=" << (settingsPanel_.pendingAMDFramegen ? 1 : 0) << "\n"; + + // Controls + out << "mouse_sensitivity=" << settingsPanel_.pendingMouseSensitivity << "\n"; + out << "invert_mouse=" << (settingsPanel_.pendingInvertMouse ? 1 : 0) << "\n"; + out << "extended_zoom=" << (settingsPanel_.pendingExtendedZoom ? 1 : 0) << "\n"; + out << "camera_stiffness=" << settingsPanel_.pendingCameraStiffness << "\n"; + out << "camera_pivot_height=" << settingsPanel_.pendingPivotHeight << "\n"; + out << "fov=" << settingsPanel_.pendingFov << "\n"; + + // Quest tracker position/size + out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n"; + out << "quest_tracker_y=" << questTrackerPos_.y << "\n"; + out << "quest_tracker_w=" << questTrackerSize_.x << "\n"; + out << "quest_tracker_h=" << questTrackerSize_.y << "\n"; + + // Chat + out << "chat_active_tab=" << chatPanel_.activeChatTab << "\n"; + out << "chat_timestamps=" << (chatPanel_.chatShowTimestamps ? 1 : 0) << "\n"; + out << "chat_font_size=" << chatPanel_.chatFontSize << "\n"; + out << "chat_autojoin_general=" << (chatPanel_.chatAutoJoinGeneral ? 1 : 0) << "\n"; + out << "chat_autojoin_trade=" << (chatPanel_.chatAutoJoinTrade ? 1 : 0) << "\n"; + out << "chat_autojoin_localdefense=" << (chatPanel_.chatAutoJoinLocalDefense ? 1 : 0) << "\n"; + out << "chat_autojoin_lfg=" << (chatPanel_.chatAutoJoinLFG ? 1 : 0) << "\n"; + out << "chat_autojoin_local=" << (chatPanel_.chatAutoJoinLocal ? 1 : 0) << "\n"; + + out.close(); + + // Save keybindings to the same config file (appends [Keybindings] section) + KeybindingManager::getInstance().saveToConfigFile(path); + + LOG_INFO("Settings saved to ", path); +} + +void GameScreen::loadSettings() { + std::string path = SettingsPanel::getSettingsPath(); + std::ifstream in(path); + if (!in.is_open()) return; + + std::string line; + while (std::getline(in, line)) { + size_t eq = line.find('='); + if (eq == std::string::npos) continue; + std::string key = line.substr(0, eq); + std::string val = line.substr(eq + 1); + + try { + // Interface + if (key == "ui_opacity") { + int v = std::stoi(val); + if (v >= 20 && v <= 100) { + settingsPanel_.pendingUiOpacity = v; + settingsPanel_.uiOpacity_ = static_cast(v) / 100.0f; + } + } else if (key == "minimap_rotate") { + // Ignore persisted rotate state; keep north-up. + settingsPanel_.minimapRotate_ = false; + settingsPanel_.pendingMinimapRotate = false; + } else if (key == "minimap_square") { + int v = std::stoi(val); + settingsPanel_.minimapSquare_ = (v != 0); + settingsPanel_.pendingMinimapSquare = settingsPanel_.minimapSquare_; + } else if (key == "minimap_npc_dots") { + int v = std::stoi(val); + settingsPanel_.minimapNpcDots_ = (v != 0); + settingsPanel_.pendingMinimapNpcDots = settingsPanel_.minimapNpcDots_; + } else if (key == "show_latency_meter") { + settingsPanel_.showLatencyMeter_ = (std::stoi(val) != 0); + settingsPanel_.pendingShowLatencyMeter = settingsPanel_.showLatencyMeter_; + } else if (key == "show_dps_meter") { + settingsPanel_.showDPSMeter_ = (std::stoi(val) != 0); + } else if (key == "show_cooldown_tracker") { + settingsPanel_.showCooldownTracker_ = (std::stoi(val) != 0); + } else if (key == "separate_bags") { + settingsPanel_.pendingSeparateBags = (std::stoi(val) != 0); + inventoryScreen.setSeparateBags(settingsPanel_.pendingSeparateBags); + } else if (key == "show_keyring") { + settingsPanel_.pendingShowKeyring = (std::stoi(val) != 0); + inventoryScreen.setShowKeyring(settingsPanel_.pendingShowKeyring); + } else if (key == "action_bar_scale") { + settingsPanel_.pendingActionBarScale = std::clamp(std::stof(val), 0.5f, 1.5f); + } else if (key == "nameplate_scale") { + settingsPanel_.nameplateScale_ = std::clamp(std::stof(val), 0.5f, 2.0f); + } else if (key == "show_friendly_nameplates") { + settingsPanel_.showFriendlyNameplates_ = (std::stoi(val) != 0); + } else if (key == "show_action_bar2") { + settingsPanel_.pendingShowActionBar2 = (std::stoi(val) != 0); + } else if (key == "action_bar2_offset_x") { + settingsPanel_.pendingActionBar2OffsetX = std::clamp(std::stof(val), -600.0f, 600.0f); + } else if (key == "action_bar2_offset_y") { + settingsPanel_.pendingActionBar2OffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "show_right_bar") { + settingsPanel_.pendingShowRightBar = (std::stoi(val) != 0); + } else if (key == "show_left_bar") { + settingsPanel_.pendingShowLeftBar = (std::stoi(val) != 0); + } else if (key == "right_bar_offset_y") { + settingsPanel_.pendingRightBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "left_bar_offset_y") { + settingsPanel_.pendingLeftBarOffsetY = std::clamp(std::stof(val), -400.0f, 400.0f); + } else if (key == "damage_flash") { + settingsPanel_.damageFlashEnabled_ = (std::stoi(val) != 0); + } else if (key == "low_health_vignette") { + settingsPanel_.lowHealthVignetteEnabled_ = (std::stoi(val) != 0); + } + // Audio + else if (key == "sound_muted") { + settingsPanel_.soundMuted_ = (std::stoi(val) != 0); + if (settingsPanel_.soundMuted_) { + // Apply mute on load; settingsPanel_.preMuteVolume_ will be set when AudioEngine is available + audio::AudioEngine::instance().setMasterVolume(0.0f); + } + } + else if (key == "use_original_soundtrack") settingsPanel_.pendingUseOriginalSoundtrack = (std::stoi(val) != 0); + else if (key == "master_volume") settingsPanel_.pendingMasterVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "music_volume") settingsPanel_.pendingMusicVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "ambient_volume") settingsPanel_.pendingAmbientVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "ui_volume") settingsPanel_.pendingUiVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "combat_volume") settingsPanel_.pendingCombatVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "spell_volume") settingsPanel_.pendingSpellVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "movement_volume") settingsPanel_.pendingMovementVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "footstep_volume") settingsPanel_.pendingFootstepVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "npc_voice_volume") settingsPanel_.pendingNpcVoiceVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "mount_volume") settingsPanel_.pendingMountVolume = std::clamp(std::stoi(val), 0, 100); + else if (key == "activity_volume") settingsPanel_.pendingActivityVolume = std::clamp(std::stoi(val), 0, 100); + // Gameplay + else if (key == "auto_loot") settingsPanel_.pendingAutoLoot = (std::stoi(val) != 0); + else if (key == "auto_sell_grey") settingsPanel_.pendingAutoSellGrey = (std::stoi(val) != 0); + else if (key == "auto_repair") settingsPanel_.pendingAutoRepair = (std::stoi(val) != 0); + else if (key == "graphics_preset") { + int presetVal = std::clamp(std::stoi(val), 0, 4); + settingsPanel_.currentGraphicsPreset = static_cast(presetVal); + settingsPanel_.pendingGraphicsPreset = settingsPanel_.currentGraphicsPreset; + } + else if (key == "ground_clutter_density") settingsPanel_.pendingGroundClutterDensity = std::clamp(std::stoi(val), 0, 150); + else if (key == "shadows") settingsPanel_.pendingShadows = (std::stoi(val) != 0); + else if (key == "shadow_distance") settingsPanel_.pendingShadowDistance = std::clamp(std::stof(val), 40.0f, 500.0f); + else if (key == "brightness") { + settingsPanel_.pendingBrightness = std::clamp(std::stoi(val), 0, 100); + if (auto* r = services_.renderer) + r->getPostProcessPipeline()->setBrightness(static_cast(settingsPanel_.pendingBrightness) / 50.0f); + } + else if (key == "water_refraction") settingsPanel_.pendingWaterRefraction = (std::stoi(val) != 0); + else if (key == "antialiasing") settingsPanel_.pendingAntiAliasing = std::clamp(std::stoi(val), 0, 3); + else if (key == "fxaa") settingsPanel_.pendingFXAA = (std::stoi(val) != 0); + else if (key == "normal_mapping") settingsPanel_.pendingNormalMapping = (std::stoi(val) != 0); + else if (key == "normal_map_strength") settingsPanel_.pendingNormalMapStrength = std::clamp(std::stof(val), 0.0f, 2.0f); + else if (key == "pom") settingsPanel_.pendingPOM = (std::stoi(val) != 0); + else if (key == "pom_quality") settingsPanel_.pendingPOMQuality = std::clamp(std::stoi(val), 0, 2); + else if (key == "upscaling_mode") { + settingsPanel_.pendingUpscalingMode = std::clamp(std::stoi(val), 0, 2); + settingsPanel_.pendingFSR = (settingsPanel_.pendingUpscalingMode == 1); + } else if (key == "fsr") { + settingsPanel_.pendingFSR = (std::stoi(val) != 0); + // Backward compatibility: old configs only had fsr=0/1. + if (settingsPanel_.pendingUpscalingMode == 0 && settingsPanel_.pendingFSR) settingsPanel_.pendingUpscalingMode = 1; + } + else if (key == "fsr_quality") settingsPanel_.pendingFSRQuality = std::clamp(std::stoi(val), 0, 3); + else if (key == "fsr_sharpness") settingsPanel_.pendingFSRSharpness = std::clamp(std::stof(val), 0.0f, 2.0f); + else if (key == "fsr2_jitter_sign") settingsPanel_.pendingFSR2JitterSign = std::clamp(std::stof(val), -2.0f, 2.0f); + else if (key == "fsr2_mv_scale_x") settingsPanel_.pendingFSR2MotionVecScaleX = std::clamp(std::stof(val), -2.0f, 2.0f); + else if (key == "fsr2_mv_scale_y") settingsPanel_.pendingFSR2MotionVecScaleY = std::clamp(std::stof(val), -2.0f, 2.0f); + else if (key == "amd_fsr3_framegen") settingsPanel_.pendingAMDFramegen = (std::stoi(val) != 0); + // Controls + else if (key == "mouse_sensitivity") settingsPanel_.pendingMouseSensitivity = std::clamp(std::stof(val), 0.05f, 1.0f); + else if (key == "invert_mouse") settingsPanel_.pendingInvertMouse = (std::stoi(val) != 0); + else if (key == "extended_zoom") settingsPanel_.pendingExtendedZoom = (std::stoi(val) != 0); + else if (key == "camera_stiffness") settingsPanel_.pendingCameraStiffness = std::clamp(std::stof(val), 5.0f, 100.0f); + else if (key == "camera_pivot_height") settingsPanel_.pendingPivotHeight = std::clamp(std::stof(val), 0.0f, 3.0f); + else if (key == "fov") { + settingsPanel_.pendingFov = std::clamp(std::stof(val), 45.0f, 110.0f); + if (auto* renderer = services_.renderer) { + if (auto* camera = renderer->getCamera()) camera->setFov(settingsPanel_.pendingFov); + } + } + // Quest tracker position/size + else if (key == "quest_tracker_x") { + // Legacy: ignore absolute X (right_offset supersedes it) + (void)val; + } + else if (key == "quest_tracker_right_offset") { + questTrackerRightOffset_ = std::stof(val); + questTrackerPosInit_ = true; + } + else if (key == "quest_tracker_y") { + questTrackerPos_.y = std::stof(val); + questTrackerPosInit_ = true; + } + else if (key == "quest_tracker_w") { + questTrackerSize_.x = std::max(100.0f, std::stof(val)); + } + else if (key == "quest_tracker_h") { + questTrackerSize_.y = std::max(60.0f, std::stof(val)); + } + // Chat + else if (key == "chat_active_tab") chatPanel_.activeChatTab = std::clamp(std::stoi(val), 0, 3); + else if (key == "chat_timestamps") chatPanel_.chatShowTimestamps = (std::stoi(val) != 0); + else if (key == "chat_font_size") chatPanel_.chatFontSize = std::clamp(std::stoi(val), 0, 2); + else if (key == "chat_autojoin_general") chatPanel_.chatAutoJoinGeneral = (std::stoi(val) != 0); + else if (key == "chat_autojoin_trade") chatPanel_.chatAutoJoinTrade = (std::stoi(val) != 0); + else if (key == "chat_autojoin_localdefense") chatPanel_.chatAutoJoinLocalDefense = (std::stoi(val) != 0); + else if (key == "chat_autojoin_lfg") chatPanel_.chatAutoJoinLFG = (std::stoi(val) != 0); + else if (key == "chat_autojoin_local") chatPanel_.chatAutoJoinLocal = (std::stoi(val) != 0); + } catch (...) {} + } + + // Load keybindings from the same config file + KeybindingManager::getInstance().loadFromConfigFile(path); + + LOG_INFO("Settings loaded from ", path); +} + +// ============================================================ +// Mail Window +// ============================================================ + + + +// ============================================================ +// Bank Window +// ============================================================ + + +// ============================================================ +// Guild Bank Window +// ============================================================ + + +// ============================================================ +// Auction House Window +// ============================================================ + + + +// --------------------------------------------------------------------------- +// Screen-space weather overlay (rain / snow / storm) +// --------------------------------------------------------------------------- +void GameScreen::renderWeatherOverlay(game::GameHandler& gameHandler) { + uint32_t wType = gameHandler.getWeatherType(); + float intensity = gameHandler.getWeatherIntensity(); + if (wType == 0 || intensity < 0.05f) return; + + const ImGuiIO& io = ImGui::GetIO(); + float sw = io.DisplaySize.x; + float sh = io.DisplaySize.y; + if (sw <= 0.0f || sh <= 0.0f) return; + + // Seeded RNG for weather particle positions — replaces std::rand() which + // shares global state and has modulo bias. + static std::mt19937 wxRng(std::random_device{}()); + auto wxRandInt = [](int maxExcl) { + return std::uniform_int_distribution(0, std::max(0, maxExcl - 1))(wxRng); + }; + + ImDrawList* dl = ImGui::GetForegroundDrawList(); + const float dt = std::min(io.DeltaTime, 0.05f); // cap delta at 50ms to avoid teleporting particles + + if (wType == 1 || wType == 3) { + // ── Rain / Storm ───────────────────────────────────────────────────── + constexpr int MAX_DROPS = 300; + struct RainState { + float x[MAX_DROPS], y[MAX_DROPS]; + bool initialized = false; + uint32_t lastType = 0; + float lastW = 0.0f, lastH = 0.0f; + }; + static RainState rs; + + // Re-seed if weather type or screen size changed + if (!rs.initialized || rs.lastType != wType || + rs.lastW != sw || rs.lastH != sh) { + for (int i = 0; i < MAX_DROPS; ++i) { + rs.x[i] = static_cast(wxRandInt(static_cast(sw) + 200)) - 100.0f; + rs.y[i] = static_cast(wxRandInt(static_cast(sh))); + } + rs.initialized = true; + rs.lastType = wType; + rs.lastW = sw; + rs.lastH = sh; + } + + const float fallSpeed = (wType == 3) ? 680.0f : 440.0f; + const float windSpeed = (wType == 3) ? 110.0f : 65.0f; + const int numDrops = static_cast(MAX_DROPS * std::min(1.0f, intensity)); + const float alpha = std::min(1.0f, 0.28f + intensity * 0.38f); + const uint8_t alphaU8 = static_cast(alpha * 255.0f); + const ImU32 dropCol = IM_COL32(175, 195, 225, alphaU8); + const float dropLen = 7.0f + intensity * 7.0f; + // Normalised wind direction for the trail endpoint + const float invSpeed = 1.0f / std::sqrt(fallSpeed * fallSpeed + windSpeed * windSpeed); + const float trailDx = -windSpeed * invSpeed * dropLen; + const float trailDy = -fallSpeed * invSpeed * dropLen; + + for (int i = 0; i < numDrops; ++i) { + rs.x[i] += windSpeed * dt; + rs.y[i] += fallSpeed * dt; + if (rs.y[i] > sh + 10.0f) { + rs.y[i] = -10.0f; + rs.x[i] = static_cast(wxRandInt(static_cast(sw) + 200)) - 100.0f; + } + if (rs.x[i] > sw + 100.0f) rs.x[i] -= sw + 200.0f; + dl->AddLine(ImVec2(rs.x[i], rs.y[i]), + ImVec2(rs.x[i] + trailDx, rs.y[i] + trailDy), + dropCol, 1.0f); + } + + // Storm: dark fog-vignette at screen edges + if (wType == 3) { + const float vigAlpha = std::min(1.0f, 0.12f + intensity * 0.18f); + const ImU32 vigCol = IM_COL32(60, 65, 80, static_cast(vigAlpha * 255.0f)); + const float vigW = sw * 0.22f; + const float vigH = sh * 0.22f; + dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(vigW, sh), vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol); + dl->AddRectFilledMultiColor(ImVec2(sw-vigW, 0), ImVec2(sw, sh), IM_COL32_BLACK_TRANS, vigCol, vigCol, IM_COL32_BLACK_TRANS); + dl->AddRectFilledMultiColor(ImVec2(0, 0), ImVec2(sw, vigH), vigCol, vigCol, IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS); + dl->AddRectFilledMultiColor(ImVec2(0, sh-vigH),ImVec2(sw, sh), IM_COL32_BLACK_TRANS, IM_COL32_BLACK_TRANS, vigCol, vigCol); + } + + } else if (wType == 2) { + // ── Snow ───────────────────────────────────────────────────────────── + constexpr int MAX_FLAKES = 120; + struct SnowState { + float x[MAX_FLAKES], y[MAX_FLAKES], phase[MAX_FLAKES]; + bool initialized = false; + float lastW = 0.0f, lastH = 0.0f; + }; + static SnowState ss; + + if (!ss.initialized || ss.lastW != sw || ss.lastH != sh) { + for (int i = 0; i < MAX_FLAKES; ++i) { + ss.x[i] = static_cast(wxRandInt(static_cast(sw))); + ss.y[i] = static_cast(wxRandInt(static_cast(sh))); + ss.phase[i] = static_cast(wxRandInt(628)) * 0.01f; + } + ss.initialized = true; + ss.lastW = sw; + ss.lastH = sh; + } + + const float fallSpeed = 45.0f + intensity * 45.0f; + const int numFlakes = static_cast(MAX_FLAKES * std::min(1.0f, intensity)); + const float alpha = std::min(1.0f, 0.5f + intensity * 0.3f); + const uint8_t alphaU8 = static_cast(alpha * 255.0f); + const float radius = 1.5f + intensity * 1.5f; + const float time = static_cast(ImGui::GetTime()); + + for (int i = 0; i < numFlakes; ++i) { + float sway = std::sin(time * 0.7f + ss.phase[i]) * 18.0f; + ss.x[i] += sway * dt; + ss.y[i] += fallSpeed * dt; + ss.phase[i] += dt * 0.25f; + if (ss.y[i] > sh + 5.0f) { + ss.y[i] = -5.0f; + ss.x[i] = static_cast(wxRandInt(static_cast(sw))); + } + if (ss.x[i] < -5.0f) ss.x[i] += sw + 10.0f; + if (ss.x[i] > sw + 5.0f) ss.x[i] -= sw + 10.0f; + // Two-tone: bright centre dot + transparent outer ring for depth + dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius, IM_COL32(220, 235, 255, alphaU8)); + dl->AddCircleFilled(ImVec2(ss.x[i], ss.y[i]), radius * 0.45f, IM_COL32(245, 250, 255, std::min(255, alphaU8 + 30))); + } + } +} + +// --------------------------------------------------------------------------- +// Dungeon Finder window (toggle with hotkey or bag-bar button) +// --------------------------------------------------------------------------- +// ============================================================ +// Instance Lockouts +// ============================================================ + + + + +// ─── Threat Window ──────────────────────────────────────────────────────────── +// ─── BG Scoreboard ──────────────────────────────────────────────────────────── + + + + + + +}} // namespace wowee::ui diff --git a/src/ui/settings_panel.cpp b/src/ui/settings_panel.cpp index f6353942..39d49c31 100644 --- a/src/ui/settings_panel.cpp +++ b/src/ui/settings_panel.cpp @@ -10,6 +10,7 @@ #include "core/application.hpp" #include "core/logger.hpp" #include "rendering/renderer.hpp" +#include "rendering/post_process_pipeline.hpp" #include "rendering/camera.hpp" #include "rendering/camera_controller.hpp" #include "rendering/minimap.hpp" @@ -739,7 +740,7 @@ void SettingsPanel::renderSettingsWindow(InventoryScreen& inventoryScreen, ChatP } { const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; - bool fsr2Active = renderer && renderer->isFSR2Enabled(); + bool fsr2Active = renderer && renderer->getPostProcessPipeline()->isFSR2Enabled(); if (fsr2Active) { ImGui::BeginDisabled(); int disabled = 0; @@ -757,7 +758,7 @@ void SettingsPanel::renderSettingsWindow(InventoryScreen& inventoryScreen, ChatP // FXAA — post-process, combinable with MSAA or FSR3 { if (ImGui::Checkbox("FXAA (post-process)", &pendingFXAA)) { - if (renderer) renderer->setFXAAEnabled(pendingFXAA); + if (renderer) renderer->getPostProcessPipeline()->setFXAAEnabled(pendingFXAA); updateGraphicsPresetFromCurrentSettings(); saveCallback(); } @@ -786,24 +787,24 @@ void SettingsPanel::renderSettingsWindow(InventoryScreen& inventoryScreen, ChatP if (fsrMode > 0) { if (fsrMode == 2 && renderer) { ImGui::TextDisabled("FSR3 backend: %s", - renderer->isAmdFsr2SdkAvailable() ? "AMD FidelityFX SDK" : "Internal fallback"); - if (renderer->isAmdFsr3FramegenSdkAvailable()) { + renderer->getPostProcessPipeline()->isAmdFsr2SdkAvailable() ? "AMD FidelityFX SDK" : "Internal fallback"); + if (renderer->getPostProcessPipeline()->isAmdFsr3FramegenSdkAvailable()) { if (ImGui::Checkbox("AMD FSR3 Frame Generation (Experimental)", &pendingAMDFramegen)) { - renderer->setAmdFsr3FramegenEnabled(pendingAMDFramegen); + renderer->getPostProcessPipeline()->setAmdFsr3FramegenEnabled(pendingAMDFramegen); saveCallback(); } const char* runtimeStatus = "Unavailable"; - if (renderer->isAmdFsr3FramegenRuntimeActive()) { + if (renderer->getPostProcessPipeline()->isAmdFsr3FramegenRuntimeActive()) { runtimeStatus = "Active"; - } else if (renderer->isAmdFsr3FramegenRuntimeReady()) { + } else if (renderer->getPostProcessPipeline()->isAmdFsr3FramegenRuntimeReady()) { runtimeStatus = "Ready"; } else { runtimeStatus = "Unavailable"; } ImGui::TextDisabled("Runtime: %s (%s)", - runtimeStatus, renderer->getAmdFsr3FramegenRuntimePath()); - if (!renderer->isAmdFsr3FramegenRuntimeReady()) { - const std::string& runtimeErr = renderer->getAmdFsr3FramegenRuntimeError(); + runtimeStatus, renderer->getPostProcessPipeline()->getAmdFsr3FramegenRuntimePath()); + if (!renderer->getPostProcessPipeline()->isAmdFsr3FramegenRuntimeReady()) { + const std::string& runtimeErr = renderer->getPostProcessPipeline()->getAmdFsr3FramegenRuntimeError(); if (!runtimeErr.empty()) { ImGui::TextDisabled("Reason: %s", runtimeErr.c_str()); } @@ -829,18 +830,18 @@ void SettingsPanel::renderSettingsWindow(InventoryScreen& inventoryScreen, ChatP } if (ImGui::Combo("FSR Quality", &fsrQualityDisplay, fsrQualityLabels, 4)) { pendingFSRQuality = displayToInternal[fsrQualityDisplay]; - if (renderer) renderer->setFSRQuality(fsrScaleFactors[pendingFSRQuality]); + if (renderer) renderer->getPostProcessPipeline()->setFSRQuality(fsrScaleFactors[pendingFSRQuality]); saveCallback(); } if (ImGui::SliderFloat("FSR Sharpness", &pendingFSRSharpness, 0.0f, 2.0f, "%.1f")) { - if (renderer) renderer->setFSRSharpness(pendingFSRSharpness); + if (renderer) renderer->getPostProcessPipeline()->setFSRSharpness(pendingFSRSharpness); saveCallback(); } if (fsrMode == 2) { ImGui::SeparatorText("FSR3 Tuning"); if (ImGui::SliderFloat("Jitter Sign", &pendingFSR2JitterSign, -2.0f, 2.0f, "%.2f")) { if (renderer) { - renderer->setFSR2DebugTuning( + renderer->getPostProcessPipeline()->setFSR2DebugTuning( pendingFSR2JitterSign, pendingFSR2MotionVecScaleX, pendingFSR2MotionVecScaleY); @@ -927,7 +928,7 @@ void SettingsPanel::renderSettingsWindow(InventoryScreen& inventoryScreen, ChatP ImGui::SetNextItemWidth(200.0f); if (ImGui::SliderInt("Brightness", &pendingBrightness, 0, 100, "%d%%")) { - if (renderer) renderer->setBrightness(static_cast(pendingBrightness) / 50.0f); + if (renderer) renderer->getPostProcessPipeline()->setBrightness(static_cast(pendingBrightness) / 50.0f); saveCallback(); } @@ -951,7 +952,7 @@ void SettingsPanel::renderSettingsWindow(InventoryScreen& inventoryScreen, ChatP window->setFullscreen(pendingFullscreen); window->setVsync(pendingVsync); window->applyResolution(kResolutions[pendingResIndex][0], kResolutions[pendingResIndex][1]); - if (renderer) renderer->setBrightness(1.0f); + if (renderer) renderer->getPostProcessPipeline()->setBrightness(1.0f); pendingWaterRefraction = false; if (renderer) { renderer->setShadowsEnabled(pendingShadows); @@ -1150,7 +1151,7 @@ void SettingsPanel::applyGraphicsPreset(GraphicsPreset preset) { renderer->setShadowsEnabled(true); renderer->setShadowDistance(500.0f); renderer->setMsaaSamples(VK_SAMPLE_COUNT_8_BIT); - renderer->setFXAAEnabled(true); + renderer->getPostProcessPipeline()->setFXAAEnabled(true); if (auto* wr = renderer->getWMORenderer()) { wr->setNormalMappingEnabled(true); wr->setNormalMapStrength(1.2f); diff --git a/src/ui/toast_manager.cpp b/src/ui/toast_manager.cpp index 957f5e2a..01488c64 100644 --- a/src/ui/toast_manager.cpp +++ b/src/ui/toast_manager.cpp @@ -2,6 +2,7 @@ #include "game/game_handler.hpp" #include "core/application.hpp" #include "rendering/renderer.hpp" +#include "rendering/animation_controller.hpp" #include "audio/audio_coordinator.hpp" #include "audio/ui_sound_manager.hpp" @@ -469,7 +470,7 @@ void ToastManager::triggerDing(uint32_t newLevel, uint32_t hpDelta, uint32_t man } } if (auto* renderer = services_.renderer) { - renderer->playEmote("cheer"); + if (auto* ac = renderer->getAnimationController()) ac->playEmote("cheer"); } }