diff --git a/Data/expansions/classic/dbc_layouts.json b/Data/expansions/classic/dbc_layouts.json index 1b9bf00f..4ec229d5 100644 --- a/Data/expansions/classic/dbc_layouts.json +++ b/Data/expansions/classic/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129 + "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/Data/expansions/tbc/dbc_layouts.json b/Data/expansions/tbc/dbc_layouts.json index af1bf479..d40a5766 100644 --- a/Data/expansions/tbc/dbc_layouts.json +++ b/Data/expansions/tbc/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 124, - "Name": 127, "Tooltip": 154, "Rank": 136 + "Name": 127, "Tooltip": 154, "Rank": 136, "SchoolMask": 215 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/Data/expansions/turtle/dbc_layouts.json b/Data/expansions/turtle/dbc_layouts.json index a3499be5..4e86338a 100644 --- a/Data/expansions/turtle/dbc_layouts.json +++ b/Data/expansions/turtle/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { "ID": 0, "Attributes": 5, "IconID": 117, - "Name": 120, "Tooltip": 147, "Rank": 129 + "Name": 120, "Tooltip": 147, "Rank": 129, "SchoolEnum": 1 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, diff --git a/Data/expansions/wotlk/dbc_layouts.json b/Data/expansions/wotlk/dbc_layouts.json index 5cbcf2eb..5b500741 100644 --- a/Data/expansions/wotlk/dbc_layouts.json +++ b/Data/expansions/wotlk/dbc_layouts.json @@ -1,7 +1,7 @@ { "Spell": { "ID": 0, "Attributes": 4, "IconID": 133, - "Name": 136, "Tooltip": 139, "Rank": 153 + "Name": 136, "Tooltip": 139, "Rank": 153, "SchoolMask": 225 }, "ItemDisplayInfo": { "ID": 0, "LeftModel": 1, "LeftModelTexture": 3, @@ -28,6 +28,7 @@ "ReputationBase0": 10, "ReputationBase1": 11, "ReputationBase2": 12, "ReputationBase3": 13 }, + "Achievement": { "ID": 0, "Title": 4, "Description": 21 }, "AreaTable": { "ID": 0, "ExploreFlag": 3 }, "CreatureDisplayInfoExtra": { "ID": 0, "RaceID": 1, "SexID": 2, "SkinID": 3, "FaceID": 4, diff --git a/assets/shaders/wmo.frag.glsl b/assets/shaders/wmo.frag.glsl index a4bae057..c2b3b1cd 100644 --- a/assets/shaders/wmo.frag.glsl +++ b/assets/shaders/wmo.frag.glsl @@ -29,6 +29,9 @@ layout(set = 1, binding = 1) uniform WMOMaterial { float heightMapVariance; float normalMapStrength; int isLava; + float wmoAmbientR; + float wmoAmbientG; + float wmoAmbientB; }; layout(set = 1, binding = 2) uniform sampler2D uNormalHeightMap; @@ -185,7 +188,13 @@ void main() { } else if (unlit != 0) { result = texColor.rgb * shadow; } else if (isInterior != 0) { - vec3 mocv = max(VertColor.rgb, vec3(0.5)); + // WMO interior: vertex colors (MOCV) are pre-baked lighting from the artist. + // The MOHD ambient color tints/floors the vertex colors so dark spots don't + // go completely black, matching the WoW client's interior shading. + vec3 wmoAmbient = vec3(wmoAmbientR, wmoAmbientG, wmoAmbientB); + // Clamp ambient to at least 0.3 to avoid total darkness when MOHD color is zero + wmoAmbient = max(wmoAmbient, vec3(0.3)); + vec3 mocv = max(VertColor.rgb, wmoAmbient); result = texColor.rgb * mocv * shadow; } else { vec3 ldir = normalize(-lightDir.xyz); diff --git a/assets/shaders/wmo.frag.spv b/assets/shaders/wmo.frag.spv index 524dbd1e..3507f3c9 100644 Binary files a/assets/shaders/wmo.frag.spv and b/assets/shaders/wmo.frag.spv differ diff --git a/include/audio/audio_engine.hpp b/include/audio/audio_engine.hpp index 20015330..c6d5e723 100644 --- a/include/audio/audio_engine.hpp +++ b/include/audio/audio_engine.hpp @@ -45,6 +45,11 @@ public: bool playSound2D(const std::vector& wavData, float volume = 1.0f, float pitch = 1.0f); bool playSound2D(const std::string& mpqPath, float volume = 1.0f, float pitch = 1.0f); + // Stoppable 2D sound — returns a non-zero handle, or 0 on failure + uint32_t playSound2DStoppable(const std::vector& wavData, float volume = 1.0f); + // Stop a sound started with playSound2DStoppable (no-op if already finished) + void stopSound(uint32_t id); + // 3D positional sound playback bool playSound3D(const std::vector& wavData, const glm::vec3& position, float volume = 1.0f, float pitch = 1.0f, float maxDistance = 100.0f); @@ -70,8 +75,10 @@ private: ma_sound* sound; void* buffer; // ma_audio_buffer* - Keep audio buffer alive std::shared_ptr> pcmDataRef; // Keep decoded PCM alive + uint32_t id = 0; // 0 = anonymous (not stoppable) }; std::vector activeSounds_; + uint32_t nextSoundId_ = 1; // Music track state ma_sound* musicSound_ = nullptr; diff --git a/include/audio/spell_sound_manager.hpp b/include/audio/spell_sound_manager.hpp index 1933a7aa..d0273c82 100644 --- a/include/audio/spell_sound_manager.hpp +++ b/include/audio/spell_sound_manager.hpp @@ -45,6 +45,7 @@ public: // Spell casting sounds void playPrecast(MagicSchool school, SpellPower power); // Channeling/preparation + void stopPrecast(); // Stop precast sound early void playCast(MagicSchool school); // When spell fires void playImpact(MagicSchool school, SpellPower power); // When spell hits target @@ -96,6 +97,7 @@ private: // State tracking float volumeScale_ = 1.0f; bool initialized_ = false; + uint32_t activePrecastId_ = 0; // Handle from AudioEngine::playSound2DStoppable() // Helper methods bool loadSound(const std::string& path, SpellSample& sample, pipeline::AssetManager* assets); diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index aba5a344..47dd523d 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -456,7 +456,55 @@ public: void dismissPet(); bool hasPet() const { return petGuid_ != 0; } uint64_t getPetGuid() const { return petGuid_; } + + // ---- Pet state (populated by SMSG_PET_SPELLS / SMSG_PET_MODE) ---- + // 10 action bar slots; each entry is a packed uint32: + // bits 0-23 = spell ID (or 0 for empty) + // bits 24-31 = action type (0x00=cast, 0xC0=autocast on, 0x40=autocast off) + static constexpr int PET_ACTION_BAR_SLOTS = 10; + uint32_t getPetActionSlot(int idx) const { + if (idx < 0 || idx >= PET_ACTION_BAR_SLOTS) return 0; + return petActionSlots_[idx]; + } + // Pet command/react state from SMSG_PET_MODE or SMSG_PET_SPELLS + uint8_t getPetCommand() const { return petCommand_; } // 0=stay,1=follow,2=attack,3=dismiss + uint8_t getPetReact() const { return petReact_; } // 0=passive,1=defensive,2=aggressive + // Spells the pet knows (from SMSG_PET_SPELLS spell list) + const std::vector& getPetSpells() const { return petSpellList_; } + // Pet autocast set (spellIds that have autocast enabled) + bool isPetSpellAutocast(uint32_t spellId) const { + return petAutocastSpells_.count(spellId) != 0; + } + // Send CMSG_PET_ACTION to issue a pet command + void sendPetAction(uint32_t action, uint64_t targetGuid = 0); const std::unordered_set& getKnownSpells() const { return knownSpells; } + + // Player proficiency bitmasks (from SMSG_SET_PROFICIENCY) + // itemClass 2 = Weapon (subClassMask bits: 0=Axe1H,1=Axe2H,2=Bow,3=Gun,4=Mace1H,5=Mace2H,6=Polearm,7=Sword1H,8=Sword2H,10=Staff,13=Fist,14=Misc,15=Dagger,16=Thrown,17=Crossbow,18=Wand,19=Fishing) + // itemClass 4 = Armor (subClassMask bits: 1=Cloth,2=Leather,3=Mail,4=Plate,6=Shield) + uint32_t getWeaponProficiency() const { return weaponProficiency_; } + uint32_t getArmorProficiency() const { return armorProficiency_; } + bool canUseWeaponSubclass(uint32_t subClass) const { return (weaponProficiency_ >> subClass) & 1u; } + bool canUseArmorSubclass(uint32_t subClass) const { return (armorProficiency_ >> subClass) & 1u; } + + // Minimap pings from party members + struct MinimapPing { + uint64_t senderGuid = 0; + float wowX = 0.0f; // canonical WoW X (north) + float wowY = 0.0f; // canonical WoW Y (west) + float age = 0.0f; // seconds since received + static constexpr float LIFETIME = 5.0f; + bool isExpired() const { return age >= LIFETIME; } + }; + const std::vector& getMinimapPings() const { return minimapPings_; } + void tickMinimapPings(float dt) { + for (auto& p : minimapPings_) p.age += dt; + minimapPings_.erase( + std::remove_if(minimapPings_.begin(), minimapPings_.end(), + [](const MinimapPing& p){ return p.isExpired(); }), + minimapPings_.end()); + } + bool isCasting() const { return casting; } bool isGameObjectInteractionCasting() const { return casting && currentCastSpellId == 0 && pendingGameObjectInteractGuid_ != 0; @@ -465,6 +513,34 @@ public: float getCastProgress() const { return castTimeTotal > 0 ? (castTimeTotal - castTimeRemaining) / castTimeTotal : 0.0f; } float getCastTimeRemaining() const { return castTimeRemaining; } + // Unit cast state (tracked per GUID for target frame + boss frames) + struct UnitCastState { + bool casting = false; + uint32_t spellId = 0; + float timeRemaining = 0.0f; + float timeTotal = 0.0f; + }; + // Returns cast state for any unit by GUID (empty/non-casting if not found) + const UnitCastState* getUnitCastState(uint64_t guid) const { + auto it = unitCastStates_.find(guid); + return (it != unitCastStates_.end() && it->second.casting) ? &it->second : nullptr; + } + // Convenience helpers for the current target + bool isTargetCasting() const { return getUnitCastState(targetGuid) != nullptr; } + uint32_t getTargetCastSpellId() const { + auto* s = getUnitCastState(targetGuid); + return s ? s->spellId : 0; + } + float getTargetCastProgress() const { + auto* s = getUnitCastState(targetGuid); + return (s && s->timeTotal > 0.0f) + ? (s->timeTotal - s->timeRemaining) / s->timeTotal : 0.0f; + } + float getTargetCastTimeRemaining() const { + auto* s = getUnitCastState(targetGuid); + return s ? s->timeRemaining : 0.0f; + } + // Talents uint8_t getActiveTalentSpec() const { return activeTalentSpec_; } uint8_t getUnspentTalentPoints() const { return unspentTalentPoints_[activeTalentSpec_]; } @@ -583,6 +659,12 @@ public: using BindPointCallback = std::function; void setBindPointCallback(BindPointCallback cb) { bindPointCallback_ = std::move(cb); } + // Called when the player starts casting Hearthstone so terrain at the bind + // point can be pre-loaded during the cast time. + // Parameters: mapId and canonical (x, y, z) of the bind location. + using HearthstonePreloadCallback = std::function; + void setHearthstonePreloadCallback(HearthstonePreloadCallback cb) { hearthstonePreloadCallback_ = std::move(cb); } + // Creature spawn callback (online mode - triggered when creature enters view) // Parameters: guid, displayId, x, y, z (canonical), orientation using CreatureSpawnCallback = std::function; @@ -702,6 +784,11 @@ public: bool isPlayerGhost() const { return releasedSpirit_; } bool showDeathDialog() const { return playerDead_ && !releasedSpirit_; } bool showResurrectDialog() const { return resurrectRequestPending_; } + const std::string& getResurrectCasterName() const { return resurrectCasterName_; } + /** True when ghost is within 40 yards of corpse position (same map). */ + bool canReclaimCorpse() const; + /** Send CMSG_RECLAIM_CORPSE; noop if not a ghost or not near corpse. */ + void reclaimCorpse(); void releaseSpirit(); void acceptResurrect(); void declineResurrect(); @@ -773,6 +860,13 @@ public: }; const std::vector& getInstanceLockouts() const { return instanceLockouts_; } + // Boss encounter unit tracking (SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) + static constexpr uint32_t kMaxEncounterSlots = 5; + // Returns boss unit guid for the given encounter slot (0 if none) + uint64_t getEncounterUnitGuid(uint32_t slot) const { + return (slot < kMaxEncounterSlots) ? encounterUnitGuids_[slot] : 0; + } + // ---- LFG / Dungeon Finder ---- enum class LfgState : uint8_t { None = 0, @@ -1016,6 +1110,10 @@ public: }; const std::unordered_map& getTaxiNodes() const { return taxiNodes_; } uint32_t getTaxiCostTo(uint32_t destNodeId) const; + bool taxiNpcHasRoutes(uint64_t guid) const { + auto it = taxiNpcHasRoutes_.find(guid); + return it != taxiNpcHasRoutes_.end() && it->second; + } // Vendor void openVendor(uint64_t npcGuid); @@ -1439,6 +1537,8 @@ private: void handleWho(network::Packet& packet); // ---- Social handlers ---- + void handleFriendList(network::Packet& packet); // Classic SMSG_FRIEND_LIST + void handleContactList(network::Packet& packet); // WotLK SMSG_CONTACT_LIST (full parse) void handleFriendStatus(network::Packet& packet); void handleRandomRoll(network::Packet& packet); @@ -1558,6 +1658,7 @@ private: // ---- Friend list cache ---- std::unordered_map friendsCache; // name -> guid + std::unordered_set friendGuids_; // all known friend GUIDs (for name backfill) uint32_t lastContactListMask_ = 0; uint32_t lastContactListCount_ = 0; @@ -1645,6 +1746,7 @@ private: UnstuckCallback unstuckGyCallback_; UnstuckCallback unstuckHearthCallback_; BindPointCallback bindPointCallback_; + HearthstonePreloadCallback hearthstonePreloadCallback_; CreatureSpawnCallback creatureSpawnCallback_; CreatureDespawnCallback creatureDespawnCallback_; PlayerSpawnCallback playerSpawnCallback_; @@ -1676,10 +1778,15 @@ private: std::unique_ptr transportManager_; // Transport movement manager std::unordered_set knownSpells; std::unordered_map spellCooldowns; // spellId -> remaining seconds + uint32_t weaponProficiency_ = 0; // bitmask from SMSG_SET_PROFICIENCY itemClass=2 + uint32_t armorProficiency_ = 0; // bitmask from SMSG_SET_PROFICIENCY itemClass=4 + std::vector minimapPings_; uint8_t castCount = 0; bool casting = false; uint32_t currentCastSpellId = 0; float castTimeRemaining = 0.0f; + // Per-unit cast state (keyed by GUID, populated from SMSG_SPELL_START) + std::unordered_map unitCastStates_; uint64_t pendingGameObjectInteractGuid_ = 0; // Talents (dual-spec support) @@ -1710,6 +1817,11 @@ private: std::vector playerAuras; std::vector targetAuras; uint64_t petGuid_ = 0; + uint32_t petActionSlots_[10] = {}; // SMSG_PET_SPELLS action bar (10 slots) + uint8_t petCommand_ = 1; // 0=stay,1=follow,2=attack,3=dismiss + uint8_t petReact_ = 1; // 0=passive,1=defensive,2=aggressive + std::vector petSpellList_; // known pet spells + std::unordered_set petAutocastSpells_; // spells with autocast on // ---- Battleground queue state ---- struct BgQueueSlot { @@ -1734,6 +1846,9 @@ private: // Instance / raid lockouts std::vector instanceLockouts_; + // Instance encounter boss units (slots 0-4 from SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT) + std::array encounterUnitGuids_ = {}; // 0 = empty slot + // LFG / Dungeon Finder state LfgState lfgState_ = LfgState::None; uint32_t lfgDungeonId_ = 0; // current dungeon entry @@ -1742,7 +1857,9 @@ private: uint32_t lfgTimeInQueueMs_= 0; // ms already in queue // Ready check state - bool pendingReadyCheck_ = false; + bool pendingReadyCheck_ = false; + uint32_t readyCheckReadyCount_ = 0; + uint32_t readyCheckNotReadyCount_ = 0; std::string readyCheckInitiator_; // Faction standings (factionId → absolute standing value) @@ -1878,6 +1995,7 @@ private: } // Taxi / Flight Paths + std::unordered_map taxiNpcHasRoutes_; // guid -> has new/available routes std::unordered_map taxiNodes_; std::vector taxiPathEdges_; std::unordered_map> taxiPathNodes_; // pathId -> ordered waypoints @@ -1971,9 +2089,14 @@ private: // Trainer bool trainerWindowOpen_ = false; TrainerListData currentTrainerList_; - struct SpellNameEntry { std::string name; std::string rank; }; + struct SpellNameEntry { std::string name; std::string rank; uint32_t schoolMask = 0; }; std::unordered_map spellNameCache_; bool spellNameCacheLoaded_ = false; + + // Achievement name cache (lazy-loaded from Achievement.dbc on first earned event) + std::unordered_map achievementNameCache_; + bool achievementNameCacheLoaded_ = false; + void loadAchievementNameCache(); std::vector trainerTabs_; void handleTrainerList(network::Packet& packet); void loadSpellNameCache(); @@ -2090,6 +2213,8 @@ private: float serverPitchRate_ = 3.14159f; bool playerDead_ = false; bool releasedSpirit_ = false; + uint32_t corpseMapId_ = 0; + float corpseX_ = 0.0f, corpseY_ = 0.0f, corpseZ_ = 0.0f; // Death Knight runes (class 6): slots 0-1=Blood, 2-3=Unholy, 4-5=Frost initially std::array playerRunes_ = [] { std::array r{}; @@ -2101,7 +2226,9 @@ private: uint64_t pendingSpiritHealerGuid_ = 0; bool resurrectPending_ = false; bool resurrectRequestPending_ = false; + bool resurrectIsSpiritHealer_ = false; // true = SMSG_SPIRIT_HEALER_CONFIRM, false = SMSG_RESURRECT_REQUEST uint64_t resurrectCasterGuid_ = 0; + std::string resurrectCasterName_; bool repopPending_ = false; uint64_t lastRepopRequestMs_ = 0; diff --git a/include/game/packet_parsers.hpp b/include/game/packet_parsers.hpp index f1466a1d..03fc502e 100644 --- a/include/game/packet_parsers.hpp +++ b/include/game/packet_parsers.hpp @@ -93,6 +93,11 @@ public: return SpellDamageLogParser::parse(packet, data); } + /** Parse SMSG_SPELLHEALLOG */ + virtual bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { + return SpellHealLogParser::parse(packet, data); + } + // --- Spells --- /** Parse SMSG_INITIAL_SPELLS */ @@ -100,11 +105,34 @@ public: return InitialSpellsParser::parse(packet, data); } + /** Parse SMSG_SPELL_START */ + virtual bool parseSpellStart(network::Packet& packet, SpellStartData& data) { + return SpellStartParser::parse(packet, data); + } + + /** Parse SMSG_SPELL_GO */ + virtual bool parseSpellGo(network::Packet& packet, SpellGoData& data) { + return SpellGoParser::parse(packet, data); + } + /** Parse SMSG_CAST_FAILED */ virtual bool parseCastFailed(network::Packet& packet, CastFailedData& data) { return CastFailedParser::parse(packet, data); } + /** Parse SMSG_CAST_RESULT header (spellId + result), expansion-aware. + * WotLK: castCount(u8) + spellId(u32) + result(u8) + * TBC/Classic: spellId(u32) + result(u8) (no castCount prefix) + */ + virtual bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) { + // WotLK default: skip castCount, read spellId + result + if (packet.getSize() - packet.getReadPos() < 6) return false; + packet.readUInt8(); // castCount + spellId = packet.readUInt32(); + result = packet.readUInt8(); + return true; + } + /** Parse SMSG_AURA_UPDATE / SMSG_AURA_UPDATE_ALL */ virtual bool parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll = false) { return AuraUpdateParser::parse(packet, data, isAll); @@ -122,6 +150,13 @@ public: return NameQueryResponseParser::parse(packet, data); } + // --- Creature Query --- + + /** Parse SMSG_CREATURE_QUERY_RESPONSE */ + virtual bool parseCreatureQueryResponse(network::Packet& packet, CreatureQueryResponseData& data) { + return CreatureQueryResponseParser::parse(packet, data); + } + // --- Item Query --- /** Build CMSG_ITEM_QUERY_SINGLE */ @@ -287,6 +322,37 @@ public: bool parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) override; bool parseItemQueryResponse(network::Packet& packet, ItemQueryResponseData& data) override; network::Packet buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) override; + // TBC 2.4.3 CMSG_CAST_SPELL has no castFlags byte (WotLK added it) + network::Packet buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) override; + // TBC 2.4.3 CMSG_USE_ITEM has no glyphIndex field (WotLK added it) + network::Packet buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId = 0) override; + // TBC 2.4.3 SMSG_MONSTER_MOVE has no unk byte after packed GUID (WotLK added it) + bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override; + // TBC 2.4.3 SMSG_GOSSIP_MESSAGE quests lack questFlags(u32)+isRepeatable(u8) (WotLK added them) + bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; + // TBC 2.4.3 SMSG_CAST_RESULT: spellId(u32) + result(u8) — WotLK added castCount(u8) prefix + bool parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) override; + // TBC 2.4.3 SMSG_CAST_FAILED: spellId(u32) + result(u8) — WotLK added castCount(u8) prefix + bool parseCastFailed(network::Packet& packet, CastFailedData& data) override; + // TBC 2.4.3 SMSG_SPELL_START: full uint64 GUIDs (WotLK uses packed GUIDs) + bool parseSpellStart(network::Packet& packet, SpellStartData& data) override; + // TBC 2.4.3 SMSG_SPELL_GO: full uint64 GUIDs, no timestamp field (WotLK added one) + bool parseSpellGo(network::Packet& packet, SpellGoData& data) override; + // TBC 2.4.3 SMSG_MAIL_LIST_RESULT: uint8 count (not uint32+uint8), no body field, + // attachment uses uint64 itemGuid (not uint32), enchants are 7×u32 id-only (not 7×{id+dur+charges}) + bool parseMailList(network::Packet& packet, std::vector& inbox) override; + // TBC 2.4.3 SMSG_ATTACKERSTATEUPDATE uses full uint64 GUIDs (WotLK uses packed GUIDs) + bool parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) override; + // TBC 2.4.3 SMSG_SPELLNONMELEEDAMAGELOG uses full uint64 GUIDs (WotLK uses packed GUIDs) + bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override; + // TBC 2.4.3 SMSG_SPELLHEALLOG uses full uint64 GUIDs (WotLK uses packed GUIDs) + bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) override; + // TBC 2.4.3 quest log has 4 update fields per slot (questId, state, counts, timer) + // WotLK expands this to 5 (splits counts into two fields). + uint8_t questLogStride() const override { return 4; } + // TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST: guid(8) + questId(4) — no trailing + // isDialogContinued byte that WotLK added + network::Packet buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) override; }; /** @@ -317,6 +383,8 @@ public: bool parseCastFailed(network::Packet& packet, CastFailedData& data) override; bool parseMessageChat(network::Packet& packet, MessageChatData& data) override; bool parseGameObjectQueryResponse(network::Packet& packet, GameObjectQueryResponseData& data) override; + // Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE lacks the iconName string that TBC/WotLK include + bool parseCreatureQueryResponse(network::Packet& packet, CreatureQueryResponseData& data) override; bool parseGossipMessage(network::Packet& packet, GossipMessageData& data) override; bool parseGuildRoster(network::Packet& packet, GuildRosterData& data) override; bool parseGuildQueryResponse(network::Packet& packet, GuildQueryResponseData& data) override; @@ -339,6 +407,19 @@ public: bool parseMonsterMove(network::Packet& packet, MonsterMoveData& data) override { return MonsterMoveParser::parseVanilla(packet, data); } + // Classic 1.12 uses PackedGuid (not full uint64) and uint16 castFlags (not uint32) + bool parseSpellStart(network::Packet& packet, SpellStartData& data) override; + bool parseSpellGo(network::Packet& packet, SpellGoData& data) override; + // Classic 1.12 melee/spell log packets use PackedGuid (not full uint64) + bool parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) override; + bool parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) override; + bool parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) override; + // Classic 1.12 has SMSG_AURA_UPDATE (unlike TBC which doesn't); + // format differs from WotLK: no caster GUID, DURATION flag is 0x10 not 0x20 + bool parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll = false) override; + // Classic 1.12 SMSG_NAME_QUERY_RESPONSE: full uint64 guid + name + realmName CString + + // uint32 race + uint32 gender + uint32 class (TBC Variant A skips the realmName CString) + bool parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) override; }; /** diff --git a/include/game/spell_defines.hpp b/include/game/spell_defines.hpp index 3d1e871f..041b44f6 100644 --- a/include/game/spell_defines.hpp +++ b/include/game/spell_defines.hpp @@ -51,7 +51,7 @@ struct CombatTextEntry { enum Type : uint8_t { MELEE_DAMAGE, SPELL_DAMAGE, HEAL, MISS, DODGE, PARRY, BLOCK, CRIT_DAMAGE, CRIT_HEAL, PERIODIC_DAMAGE, PERIODIC_HEAL, ENVIRONMENTAL, - ENERGIZE, XP_GAIN + ENERGIZE, XP_GAIN, IMMUNE }; Type type; int32_t amount = 0; diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 42f64bc9..7f62b622 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -717,7 +717,9 @@ struct TextEmoteData { */ class TextEmoteParser { public: - static bool parse(network::Packet& packet, TextEmoteData& data); + // legacyFormat: Classic 1.12 and TBC 2.4.3 send textEmoteId+emoteNum first, then senderGuid. + // WotLK 3.3.5a reverses this: senderGuid first, then textEmoteId+emoteNum. + static bool parse(network::Packet& packet, TextEmoteData& data, bool legacyFormat = false); }; // ============================================================ @@ -1729,7 +1731,8 @@ public: /** CMSG_PET_ACTION packet builder */ class PetActionPacket { public: - static network::Packet build(uint64_t petGuid, uint32_t action); + /** CMSG_PET_ACTION: petGuid + action + targetGuid (0 = no target) */ + static network::Packet build(uint64_t petGuid, uint32_t action, uint64_t targetGuid = 0); }; /** SMSG_CAST_FAILED data */ @@ -1765,6 +1768,11 @@ public: }; /** SMSG_SPELL_GO data (simplified) */ +struct SpellGoMissEntry { + uint64_t targetGuid = 0; + uint8_t missType = 0; // 0=MISS 1=DODGE 2=PARRY 3=BLOCK 4=EVADE 5=IMMUNE 6=DEFLECT 7=ABSORB 8=RESIST +}; + struct SpellGoData { uint64_t casterGuid = 0; uint64_t casterUnit = 0; @@ -1772,8 +1780,9 @@ struct SpellGoData { uint32_t spellId = 0; uint32_t castFlags = 0; uint8_t hitCount = 0; - std::vector hitTargets; + std::vector hitTargets; uint8_t missCount = 0; + std::vector missTargets; bool isValid() const { return spellId != 0; } }; @@ -1848,7 +1857,9 @@ public: /** SMSG_GROUP_LIST parser */ class GroupListParser { public: - static bool parse(network::Packet& packet, GroupListData& data); + // hasRoles: WotLK 3.3.5a added a roles byte at group level and per-member for LFD. + // Classic 1.12 and TBC 2.4.3 do not send this byte. + static bool parse(network::Packet& packet, GroupListData& data, bool hasRoles = true); }; /** SMSG_PARTY_COMMAND_RESULT data */ @@ -2220,7 +2231,9 @@ struct TrainerListData { class TrainerListParser { public: - static bool parse(network::Packet& packet, TrainerListData& data); + // isClassic: Classic 1.12 per-spell layout has no profDialog/profButton fields + // (reqLevel immediately follows cost), plus a trailing unk uint32 per entry. + static bool parse(network::Packet& packet, TrainerListData& data, bool isClassic = false); }; class TrainerBuySpellPacket { @@ -2266,6 +2279,13 @@ public: static network::Packet build(bool accept); }; +/** CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3) — switch dual-spec talent group */ +class ActivateTalentGroupPacket { +public: + /** @param group 0 = primary spec, 1 = secondary spec */ + static network::Packet build(uint32_t group); +}; + // ============================================================ // Taxi / Flight Paths // ============================================================ @@ -2612,7 +2632,8 @@ public: /** SMSG_AUCTION_LIST_RESULT parser (shared for browse/owner/bidder) */ class AuctionListResultParser { public: - static bool parse(network::Packet& packet, AuctionListResult& data); + // numEnchantSlots: Classic 1.12 = 1, TBC/WotLK = 3 (extra enchant slots per entry) + static bool parse(network::Packet& packet, AuctionListResult& data, int numEnchantSlots = 3); }; /** SMSG_AUCTION_COMMAND_RESULT parser */ diff --git a/include/network/net_platform.hpp b/include/network/net_platform.hpp index 29eaf2c8..0cc38e1a 100644 --- a/include/network/net_platform.hpp +++ b/include/network/net_platform.hpp @@ -91,6 +91,20 @@ inline bool isWouldBlock(int err) { #endif } +// Returns true for errors that mean the peer closed the connection cleanly. +// On Windows, WSAENOTCONN / WSAECONNRESET / WSAESHUTDOWN can be returned by +// recv() when the server closes the connection, rather than returning 0. +inline bool isConnectionClosed(int err) { +#ifdef _WIN32 + return err == WSAENOTCONN || // socket not connected (server closed) + err == WSAECONNRESET || // connection reset by peer + err == WSAESHUTDOWN || // socket shut down + err == WSAECONNABORTED; // connection aborted +#else + return err == ENOTCONN || err == ECONNRESET; +#endif +} + inline bool isInProgress(int err) { #ifdef _WIN32 return err == WSAEWOULDBLOCK || err == WSAEALREADY; diff --git a/include/pipeline/wmo_loader.hpp b/include/pipeline/wmo_loader.hpp index ed2cf149..4b952b5b 100644 --- a/include/pipeline/wmo_loader.hpp +++ b/include/pipeline/wmo_loader.hpp @@ -185,6 +185,7 @@ struct WMOModel { uint32_t nDoodadDefs; uint32_t nDoodadSets; + glm::vec3 ambientColor; // MOHD ambient color (used for interior group lighting) glm::vec3 boundingBoxMin; glm::vec3 boundingBoxMax; diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index a37c4a2d..e26583b5 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -122,6 +122,7 @@ struct M2ModelGPU { bool isKoboldFlame = false; // Model name matches kobold+(candle/torch/mine) (precomputed) bool isLavaModel = false; // Model name contains lava/molten/magma (UV scroll fallback) bool hasTextureAnimation = false; // True if any batch has UV animation + bool hasTransparentBatches = false; // True if any batch uses alpha-blend or additive (blendMode >= 2) uint8_t availableLODs = 0; // Bitmask: bit N set if any batch has submeshLevel==N // Particle emitter data (kept from M2Model) diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 90432595..93bbed03 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -241,13 +241,17 @@ private: std::unique_ptr zoneManager; // Shadow mapping (Vulkan) static constexpr uint32_t SHADOW_MAP_SIZE = 4096; - VkImage shadowDepthImage = VK_NULL_HANDLE; - VmaAllocation shadowDepthAlloc = VK_NULL_HANDLE; - VkImageView shadowDepthView = VK_NULL_HANDLE; + // Per-frame shadow resources: each in-flight frame has its own depth image and + // framebuffer so that frame N's shadow read and frame N+1's shadow write don't + // race on the same image across concurrent GPU submissions. + // Array size must match MAX_FRAMES (= 2, defined in the private section below). + VkImage shadowDepthImage[2] = {}; + VmaAllocation shadowDepthAlloc[2] = {}; + VkImageView shadowDepthView[2] = {}; VkSampler shadowSampler = VK_NULL_HANDLE; VkRenderPass shadowRenderPass = VK_NULL_HANDLE; - VkFramebuffer shadowFramebuffer = VK_NULL_HANDLE; - VkImageLayout shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; + VkFramebuffer shadowFramebuffer[2] = {}; + VkImageLayout shadowDepthLayout_[2] = {}; glm::mat4 lightSpaceMatrix = glm::mat4(1.0f); glm::vec3 shadowCenter = glm::vec3(0.0f); bool shadowCenterInitialized = false; diff --git a/include/rendering/terrain_manager.hpp b/include/rendering/terrain_manager.hpp index 2a746d3e..290c45eb 100644 --- a/include/rendering/terrain_manager.hpp +++ b/include/rendering/terrain_manager.hpp @@ -152,9 +152,11 @@ struct FinalizingTile { FinalizationPhase phase = FinalizationPhase::TERRAIN; // Progress indices within current phase - size_t m2ModelIndex = 0; // Next M2 model to upload - size_t wmoModelIndex = 0; // Next WMO model to upload - size_t wmoDoodadIndex = 0; // Next WMO doodad to upload + size_t m2ModelIndex = 0; // Next M2 model to upload + size_t m2InstanceIndex = 0; // Next M2 placement to instantiate + size_t wmoModelIndex = 0; // Next WMO model to upload + size_t wmoInstanceIndex = 0; // Next WMO placement to instantiate + size_t wmoDoodadIndex = 0; // Next WMO doodad to upload // Incremental terrain upload state (splits TERRAIN phase across frames) bool terrainPreloaded = false; // True after preloaded textures uploaded diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 4546d41c..50261865 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -353,7 +353,9 @@ private: float heightMapVariance; // 40 (low variance = skip POM) float normalMapStrength; // 44 (0=flat, 1=full, 2=exaggerated) int32_t isLava; // 48 (1=lava/magma UV scroll) - float pad[3]; // 52-60 padding to 64 bytes + float wmoAmbientR; // 52 (interior ambient color R) + float wmoAmbientG; // 56 (interior ambient color G) + float wmoAmbientB; // 60 (interior ambient color B) }; // 64 bytes total /** @@ -472,6 +474,7 @@ private: std::vector groups; glm::vec3 boundingBoxMin; glm::vec3 boundingBoxMax; + glm::vec3 wmoAmbientColor{0.5f, 0.5f, 0.5f}; // From MOHD, used for interior lighting bool isLowPlatform = false; // Doodad templates (M2 models placed in WMO, stored for instancing) diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index cd944b47..1496ea28 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -210,6 +210,7 @@ private: void renderMirrorTimers(game::GameHandler& gameHandler); void renderCombatText(game::GameHandler& gameHandler); void renderPartyFrames(game::GameHandler& gameHandler); + void renderBossFrames(game::GameHandler& gameHandler); void renderGroupInvitePopup(game::GameHandler& gameHandler); void renderDuelRequestPopup(game::GameHandler& gameHandler); void renderLootRollPopup(game::GameHandler& gameHandler); @@ -227,6 +228,7 @@ private: void renderTrainerWindow(game::GameHandler& gameHandler); void renderTaxiWindow(game::GameHandler& gameHandler); void renderDeathScreen(game::GameHandler& gameHandler); + void renderReclaimCorpseButton(game::GameHandler& gameHandler); void renderResurrectDialog(game::GameHandler& gameHandler); void renderEscapeMenu(); void renderSettingsWindow(); @@ -245,6 +247,7 @@ private: void renderDungeonFinderWindow(game::GameHandler& gameHandler); void renderInstanceLockouts(game::GameHandler& gameHandler); void renderNameplates(game::GameHandler& gameHandler); + void renderBattlegroundScore(game::GameHandler& gameHandler); /** * Inventory screen diff --git a/src/audio/audio_engine.cpp b/src/audio/audio_engine.cpp index 7fdcb952..f15b161a 100644 --- a/src/audio/audio_engine.cpp +++ b/src/audio/audio_engine.cpp @@ -288,11 +288,77 @@ bool AudioEngine::playSound2D(const std::vector& wavData, float volume, } // Track this sound for cleanup (decoded PCM shared across plays) - activeSounds_.push_back({sound, audioBuffer, decoded.pcmData}); + activeSounds_.push_back({sound, audioBuffer, decoded.pcmData, 0u}); return true; } +uint32_t AudioEngine::playSound2DStoppable(const std::vector& wavData, float volume) { + if (!initialized_ || !engine_ || wavData.empty()) return 0; + if (masterVolume_ <= 0.0f) return 0; + + DecodedWavCacheEntry decoded; + if (!decodeWavCached(wavData, decoded) || !decoded.pcmData || decoded.frames == 0) return 0; + + ma_audio_buffer_config bufferConfig = ma_audio_buffer_config_init( + decoded.format, decoded.channels, decoded.frames, decoded.pcmData->data(), nullptr); + bufferConfig.sampleRate = decoded.sampleRate; + + ma_audio_buffer* audioBuffer = static_cast(std::malloc(sizeof(ma_audio_buffer))); + if (!audioBuffer) return 0; + if (ma_audio_buffer_init(&bufferConfig, audioBuffer) != MA_SUCCESS) { + std::free(audioBuffer); + return 0; + } + + ma_sound* sound = static_cast(std::malloc(sizeof(ma_sound))); + if (!sound) { + ma_audio_buffer_uninit(audioBuffer); + std::free(audioBuffer); + return 0; + } + ma_result result = ma_sound_init_from_data_source( + engine_, audioBuffer, + MA_SOUND_FLAG_DECODE | MA_SOUND_FLAG_ASYNC | MA_SOUND_FLAG_NO_PITCH | MA_SOUND_FLAG_NO_SPATIALIZATION, + nullptr, sound); + if (result != MA_SUCCESS) { + ma_audio_buffer_uninit(audioBuffer); + std::free(audioBuffer); + std::free(sound); + return 0; + } + + ma_sound_set_volume(sound, volume); + if (ma_sound_start(sound) != MA_SUCCESS) { + ma_sound_uninit(sound); + ma_audio_buffer_uninit(audioBuffer); + std::free(audioBuffer); + std::free(sound); + return 0; + } + + uint32_t id = nextSoundId_++; + if (nextSoundId_ == 0) nextSoundId_ = 1; // Skip 0 (sentinel) + activeSounds_.push_back({sound, audioBuffer, decoded.pcmData, id}); + return id; +} + +void AudioEngine::stopSound(uint32_t id) { + if (id == 0) return; + for (auto it = activeSounds_.begin(); it != activeSounds_.end(); ++it) { + if (it->id == id) { + ma_sound_stop(it->sound); + ma_sound_uninit(it->sound); + std::free(it->sound); + ma_audio_buffer* buffer = static_cast(it->buffer); + ma_audio_buffer_uninit(buffer); + std::free(buffer); + activeSounds_.erase(it); + return; + } + } +} + bool AudioEngine::playSound2D(const std::string& mpqPath, float volume, float pitch) { if (!assetManager_) { LOG_WARNING("AudioEngine::playSound2D(path): no AssetManager set"); diff --git a/src/audio/spell_sound_manager.cpp b/src/audio/spell_sound_manager.cpp index 4c024b88..c72f6d7c 100644 --- a/src/audio/spell_sound_manager.cpp +++ b/src/audio/spell_sound_manager.cpp @@ -220,12 +220,22 @@ void SpellSoundManager::playPrecast(MagicSchool school, SpellPower power) { return; } - if (library) { - playSound(*library); + if (library && !library->empty() && (*library)[0].loaded) { + stopPrecast(); // Stop any previous precast still playing + float volume = 0.75f * volumeScale_; + activePrecastId_ = AudioEngine::instance().playSound2DStoppable((*library)[0].data, volume); + } +} + +void SpellSoundManager::stopPrecast() { + if (activePrecastId_ != 0) { + AudioEngine::instance().stopSound(activePrecastId_); + activePrecastId_ = 0; } } void SpellSoundManager::playCast(MagicSchool school) { + stopPrecast(); // Ensure precast doesn't overlap the cast sound switch (school) { case MagicSchool::FIRE: playSound(castFireSounds_); diff --git a/src/core/application.cpp b/src/core/application.cpp index be239cfc..b3883e0c 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -1108,8 +1108,8 @@ void Application::update(float deltaTime) { // Taxi flights move fast (32 u/s) — load further ahead so terrain is ready // before the camera arrives. Keep updates frequent to spot new tiles early. renderer->getTerrainManager()->setUpdateInterval(onTaxi ? 0.033f : 0.033f); - renderer->getTerrainManager()->setLoadRadius(onTaxi ? 6 : 4); - renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 9 : 7); + renderer->getTerrainManager()->setLoadRadius(onTaxi ? 8 : 4); + renderer->getTerrainManager()->setUnloadRadius(onTaxi ? 12 : 7); renderer->getTerrainManager()->setTaxiStreamingMode(onTaxi); } lastTaxiFlight_ = onTaxi; @@ -1710,6 +1710,10 @@ void Application::setupUICallbacks() { renderer->getCameraController()->clearMovementInputs(); renderer->getCameraController()->suppressMovementFor(0.5f); } + // Flush any tiles that finished background parsing during the cast + // (e.g. Hearthstone pre-loaded them) so they're GPU-uploaded before + // the first frame at the new position. + renderer->getTerrainManager()->processAllReadyTiles(); return; } @@ -1950,6 +1954,51 @@ void Application::setupUICallbacks() { LOG_INFO("Bindpoint set: mapId=", mapId, " pos=(", x, ", ", y, ", ", z, ")"); }); + // Hearthstone preload callback: begin loading terrain at the bind point as soon as + // the player starts casting Hearthstone. The ~10 s cast gives enough time for + // the background streaming workers to bring tiles into the cache so the player + // lands on solid ground instead of falling through un-loaded terrain. + gameHandler->setHearthstonePreloadCallback([this](uint32_t mapId, float x, float y, float z) { + if (!renderer || !assetManager) return; + + auto* terrainMgr = renderer->getTerrainManager(); + if (!terrainMgr) return; + + // Resolve map name from the cached Map.dbc table + std::string mapName; + if (auto it = mapNameById_.find(mapId); it != mapNameById_.end()) { + mapName = it->second; + } else { + mapName = mapIdToName(mapId); + } + if (mapName.empty()) mapName = "Azeroth"; + + if (mapId == loadedMapId_) { + // Same map: pre-enqueue tiles around the bind point so workers start + // loading them now. Uses render-space coords (canonicalToRender). + glm::vec3 renderPos = core::coords::canonicalToRender(glm::vec3(x, y, z)); + auto [tileX, tileY] = core::coords::worldToTile(renderPos.x, renderPos.y); + + std::vector> tiles; + tiles.reserve(25); + for (int dy = -2; dy <= 2; dy++) + for (int dx = -2; dx <= 2; dx++) + tiles.push_back({tileX + dx, tileY + dy}); + + terrainMgr->precacheTiles(tiles); + LOG_INFO("Hearthstone preload: enqueued ", tiles.size(), + " tiles around bind point (same map) tile=[", tileX, ",", tileY, "]"); + } else { + // Different map: warm the file cache so ADT parsing is fast when + // loadOnlineWorldTerrain runs its blocking load loop. + // homeBindPos_ is canonical; startWorldPreload expects server coords. + glm::vec3 server = core::coords::canonicalToServer(glm::vec3(x, y, z)); + startWorldPreload(mapId, mapName, server.x, server.y); + LOG_INFO("Hearthstone preload: started file cache warm for map '", mapName, + "' (id=", mapId, ")"); + } + }); + // Faction hostility map is built in buildFactionHostilityMap() when character enters world // Creature spawn callback (online mode) - spawn creature models diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f1402b57..9d5e4587 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -482,6 +482,7 @@ void GameHandler::disconnect() { activeCharacterGuid_ = 0; playerNameCache.clear(); pendingNameQueries.clear(); + friendGuids_.clear(); transportAttachments_.clear(); serverUpdatedTransportGuids_.clear(); requiresWarden_ = false; @@ -759,6 +760,19 @@ void GameHandler::update(float deltaTime) { } } + // Tick down all tracked unit cast bars + for (auto it = unitCastStates_.begin(); it != unitCastStates_.end(); ) { + auto& s = it->second; + if (s.casting && s.timeRemaining > 0.0f) { + s.timeRemaining -= deltaTime; + if (s.timeRemaining <= 0.0f) { + it = unitCastStates_.erase(it); + continue; + } + } + ++it; + } + // Update spell cooldowns (Phase 3) for (auto it = spellCooldowns.begin(); it != spellCooldowns.end(); ) { it->second -= deltaTime; @@ -779,6 +793,7 @@ void GameHandler::update(float deltaTime) { // Update combat text (Phase 2) updateCombatText(deltaTime); + tickMinimapPings(deltaTime); // Update taxi landing cooldown if (taxiLandingCooldown_ > 0.0f) { @@ -1470,27 +1485,15 @@ void GameHandler::handlePacket(network::Packet& packet) { handleFriendStatus(packet); } break; - case Opcode::SMSG_CONTACT_LIST: { - // Known variants: - // - Full form: uint32 listMask, uint32 count, then variable-size entries. - // - Minimal/legacy keepalive-ish form observed on some servers: 1 byte. - size_t remaining = packet.getSize() - packet.getReadPos(); - if (remaining >= 8) { - lastContactListMask_ = packet.readUInt32(); - lastContactListCount_ = packet.readUInt32(); - } else if (remaining == 1) { - /*uint8_t marker =*/ packet.readUInt8(); - lastContactListMask_ = 0; - lastContactListCount_ = 0; - } else if (remaining > 0) { - // Unknown short variant: consume to keep stream aligned, no warning spam. - packet.setReadPos(packet.getSize()); - } + case Opcode::SMSG_CONTACT_LIST: + handleContactList(packet); break; - } case Opcode::SMSG_FRIEND_LIST: + // Classic 1.12 and TBC friend list (WotLK uses SMSG_CONTACT_LIST instead) + handleFriendList(packet); + break; case Opcode::SMSG_IGNORE_LIST: - // Legacy social list variants; CONTACT_LIST is primary in modern flow. + // Ignore list: consume to avoid spurious warnings; not parsed. packet.setReadPos(packet.getSize()); break; @@ -1630,9 +1633,13 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Entity health/power delta updates ---- case Opcode::SMSG_HEALTH_UPDATE: { - // packed_guid + uint32 health - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid + uint32 health + // TBC: full uint64 + uint32 health + // Classic/Vanilla: packed_guid + uint32 health (same as WotLK) + const bool huTbc = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (huTbc ? 8u : 2u)) break; + uint64_t guid = huTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) break; uint32_t hp = packet.readUInt32(); auto entity = entityManager.getEntity(guid); @@ -1642,9 +1649,13 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_POWER_UPDATE: { - // packed_guid + uint8 powerType + uint32 value - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid + uint8 powerType + uint32 value + // TBC: full uint64 + uint8 powerType + uint32 value + // Classic/Vanilla: packed_guid + uint8 powerType + uint32 value (same as WotLK) + const bool puTbc = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (puTbc ? 8u : 2u)) break; + uint64_t guid = puTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 5) break; uint8_t powerType = packet.readUInt8(); uint32_t value = packet.readUInt32(); @@ -1689,9 +1700,13 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Combo points ---- case Opcode::SMSG_UPDATE_COMBO_POINTS: { - // packed_guid (target) + uint8 points - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t target = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid (target) + uint8 points + // TBC: full uint64 (target) + uint8 points + // Classic/Vanilla: packed_guid (target) + uint8 points (same as WotLK) + const bool cpTbc = isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (cpTbc ? 8u : 2u)) break; + uint64_t target = cpTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 1) break; comboPoints_ = packet.readUInt8(); comboTarget_ = target; @@ -1741,40 +1756,56 @@ void GameHandler::handlePacket(network::Packet& packet) { } // ---- Cast result (WotLK extended cast failed) ---- - case Opcode::SMSG_CAST_RESULT: - // WotLK: uint8 castCount + uint32 spellId + uint8 result [+ optional extra] + case Opcode::SMSG_CAST_RESULT: { + // WotLK: castCount(u8) + spellId(u32) + result(u8) + // TBC/Classic: spellId(u32) + result(u8) (no castCount prefix) // If result == 0, the spell successfully began; otherwise treat like SMSG_CAST_FAILED. - if (packet.getSize() - packet.getReadPos() >= 6) { - /*uint8_t castCount =*/ packet.readUInt8(); - /*uint32_t spellId =*/ packet.readUInt32(); - uint8_t result = packet.readUInt8(); - if (result != 0) { - // Failure — clear cast bar and show message + uint32_t castResultSpellId = 0; + uint8_t castResult = 0; + if (packetParsers_->parseCastResult(packet, castResultSpellId, castResult)) { + if (castResult != 0) { casting = false; currentCastSpellId = 0; castTimeRemaining = 0.0f; - const char* reason = getSpellCastResultString(result, -1); + const char* reason = getSpellCastResultString(castResult, -1); MessageChatData msg; msg.type = ChatType::SYSTEM; msg.language = ChatLanguage::UNIVERSAL; msg.message = reason ? reason - : ("Spell cast failed (error " + std::to_string(result) + ")"); + : ("Spell cast failed (error " + std::to_string(castResult) + ")"); addLocalChatMessage(msg); } } break; + } // ---- Spell failed on another unit ---- - case Opcode::SMSG_SPELL_FAILED_OTHER: - // packed_guid + uint8 castCount + uint32 spellId + uint8 reason — just consume + case Opcode::SMSG_SPELL_FAILED_OTHER: { + // WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 reason + // TBC/Classic: full uint64 + uint8 castCount + uint32 spellId + uint8 reason + const bool tbcLike2 = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t failOtherGuid = tbcLike2 + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (failOtherGuid != 0 && failOtherGuid != playerGuid) { + unitCastStates_.erase(failOtherGuid); + } packet.setReadPos(packet.getSize()); break; + } // ---- Spell proc resist log ---- - case Opcode::SMSG_PROCRESIST: - // guid(8) + guid(8) + uint32 spellId + uint8 logSchoolMask — just consume - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_PROCRESIST: { + // casterGuid(8) + victimGuid(8) + uint32 spellId + uint8 logSchoolMask + if (packet.getSize() - packet.getReadPos() >= 17) { + /*uint64_t caster =*/ packet.readUInt64(); + uint64_t victim = packet.readUInt64(); + uint32_t spellId = packet.readUInt32(); + if (victim == playerGuid) + addCombatText(CombatTextEntry::MISS, 0, spellId, false); + } break; + } // ---- Loot start roll (Need/Greed popup trigger) ---- case Opcode::SMSG_LOOT_START_ROLL: { @@ -2013,13 +2044,14 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_DEATH_RELEASE_LOC: { - // uint32 mapId + float x + float y + float z — spirit healer position + // uint32 mapId + float x + float y + float z — corpse/spirit healer position if (packet.getSize() - packet.getReadPos() >= 16) { - uint32_t mapId = packet.readUInt32(); - float x = packet.readFloat(); - float y = packet.readFloat(); - float z = packet.readFloat(); - LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", mapId, " x=", x, " y=", y, " z=", z); + corpseMapId_ = packet.readUInt32(); + corpseX_ = packet.readFloat(); + corpseY_ = packet.readFloat(); + corpseZ_ = packet.readFloat(); + LOG_INFO("SMSG_DEATH_RELEASE_LOC: map=", corpseMapId_, + " x=", corpseX_, " y=", corpseY_, " z=", corpseZ_); } break; } @@ -2166,10 +2198,35 @@ void GameHandler::handlePacket(network::Packet& packet) { pendingLootRollActive_ = false; break; } - case Opcode::SMSG_LOOT_ITEM_NOTIFY: - // uint64 looterGuid + uint64 lootGuid + uint32 itemId + uint32 count — consume - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_LOOT_ITEM_NOTIFY: { + // uint64 looterGuid + uint64 lootGuid + uint32 itemId + uint32 count + if (packet.getSize() - packet.getReadPos() < 24) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t looterGuid = packet.readUInt64(); + /*uint64_t lootGuid =*/ packet.readUInt64(); + uint32_t itemId = packet.readUInt32(); + uint32_t count = packet.readUInt32(); + // Show loot message for party members (not the player — SMSG_ITEM_PUSH_RESULT covers that) + if (isInGroup() && looterGuid != playerGuid) { + auto nit = playerNameCache.find(looterGuid); + std::string looterName = (nit != playerNameCache.end()) ? nit->second : ""; + if (!looterName.empty()) { + queryItemInfo(itemId, 0); + std::string itemName = "item #" + std::to_string(itemId); + if (const ItemQueryResponseData* info = getItemInfo(itemId)) { + if (!info->name.empty()) itemName = info->name; + } + char buf[256]; + if (count > 1) + std::snprintf(buf, sizeof(buf), "%s loots %s x%u.", looterName.c_str(), itemName.c_str(), count); + else + std::snprintf(buf, sizeof(buf), "%s loots %s.", looterName.c_str(), itemName.c_str()); + addSystemChatMessage(buf); + } + } break; + } case Opcode::SMSG_LOOT_SLOT_CHANGED: // uint64 objectGuid + uint32 slot + ... — consume packet.setReadPos(packet.getSize()); @@ -2177,12 +2234,19 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell log miss ---- case Opcode::SMSG_SPELLLOGMISS: { - // packed_guid caster + packed_guid target + uint8 isCrit + uint32 count + // WotLK: packed_guid caster + packed_guid target + uint8 isCrit + uint32 count + // TBC/Classic: full uint64 caster + full uint64 target + uint8 isCrit + uint32 count // + count × (uint64 victimGuid + uint8 missInfo) - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) break; - /*uint64_t targetGuidLog =*/ UpdateObjectParser::readPackedGuid(packet); + const bool spellMissTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto readSpellMissGuid = [&]() -> uint64_t { + if (spellMissTbcLike) + return (packet.getSize() - packet.getReadPos() >= 8) ? packet.readUInt64() : 0; + return UpdateObjectParser::readPackedGuid(packet); + }; + if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; + uint64_t casterGuid = readSpellMissGuid(); + if (packet.getSize() - packet.getReadPos() < (spellMissTbcLike ? 8 : 1)) break; + /*uint64_t targetGuidLog =*/ readSpellMissGuid(); if (packet.getSize() - packet.getReadPos() < 5) break; /*uint8_t isCrit =*/ packet.readUInt8(); uint32_t count = packet.readUInt32(); @@ -2479,11 +2543,28 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SPELL_GO: handleSpellGo(packet); break; - case Opcode::SMSG_SPELL_FAILURE: - // Spell failed mid-cast - casting = false; - currentCastSpellId = 0; + case Opcode::SMSG_SPELL_FAILURE: { + // WotLK: packed_guid + uint8 castCount + uint32 spellId + uint8 failReason + // TBC/Classic: full uint64 + uint8 castCount + uint32 spellId + uint8 failReason + const bool tbcOrClassic = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t failGuid = tbcOrClassic + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (failGuid == playerGuid || failGuid == 0) { + // Player's own cast failed + casting = false; + currentCastSpellId = 0; + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + ssm->stopPrecast(); + } + } + } else { + // Another unit's cast failed — clear their tracked cast bar + unitCastStates_.erase(failGuid); + } break; + } case Opcode::SMSG_SPELL_COOLDOWN: handleSpellCooldown(packet); break; @@ -2556,10 +2637,24 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_FISH_ESCAPED: addSystemChatMessage("Your fish escaped!"); break; - case Opcode::MSG_MINIMAP_PING: - // Minimap ping from a party member — consume; no visual support yet. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_MINIMAP_PING: { + // WotLK: packed_guid + float posX + float posY + // TBC/Classic: uint64 + float posX + float posY + const bool mmTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (mmTbcLike ? 8u : 1u)) break; + uint64_t senderGuid = mmTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) break; + float pingX = packet.readFloat(); // server sends map-coord X (east-west) + float pingY = packet.readFloat(); // server sends map-coord Y (north-south) + MinimapPing ping; + ping.senderGuid = senderGuid; + ping.wowX = pingY; // canonical WoW X = north = server's posY + ping.wowY = pingX; // canonical WoW Y = west = server's posX + ping.age = 0.0f; + minimapPings_.push_back(ping); break; + } case Opcode::SMSG_ZONE_UNDER_ATTACK: { // uint32 areaId if (packet.getSize() - packet.getReadPos() >= 4) { @@ -2680,7 +2775,9 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_RAID_READY_CHECK: { // Server is broadcasting a ready check (someone in the raid initiated it). // Payload: empty body, or optional uint64 initiator GUID in some builds. - pendingReadyCheck_ = true; + pendingReadyCheck_ = true; + readyCheckReadyCount_ = 0; + readyCheckNotReadyCount_ = 0; readyCheckInitiator_.clear(); if (packet.getSize() - packet.getReadPos() >= 8) { uint64_t initiatorGuid = packet.readUInt64(); @@ -2701,10 +2798,38 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("MSG_RAID_READY_CHECK: initiator=", readyCheckInitiator_); break; } - case Opcode::MSG_RAID_READY_CHECK_CONFIRM: - // Another member responded to the ready check — consume. - packet.setReadPos(packet.getSize()); + case Opcode::MSG_RAID_READY_CHECK_CONFIRM: { + // guid (8) + uint8 isReady (0=not ready, 1=ready) + if (packet.getSize() - packet.getReadPos() < 9) { packet.setReadPos(packet.getSize()); break; } + uint64_t respGuid = packet.readUInt64(); + uint8_t isReady = packet.readUInt8(); + if (isReady) ++readyCheckReadyCount_; + else ++readyCheckNotReadyCount_; + auto nit = playerNameCache.find(respGuid); + std::string rname; + if (nit != playerNameCache.end()) rname = nit->second; + else { + auto ent = entityManager.getEntity(respGuid); + if (ent) rname = std::static_pointer_cast(ent)->getName(); + } + if (!rname.empty()) { + char rbuf[128]; + std::snprintf(rbuf, sizeof(rbuf), "%s is %s.", rname.c_str(), isReady ? "Ready" : "Not Ready"); + addSystemChatMessage(rbuf); + } break; + } + case Opcode::MSG_RAID_READY_CHECK_FINISHED: { + // Ready check complete — summarize results + char fbuf[128]; + std::snprintf(fbuf, sizeof(fbuf), "Ready check complete: %u ready, %u not ready.", + readyCheckReadyCount_, readyCheckNotReadyCount_); + addSystemChatMessage(fbuf); + pendingReadyCheck_ = false; + readyCheckReadyCount_ = 0; + readyCheckNotReadyCount_ = 0; + break; + } case Opcode::SMSG_RAID_INSTANCE_INFO: handleRaidInstanceInfo(packet); break; @@ -2726,11 +2851,35 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DUEL_COUNTDOWN: // Countdown timer — no action needed; server also sends UNIT_FIELD_FLAGS update. break; - case Opcode::SMSG_PARTYKILLLOG: - // Classic-era packet: killer GUID + victim GUID. - // XP and combat state are handled by other packets; consume to avoid warning spam. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_PARTYKILLLOG: { + // uint64 killerGuid + uint64 victimGuid + if (packet.getSize() - packet.getReadPos() < 16) break; + uint64_t killerGuid = packet.readUInt64(); + uint64_t victimGuid = packet.readUInt64(); + // Show kill message in party chat style + auto nameForGuid = [&](uint64_t g) -> std::string { + // Check player name cache first + auto nit = playerNameCache.find(g); + if (nit != playerNameCache.end()) return nit->second; + // Fall back to entity name (NPCs) + auto ent = entityManager.getEntity(g); + if (ent && (ent->getType() == game::ObjectType::UNIT || + ent->getType() == game::ObjectType::PLAYER)) { + auto unit = std::static_pointer_cast(ent); + return unit->getName(); + } + return {}; + }; + std::string killerName = nameForGuid(killerGuid); + std::string victimName = nameForGuid(victimGuid); + if (!killerName.empty() && !victimName.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "%s killed %s.", + killerName.c_str(), victimName.c_str()); + addSystemChatMessage(buf); + } break; + } // ---- Guild ---- case Opcode::SMSG_GUILD_INFO: @@ -2839,6 +2988,8 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_INFO("Spirit healer confirm from 0x", std::hex, npcGuid, std::dec); if (npcGuid) { resurrectCasterGuid_ = npcGuid; + resurrectCasterName_ = ""; + resurrectIsSpiritHealer_ = true; resurrectRequestPending_ = true; } break; @@ -2849,9 +3000,22 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } uint64_t casterGuid = packet.readUInt64(); - LOG_INFO("Resurrect request from 0x", std::hex, casterGuid, std::dec); + // Optional caster name (CString, may be absent on some server builds) + std::string casterName; + if (packet.getReadPos() < packet.getSize()) { + casterName = packet.readString(); + } + LOG_INFO("Resurrect request from 0x", std::hex, casterGuid, std::dec, + " name='", casterName, "'"); if (casterGuid) { resurrectCasterGuid_ = casterGuid; + resurrectIsSpiritHealer_ = false; + if (!casterName.empty()) { + resurrectCasterName_ = casterName; + } else { + auto nit = playerNameCache.find(casterGuid); + resurrectCasterName_ = (nit != playerNameCache.end()) ? nit->second : ""; + } resurrectRequestPending_ = true; } break; @@ -3021,14 +3185,49 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_FEATURE_SYSTEM_STATUS: case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: - case Opcode::SMSG_SPELL_DELAYED: + // Different formats than SMSG_SPELL_DELAYED — consume and ignore + packet.setReadPos(packet.getSize()); + break; + + case Opcode::SMSG_SPELL_DELAYED: { + // WotLK: packed_guid (caster) + uint32 delayMs + // TBC/Classic: uint64 (caster) + uint32 delayMs + const bool spellDelayTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (spellDelayTbcLike ? 8u : 1u)) break; + uint64_t caster = spellDelayTbcLike + ? packet.readUInt64() + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t delayMs = packet.readUInt32(); + if (delayMs == 0) break; + float delaySec = delayMs / 1000.0f; + if (caster == playerGuid) { + if (casting) castTimeRemaining += delaySec; + } else { + auto it = unitCastStates_.find(caster); + if (it != unitCastStates_.end() && it->second.casting) { + it->second.timeRemaining += delaySec; + it->second.timeTotal += delaySec; + } + } + break; + } case Opcode::SMSG_EQUIPMENT_SET_SAVED: + // uint32 setIndex + uint64 guid — equipment set was successfully saved + LOG_DEBUG("Equipment set saved"); + break; case Opcode::SMSG_PERIODICAURALOG: { - // packed_guid victim, packed_guid caster, uint32 spellId, uint32 count, then per-effect - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); - if (packet.getSize() - packet.getReadPos() < 2) break; - uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint32 count + effects + // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint32 count + effects + // Classic/Vanilla: packed_guid (same as WotLK) + const bool periodicTbc = isActiveExpansion("tbc"); + const size_t guidMinSz = periodicTbc ? 8u : 2u; + if (packet.getSize() - packet.getReadPos() < guidMinSz) break; + uint64_t victimGuid = periodicTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < guidMinSz) break; + uint64_t casterGuid = periodicTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 8) break; uint32_t spellId = packet.readUInt32(); uint32_t count = packet.readUInt32(); @@ -3067,11 +3266,16 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } case Opcode::SMSG_SPELLENERGIZELOG: { - // packed victim GUID, packed caster GUID, uint32 spellId, uint8 powerType, int32 amount + // WotLK: packed_guid victim + packed_guid caster + uint32 spellId + uint8 powerType + int32 amount + // TBC: full uint64 victim + uint64 caster + uint32 spellId + uint8 powerType + int32 amount + // Classic/Vanilla: packed_guid (same as WotLK) + const bool energizeTbc = isActiveExpansion("tbc"); size_t rem = packet.getSize() - packet.getReadPos(); - if (rem < 4) { packet.setReadPos(packet.getSize()); break; } - uint64_t victimGuid = UpdateObjectParser::readPackedGuid(packet); - uint64_t casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem < (energizeTbc ? 8u : 2u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t victimGuid = energizeTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + uint64_t casterGuid = energizeTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); rem = packet.getSize() - packet.getReadPos(); if (rem < 6) { packet.setReadPos(packet.getSize()); break; } uint32_t spellId = packet.readUInt32(); @@ -3097,9 +3301,20 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; } - case Opcode::SMSG_SET_PROFICIENCY: - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_SET_PROFICIENCY: { + // uint8 itemClass + uint32 itemSubClassMask + if (packet.getSize() - packet.getReadPos() < 5) break; + uint8_t itemClass = packet.readUInt8(); + uint32_t mask = packet.readUInt32(); + if (itemClass == 2) { // Weapon + weaponProficiency_ = mask; + LOG_DEBUG("SMSG_SET_PROFICIENCY: weapon mask=0x", std::hex, mask, std::dec); + } else if (itemClass == 4) { // Armor + armorProficiency_ = mask; + LOG_DEBUG("SMSG_SET_PROFICIENCY: armor mask=0x", std::hex, mask, std::dec); + } break; + } case Opcode::SMSG_ACTION_BUTTONS: { // uint8 mode (0=initial, 1=update) + 144 × uint32 packed buttons @@ -4282,15 +4497,57 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_SET_EXTRA_AURA_INFO_NEED_UPDATE: packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_TAXINODE_STATUS: - // Node status cache not implemented yet. - packet.setReadPos(packet.getSize()); + case Opcode::SMSG_TAXINODE_STATUS: { + // guid(8) + status(1): status 1 = NPC has available/new routes for this player + if (packet.getSize() - packet.getReadPos() >= 9) { + uint64_t npcGuid = packet.readUInt64(); + uint8_t status = packet.readUInt8(); + taxiNpcHasRoutes_[npcGuid] = (status != 0); + } break; + } case Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE: - case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: - // Extra aura metadata (icons/durations) not yet consumed by aura UI. + case Opcode::SMSG_SET_EXTRA_AURA_INFO_OBSOLETE: { + // TBC 2.4.3 aura tracking: replaces SMSG_AURA_UPDATE which doesn't exist in TBC. + // Format: uint64 targetGuid + uint8 count + N×{uint8 slot, uint32 spellId, + // uint8 effectIndex, uint8 flags, uint32 durationMs, uint32 maxDurationMs} + const bool isInit = (*logicalOp == Opcode::SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE); + auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (remaining() < 9) { packet.setReadPos(packet.getSize()); break; } + uint64_t auraTargetGuid = packet.readUInt64(); + uint8_t count = packet.readUInt8(); + + std::vector* auraList = nullptr; + if (auraTargetGuid == playerGuid) auraList = &playerAuras; + else if (auraTargetGuid == targetGuid) auraList = &targetAuras; + + if (auraList && isInit) auraList->clear(); + + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + + for (uint8_t i = 0; i < count && remaining() >= 13; i++) { + uint8_t slot = packet.readUInt8(); + uint32_t spellId = packet.readUInt32(); + (void) packet.readUInt8(); // effectIndex (unused for slot display) + uint8_t flags = packet.readUInt8(); + uint32_t durationMs = packet.readUInt32(); + uint32_t maxDurMs = packet.readUInt32(); + + if (auraList) { + while (auraList->size() <= slot) auraList->push_back(AuraSlot{}); + AuraSlot& a = (*auraList)[slot]; + a.spellId = spellId; + a.flags = flags; + a.durationMs = (durationMs == 0xFFFFFFFF) ? -1 : static_cast(durationMs); + a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast(maxDurMs); + a.receivedAtMs = nowMs; + } + } packet.setReadPos(packet.getSize()); break; + } case Opcode::MSG_MOVE_WORLDPORT_ACK: // Client uses this outbound; treat inbound variant as no-op for robustness. packet.setReadPos(packet.getSize()); @@ -4503,29 +4760,254 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Spell combat logs (consume) ---- case Opcode::SMSG_AURACASTLOG: case Opcode::SMSG_SPELLBREAKLOG: - case Opcode::SMSG_SPELLDAMAGESHIELD: - case Opcode::SMSG_SPELLDISPELLOG: + case Opcode::SMSG_SPELLDAMAGESHIELD: { + // victimGuid(8) + casterGuid(8) + spellId(4) + damage(4) + schoolMask(4) + if (packet.getSize() - packet.getReadPos() < 24) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t victimGuid = packet.readUInt64(); + uint64_t casterGuid = packet.readUInt64(); + /*uint32_t spellId =*/ packet.readUInt32(); + uint32_t damage = packet.readUInt32(); + /*uint32_t school =*/ packet.readUInt32(); + // Show combat text: damage shield reflect + if (casterGuid == playerGuid) { + // We have a damage shield that reflected damage + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, true); + } else if (victimGuid == playerGuid) { + // A damage shield hit us (e.g. target's Thorns) + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(damage), 0, false); + } + break; + } + case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: { + // WotLK: packed casterGuid + packed victimGuid + uint32 spellId + uint8 saveType + // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + uint32 + uint8 + const bool immuneTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + const size_t minSz = immuneTbcLike ? 21u : 2u; + if (packet.getSize() - packet.getReadPos() < minSz) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = immuneTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < (immuneTbcLike ? 8u : 2u)) break; + uint64_t victimGuid = immuneTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 5) break; + /*uint32_t spellId =*/ packet.readUInt32(); + /*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 == playerGuid || victimGuid == playerGuid) { + addCombatText(CombatTextEntry::IMMUNE, 0, 0, + casterGuid == playerGuid); + } + break; + } + case Opcode::SMSG_SPELLDISPELLOG: { + // WotLK: packed casterGuid + packed victimGuid + uint32 dispelSpell + uint8 isStolen + // TBC/Classic: full uint64 casterGuid + full uint64 victimGuid + ... + // + uint32 count + count × (uint32 dispelled_spellId + uint32 unk) + const bool dispelTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t casterGuid = dispelTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < (dispelTbcLike ? 8u : 2u)) break; + uint64_t victimGuid = dispelTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 9) break; + /*uint32_t dispelSpell =*/ packet.readUInt32(); + uint8_t isStolen = packet.readUInt8(); + uint32_t count = packet.readUInt32(); + // Show system message if player was victim or caster + if (victimGuid == playerGuid || casterGuid == playerGuid) { + const char* verb = isStolen ? "stolen" : "dispelled"; + // Collect first dispelled spell name for the message + std::string firstSpellName; + for (uint32_t i = 0; i < count && packet.getSize() - packet.getReadPos() >= 8; ++i) { + uint32_t dispelledId = packet.readUInt32(); + /*uint32_t unk =*/ packet.readUInt32(); + if (i == 0) { + const std::string& nm = getSpellName(dispelledId); + firstSpellName = nm.empty() ? ("spell " + std::to_string(dispelledId)) : nm; + } + } + if (!firstSpellName.empty()) { + char buf[256]; + if (victimGuid == playerGuid && casterGuid != playerGuid) + std::snprintf(buf, sizeof(buf), "%s was %s.", firstSpellName.c_str(), verb); + else if (casterGuid == playerGuid) + std::snprintf(buf, sizeof(buf), "You %s %s.", verb, firstSpellName.c_str()); + else + std::snprintf(buf, sizeof(buf), "%s %s.", firstSpellName.c_str(), verb); + addSystemChatMessage(buf); + } + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_SPELLSTEALLOG: { + // Similar to SPELLDISPELLOG but always isStolen=true; same wire format + // Just consume — SPELLDISPELLOG handles the player-facing case above + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_SPELLINSTAKILLLOG: case Opcode::SMSG_SPELLLOGEXECUTE: - case Opcode::SMSG_SPELLORDAMAGE_IMMUNE: - case Opcode::SMSG_SPELLSTEALLOG: case Opcode::SMSG_SPELL_CHANCE_PROC_LOG: case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_CLEAR_EXTRA_AURA_INFO: { + // TBC 2.4.3: clear a single aura slot for a unit + // Format: uint64 targetGuid + uint8 slot + if (packet.getSize() - packet.getReadPos() >= 9) { + uint64_t clearGuid = packet.readUInt64(); + uint8_t slot = packet.readUInt8(); + std::vector* auraList = nullptr; + if (clearGuid == playerGuid) auraList = &playerAuras; + else if (clearGuid == targetGuid) auraList = &targetAuras; + if (auraList && slot < auraList->size()) { + (*auraList)[slot] = AuraSlot{}; + } + } + packet.setReadPos(packet.getSize()); + break; + } + // ---- Misc consume ---- - case Opcode::SMSG_CLEAR_EXTRA_AURA_INFO: case Opcode::SMSG_COMPLAIN_RESULT: case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: case Opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE: case Opcode::SMSG_LOOT_LIST: - case Opcode::SMSG_RESUME_CAST_BAR: - case Opcode::SMSG_THREAT_UPDATE: + // Consume — not yet processed + packet.setReadPos(packet.getSize()); + break; + + case Opcode::SMSG_RESUME_CAST_BAR: { + // WotLK: packed_guid caster + packed_guid target + uint32 spellId + uint32 remainingMs + uint32 totalMs + uint8 schoolMask + // TBC/Classic: uint64 caster + uint64 target + ... + const bool rcbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto remaining = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (remaining() < (rcbTbc ? 8u : 1u)) break; + uint64_t caster = rcbTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (remaining() < (rcbTbc ? 8u : 1u)) break; + if (rcbTbc) packet.readUInt64(); // target (discard) + else (void)UpdateObjectParser::readPackedGuid(packet); // target + if (remaining() < 12) break; + uint32_t spellId = packet.readUInt32(); + uint32_t remainMs = packet.readUInt32(); + uint32_t totalMs = packet.readUInt32(); + if (totalMs > 0) { + if (caster == playerGuid) { + casting = true; + currentCastSpellId = spellId; + castTimeTotal = totalMs / 1000.0f; + castTimeRemaining = remainMs / 1000.0f; + } else { + auto& s = unitCastStates_[caster]; + s.casting = true; + s.spellId = spellId; + s.timeTotal = totalMs / 1000.0f; + s.timeRemaining = remainMs / 1000.0f; + } + LOG_DEBUG("SMSG_RESUME_CAST_BAR: caster=0x", std::hex, caster, std::dec, + " spell=", spellId, " remaining=", remainMs, "ms total=", totalMs, "ms"); + } + break; + } + // ---- Channeled spell start/tick (WotLK: packed GUIDs; TBC/Classic: full uint64) ---- + case Opcode::MSG_CHANNEL_START: { + // casterGuid + uint32 spellId + uint32 totalDurationMs + const bool tbcOrClassic = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t chanCaster = tbcOrClassic + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) break; + uint32_t chanSpellId = packet.readUInt32(); + uint32_t chanTotalMs = packet.readUInt32(); + if (chanTotalMs > 0 && chanCaster != 0) { + if (chanCaster == playerGuid) { + casting = true; + currentCastSpellId = chanSpellId; + castTimeTotal = chanTotalMs / 1000.0f; + castTimeRemaining = castTimeTotal; + } else { + auto& s = unitCastStates_[chanCaster]; + s.casting = true; + s.spellId = chanSpellId; + s.timeTotal = chanTotalMs / 1000.0f; + s.timeRemaining = s.timeTotal; + } + LOG_DEBUG("MSG_CHANNEL_START: caster=0x", std::hex, chanCaster, std::dec, + " spell=", chanSpellId, " total=", chanTotalMs, "ms"); + } + break; + } + case Opcode::MSG_CHANNEL_UPDATE: { + // casterGuid + uint32 remainingMs + const bool tbcOrClassic2 = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t chanCaster2 = tbcOrClassic2 + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t chanRemainMs = packet.readUInt32(); + if (chanCaster2 == playerGuid) { + castTimeRemaining = chanRemainMs / 1000.0f; + if (chanRemainMs == 0) { + casting = false; + currentCastSpellId = 0; + } + } else if (chanCaster2 != 0) { + auto it = unitCastStates_.find(chanCaster2); + if (it != unitCastStates_.end()) { + it->second.timeRemaining = chanRemainMs / 1000.0f; + if (chanRemainMs == 0) unitCastStates_.erase(it); + } + } + LOG_DEBUG("MSG_CHANNEL_UPDATE: caster=0x", std::hex, chanCaster2, std::dec, + " remaining=", chanRemainMs, "ms"); + break; + } + + case Opcode::SMSG_THREAT_UPDATE: { + // packed_guid (unit) + packed_guid (target) + uint32 count + // + count × (packed_guid victim + uint32 threat) — consume to suppress warnings + if (packet.getSize() - packet.getReadPos() < 1) break; + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 1) break; + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t cnt = packet.readUInt32(); + for (uint32_t i = 0; i < cnt && packet.getSize() - packet.getReadPos() >= 1; ++i) { + (void)UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() >= 4) + packet.readUInt32(); + } + break; + } + case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: { + // uint32 slot + packed_guid unit (0 packed = clear slot) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); + break; + } + uint32_t slot = packet.readUInt32(); + uint64_t unit = UpdateObjectParser::readPackedGuid(packet); + if (slot < kMaxEncounterSlots) { + encounterUnitGuids_[slot] = unit; + LOG_DEBUG("SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: slot=", slot, + " guid=0x", std::hex, unit, std::dec); + } + break; + } case Opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP: case Opcode::SMSG_UPDATE_LAST_INSTANCE: - case Opcode::SMSG_UPDATE_INSTANCE_ENCOUNTER_UNIT: case Opcode::SMSG_SEND_ALL_COMBAT_LOG: case Opcode::SMSG_SET_PROJECTILE_POSITION: case Opcode::SMSG_AUCTION_LIST_PENDING_SALES: @@ -4540,10 +5022,18 @@ void GameHandler::handlePacket(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() >= 12) { /*uint64_t guid =*/ packet.readUInt64(); uint32_t achievementId = packet.readUInt32(); - char buf[192]; - std::snprintf(buf, sizeof(buf), - "%s is the first on the realm to earn achievement #%u!", - charName.c_str(), achievementId); + 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); } } @@ -4698,9 +5188,33 @@ void GameHandler::handlePacket(network::Packet& packet) { break; // ---- Resistance/combat log ---- - case Opcode::SMSG_RESISTLOG: + case Opcode::SMSG_RESISTLOG: { + // WotLK: uint32 hitInfo + packed_guid attacker + packed_guid victim + uint32 spellId + // + float resistFactor + uint32 targetRes + uint32 resistedValue + ... + // TBC/Classic: same but full uint64 GUIDs + // Show RESIST combat text when player resists an incoming spell. + const bool rlTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto rl_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } + /*uint32_t hitInfo =*/ packet.readUInt32(); + if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t attackerGuid = rlTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (rl_rem() < (rlTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t victimGuid = rlTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (rl_rem() < 4) { packet.setReadPos(packet.getSize()); break; } + uint32_t spellId = packet.readUInt32(); + (void)attackerGuid; + // Show RESIST when player is the victim; show as caster-side MISS when player is attacker + if (victimGuid == playerGuid) { + addCombatText(CombatTextEntry::MISS, 0, spellId, false); + } else if (attackerGuid == playerGuid) { + addCombatText(CombatTextEntry::MISS, 0, spellId, true); + } packet.setReadPos(packet.getSize()); break; + } // ---- Read item results ---- case Opcode::SMSG_READ_ITEM_OK: @@ -4766,15 +5280,69 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; - // ---- Pet system (not yet implemented) ---- - case Opcode::SMSG_PET_GUIDS: - case Opcode::SMSG_PET_MODE: + // ---- Pet system ---- + case Opcode::SMSG_PET_MODE: { + // uint64 petGuid, uint32 mode + // mode bits: low byte = command state, next byte = react state + if (packet.getSize() - packet.getReadPos() >= 12) { + uint64_t modeGuid = packet.readUInt64(); + uint32_t mode = packet.readUInt32(); + if (modeGuid == petGuid_) { + petCommand_ = static_cast(mode & 0xFF); + petReact_ = static_cast((mode >> 8) & 0xFF); + LOG_DEBUG("SMSG_PET_MODE: command=", (int)petCommand_, + " react=", (int)petReact_); + } + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_PET_BROKEN: - case Opcode::SMSG_PET_CAST_FAILED: + // Pet bond broken (died or forcibly dismissed) — clear pet state + petGuid_ = 0; + petSpellList_.clear(); + petAutocastSpells_.clear(); + memset(petActionSlots_, 0, sizeof(petActionSlots_)); + addSystemChatMessage("Your pet has died."); + LOG_INFO("SMSG_PET_BROKEN: pet bond broken"); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_PET_LEARNED_SPELL: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t spellId = packet.readUInt32(); + petSpellList_.push_back(spellId); + LOG_DEBUG("SMSG_PET_LEARNED_SPELL: spellId=", spellId); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_PET_UNLEARNED_SPELL: { + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t spellId = packet.readUInt32(); + petSpellList_.erase( + std::remove(petSpellList_.begin(), petSpellList_.end(), spellId), + petSpellList_.end()); + petAutocastSpells_.erase(spellId); + LOG_DEBUG("SMSG_PET_UNLEARNED_SPELL: spellId=", spellId); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_PET_CAST_FAILED: { + if (packet.getSize() - packet.getReadPos() >= 5) { + uint8_t castCount = packet.readUInt8(); + uint32_t spellId = packet.readUInt32(); + uint32_t reason = (packet.getSize() - packet.getReadPos() >= 4) + ? packet.readUInt32() : 0; + LOG_DEBUG("SMSG_PET_CAST_FAILED: spell=", spellId, + " reason=", reason, " castCount=", (int)castCount); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_PET_GUIDS: case Opcode::SMSG_PET_DISMISS_SOUND: case Opcode::SMSG_PET_ACTION_SOUND: - case Opcode::SMSG_PET_LEARNED_SPELL: - case Opcode::SMSG_PET_UNLEARNED_SPELL: case Opcode::SMSG_PET_UNLEARN_CONFIRM: case Opcode::SMSG_PET_NAME_INVALID: case Opcode::SMSG_PET_RENAMEABLE: @@ -4789,7 +5357,8 @@ void GameHandler::handlePacket(network::Packet& packet) { // ---- Multiple aggregated packets/moves ---- case Opcode::SMSG_MULTIPLE_MOVES: - packet.setReadPos(packet.getSize()); + // Same wire format as SMSG_COMPRESSED_MOVES: uint8 size + uint16 opcode + payload[] + handleCompressedMoves(packet); break; case Opcode::SMSG_MULTIPLE_PACKETS: { @@ -5290,6 +5859,7 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { actionBar = {}; playerAuras.clear(); targetAuras.clear(); + unitCastStates_.clear(); petGuid_ = 0; playerXp_ = 0; playerNextLevelXp_ = 0; @@ -5408,6 +5978,9 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { mountCallback_(0); } + // Clear boss encounter unit slots on world transfer + encounterUnitGuids_.fill(0); + // Suppress area triggers on initial login — prevents exit portals from // immediately firing when spawning inside a dungeon/instance. activeAreaTriggers_.clear(); @@ -7976,8 +8549,11 @@ void GameHandler::sendTextEmote(uint32_t textEmoteId, uint64_t targetGuid) { } void GameHandler::handleTextEmote(network::Packet& packet) { + // Classic 1.12 and TBC 2.4.3 send: textEmoteId(u32) + emoteNum(u32) + senderGuid(u64) + nameLen(u32) + name + // WotLK 3.3.5a reversed this to: senderGuid(u64) + textEmoteId(u32) + emoteNum(u32) + nameLen(u32) + name + const bool legacyFormat = isClassicLikeExpansion() || isActiveExpansion("tbc"); TextEmoteData data; - if (!TextEmoteParser::parse(packet, data)) { + if (!TextEmoteParser::parse(packet, data, legacyFormat)) { LOG_WARNING("Failed to parse SMSG_TEXT_EMOTE"); return; } @@ -8143,6 +8719,9 @@ void GameHandler::setTarget(uint64_t guid) { targetGuid = guid; + // Clear previous target's cast bar on target change + // (the new target's cast state is naturally fetched from unitCastStates_ by GUID) + // Inform server of target selection (Phase 1) if (state == WorldState::IN_WORLD && socket) { auto packet = SetSelectionPacket::build(guid); @@ -9111,6 +9690,24 @@ void GameHandler::releaseSpirit() { } } +bool GameHandler::canReclaimCorpse() const { + if (!releasedSpirit_ || corpseMapId_ == 0) return false; + // Only if ghost is on the same map as their corpse + if (currentMapId_ != corpseMapId_) return false; + // Must be within 40 yards (server also validates proximity) + float dx = movementInfo.x - corpseX_; + float dy = movementInfo.y - corpseY_; + float dz = movementInfo.z - corpseZ_; + return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); +} + +void GameHandler::reclaimCorpse() { + if (!canReclaimCorpse() || !socket) return; + network::Packet packet(wireOpcode(Opcode::CMSG_RECLAIM_CORPSE)); + socket->send(packet); + LOG_INFO("Sent CMSG_RECLAIM_CORPSE"); +} + void GameHandler::activateSpiritHealer(uint64_t npcGuid) { if (state != WorldState::IN_WORLD || !socket) return; pendingSpiritHealerGuid_ = npcGuid; @@ -9122,11 +9719,19 @@ void GameHandler::activateSpiritHealer(uint64_t npcGuid) { void GameHandler::acceptResurrect() { if (state != WorldState::IN_WORLD || !socket || !resurrectRequestPending_) return; - // Send spirit healer activate (correct response to SMSG_SPIRIT_HEALER_CONFIRM) - auto activate = SpiritHealerActivatePacket::build(resurrectCasterGuid_); - socket->send(activate); - LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE (0x21C) for 0x", - std::hex, resurrectCasterGuid_, std::dec); + if (resurrectIsSpiritHealer_) { + // Spirit healer resurrection — SMSG_SPIRIT_HEALER_CONFIRM → CMSG_SPIRIT_HEALER_ACTIVATE + auto activate = SpiritHealerActivatePacket::build(resurrectCasterGuid_); + socket->send(activate); + LOG_INFO("Sent CMSG_SPIRIT_HEALER_ACTIVATE for 0x", + std::hex, resurrectCasterGuid_, std::dec); + } else { + // Player-cast resurrection — SMSG_RESURRECT_REQUEST → CMSG_RESURRECT_RESPONSE (accept=1) + auto resp = ResurrectResponsePacket::build(resurrectCasterGuid_, true); + socket->send(resp); + LOG_INFO("Sent CMSG_RESURRECT_RESPONSE (accept) for 0x", + std::hex, resurrectCasterGuid_, std::dec); + } resurrectRequestPending_ = false; resurrectPending_ = true; } @@ -9293,12 +9898,18 @@ void GameHandler::handleNameQueryResponse(network::Packet& packet) { mail.senderName = data.name; } } + + // Backfill friend list: if this GUID came from a friend list packet, + // register the name in friendsCache now that we know it. + if (friendGuids_.count(data.guid)) { + friendsCache[data.name] = data.guid; + } } } void GameHandler::handleCreatureQueryResponse(network::Packet& packet) { CreatureQueryResponseData data; - if (!CreatureQueryResponseParser::parse(packet, data)) return; + if (!packetParsers_->parseCreatureQueryResponse(packet, data)) return; pendingCreatureQueries.erase(data.entry); @@ -9501,9 +10112,12 @@ void GameHandler::handleInspectResults(network::Packet& packet) { } // talentType == 1: inspect result - if (packet.getSize() - packet.getReadPos() < 2) return; + // WotLK: packed GUID; TBC: full uint64 + const bool talentTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (talentTbc ? 8u : 2u)) return; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + uint64_t guid = talentTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (guid == 0) return; size_t bytesLeft = packet.getSize() - packet.getReadPos(); @@ -10416,8 +11030,10 @@ void GameHandler::dismount() { void GameHandler::handleForceSpeedChange(network::Packet& packet, const char* name, Opcode ackOpcode, float* speedStorage) { - // Packed GUID - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed GUID; TBC/Classic: full uint64 + const bool fscTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t guid = fscTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); // uint32 counter uint32_t counter = packet.readUInt32(); @@ -10504,10 +11120,13 @@ void GameHandler::handleForceRunSpeedChange(network::Packet& packet) { void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) { // Packet is server movement control update: - // packedGuid + uint32 counter + [optional unknown field(s)]. + // WotLK: packed GUID + uint32 counter + [optional unknown field(s)] + // TBC/Classic: full uint64 + uint32 counter // We always ACK with current movement state, same pattern as speed-change ACKs. - if (packet.getSize() - packet.getReadPos() < 2) return; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + const bool rootTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (rootTbc ? 8u : 2u)) return; + uint64_t guid = rootTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t counter = packet.readUInt32(); @@ -10563,8 +11182,11 @@ void GameHandler::handleForceMoveRootState(network::Packet& packet, bool rooted) void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* name, Opcode ackOpcode, uint32_t flag, bool set) { - if (packet.getSize() - packet.getReadPos() < 2) return; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed GUID; TBC/Classic: full uint64 + const bool fmfTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (fmfTbcLike ? 8u : 2u)) return; + uint64_t guid = fmfTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t counter = packet.readUInt32(); @@ -10619,8 +11241,11 @@ void GameHandler::handleForceMoveFlagChange(network::Packet& packet, const char* } void GameHandler::handleMoveKnockBack(network::Packet& packet) { - if (packet.getSize() - packet.getReadPos() < 2) return; - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + // WotLK: packed GUID; TBC/Classic: full uint64 + const bool mkbTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (mkbTbc ? 8u : 2u)) return; + uint64_t guid = mkbTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 20) return; // counter(4) + vcos(4) + vsin(4) + hspeed(4) + vspeed(4) uint32_t counter = packet.readUInt32(); [[maybe_unused]] float vcos = packet.readFloat(); @@ -10786,23 +11411,33 @@ void GameHandler::acceptBattlefield(uint32_t queueSlot) { } void GameHandler::handleRaidInstanceInfo(network::Packet& packet) { - // SMSG_RAID_INSTANCE_INFO: uint32 count, then for each: - // mapId(u32) + difficulty(u32) + resetTime(u64) + locked(u8) + extended(u8) + // TBC 2.4.3 format: mapId(4) + difficulty(4) + resetTime(4 — uint32 seconds) + locked(1) + // WotLK 3.3.5a format: mapId(4) + difficulty(4) + resetTime(8 — uint64 timestamp) + locked(1) + extended(1) + const bool isTbc = isActiveExpansion("tbc"); + const bool isClassic = isClassicLikeExpansion(); + const bool useTbcFormat = isTbc || isClassic; + if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t count = packet.readUInt32(); instanceLockouts_.clear(); instanceLockouts_.reserve(count); - constexpr size_t kEntrySize = 4 + 4 + 8 + 1 + 1; + const size_t kEntrySize = useTbcFormat ? (4 + 4 + 4 + 1) : (4 + 4 + 8 + 1 + 1); for (uint32_t i = 0; i < count; ++i) { if (packet.getSize() - packet.getReadPos() < kEntrySize) break; InstanceLockout lo; lo.mapId = packet.readUInt32(); lo.difficulty = packet.readUInt32(); - lo.resetTime = packet.readUInt64(); - lo.locked = packet.readUInt8() != 0; - lo.extended = packet.readUInt8() != 0; + if (useTbcFormat) { + lo.resetTime = packet.readUInt32(); // TBC/Classic: 4-byte seconds + lo.locked = packet.readUInt8() != 0; + lo.extended = false; + } else { + lo.resetTime = packet.readUInt64(); // WotLK: 8-byte timestamp + lo.locked = packet.readUInt8() != 0; + lo.extended = packet.readUInt8() != 0; + } instanceLockouts_.push_back(lo); LOG_INFO("Instance lockout: mapId=", lo.mapId, " diff=", lo.difficulty, " reset=", lo.resetTime, " locked=", lo.locked, " extended=", lo.extended); @@ -11026,8 +11661,20 @@ void GameHandler::handleLfgPlayerReward(network::Packet& packet) { uint32_t money = packet.readUInt32(); uint32_t xp = packet.readUInt32(); - std::string rewardMsg = "Dungeon Finder reward: " + std::to_string(money) + "g " + - std::to_string(xp) + " XP"; + // Convert copper to gold/silver/copper + uint32_t gold = money / 10000; + uint32_t silver = (money % 10000) / 100; + uint32_t copper = money % 100; + char moneyBuf[64]; + if (gold > 0) + snprintf(moneyBuf, sizeof(moneyBuf), "%ug %us %uc", gold, silver, copper); + else if (silver > 0) + snprintf(moneyBuf, sizeof(moneyBuf), "%us %uc", silver, copper); + else + snprintf(moneyBuf, sizeof(moneyBuf), "%uc", copper); + + std::string rewardMsg = std::string("Dungeon Finder reward: ") + moneyBuf + + ", " + std::to_string(xp) + " XP"; if (packet.getSize() - packet.getReadPos() >= 4) { uint32_t rewardCount = packet.readUInt32(); @@ -11337,8 +11984,10 @@ void GameHandler::handleArenaError(network::Packet& packet) { } void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { - // Server relays MSG_MOVE_* for other players: PackedGuid + MovementInfo - uint64_t moverGuid = UpdateObjectParser::readPackedGuid(packet); + // Server relays MSG_MOVE_* for other players: packed GUID (WotLK) or full uint64 (TBC/Classic) + const bool otherMoveTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t moverGuid = otherMoveTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (moverGuid == playerGuid || moverGuid == 0) { return; // Skip our own echoes } @@ -11408,6 +12057,26 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { uint16_t monsterMoveWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE); uint16_t monsterMoveTransportWire = wireOpcode(Opcode::SMSG_MONSTER_MOVE_TRANSPORT); + // Player movement sub-opcodes (SMSG_MULTIPLE_MOVES carries MSG_MOVE_*) + // Not static — wireOpcode() depends on runtime active opcode table. + const std::array kMoveOpcodes = { + wireOpcode(Opcode::MSG_MOVE_START_FORWARD), + wireOpcode(Opcode::MSG_MOVE_START_BACKWARD), + wireOpcode(Opcode::MSG_MOVE_STOP), + wireOpcode(Opcode::MSG_MOVE_START_STRAFE_LEFT), + wireOpcode(Opcode::MSG_MOVE_START_STRAFE_RIGHT), + wireOpcode(Opcode::MSG_MOVE_STOP_STRAFE), + wireOpcode(Opcode::MSG_MOVE_JUMP), + wireOpcode(Opcode::MSG_MOVE_START_TURN_LEFT), + wireOpcode(Opcode::MSG_MOVE_START_TURN_RIGHT), + wireOpcode(Opcode::MSG_MOVE_STOP_TURN), + wireOpcode(Opcode::MSG_MOVE_SET_FACING), + wireOpcode(Opcode::MSG_MOVE_FALL_LAND), + wireOpcode(Opcode::MSG_MOVE_HEARTBEAT), + wireOpcode(Opcode::MSG_MOVE_START_SWIM), + wireOpcode(Opcode::MSG_MOVE_STOP_SWIM), + }; + // Track unhandled sub-opcodes once per compressed packet (avoid log spam) std::unordered_set unhandledSeen; @@ -11433,6 +12102,10 @@ void GameHandler::handleCompressedMoves(network::Packet& packet) { handleMonsterMove(subPacket); } else if (subOpcode == monsterMoveTransportWire) { handleMonsterMoveTransport(subPacket); + } else if (state == WorldState::IN_WORLD && + std::find(kMoveOpcodes.begin(), kMoveOpcodes.end(), subOpcode) != kMoveOpcodes.end()) { + // Player/NPC movement update packed in SMSG_MULTIPLE_MOVES + handleOtherPlayerMovement(subPacket); } else { if (unhandledSeen.insert(subOpcode).second) { LOG_INFO("SMSG_COMPRESSED_MOVES: unhandled sub-opcode 0x", @@ -11775,7 +12448,7 @@ void GameHandler::handleMonsterMoveTransport(network::Packet& packet) { void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { AttackerStateUpdateData data; - if (!AttackerStateUpdateParser::parse(packet, data)) return; + if (!packetParsers_->parseAttackerStateUpdate(packet, data)) return; bool isPlayerAttacker = (data.attackerGuid == playerGuid); bool isPlayerTarget = (data.targetGuid == playerGuid); @@ -11837,7 +12510,7 @@ void GameHandler::handleAttackerStateUpdate(network::Packet& packet) { void GameHandler::handleSpellDamageLog(network::Packet& packet) { SpellDamageLogData data; - if (!SpellDamageLogParser::parse(packet, data)) return; + if (!packetParsers_->parseSpellDamageLog(packet, data)) return; bool isPlayerSource = (data.attackerGuid == playerGuid); bool isPlayerTarget = (data.targetGuid == playerGuid); @@ -11854,7 +12527,7 @@ void GameHandler::handleSpellDamageLog(network::Packet& packet) { void GameHandler::handleSpellHealLog(network::Packet& packet) { SpellHealLogData data; - if (!SpellHealLogParser::parse(packet, data)) return; + if (!packetParsers_->parseSpellHealLog(packet, data)) return; bool isPlayerSource = (data.casterGuid == playerGuid); bool isPlayerTarget = (data.targetGuid == playerGuid); @@ -11998,14 +12671,72 @@ void GameHandler::cancelAura(uint32_t spellId) { } void GameHandler::handlePetSpells(network::Packet& packet) { - if (packet.getSize() - packet.getReadPos() < 8) { - // Empty packet = pet dismissed/died + const size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 8) { + // Empty or undersized → pet cleared (dismissed / died) petGuid_ = 0; - LOG_INFO("SMSG_PET_SPELLS: pet cleared (empty packet)"); + petSpellList_.clear(); + petAutocastSpells_.clear(); + memset(petActionSlots_, 0, sizeof(petActionSlots_)); + LOG_INFO("SMSG_PET_SPELLS: pet cleared"); return; } + petGuid_ = packet.readUInt64(); - LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec); + if (petGuid_ == 0) { + petSpellList_.clear(); + petAutocastSpells_.clear(); + memset(petActionSlots_, 0, sizeof(petActionSlots_)); + LOG_INFO("SMSG_PET_SPELLS: pet cleared (guid=0)"); + return; + } + + // uint16 duration (ms, 0 = permanent), uint16 timer (ms) + if (packet.getSize() - packet.getReadPos() < 4) goto done; + /*uint16_t dur =*/ packet.readUInt16(); + /*uint16_t timer =*/ packet.readUInt16(); + + // uint8 reactState, uint8 commandState (packed order varies; WotLK: react first) + if (packet.getSize() - packet.getReadPos() < 2) goto done; + petReact_ = packet.readUInt8(); // 0=passive, 1=defensive, 2=aggressive + petCommand_ = packet.readUInt8(); // 0=stay, 1=follow, 2=attack, 3=dismiss + + // 10 × uint32 action bar slots + if (packet.getSize() - packet.getReadPos() < PET_ACTION_BAR_SLOTS * 4u) goto done; + for (int i = 0; i < PET_ACTION_BAR_SLOTS; ++i) { + petActionSlots_[i] = packet.readUInt32(); + } + + // uint8 spell count, then per-spell: uint32 spellId, uint16 active flags + if (packet.getSize() - packet.getReadPos() < 1) goto done; + { + uint8_t spellCount = packet.readUInt8(); + petSpellList_.clear(); + petAutocastSpells_.clear(); + for (uint8_t i = 0; i < spellCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 6) break; + uint32_t spellId = packet.readUInt32(); + uint16_t activeFlags = packet.readUInt16(); + petSpellList_.push_back(spellId); + // activeFlags bit 0 = autocast on + if (activeFlags & 0x0001) { + petAutocastSpells_.insert(spellId); + } + } + } + +done: + LOG_INFO("SMSG_PET_SPELLS: petGuid=0x", std::hex, petGuid_, std::dec, + " react=", (int)petReact_, " command=", (int)petCommand_, + " spells=", petSpellList_.size()); +} + +void GameHandler::sendPetAction(uint32_t action, uint64_t targetGuid) { + if (!hasPet() || state != WorldState::IN_WORLD || !socket) return; + auto pkt = PetActionPacket::build(petGuid_, action, targetGuid); + socket->send(pkt); + LOG_DEBUG("sendPetAction: petGuid=0x", std::hex, petGuid_, + " action=0x", action, " target=0x", targetGuid, std::dec); } void GameHandler::dismissPet() { @@ -12028,7 +12759,7 @@ float GameHandler::getSpellCooldown(uint32_t spellId) const { void GameHandler::handleInitialSpells(network::Packet& packet) { InitialSpellsData data; - if (!InitialSpellsParser::parse(packet, data)) return; + if (!packetParsers_->parseInitialSpells(packet, data)) return; knownSpells = {data.spellIds.begin(), data.spellIds.end()}; @@ -12069,6 +12800,13 @@ void GameHandler::handleCastFailed(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; + // Stop precast sound — spell failed before completing + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + ssm->stopPrecast(); + } + } + // Add system message about failed cast with readable reason int powerType = -1; auto playerEntity = entityManager.getEntity(playerGuid); @@ -12087,9 +12825,28 @@ void GameHandler::handleCastFailed(network::Packet& packet) { addLocalChatMessage(msg); } +static audio::SpellSoundManager::MagicSchool schoolMaskToMagicSchool(uint32_t mask) { + if (mask & 0x04) return audio::SpellSoundManager::MagicSchool::FIRE; + if (mask & 0x10) return audio::SpellSoundManager::MagicSchool::FROST; + if (mask & 0x02) return audio::SpellSoundManager::MagicSchool::HOLY; + if (mask & 0x08) return audio::SpellSoundManager::MagicSchool::NATURE; + if (mask & 0x20) return audio::SpellSoundManager::MagicSchool::SHADOW; + if (mask & 0x40) return audio::SpellSoundManager::MagicSchool::ARCANE; + return audio::SpellSoundManager::MagicSchool::ARCANE; +} + void GameHandler::handleSpellStart(network::Packet& packet) { SpellStartData data; - if (!SpellStartParser::parse(packet, data)) return; + if (!packetParsers_->parseSpellStart(packet, data)) return; + + // Track cast bar for any non-player caster (target frame + boss frames) + if (data.casterUnit != playerGuid && data.castTime > 0) { + auto& s = unitCastStates_[data.casterUnit]; + s.casting = true; + s.spellId = data.spellId; + s.timeTotal = data.castTime / 1000.0f; + s.timeRemaining = s.timeTotal; + } // If this is the player's own cast, start cast bar if (data.casterUnit == playerGuid && data.castTime > 0) { @@ -12098,25 +12855,43 @@ void GameHandler::handleSpellStart(network::Packet& packet) { castTimeTotal = data.castTime / 1000.0f; castTimeRemaining = castTimeTotal; - // Play precast (channeling) sound + // Play precast (channeling) sound with correct magic school if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { - ssm->playPrecast(audio::SpellSoundManager::MagicSchool::ARCANE, audio::SpellSoundManager::SpellPower::MEDIUM); + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playPrecast(school, audio::SpellSoundManager::SpellPower::MEDIUM); } } + + // Hearthstone cast: begin pre-loading terrain at bind point during cast time + // so tiles are ready when the teleport fires (avoids falling through un-loaded terrain). + // Spell IDs: 6948 = Vanilla Hearthstone (rank 1), 8690 = TBC/WotLK Hearthstone + const bool isHearthstone = (data.spellId == 6948 || data.spellId == 8690); + if (isHearthstone && hasHomeBind_ && hearthstonePreloadCallback_) { + hearthstonePreloadCallback_(homeBindMapId_, homeBindPos_.x, homeBindPos_.y, homeBindPos_.z); + } } } void GameHandler::handleSpellGo(network::Packet& packet) { SpellGoData data; - if (!SpellGoParser::parse(packet, data)) return; + if (!packetParsers_->parseSpellGo(packet, data)) return; // Cast completed if (data.casterUnit == playerGuid) { - // Play cast-complete sound before clearing state + // Play cast-complete sound with correct magic school if (auto* renderer = core::Application::getInstance().getRenderer()) { if (auto* ssm = renderer->getSpellSoundManager()) { - ssm->playCast(audio::SpellSoundManager::MagicSchool::ARCANE); + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playCast(school); } } @@ -12144,6 +12919,47 @@ void GameHandler::handleSpellGo(network::Packet& packet) { currentCastSpellId = 0; castTimeRemaining = 0.0f; } + + // Clear unit cast bar when the spell lands (for any tracked unit) + unitCastStates_.erase(data.casterUnit); + + // Show miss/dodge/parry/etc combat text when player's spells miss targets + if (data.casterUnit == playerGuid && !data.missTargets.empty()) { + static const CombatTextEntry::Type missTypes[] = { + CombatTextEntry::MISS, // 0=MISS + CombatTextEntry::DODGE, // 1=DODGE + CombatTextEntry::PARRY, // 2=PARRY + CombatTextEntry::BLOCK, // 3=BLOCK + CombatTextEntry::MISS, // 4=EVADE → show as MISS + CombatTextEntry::MISS, // 5=IMMUNE → show as MISS + CombatTextEntry::MISS, // 6=DEFLECT + CombatTextEntry::MISS, // 7=ABSORB + CombatTextEntry::MISS, // 8=RESIST + }; + // Show text for each miss (usually just 1 target per spell go) + for (const auto& m : data.missTargets) { + CombatTextEntry::Type ct = (m.missType < 9) ? missTypes[m.missType] : CombatTextEntry::MISS; + addCombatText(ct, 0, 0, true); + } + } + + // Play impact sound when player is hit by any spell (from self or others) + bool playerIsHit = false; + for (const auto& tgt : data.hitTargets) { + if (tgt == playerGuid) { playerIsHit = true; break; } + } + if (playerIsHit && data.casterUnit != playerGuid) { + if (auto* renderer = core::Application::getInstance().getRenderer()) { + if (auto* ssm = renderer->getSpellSoundManager()) { + loadSpellNameCache(); + auto it = spellNameCache_.find(data.spellId); + auto school = (it != spellNameCache_.end() && it->second.schoolMask) + ? schoolMaskToMagicSchool(it->second.schoolMask) + : audio::SpellSoundManager::MagicSchool::ARCANE; + ssm->playImpact(school, audio::SpellSoundManager::SpellPower::MEDIUM); + } + } + } } void GameHandler::handleSpellCooldown(network::Packet& packet) { @@ -12176,7 +12992,7 @@ void GameHandler::handleCooldownEvent(network::Packet& packet) { void GameHandler::handleAuraUpdate(network::Packet& packet, bool isAll) { AuraUpdateData data; - if (!AuraUpdateParser::parse(packet, data, isAll)) return; + if (!packetParsers_->parseAuraUpdate(packet, data, isAll)) return; // Determine which aura list to update std::vector* auraList = nullptr; @@ -12358,9 +13174,16 @@ void GameHandler::switchTalentSpec(uint8_t newSpec) { return; } - // For now, just switch locally. In a real implementation, we'd send - // MSG_TALENT_WIPE_CONFIRM to the server to trigger a spec switch. - // The server would respond with new SMSG_TALENTS_INFO for the new spec. + // Send CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE (0x4C3) to the server. + // The server will validate the swap, apply the new spec's spells/auras, + // and respond with SMSG_TALENTS_INFO for the newly active group. + // We optimistically update the local state so the UI reflects the change + // immediately; the server response will correct us if needed. + if (state == WorldState::IN_WORLD && socket) { + auto pkt = ActivateTalentGroupPacket::build(static_cast(newSpec)); + socket->send(pkt); + LOG_INFO("Sent CMSG_SET_ACTIVE_TALENT_GROUP_OBSOLETE: group=", (int)newSpec); + } activeTalentSpec_ = newSpec; LOG_INFO("Switched to talent spec ", (int)newSpec, @@ -12435,7 +13258,10 @@ void GameHandler::handleGroupDecline(network::Packet& packet) { } void GameHandler::handleGroupList(network::Packet& packet) { - if (!GroupListParser::parse(packet, partyData)) return; + // WotLK 3.3.5a added a roles byte (group level + per-member) for the dungeon finder. + // Classic 1.12 and TBC 2.4.3 do not send the roles byte. + const bool hasRoles = isActiveExpansion("wotlk"); + if (!GroupListParser::parse(packet, partyData, hasRoles)) return; if (partyData.isEmpty()) { LOG_INFO("No longer in a group"); @@ -12485,7 +13311,12 @@ void GameHandler::handlePartyMemberStats(network::Packet& packet, bool isFull) { packet.readUInt8(); } - uint64_t memberGuid = UpdateObjectParser::readPackedGuid(packet); + // WotLK and Classic/Vanilla use packed GUID; TBC uses full uint64 + // (Classic uses ObjectGuid::WriteAsPacked() = packed format, same as WotLK) + const bool pmsTbc = isActiveExpansion("tbc"); + if (remaining() < (pmsTbc ? 8u : 1u)) return; + uint64_t memberGuid = pmsTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (remaining() < 4) return; uint32_t updateFlags = packet.readUInt32(); @@ -14056,21 +14887,16 @@ void GameHandler::handleQuestgiverQuestList(network::Packet& packet) { } (void)header; - auto readQuestCount = [&](network::Packet& pkt) -> uint32_t { - size_t rem = pkt.getSize() - pkt.getReadPos(); - if (rem >= 4) { - size_t p = pkt.getReadPos(); - uint32_t c = pkt.readUInt32(); - if (c <= 64) return c; - pkt.setReadPos(p); - } - if (rem >= 1) { - return static_cast(pkt.readUInt8()); - } - return 0; - }; + // questCount is uint8 in all WoW versions for SMSG_QUESTGIVER_QUEST_LIST. + uint32_t questCount = 0; + if (packet.getSize() - packet.getReadPos() >= 1) { + questCount = packet.readUInt8(); + } + + // Classic 1.12 and TBC 2.4.3 don't include questFlags(u32) + isRepeatable(u8) + // before the quest title. WotLK 3.3.5a added those 5 bytes. + const bool hasQuestFlagsField = !isClassicLikeExpansion() && !isActiveExpansion("tbc"); - uint32_t questCount = readQuestCount(packet); data.quests.reserve(questCount); for (uint32_t i = 0; i < questCount; ++i) { if (packet.getSize() - packet.getReadPos() < 12) break; @@ -14079,23 +14905,14 @@ void GameHandler::handleQuestgiverQuestList(network::Packet& packet) { q.questIcon = packet.readUInt32(); q.questLevel = static_cast(packet.readUInt32()); - // WotLK includes questFlags + isRepeatable; Classic variants may omit. - size_t titlePos = packet.getReadPos(); - if (packet.getSize() - packet.getReadPos() >= 5) { + if (hasQuestFlagsField && packet.getSize() - packet.getReadPos() >= 5) { q.questFlags = packet.readUInt32(); q.isRepeatable = packet.readUInt8(); - q.title = normalizeWowTextTokens(packet.readString()); - if (q.title.empty()) { - packet.setReadPos(titlePos); - q.questFlags = 0; - q.isRepeatable = 0; - q.title = normalizeWowTextTokens(packet.readString()); - } } else { q.questFlags = 0; q.isRepeatable = 0; - q.title = normalizeWowTextTokens(packet.readString()); } + q.title = normalizeWowTextTokens(packet.readString()); if (q.questId != 0) { data.quests.push_back(std::move(q)); } @@ -14181,7 +14998,8 @@ void GameHandler::handleListInventory(network::Packet& packet) { // ============================================================ void GameHandler::handleTrainerList(network::Packet& packet) { - if (!TrainerListParser::parse(packet, currentTrainerList_)) return; + const bool isClassic = isClassicLikeExpansion(); + if (!TrainerListParser::parse(packet, currentTrainerList_, isClassic)) return; trainerWindowOpen_ = true; gossipWindowOpen = false; @@ -14269,6 +15087,17 @@ void GameHandler::loadSpellNameCache() { } const auto* spellL = pipeline::getActiveDBCLayout() ? pipeline::getActiveDBCLayout()->getLayout("Spell") : nullptr; + + // Determine school field (bitmask for TBC/WotLK, enum for Classic/Vanilla) + uint32_t schoolMaskField = 0, schoolEnumField = 0; + bool hasSchoolMask = false, hasSchoolEnum = false; + if (spellL) { + uint32_t f = spellL->field("SchoolMask"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolMaskField = f; hasSchoolMask = true; } + f = spellL->field("SchoolEnum"); + if (f != 0xFFFFFFFF && f < dbc->getFieldCount()) { schoolEnumField = f; hasSchoolEnum = true; } + } + uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t id = dbc->getUInt32(i, spellL ? (*spellL)["ID"] : 0); @@ -14276,7 +15105,16 @@ void GameHandler::loadSpellNameCache() { std::string name = dbc->getString(i, spellL ? (*spellL)["Name"] : 136); std::string rank = dbc->getString(i, spellL ? (*spellL)["Rank"] : 153); if (!name.empty()) { - spellNameCache_[id] = {std::move(name), std::move(rank)}; + SpellNameEntry entry{std::move(name), std::move(rank), 0}; + if (hasSchoolMask) { + entry.schoolMask = dbc->getUInt32(i, schoolMaskField); + } else if (hasSchoolEnum) { + // Classic/Vanilla enum: 0=Physical,1=Holy,2=Fire,3=Nature,4=Frost,5=Shadow,6=Arcane + static const uint32_t enumToBitmask[] = {0x01,0x02,0x04,0x08,0x10,0x20,0x40}; + uint32_t e = dbc->getUInt32(i, schoolEnumField); + entry.schoolMask = (e < 7) ? enumToBitmask[e] : 0; + } + spellNameCache_[id] = std::move(entry); } } LOG_INFO("Trainer: Loaded ", spellNameCache_.size(), " spell names from Spell.dbc"); @@ -14579,14 +15417,17 @@ void GameHandler::addSystemChatMessage(const std::string& message) { // ============================================================ void GameHandler::handleTeleportAck(network::Packet& packet) { - // MSG_MOVE_TELEPORT_ACK (server→client): packedGuid + u32 counter + u32 time - // followed by movement info with the new position - if (packet.getSize() - packet.getReadPos() < 4) { + // MSG_MOVE_TELEPORT_ACK (server→client): + // WotLK: packed GUID + u32 counter + u32 time + movement info with new position + // TBC/Classic: uint64 + u32 counter + u32 time + movement info + const bool taTbc = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (taTbc ? 8u : 4u)) { LOG_WARNING("MSG_MOVE_TELEPORT_ACK too short"); return; } - uint64_t guid = UpdateObjectParser::readPackedGuid(packet); + uint64_t guid = taTbc + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); if (packet.getSize() - packet.getReadPos() < 4) return; uint32_t counter = packet.readUInt32(); @@ -15531,7 +16372,10 @@ void GameHandler::handlePlayedTime(network::Packet& packet) { } void GameHandler::handleWho(network::Packet& packet) { - // Parse WHO response + // Classic 1.12 / TBC 2.4.3 per-player: name + guild + level(u32) + class(u32) + race(u32) + zone(u32) + // WotLK 3.3.5a added a gender(u8) field between race and zone. + const bool hasGender = isActiveExpansion("wotlk"); + uint32_t displayCount = packet.readUInt32(); uint32_t onlineCount = packet.readUInt32(); @@ -15545,18 +16389,21 @@ void GameHandler::handleWho(network::Packet& packet) { addSystemChatMessage(std::to_string(onlineCount) + " player(s) online:"); for (uint32_t i = 0; i < displayCount; ++i) { + if (packet.getReadPos() >= packet.getSize()) break; std::string playerName = packet.readString(); std::string guildName = packet.readString(); - uint32_t level = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < 12) break; + uint32_t level = packet.readUInt32(); uint32_t classId = packet.readUInt32(); - uint32_t raceId = packet.readUInt32(); - packet.readUInt8(); // gender (unused) - packet.readUInt32(); // zoneId (unused) + uint32_t raceId = packet.readUInt32(); + if (hasGender && packet.getSize() - packet.getReadPos() >= 1) + packet.readUInt8(); // gender (WotLK only, unused) + if (packet.getSize() - packet.getReadPos() >= 4) + packet.readUInt32(); // zoneId (unused) std::string msg = " " + playerName; - if (!guildName.empty()) { + if (!guildName.empty()) msg += " <" + guildName + ">"; - } msg += " - Level " + std::to_string(level); addSystemChatMessage(msg); @@ -15564,6 +16411,92 @@ void GameHandler::handleWho(network::Packet& packet) { } } +void GameHandler::handleFriendList(network::Packet& packet) { + // Classic 1.12 / TBC 2.4.3 SMSG_FRIEND_LIST format: + // uint8 count + // for each entry: + // uint64 guid (full) + // uint8 status (0=offline, 1=online, 2=AFK, 3=DND) + // if status != 0: + // uint32 area + // uint32 level + // uint32 class + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return; + uint8_t count = packet.readUInt8(); + LOG_INFO("SMSG_FRIEND_LIST: ", (int)count, " entries"); + for (uint8_t i = 0; i < count && rem() >= 9; ++i) { + uint64_t guid = packet.readUInt64(); + uint8_t status = packet.readUInt8(); + uint32_t area = 0, level = 0, classId = 0; + if (status != 0 && rem() >= 12) { + area = packet.readUInt32(); + level = packet.readUInt32(); + classId = packet.readUInt32(); + } + (void)area; (void)level; (void)classId; + // Track as a friend GUID; resolve name via name query + friendGuids_.insert(guid); + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) { + friendsCache[nit->second] = guid; + LOG_INFO(" Friend: ", nit->second, " status=", (int)status); + } else { + LOG_INFO(" Friend guid=0x", std::hex, guid, std::dec, + " status=", (int)status, " (name pending)"); + queryPlayerName(guid); + } + } +} + +void GameHandler::handleContactList(network::Packet& packet) { + // WotLK SMSG_CONTACT_LIST format: + // uint32 listMask (1=friend, 2=ignore, 4=mute) + // uint32 count + // for each entry: + // uint64 guid (full) + // uint32 flags + // string note (null-terminated) + // if flags & 0x1 (friend): + // uint8 status (0=offline, 1=online, 2=AFK, 3=DND) + // if status != 0: + // uint32 area, uint32 level, uint32 class + // Short/keepalive variant (1-7 bytes): consume silently. + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 8) { + packet.setReadPos(packet.getSize()); + return; + } + lastContactListMask_ = packet.readUInt32(); + lastContactListCount_ = packet.readUInt32(); + for (uint32_t i = 0; i < lastContactListCount_ && rem() >= 8; ++i) { + uint64_t guid = packet.readUInt64(); + if (rem() < 4) break; + uint32_t flags = packet.readUInt32(); + std::string note = packet.readString(); // may be empty + (void)note; + if (flags & 0x1) { // SOCIAL_FLAG_FRIEND + if (rem() < 1) break; + uint8_t status = packet.readUInt8(); + if (status != 0 && rem() >= 12) { + packet.readUInt32(); // area + packet.readUInt32(); // level + packet.readUInt32(); // class + } + friendGuids_.insert(guid); + auto nit = playerNameCache.find(guid); + if (nit != playerNameCache.end()) { + friendsCache[nit->second] = guid; + } else { + queryPlayerName(guid); + } + } + // ignore / mute entries: no additional fields beyond guid+flags+note + } + LOG_INFO("SMSG_CONTACT_LIST: mask=", lastContactListMask_, + " count=", lastContactListCount_); +} + void GameHandler::handleFriendStatus(network::Packet& packet) { FriendStatusData data; if (!FriendStatusParser::parse(packet, data)) { @@ -16629,8 +17562,10 @@ void GameHandler::handleAuctionHello(network::Packet& packet) { } void GameHandler::handleAuctionListResult(network::Packet& packet) { + // Classic 1.12 has 1 enchant slot per auction entry; TBC/WotLK have 3. + const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; - if (!AuctionListResultParser::parse(packet, result)) { + if (!AuctionListResultParser::parse(packet, result, enchSlots)) { LOG_WARNING("Failed to parse SMSG_AUCTION_LIST_RESULT"); return; } @@ -16647,8 +17582,9 @@ void GameHandler::handleAuctionListResult(network::Packet& packet) { } void GameHandler::handleAuctionOwnerListResult(network::Packet& packet) { + const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; - if (!AuctionListResultParser::parse(packet, result)) { + if (!AuctionListResultParser::parse(packet, result, enchSlots)) { LOG_WARNING("Failed to parse SMSG_AUCTION_OWNER_LIST_RESULT"); return; } @@ -16660,8 +17596,9 @@ void GameHandler::handleAuctionOwnerListResult(network::Packet& packet) { } void GameHandler::handleAuctionBidderListResult(network::Packet& packet) { + const int enchSlots = isClassicLikeExpansion() ? 1 : 3; AuctionListResult result; - if (!AuctionListResultParser::parse(packet, result)) { + if (!AuctionListResultParser::parse(packet, result, enchSlots)) { LOG_WARNING("Failed to parse SMSG_AUCTION_BIDDER_LIST_RESULT"); return; } @@ -17049,6 +17986,30 @@ void GameHandler::sendLootRoll(uint64_t objectGuid, uint32_t slot, uint8_t rollT // PackedTime date — uint32 bitfield (seconds since epoch) // uint32 realmFirst — how many on realm also got it (0 = realm first) // --------------------------------------------------------------------------- +void GameHandler::loadAchievementNameCache() { + if (achievementNameCacheLoaded_) return; + achievementNameCacheLoaded_ = true; + + auto* am = core::Application::getInstance().getAssetManager(); + if (!am || !am->isInitialized()) return; + + auto dbc = am->loadDBC("Achievement.dbc"); + if (!dbc || !dbc->isLoaded() || dbc->getFieldCount() < 22) return; + + const auto* achL = pipeline::getActiveDBCLayout() + ? pipeline::getActiveDBCLayout()->getLayout("Achievement") : nullptr; + uint32_t titleField = achL ? achL->field("Title") : 4; + if (titleField == 0xFFFFFFFF) titleField = 4; + + for (uint32_t i = 0; i < dbc->getRecordCount(); ++i) { + uint32_t id = dbc->getUInt32(i, 0); + if (id == 0) continue; + std::string title = dbc->getString(i, titleField); + if (!title.empty()) achievementNameCache_[id] = std::move(title); + } + LOG_INFO("Achievement: loaded ", achievementNameCache_.size(), " names from Achievement.dbc"); +} + void GameHandler::handleAchievementEarned(network::Packet& packet) { size_t remaining = packet.getSize() - packet.getReadPos(); if (remaining < 16) return; // guid(8) + id(4) + date(4) @@ -17057,12 +18018,20 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { uint32_t achievementId = packet.readUInt32(); /*uint32_t date =*/ packet.readUInt32(); // PackedTime — not displayed + loadAchievementNameCache(); + auto nameIt = achievementNameCache_.find(achievementId); + const std::string& achName = (nameIt != achievementNameCache_.end()) + ? nameIt->second : std::string(); + // Show chat notification bool isSelf = (guid == playerGuid); if (isSelf) { - char buf[128]; - std::snprintf(buf, sizeof(buf), - "Achievement earned! (ID %u)", achievementId); + char buf[256]; + if (!achName.empty()) { + std::snprintf(buf, sizeof(buf), "Achievement earned: %s", achName.c_str()); + } else { + std::snprintf(buf, sizeof(buf), "Achievement earned! (ID %u)", achievementId); + } addSystemChatMessage(buf); if (achievementEarnedCallback_) { @@ -17082,13 +18051,19 @@ void GameHandler::handleAchievementEarned(network::Packet& packet) { senderName = tmp; } char buf[256]; - std::snprintf(buf, sizeof(buf), - "%s has earned an achievement! (ID %u)", senderName.c_str(), achievementId); + if (!achName.empty()) { + std::snprintf(buf, sizeof(buf), "%s has earned the achievement: %s", + senderName.c_str(), achName.c_str()); + } else { + std::snprintf(buf, sizeof(buf), "%s has earned an achievement! (ID %u)", + senderName.c_str(), achievementId); + } addSystemChatMessage(buf); } LOG_INFO("SMSG_ACHIEVEMENT_EARNED: guid=0x", std::hex, guid, std::dec, - " achievementId=", achievementId, " self=", isSelf); + " achievementId=", achievementId, " self=", isSelf, + achName.empty() ? "" : " name=", achName); } // --------------------------------------------------------------------------- diff --git a/src/game/packet_parsers_classic.cpp b/src/game/packet_parsers_classic.cpp index 2e335af1..c0ab0c88 100644 --- a/src/game/packet_parsers_classic.cpp +++ b/src/game/packet_parsers_classic.cpp @@ -314,6 +314,308 @@ network::Packet ClassicPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slo return packet; } +// ============================================================================ +// Classic parseSpellStart — Vanilla 1.12 SMSG_SPELL_START +// +// Key differences from TBC: +// - GUIDs are PackedGuid (variable-length byte mask + non-zero bytes), +// NOT full uint64 as in TBC/WotLK. +// - castFlags is uint16 (NOT uint32 as in TBC/WotLK). +// - SpellCastTargets uses uint16 targetFlags (NOT uint32 as in TBC). +// +// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) + uint8(castCount) +// + uint32(spellId) + uint16(castFlags) + uint32(castTime) +// + uint16(targetFlags) [+ PackedGuid(unitTarget) if TARGET_FLAG_UNIT] +// ============================================================================ +bool ClassicPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 1) return false; + data.casterUnit = UpdateObjectParser::readPackedGuid(packet); + + // uint8 castCount + uint32 spellId + uint16 castFlags + uint32 castTime = 11 bytes + if (rem() < 11) return false; + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) + data.castTime = packet.readUInt32(); + + // SpellCastTargets: uint16 targetFlags in Vanilla (uint32 in TBC/WotLK) + if (rem() < 2) return true; + uint16_t targetFlags = packet.readUInt16(); + // TARGET_FLAG_UNIT (0x02) or TARGET_FLAG_OBJECT (0x800) carry a packed GUID + if (((targetFlags & 0x02) || (targetFlags & 0x800)) && rem() >= 1) { + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); + } + + LOG_DEBUG("[Classic] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + return true; +} + +// ============================================================================ +// Classic parseSpellGo — Vanilla 1.12 SMSG_SPELL_GO +// +// Same GUID and castFlags format differences as parseSpellStart: +// - GUIDs are PackedGuid (not full uint64) +// - castFlags is uint16 (not uint32) +// - Hit/miss target GUIDs are also PackedGuid in Vanilla +// +// Format: PackedGuid(casterObj) + PackedGuid(casterUnit) + uint8(castCount) +// + uint32(spellId) + uint16(castFlags) +// + uint8(hitCount) + [PackedGuid(hitTarget) × hitCount] +// + uint8(missCount) + [PackedGuid(missTarget) + uint8(missType)] × missCount +// ============================================================================ +bool ClassicPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 1) return false; + data.casterUnit = UpdateObjectParser::readPackedGuid(packet); + + // uint8 castCount + uint32 spellId + uint16 castFlags = 7 bytes + if (rem() < 7) return false; + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt16(); // uint16 in Vanilla (uint32 in TBC/WotLK) + + // Hit targets + if (rem() < 1) return true; + data.hitCount = packet.readUInt8(); + data.hitTargets.reserve(data.hitCount); + for (uint8_t i = 0; i < data.hitCount && rem() >= 1; ++i) { + data.hitTargets.push_back(UpdateObjectParser::readPackedGuid(packet)); + } + + // Miss targets + if (rem() < 1) return true; + data.missCount = packet.readUInt8(); + data.missTargets.reserve(data.missCount); + for (uint8_t i = 0; i < data.missCount && rem() >= 2; ++i) { + SpellGoMissEntry m; + m.targetGuid = UpdateObjectParser::readPackedGuid(packet); + if (rem() < 1) break; + m.missType = packet.readUInt8(); + data.missTargets.push_back(m); + } + + LOG_DEBUG("[Classic] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, + " misses=", (int)data.missCount); + return true; +} + +// ============================================================================ +// Classic parseAttackerStateUpdate — Vanilla 1.12 SMSG_ATTACKERSTATEUPDATE +// +// Identical to TBC format except GUIDs are PackedGuid (not full uint64). +// Format: uint32(hitInfo) + PackedGuid(attacker) + PackedGuid(target) +// + int32(totalDamage) + uint8(subDamageCount) +// + [per sub: uint32(schoolMask) + float(damage) + uint32(intDamage) +// + uint32(absorbed) + uint32(resisted)] +// + uint32(victimState) + int32(overkill) [+ uint32(blocked)] +// ============================================================================ +bool ClassicPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 5) return false; // hitInfo(4) + at least GUID mask byte(1) + + data.hitInfo = packet.readUInt32(); + data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + if (rem() < 1) return false; + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + + if (rem() < 5) return false; // int32 totalDamage + uint8 subDamageCount + data.totalDamage = static_cast(packet.readUInt32()); + data.subDamageCount = packet.readUInt8(); + + for (uint8_t i = 0; i < data.subDamageCount && rem() >= 20; ++i) { + 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); + } + + if (rem() < 8) return true; + data.victimState = packet.readUInt32(); + data.overkill = static_cast(packet.readUInt32()); + + if (rem() >= 4) { + data.blocked = packet.readUInt32(); + } + + LOG_INFO("[Classic] Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); + return true; +} + +// ============================================================================ +// Classic parseSpellDamageLog — Vanilla 1.12 SMSG_SPELLNONMELEEDAMAGELOG +// +// Identical to TBC except GUIDs are PackedGuid (not full uint64). +// Format: PackedGuid(target) + PackedGuid(caster) + uint32(spellId) +// + uint32(damage) + uint8(schoolMask) + uint32(absorbed) + uint32(resisted) +// + uint8(periodicLog) + uint8(unused) + uint32(blocked) + uint32(flags) +// ============================================================================ +bool ClassicPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + if (rem() < 1) return false; + data.attackerGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + + // uint32(spellId) + uint32(damage) + uint8(schoolMask) + uint32(absorbed) + // + uint32(resisted) + uint8 + uint8 + uint32(blocked) + uint32(flags) = 21 bytes + if (rem() < 21) return false; + data.spellId = packet.readUInt32(); + data.damage = packet.readUInt32(); + data.schoolMask = packet.readUInt8(); + data.absorbed = packet.readUInt32(); + data.resisted = packet.readUInt32(); + packet.readUInt8(); // periodicLog + packet.readUInt8(); // unused + packet.readUInt32(); // blocked + uint32_t flags = packet.readUInt32(); + data.isCrit = (flags & 0x02) != 0; + data.overkill = 0; // no overkill field in Vanilla (same as TBC) + + LOG_INFO("[Classic] Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); + return true; +} + +// ============================================================================ +// Classic parseSpellHealLog — Vanilla 1.12 SMSG_SPELLHEALLOG +// +// Identical to TBC except GUIDs are PackedGuid (not full uint64). +// Format: PackedGuid(target) + PackedGuid(caster) + uint32(spellId) +// + uint32(heal) + uint32(overheal) + uint8(crit) +// ============================================================================ +bool ClassicPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 2) return false; + + data.targetGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + if (rem() < 1) return false; + data.casterGuid = UpdateObjectParser::readPackedGuid(packet); // PackedGuid in Vanilla + + if (rem() < 13) return false; // uint32 + uint32 + uint32 + uint8 = 13 bytes + data.spellId = packet.readUInt32(); + data.heal = packet.readUInt32(); + data.overheal = packet.readUInt32(); + data.isCrit = (packet.readUInt8() != 0); + + LOG_INFO("[Classic] Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); + return true; +} + +// ============================================================================ +// Classic parseAuraUpdate — Vanilla 1.12 SMSG_AURA_UPDATE / SMSG_AURA_UPDATE_ALL +// +// Classic has SMSG_AURA_UPDATE (TBC does not — TBC uses a different aura system +// and the TBC override returns false with a warning). Classic inherits TBC's +// override by default, so this override is needed to restore aura tracking. +// +// Classic aura flags differ from WotLK: +// 0x01/0x02/0x04 = effect indices active (same as WotLK) +// 0x08 = CANCELABLE / NOT-NEGATIVE (WotLK: 0x08 = NOT_CASTER) +// 0x10 = DURATION (WotLK: 0x20 = DURATION) +// 0x20 = NOT_CASTER (WotLK: no caster GUID at all if 0x08) +// 0x40 = POSITIVE (WotLK: 0x40 = EFFECT_AMOUNTS) +// +// Key differences from WotLK parser: +// - No caster GUID field in Classic SMSG_AURA_UPDATE packets +// - DURATION bit is 0x10, not 0x20 +// - No effect amounts field (WotLK 0x40 = EFFECT_AMOUNTS does not exist here) +// +// Format: PackedGuid(entity) + [uint8(slot) + uint32(spellId) +// [+ uint8(flags) + uint8(level) + uint8(charges) +// + [uint32(maxDuration) + uint32(duration) if flags & 0x10]]* +// ============================================================================ +bool ClassicPacketParsers::parseAuraUpdate(network::Packet& packet, AuraUpdateData& data, bool isAll) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 1) return false; + + data.guid = UpdateObjectParser::readPackedGuid(packet); + + while (rem() > 0) { + if (rem() < 1) break; + uint8_t slot = packet.readUInt8(); + if (rem() < 4) break; + uint32_t spellId = packet.readUInt32(); + + AuraSlot aura; + if (spellId != 0) { + aura.spellId = spellId; + if (rem() < 3) { data.updates.push_back({slot, aura}); break; } + aura.flags = packet.readUInt8(); + aura.level = packet.readUInt8(); + aura.charges = packet.readUInt8(); + + // Classic DURATION flag is 0x10 (WotLK uses 0x20) + if ((aura.flags & 0x10) && rem() >= 8) { + aura.maxDurationMs = static_cast(packet.readUInt32()); + aura.durationMs = static_cast(packet.readUInt32()); + } + // No caster GUID field in Classic (WotLK added it gated by 0x08 NOT_CASTER) + // No effect amounts field in Classic (WotLK added it gated by 0x40) + } + + data.updates.push_back({slot, aura}); + if (!isAll) break; + } + + LOG_DEBUG("[Classic] Aura update for 0x", std::hex, data.guid, std::dec, + ": ", data.updates.size(), " slots"); + return true; +} + +// ============================================================================ +// Classic SMSG_NAME_QUERY_RESPONSE format (1.12 / vmangos): +// uint64 guid (full, GetObjectGuid) +// CString name +// CString realmName (usually empty = single \0 byte) +// uint32 race +// uint32 gender +// uint32 class +// +// TBC Variant A (inherited from TbcPacketParsers) skips the realmName CString, +// causing it to misread the uint32 race field (absorbs the realmName \0 byte +// as the low byte), producing race=0 and shifted gender/class values. +// ============================================================================ +bool ClassicPacketParsers::parseNameQueryResponse(network::Packet& packet, NameQueryResponseData& data) { + data = NameQueryResponseData{}; + + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() < 8) return false; + + data.guid = packet.readUInt64(); // full uint64, not PackedGuid + data.name = packet.readString(); // null-terminated name + if (rem() == 0) return !data.name.empty(); + + data.realmName = packet.readString(); // null-terminated realm name (usually "") + if (rem() < 12) return !data.name.empty(); + + uint32_t race = packet.readUInt32(); + uint32_t gender = packet.readUInt32(); + uint32_t cls = packet.readUInt32(); + data.race = static_cast(race & 0xFF); + data.gender = static_cast(gender & 0xFF); + data.classId = static_cast(cls & 0xFF); + data.found = 0; + + LOG_DEBUG("[Classic] Name query response: ", data.name, + " (race=", (int)data.race, " gender=", (int)data.gender, + " class=", (int)data.classId, ")"); + return !data.name.empty(); +} + // ============================================================================ // Classic SMSG_CAST_FAILED: no castCount byte (added in TBC/WotLK) // Format: spellId(u32) + result(u8) @@ -1282,6 +1584,16 @@ bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetai /*activateAccept*/ packet.readUInt8(); data.suggestedPlayers = packet.readUInt32(); + // Vanilla 1.12: emote section before reward items + // Format: emoteCount(u32) + [delay(u32) + type(u32)] × emoteCount + if (packet.getReadPos() + 4 <= packet.getSize()) { + uint32_t emoteCount = packet.readUInt32(); + for (uint32_t i = 0; i < emoteCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { + packet.readUInt32(); // delay + packet.readUInt32(); // type + } + } + // Choice reward items: variable count + 3 uint32s each if (packet.getReadPos() + 4 <= packet.getSize()) { uint32_t choiceCount = packet.readUInt32(); @@ -1309,5 +1621,42 @@ bool ClassicPacketParsers::parseQuestDetails(network::Packet& packet, QuestDetai return true; } +// ============================================================================ +// ClassicPacketParsers::parseCreatureQueryResponse +// +// Classic 1.12 SMSG_CREATURE_QUERY_RESPONSE lacks the iconName CString field +// that TBC 2.4.3 and WotLK 3.3.5a include between subName and typeFlags. +// Without this override, the TBC/WotLK parser reads typeFlags bytes as the +// iconName string, shifting typeFlags/creatureType/family/rank by 1-4 bytes. +// ============================================================================ +bool ClassicPacketParsers::parseCreatureQueryResponse(network::Packet& packet, + CreatureQueryResponseData& data) { + data.entry = packet.readUInt32(); + if (data.entry & 0x80000000) { + data.entry &= ~0x80000000; + data.name = ""; + return true; + } + + data.name = packet.readString(); + packet.readString(); // name2 + packet.readString(); // name3 + packet.readString(); // name4 + data.subName = packet.readString(); + // NOTE: NO iconName field in Classic 1.12 — goes straight to typeFlags + if (packet.getReadPos() + 16 > packet.getSize()) { + LOG_WARNING("[Classic] Creature query: truncated at typeFlags (entry=", data.entry, ")"); + return true; + } + data.typeFlags = packet.readUInt32(); + data.creatureType = packet.readUInt32(); + data.family = packet.readUInt32(); + data.rank = packet.readUInt32(); + + LOG_DEBUG("[Classic] Creature query: ", data.name, " type=", data.creatureType, + " rank=", data.rank); + return true; +} + } // namespace game } // namespace wowee diff --git a/src/game/packet_parsers_tbc.cpp b/src/game/packet_parsers_tbc.cpp index e4275640..5cef0290 100644 --- a/src/game/packet_parsers_tbc.cpp +++ b/src/game/packet_parsers_tbc.cpp @@ -497,6 +497,199 @@ bool TbcPacketParsers::parseUpdateObject(network::Packet& packet, UpdateObjectDa return false; } +// ============================================================================ +// TBC 2.4.3 SMSG_GOSSIP_MESSAGE +// Identical to WotLK except each quest entry lacks questFlags(u32) and +// isRepeatable(u8) that WotLK added. Without this override the WotLK parser +// reads those 5 bytes as part of the quest title, corrupting all gossip quests. +// ============================================================================ +bool TbcPacketParsers::parseGossipMessage(network::Packet& packet, GossipMessageData& data) { + if (packet.getSize() - packet.getReadPos() < 16) return false; + + data.npcGuid = packet.readUInt64(); + data.menuId = packet.readUInt32(); // TBC added menuId (Classic doesn't have it) + data.titleTextId = packet.readUInt32(); + uint32_t optionCount = packet.readUInt32(); + + data.options.clear(); + data.options.reserve(optionCount); + for (uint32_t i = 0; i < optionCount; ++i) { + 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); + } + + uint32_t questCount = packet.readUInt32(); + data.quests.clear(); + data.quests.reserve(questCount); + for (uint32_t i = 0; i < questCount; ++i) { + GossipQuestItem quest; + quest.questId = packet.readUInt32(); + quest.questIcon = packet.readUInt32(); + quest.questLevel = static_cast(packet.readUInt32()); + // TBC 2.4.3: NO questFlags(u32) and NO isRepeatable(u8) here + // WotLK adds these 5 bytes — reading them from TBC garbles the quest title + quest.questFlags = 0; + quest.isRepeatable = 0; + quest.title = normalizeWowTextTokens(packet.readString()); + data.quests.push_back(quest); + } + + LOG_INFO("[TBC] Gossip: ", optionCount, " options, ", questCount, " quests"); + return true; +} + +// ============================================================================ +// TBC 2.4.3 SMSG_MONSTER_MOVE +// Identical to WotLK except WotLK added a uint8 unk byte immediately after the +// packed GUID (toggles MOVEMENTFLAG2_UNK7). TBC does NOT have this byte. +// Without this override, all NPC movement positions/durations are offset by 1 +// byte and parse as garbage. +// ============================================================================ +bool TbcPacketParsers::parseMonsterMove(network::Packet& packet, MonsterMoveData& data) { + data.guid = UpdateObjectParser::readPackedGuid(packet); + if (data.guid == 0) return false; + // No unk byte here in TBC 2.4.3 + + if (packet.getReadPos() + 12 > packet.getSize()) return false; + data.x = packet.readFloat(); + data.y = packet.readFloat(); + data.z = packet.readFloat(); + + if (packet.getReadPos() + 4 > packet.getSize()) return false; + packet.readUInt32(); // splineId + + if (packet.getReadPos() >= packet.getSize()) 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.getReadPos() + 12 > packet.getSize()) return false; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } else if (data.moveType == 3) { + if (packet.getReadPos() + 8 > packet.getSize()) return false; + data.facingTarget = packet.readUInt64(); + } else if (data.moveType == 4) { + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.facingAngle = packet.readFloat(); + } + + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.splineFlags = packet.readUInt32(); + + // TBC 2.4.3 SplineFlags animation bit is same as WotLK: 0x00400000 + if (data.splineFlags & 0x00400000) { + if (packet.getReadPos() + 5 > packet.getSize()) return false; + packet.readUInt8(); // animationType + packet.readUInt32(); // effectStartTime + } + + if (packet.getReadPos() + 4 > packet.getSize()) return false; + data.duration = packet.readUInt32(); + + if (data.splineFlags & 0x00000800) { + if (packet.getReadPos() + 8 > packet.getSize()) return false; + packet.readFloat(); // verticalAcceleration + packet.readUInt32(); // effectStartTime + } + + if (packet.getReadPos() + 4 > packet.getSize()) return false; + uint32_t pointCount = packet.readUInt32(); + if (pointCount == 0) return true; + if (pointCount > 16384) return false; + + bool uncompressed = (data.splineFlags & (0x00080000 | 0x00002000)) != 0; + if (uncompressed) { + for (uint32_t i = 0; i < pointCount - 1; i++) { + if (packet.getReadPos() + 12 > packet.getSize()) return true; + packet.readFloat(); packet.readFloat(); packet.readFloat(); + } + if (packet.getReadPos() + 12 > packet.getSize()) return true; + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + } else { + if (packet.getReadPos() + 12 > packet.getSize()) return true; + data.destX = packet.readFloat(); + data.destY = packet.readFloat(); + data.destZ = packet.readFloat(); + data.hasDest = true; + } + + LOG_DEBUG("[TBC] MonsterMove: guid=0x", std::hex, data.guid, std::dec, + " type=", (int)data.moveType, " dur=", data.duration, "ms", + " dest=(", data.destX, ",", data.destY, ",", data.destZ, ")"); + return true; +} + +// ============================================================================ +// TBC 2.4.3 CMSG_CAST_SPELL +// Format: castCount(u8) + spellId(u32) + SpellCastTargets +// WotLK 3.3.5a adds castFlags(u8) between spellId and targets — TBC does NOT. +// ============================================================================ +network::Packet TbcPacketParsers::buildCastSpell(uint32_t spellId, uint64_t targetGuid, uint8_t castCount) { + network::Packet packet(wireOpcode(LogicalOpcode::CMSG_CAST_SPELL)); + packet.writeUInt8(castCount); + packet.writeUInt32(spellId); + // No castFlags byte in TBC 2.4.3 + + 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 + } + + return packet; +} + +// ============================================================================ +// TBC 2.4.3 CMSG_USE_ITEM +// Format: bag(u8) + slot(u8) + castCount(u8) + spellId(u32) + itemGuid(u64) + +// castFlags(u8) + SpellCastTargets +// WotLK 3.3.5a adds glyphIndex(u32) between itemGuid and castFlags — TBC does NOT. +// ============================================================================ +network::Packet TbcPacketParsers::buildUseItem(uint8_t bagIndex, uint8_t slotIndex, uint64_t itemGuid, uint32_t spellId) { + network::Packet packet(wireOpcode(LogicalOpcode::CMSG_USE_ITEM)); + packet.writeUInt8(bagIndex); + packet.writeUInt8(slotIndex); + packet.writeUInt8(0); // cast count + packet.writeUInt32(spellId); // on-use spell id + packet.writeUInt64(itemGuid); // full 8-byte GUID + // No glyph index field in TBC 2.4.3 + packet.writeUInt8(0); // cast flags + packet.writeUInt32(0x00); // SpellCastTargets: TARGET_FLAG_SELF + return packet; +} + network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint32_t questId) { network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_ACCEPT_QUEST)); packet.writeUInt64(npcGuid); @@ -505,6 +698,20 @@ network::Packet TbcPacketParsers::buildAcceptQuestPacket(uint64_t npcGuid, uint3 return packet; } +// ============================================================================ +// TBC 2.4.3 CMSG_QUESTGIVER_QUERY_QUEST +// +// WotLK adds a trailing uint8 isDialogContinued byte; TBC does not. +// TBC format: guid(8) + questId(4) = 12 bytes. +// ============================================================================ +network::Packet TbcPacketParsers::buildQueryQuestPacket(uint64_t npcGuid, uint32_t questId) { + network::Packet packet(wireOpcode(Opcode::CMSG_QUESTGIVER_QUERY_QUEST)); + packet.writeUInt64(npcGuid); + packet.writeUInt32(questId); + // No isDialogContinued byte (WotLK-only addition) + return packet; +} + // ============================================================================ // TBC parseAuraUpdate - SMSG_AURA_UPDATE doesn't exist in TBC // TBC uses inline aura update fields + SMSG_INIT_EXTRA_AURA_INFO_OBSOLETE (0x3A3) / @@ -696,5 +903,294 @@ bool TbcPacketParsers::parseItemQueryResponse(network::Packet& packet, ItemQuery return true; } +// ============================================================================ +// TbcPacketParsers::parseMailList — TBC 2.4.3 SMSG_MAIL_LIST_RESULT +// +// Differences from WotLK 3.3.5a (base implementation): +// - Header: uint8 count only (WotLK: uint32 totalCount + uint8 shownCount) +// - No body field — subject IS the full text (WotLK added body when mailTemplateId==0) +// - Attachment item GUID: full uint64 (WotLK: uint32 low GUID) +// - Attachment enchants: 7 × uint32 id only (WotLK: 7 × {id+duration+charges} = 84 bytes) +// - Header fields: cod + itemTextId + stationery (WotLK has extra unknown uint32 between +// itemTextId and stationery) +// ============================================================================ +bool TbcPacketParsers::parseMailList(network::Packet& packet, std::vector& inbox) { + size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 1) return false; + + uint8_t count = packet.readUInt8(); + LOG_INFO("SMSG_MAIL_LIST_RESULT (TBC): count=", (int)count); + + inbox.clear(); + inbox.reserve(count); + + for (uint8_t i = 0; i < count; ++i) { + remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 2) break; + + uint16_t msgSize = packet.readUInt16(); + size_t startPos = packet.getReadPos(); + + MailMessage msg; + if (remaining < static_cast(msgSize) + 2) { + LOG_WARNING("[TBC] Mail entry ", i, " truncated"); + break; + } + + msg.messageId = packet.readUInt32(); + msg.messageType = packet.readUInt8(); + + switch (msg.messageType) { + case 0: msg.senderGuid = packet.readUInt64(); break; + default: msg.senderEntry = packet.readUInt32(); break; + } + + msg.cod = packet.readUInt32(); + packet.readUInt32(); // itemTextId + // NOTE: TBC has NO extra unknown uint32 here (WotLK added one between itemTextId and stationery) + msg.stationeryId = packet.readUInt32(); + msg.money = packet.readUInt32(); + msg.flags = packet.readUInt32(); + msg.expirationTime = packet.readFloat(); + msg.mailTemplateId = packet.readUInt32(); + msg.subject = packet.readString(); + // TBC has no separate body field at all + + uint8_t attachCount = packet.readUInt8(); + msg.attachments.reserve(attachCount); + for (uint8_t j = 0; j < attachCount; ++j) { + MailAttachment att; + att.slot = packet.readUInt8(); + uint64_t itemGuid = packet.readUInt64(); // full 64-bit GUID (TBC) + att.itemGuidLow = static_cast(itemGuid & 0xFFFFFFFF); + att.itemId = packet.readUInt32(); + // TBC: 7 × uint32 enchant ID only (no duration/charges per slot) + for (int e = 0; e < 7; ++e) { + uint32_t enchId = packet.readUInt32(); + 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(); // current durability (separate from chargesOrDurability) + msg.attachments.push_back(att); + } + + msg.read = (msg.flags & 0x01) != 0; + inbox.push_back(std::move(msg)); + + // Skip any unread bytes within this mail entry + size_t consumed = packet.getReadPos() - startPos; + if (consumed < static_cast(msgSize)) { + packet.setReadPos(startPos + msgSize); + } + } + + return !inbox.empty(); +} + +// ============================================================================ +// TbcPacketParsers::parseSpellStart — TBC 2.4.3 SMSG_SPELL_START +// +// TBC uses full uint64 GUIDs for casterGuid and casterUnit. +// WotLK uses packed (variable-length) GUIDs. +// TBC also lacks the castCount byte — format: +// casterGuid(u64) + casterUnit(u64) + castCount(u8) + spellId(u32) + castFlags(u32) + castTime(u32) +// Wait: TBC DOES have castCount. But WotLK removed spellId in some paths. +// Correct TBC format (cmangos-tbc): objectGuid(u64) + casterGuid(u64) + castCount(u8) + spellId(u32) + castFlags(u32) + castTime(u32) +// ============================================================================ +bool TbcPacketParsers::parseSpellStart(network::Packet& packet, SpellStartData& data) { + if (packet.getSize() - packet.getReadPos() < 22) return false; + + data.casterGuid = packet.readUInt64(); // full GUID (object) + data.casterUnit = packet.readUInt64(); // full GUID (caster unit) + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt32(); + data.castTime = packet.readUInt32(); + + if (packet.getReadPos() + 4 <= packet.getSize()) { + uint32_t targetFlags = packet.readUInt32(); + if ((targetFlags & 0x02) && packet.getReadPos() + 8 <= packet.getSize()) { + data.targetGuid = packet.readUInt64(); // full GUID in TBC + } + } + + LOG_DEBUG("[TBC] Spell start: spell=", data.spellId, " castTime=", data.castTime, "ms"); + return true; +} + +// ============================================================================ +// TbcPacketParsers::parseSpellGo — TBC 2.4.3 SMSG_SPELL_GO +// +// TBC uses full uint64 GUIDs, no timestamp field after castFlags. +// WotLK uses packed GUIDs and adds a timestamp (u32) after castFlags. +// ============================================================================ +bool TbcPacketParsers::parseSpellGo(network::Packet& packet, SpellGoData& data) { + if (packet.getSize() - packet.getReadPos() < 19) return false; + + data.casterGuid = packet.readUInt64(); // full GUID in TBC + data.casterUnit = packet.readUInt64(); // full GUID in TBC + data.castCount = packet.readUInt8(); + data.spellId = packet.readUInt32(); + data.castFlags = packet.readUInt32(); + // NOTE: NO timestamp field here in TBC (WotLK added packet.readUInt32()) + + if (packet.getReadPos() >= packet.getSize()) { + LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " (no hit data)"); + return true; + } + + data.hitCount = packet.readUInt8(); + data.hitTargets.reserve(data.hitCount); + for (uint8_t i = 0; i < data.hitCount && packet.getReadPos() + 8 <= packet.getSize(); ++i) { + data.hitTargets.push_back(packet.readUInt64()); // full GUID in TBC + } + + if (packet.getReadPos() < packet.getSize()) { + data.missCount = packet.readUInt8(); + data.missTargets.reserve(data.missCount); + for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 9 <= packet.getSize(); ++i) { + SpellGoMissEntry m; + m.targetGuid = packet.readUInt64(); // full GUID in TBC + m.missType = packet.readUInt8(); + data.missTargets.push_back(m); + } + } + + LOG_DEBUG("[TBC] Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, + " misses=", (int)data.missCount); + return true; +} + +// ============================================================================ +// TbcPacketParsers::parseCastResult — TBC 2.4.3 SMSG_CAST_RESULT +// +// TBC format: spellId(u32) + result(u8) = 5 bytes +// WotLK adds a castCount(u8) prefix making it 6 bytes. +// Without this override, WotLK parser reads spellId[0] as castCount, +// then the remaining 4 bytes as spellId (off by one), producing wrong result. +// ============================================================================ +bool TbcPacketParsers::parseCastResult(network::Packet& packet, uint32_t& spellId, uint8_t& result) { + if (packet.getSize() - packet.getReadPos() < 5) return false; + spellId = packet.readUInt32(); // No castCount prefix in TBC + result = packet.readUInt8(); + return true; +} + +// ============================================================================ +// TbcPacketParsers::parseCastFailed — TBC 2.4.3 SMSG_CAST_FAILED +// +// TBC format: spellId(u32) + result(u8) +// WotLK added castCount(u8) before spellId; reading it on TBC would shift +// the spellId by one byte and corrupt all subsequent fields. +// Classic has the same layout, but the result enum starts differently (offset +1); +// TBC uses the same result values as WotLK so no offset is needed. +// ============================================================================ +bool TbcPacketParsers::parseCastFailed(network::Packet& packet, CastFailedData& data) { + if (packet.getSize() - packet.getReadPos() < 5) return false; + data.castCount = 0; // not present in TBC + data.spellId = packet.readUInt32(); + data.result = packet.readUInt8(); // same enum as WotLK + LOG_DEBUG("[TBC] Cast failed: spell=", data.spellId, " result=", (int)data.result); + return true; +} + +// ============================================================================ +// TbcPacketParsers::parseAttackerStateUpdate — TBC 2.4.3 SMSG_ATTACKERSTATEUPDATE +// +// TBC uses full uint64 GUIDs for attacker and target. +// WotLK uses packed (variable-length) GUIDs — using the WotLK reader here +// would mis-parse TBC's GUIDs and corrupt all subsequent damage fields. +// ============================================================================ +bool TbcPacketParsers::parseAttackerStateUpdate(network::Packet& packet, AttackerStateUpdateData& data) { + if (packet.getSize() - packet.getReadPos() < 21) return false; + + data.hitInfo = packet.readUInt32(); + data.attackerGuid = packet.readUInt64(); // full GUID in TBC + data.targetGuid = packet.readUInt64(); // full GUID in TBC + data.totalDamage = static_cast(packet.readUInt32()); + data.subDamageCount = packet.readUInt8(); + + for (uint8_t i = 0; i < data.subDamageCount; ++i) { + 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); + } + + data.victimState = packet.readUInt32(); + data.overkill = static_cast(packet.readUInt32()); + + if (packet.getReadPos() < packet.getSize()) { + data.blocked = packet.readUInt32(); + } + + LOG_INFO("[TBC] Melee hit: ", data.totalDamage, " damage", + data.isCrit() ? " (CRIT)" : "", + data.isMiss() ? " (MISS)" : ""); + return true; +} + +// ============================================================================ +// TbcPacketParsers::parseSpellDamageLog — TBC 2.4.3 SMSG_SPELLNONMELEEDAMAGELOG +// +// TBC uses full uint64 GUIDs; WotLK uses packed GUIDs. +// ============================================================================ +bool TbcPacketParsers::parseSpellDamageLog(network::Packet& packet, SpellDamageLogData& data) { + if (packet.getSize() - packet.getReadPos() < 29) return false; + + data.targetGuid = packet.readUInt64(); // full GUID in TBC + data.attackerGuid = packet.readUInt64(); // full GUID in TBC + data.spellId = packet.readUInt32(); + data.damage = packet.readUInt32(); + data.schoolMask = packet.readUInt8(); + data.absorbed = packet.readUInt32(); + data.resisted = packet.readUInt32(); + + uint8_t periodicLog = packet.readUInt8(); + (void)periodicLog; + packet.readUInt8(); // unused + packet.readUInt32(); // blocked + uint32_t flags = packet.readUInt32(); + data.isCrit = (flags & 0x02) != 0; + + // TBC does not have an overkill field here + data.overkill = 0; + + LOG_INFO("[TBC] Spell damage: spellId=", data.spellId, " dmg=", data.damage, + data.isCrit ? " CRIT" : ""); + return true; +} + +// ============================================================================ +// TbcPacketParsers::parseSpellHealLog — TBC 2.4.3 SMSG_SPELLHEALLOG +// +// TBC uses full uint64 GUIDs; WotLK uses packed GUIDs. +// ============================================================================ +bool TbcPacketParsers::parseSpellHealLog(network::Packet& packet, SpellHealLogData& data) { + if (packet.getSize() - packet.getReadPos() < 25) return false; + + data.targetGuid = packet.readUInt64(); // full GUID in TBC + data.casterGuid = packet.readUInt64(); // full GUID in TBC + data.spellId = packet.readUInt32(); + data.heal = packet.readUInt32(); + data.overheal = packet.readUInt32(); + // TBC has no absorbed field in SMSG_SPELLHEALLOG; skip crit flag + if (packet.getReadPos() < packet.getSize()) { + uint8_t critFlag = packet.readUInt8(); + data.isCrit = (critFlag != 0); + } + + LOG_INFO("[TBC] Spell heal: spellId=", data.spellId, " heal=", data.heal, + data.isCrit ? " CRIT" : ""); + return true; +} + } // namespace game } // namespace wowee diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index c83563f0..4ecc1555 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1510,20 +1510,30 @@ network::Packet TextEmotePacket::build(uint32_t textEmoteId, uint64_t targetGuid return packet; } -bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data) { +bool TextEmoteParser::parse(network::Packet& packet, TextEmoteData& data, bool legacyFormat) { size_t bytesLeft = packet.getSize() - packet.getReadPos(); if (bytesLeft < 20) { LOG_WARNING("SMSG_TEXT_EMOTE too short: ", bytesLeft, " bytes"); return false; } - data.senderGuid = packet.readUInt64(); - data.textEmoteId = packet.readUInt32(); - data.emoteNum = packet.readUInt32(); + + 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) { - // Skip garbage + // Implausible name length — misaligned read return false; } return true; @@ -2934,10 +2944,12 @@ network::Packet CancelAuraPacket::build(uint32_t spellId) { return packet; } -network::Packet PetActionPacket::build(uint64_t petGuid, uint32_t action) { +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; } @@ -2985,7 +2997,13 @@ bool SpellGoParser::parse(network::Packet& packet, SpellGoData& data) { } data.missCount = packet.readUInt8(); - // Skip miss details for now + data.missTargets.reserve(data.missCount); + for (uint8_t i = 0; i < data.missCount && packet.getReadPos() + 2 <= packet.getSize(); ++i) { + SpellGoMissEntry m; + m.targetGuid = UpdateObjectParser::readPackedGuid(packet); // packed GUID in WotLK + m.missType = (packet.getReadPos() < packet.getSize()) ? packet.readUInt8() : 0; + data.missTargets.push_back(m); + } LOG_DEBUG("Spell go: spell=", data.spellId, " hits=", (int)data.hitCount, " misses=", (int)data.missCount); @@ -3082,47 +3100,82 @@ network::Packet GroupDeclinePacket::build() { return packet; } -bool GroupListParser::parse(network::Packet& packet, GroupListData& data) { - data.groupType = packet.readUInt8(); - data.subGroup = packet.readUInt8(); - data.flags = packet.readUInt8(); - data.roles = packet.readUInt8(); +bool GroupListParser::parse(network::Packet& packet, GroupListData& data, bool hasRoles) { + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; - // Skip LFG data if present - if (data.groupType & 0x04) { - packet.readUInt8(); // lfg state - packet.readUInt32(); // lfg entry - packet.readUInt8(); // lfg flags (3.3.5a may not have this) + 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; } - packet.readUInt64(); // group GUID - packet.readUInt32(); // counter + // 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(); - member.guid = packet.readUInt64(); + 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(); - member.roles = 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 && packet.getReadPos() < packet.getSize()) { - data.lootMethod = packet.readUInt8(); - data.looterGuid = packet.readUInt64(); + if (data.memberCount > 0 && rem() >= 10) { + data.lootMethod = packet.readUInt8(); + data.looterGuid = packet.readUInt64(); data.lootThreshold = packet.readUInt8(); - data.difficultyId = packet.readUInt8(); - data.raidDifficultyId = packet.readUInt8(); - if (packet.getReadPos() < packet.getSize()) { - packet.readUInt8(); // unknown byte - } + // 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", @@ -3780,7 +3833,11 @@ bool ListInventoryParser::parse(network::Packet& packet, ListInventoryData& data // Trainer // ============================================================ -bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) { +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{}; data.trainerGuid = packet.readUInt64(); data.trainerType = packet.readUInt32(); @@ -3794,23 +3851,35 @@ bool TrainerListParser::parse(network::Packet& packet, TrainerListData& data) { data.spells.reserve(spellCount); for (uint32_t i = 0; i < spellCount; ++i) { TrainerSpell spell; - spell.spellId = packet.readUInt32(); - spell.state = packet.readUInt8(); - spell.spellCost = packet.readUInt32(); - spell.profDialog = packet.readUInt32(); - spell.profButton = packet.readUInt32(); - spell.reqLevel = packet.readUInt8(); - spell.reqSkill = packet.readUInt32(); + 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(); + 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); } data.greeting = packet.readString(); - LOG_INFO("Trainer list: ", spellCount, " spells, type=", data.trainerType, + LOG_INFO("Trainer list (", isClassic ? "Classic" : "TBC/WotLK", "): ", + spellCount, " spells, type=", data.trainerType, ", greeting=\"", data.greeting, "\""); return true; } @@ -3935,6 +4004,14 @@ network::Packet TalentWipeConfirmPacket::build(bool accept) { 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 // ============================================================ @@ -4437,45 +4514,54 @@ network::Packet AuctionListBidderItemsPacket::build( return p; } -bool AuctionListResultParser::parse(network::Packet& packet, AuctionListResult& data) { +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.getSize() - packet.getReadPos() < 4) return false; uint32_t count = packet.readUInt32(); 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.getReadPos() + 64 > packet.getSize()) break; + if (packet.getReadPos() + minPerEntry > packet.getSize()) break; AuctionEntry e; e.auctionId = packet.readUInt32(); e.itemEntry = packet.readUInt32(); - // 3 enchant slots: enchantId, duration, charges + // First enchant slot always present e.enchantId = packet.readUInt32(); - packet.readUInt32(); // enchant duration - packet.readUInt32(); // enchant charges - packet.readUInt32(); // enchant2 id - packet.readUInt32(); // enchant2 duration - packet.readUInt32(); // enchant2 charges - packet.readUInt32(); // enchant3 id - packet.readUInt32(); // enchant3 duration - packet.readUInt32(); // enchant3 charges + 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(); + 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(); + 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); } - data.totalCount = packet.readUInt32(); - data.searchDelay = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() >= 8) { + data.totalCount = packet.readUInt32(); + data.searchDelay = packet.readUInt32(); + } return true; } diff --git a/src/network/tcp_socket.cpp b/src/network/tcp_socket.cpp index 38a1cf6a..2dbf1b57 100644 --- a/src/network/tcp_socket.cpp +++ b/src/network/tcp_socket.cpp @@ -153,6 +153,11 @@ void TCPSocket::update() { if (net::isWouldBlock(err)) { break; } + if (net::isConnectionClosed(err)) { + // Peer closed the connection — treat the same as recv() returning 0 + sawClose = true; + break; + } LOG_ERROR("Receive failed: ", net::errorString(err)); disconnect(); diff --git a/src/network/world_socket.cpp b/src/network/world_socket.cpp index ab29a271..78c90c8e 100644 --- a/src/network/world_socket.cpp +++ b/src/network/world_socket.cpp @@ -128,6 +128,39 @@ bool WorldSocket::connect(const std::string& host, uint16_t port) { sockfd = INVALID_SOCK; return false; } + + // Non-blocking connect in progress — wait up to 10s for completion. + // On Windows, calling recv() before the connect completes returns + // WSAENOTCONN; we must poll writability before declaring connected. + fd_set writefds, errfds; + FD_ZERO(&writefds); + FD_ZERO(&errfds); + FD_SET(sockfd, &writefds); + FD_SET(sockfd, &errfds); + + struct timeval tv; + tv.tv_sec = 10; + tv.tv_usec = 0; + + int sel = ::select(static_cast(sockfd) + 1, nullptr, &writefds, &errfds, &tv); + if (sel <= 0) { + LOG_ERROR("World server connection timed out (", host, ":", port, ")"); + net::closeSocket(sockfd); + sockfd = INVALID_SOCK; + return false; + } + + // Verify the socket error code — writeable doesn't guarantee success on all platforms + int sockErr = 0; + socklen_t errLen = sizeof(sockErr); + getsockopt(sockfd, SOL_SOCKET, SO_ERROR, + reinterpret_cast(&sockErr), &errLen); + if (sockErr != 0) { + LOG_ERROR("Failed to connect to world server: ", net::errorString(sockErr)); + net::closeSocket(sockfd); + sockfd = INVALID_SOCK; + return false; + } } connected = true; @@ -369,6 +402,11 @@ void WorldSocket::update() { if (net::isWouldBlock(err)) { break; } + if (net::isConnectionClosed(err)) { + // Peer closed the connection — treat the same as recv() returning 0 + sawClose = true; + break; + } LOG_ERROR("Receive failed: ", net::errorString(err)); disconnect(); diff --git a/src/pipeline/wmo_loader.cpp b/src/pipeline/wmo_loader.cpp index 22a4df42..076e4579 100644 --- a/src/pipeline/wmo_loader.cpp +++ b/src/pipeline/wmo_loader.cpp @@ -109,7 +109,11 @@ WMOModel WMOLoader::load(const std::vector& wmoData) { model.nDoodadDefs = read(wmoData, offset); model.nDoodadSets = read(wmoData, offset); - [[maybe_unused]] uint32_t ambColor = read(wmoData, offset); // Ambient color (BGRA) + uint32_t ambColor = read(wmoData, offset); // Ambient color (BGRA) + // Unpack BGRA bytes to normalized [0,1] RGB + model.ambientColor.r = ((ambColor >> 16) & 0xFF) / 255.0f; + model.ambientColor.g = ((ambColor >> 8) & 0xFF) / 255.0f; + model.ambientColor.b = ((ambColor >> 0) & 0xFF) / 255.0f; [[maybe_unused]] uint32_t wmoID = read(wmoData, offset); model.boundingBoxMin.x = read(wmoData, offset); diff --git a/src/rendering/character_preview.cpp b/src/rendering/character_preview.cpp index 964357f4..9dc0efcf 100644 --- a/src/rendering/character_preview.cpp +++ b/src/rendering/character_preview.cpp @@ -543,9 +543,24 @@ bool CharacterPreview::applyEquipment(const std::vector& eq auto displayInfoDbc = assetManager_->loadDBC("ItemDisplayInfo.dbc"); if (!displayInfoDbc || !displayInfoDbc->isLoaded()) { + LOG_WARNING("applyEquipment: ItemDisplayInfo.dbc not loaded"); return false; } + // Diagnostic: log equipment vector and DBC state + LOG_INFO("applyEquipment: ", equipment.size(), " items, ItemDisplayInfo.dbc records=", + displayInfoDbc->getRecordCount(), " fields=", displayInfoDbc->getFieldCount(), + " bodySkin=", bodySkinPath_.empty() ? "(empty)" : bodySkinPath_); + for (size_t ei = 0; ei < equipment.size(); ++ei) { + const auto& it = equipment[ei]; + if (it.displayModel == 0) continue; + int32_t dbcRec = displayInfoDbc->findRecordById(it.displayModel); + LOG_INFO(" slot[", ei, "]: displayModel=", it.displayModel, + " invType=", (int)it.inventoryType, + " dbcRec=", dbcRec, + (dbcRec >= 0 ? " (found)" : " (NOT FOUND in ItemDisplayInfo.dbc)")); + } + auto hasInvType = [&](std::initializer_list types) -> bool { for (const auto& it : equipment) { if (it.displayModel == 0) continue; @@ -560,7 +575,7 @@ bool CharacterPreview::applyEquipment(const std::vector& eq for (const auto& it : equipment) { if (it.displayModel == 0) continue; for (uint8_t t : types) { - if (it.inventoryType == t) return it.displayModel; // ItemDisplayInfo ID (3.3.5a char enum) + if (it.inventoryType == t) return it.displayModel; } } return 0; @@ -570,7 +585,12 @@ bool CharacterPreview::applyEquipment(const std::vector& eq if (displayInfoId == 0) return 0; int32_t recIdx = displayInfoDbc->findRecordById(displayInfoId); if (recIdx < 0) return 0; - return displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); + uint32_t val = displayInfoDbc->getUInt32(static_cast(recIdx), 7 + groupField); + if (val > 0) { + LOG_INFO(" getGeosetGroup: displayInfoId=", displayInfoId, + " groupField=", groupField, " field=", (7 + groupField), " val=", val); + } + return val; }; // --- Geosets --- @@ -654,6 +674,9 @@ bool CharacterPreview::applyEquipment(const std::vector& eq std::string texName = displayInfoDbc->getString(static_cast(recIdx), fieldIdx); if (texName.empty()) continue; + LOG_INFO(" texture region ", region, " (field ", fieldIdx, "): texName=", texName, + " for displayModel=", it.displayModel); + std::string base = "Item\\TextureComponents\\" + std::string(componentDirs[region]) + "\\" + texName; @@ -669,6 +692,7 @@ bool CharacterPreview::applyEquipment(const std::vector& eq } else if (assetManager_->fileExists(basePath)) { fullPath = basePath; } else { + LOG_INFO(" texture path not found: ", base, " (_M/_F/_U/.blp)"); continue; } regionLayers.emplace_back(region, fullPath); diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index a28e49a6..b079f50a 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -1185,7 +1185,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { " tris, grid ", gpuModel.collision.gridCellsX, "x", gpuModel.collision.gridCellsY); } - // Flag smoke models for UV scroll animation (particle emitters not implemented) + // Flag smoke models for UV scroll animation (in addition to particle emitters) { std::string smokeName = model.name; std::transform(smokeName.begin(), smokeName.end(), smokeName.begin(), @@ -1357,6 +1357,7 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { if (batch.materialIndex < model.materials.size()) { bgpu.blendMode = model.materials[batch.materialIndex].blendMode; bgpu.materialFlags = model.materials[batch.materialIndex].flags; + if (bgpu.blendMode >= 2) gpuModel.hasTransparentBatches = true; } // Copy LOD level from batch @@ -2349,7 +2350,11 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const sortedVisible_.push_back({i, instance.modelId, distSq, effectiveMaxDistSq}); } - // Sort by modelId to minimize vertex/index buffer rebinds + // 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; }); @@ -2377,6 +2382,7 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const // Start with opaque pipeline vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, opaquePipeline_); currentPipeline = opaquePipeline_; + bool opaquePass = true; // Pass 1 = opaque, pass 2 = transparent (set below for second pass) for (const auto& entry : sortedVisible_) { if (entry.index >= instances.size()) continue; @@ -2475,6 +2481,15 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const if (!model.isGroundDetail && batch.submeshLevel != targetLOD) continue; if (batch.batchOpacity < 0.01f) continue; + // Two-pass gate: pass 1 = opaque/cutout only, pass 2 = transparent/additive only. + // Alpha-test (blendMode==1) and spell effects that force-additive are handled + // by their effective blend mode below; gate on raw blendMode here. + { + const bool rawTransparent = (batch.blendMode >= 2) || model.isSpellEffect; + if (opaquePass && rawTransparent) continue; // skip transparent in opaque pass + if (!opaquePass && !rawTransparent) continue; // skip opaque in transparent pass + } + const bool koboldFlameCard = batch.colorKeyBlack && model.isKoboldFlame; const bool smallCardLikeBatch = (batch.glowSize <= 1.35f) || @@ -2628,6 +2643,162 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } + // Pass 2: transparent/additive batches — sort back-to-front by distance so + // overlapping transparent geometry composites in the correct painter's order. + opaquePass = false; + std::sort(sortedVisible_.begin(), sortedVisible_.end(), + [](const VisibleEntry& a, const VisibleEntry& b) { return a.distSq > b.distSq; }); + + currentModelId = UINT32_MAX; + currentModel = nullptr; + // Reset pipeline to opaque so the first transparent bind always sets explicitly + currentPipeline = opaquePipeline_; + + 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, skip it entirely + if (entry.modelId != currentModelId) { + auto mdlIt = models.find(entry.modelId); + if (mdlIt == models.end()) continue; + if (!mdlIt->second.hasTransparentBatches && !mdlIt->second.isSpellEffect) continue; + } + + // Reuse the same rendering logic as pass 1 (via fallthrough — the batch gate + // `!opaquePass && !rawTransparent → continue` handles opaque skipping) + if (entry.modelId != currentModelId) { + currentModelId = entry.modelId; + auto mdlIt = models.find(currentModelId); + if (mdlIt == models.end()) continue; + currentModel = &mdlIt->second; + if (!currentModel->vertexBuffer) continue; + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, ¤tModel->vertexBuffer, &offset); + vkCmdBindIndexBuffer(cmd, currentModel->indexBuffer, 0, VK_INDEX_TYPE_UINT16); + } + + const M2ModelGPU& model = *currentModel; + + // Distance-based fade alpha (same as pass 1) + 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.boneBuffer[frameIndex] || !instance.boneSet[frameIndex])) continue; + bool useBones = needsBones; + if (useBones && instance.boneSet[frameIndex]) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 2, 1, &instance.boneSet[frameIndex], 0, nullptr); + } + + 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 after loop) + const bool batchUnlit = (batch.materialFlags & 0x01) != 0; + const bool shouldUseGlowSprite = + !batch.colorKeyBlack && + (model.isElvenLike || model.isLanternLike) && + !model.isSpellEffect && + (batch.glowSize <= 1.35f || (batch.lanternGlowHint && batch.glowSize <= 6.0f)) && + (batch.lanternGlowHint || (batch.blendMode >= 3) || + (batch.colorKeyBlack && batchUnlit && batch.blendMode >= 1)); + if (shouldUseGlowSprite) { + const bool cardLikeSkipMesh = (batch.blendMode >= 3) || batch.colorKeyBlack || batchUnlit; + if ((batch.glowCardLike && model.isLanternLike) || (cardLikeSkipMesh && !model.isLanternLike)) + continue; + } + + glm::vec2 uvOffset(0.0f, 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)) { + static auto startTime2 = std::chrono::steady_clock::now(); + float t = std::chrono::duration(std::chrono::steady_clock::now() - startTime2).count(); + uvOffset = glm::vec2(t * 0.03f, -t * 0.08f); + } + + 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; + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); + + M2PushConstants pc; + pc.model = instance.modelMatrix; + pc.uvOffset = uvOffset; + pc.texCoordSet = static_cast(batch.textureUnit); + pc.useBones = useBones ? 1 : 0; + pc.isFoliage = model.shadowWindFoliage ? 1 : 0; + pc.fadeAlpha = instanceFadeAlpha; + if (particleDominantEffect) continue; // emission-only mesh + 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_); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index 0fd4beb9..6da94182 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -288,7 +288,9 @@ Renderer::~Renderer() = default; bool Renderer::createPerFrameResources() { VkDevice device = vkCtx->getDevice(); - // --- Create shadow depth image --- + // --- Create per-frame shadow depth images (one per in-flight frame) --- + // Each frame slot has its own depth image so that frame N's shadow read and + // frame N+1's shadow write cannot race on the same image. VkImageCreateInfo imgCI{}; imgCI.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; imgCI.imageType = VK_IMAGE_TYPE_2D; @@ -301,26 +303,30 @@ bool Renderer::createPerFrameResources() { imgCI.usage = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; VmaAllocationCreateInfo imgAllocCI{}; imgAllocCI.usage = VMA_MEMORY_USAGE_GPU_ONLY; - if (vmaCreateImage(vkCtx->getAllocator(), &imgCI, &imgAllocCI, - &shadowDepthImage, &shadowDepthAlloc, nullptr) != VK_SUCCESS) { - LOG_ERROR("Failed to create shadow depth image"); - return false; + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + if (vmaCreateImage(vkCtx->getAllocator(), &imgCI, &imgAllocCI, + &shadowDepthImage[i], &shadowDepthAlloc[i], nullptr) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow depth image [", i, "]"); + return false; + } + shadowDepthLayout_[i] = VK_IMAGE_LAYOUT_UNDEFINED; } - shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; - // --- Create shadow depth image view --- + // --- Create per-frame shadow depth image views --- VkImageViewCreateInfo viewCI{}; viewCI.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; - viewCI.image = shadowDepthImage; viewCI.viewType = VK_IMAGE_VIEW_TYPE_2D; viewCI.format = VK_FORMAT_D32_SFLOAT; viewCI.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; - if (vkCreateImageView(device, &viewCI, nullptr, &shadowDepthView) != VK_SUCCESS) { - LOG_ERROR("Failed to create shadow depth image view"); - return false; + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + viewCI.image = shadowDepthImage[i]; + if (vkCreateImageView(device, &viewCI, nullptr, &shadowDepthView[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow depth image view [", i, "]"); + return false; + } } - // --- Create shadow sampler --- + // --- Create shadow sampler (shared — read-only, no per-frame needed) --- VkSamplerCreateInfo sampCI{}; sampCI.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; sampCI.magFilter = VK_FILTER_LINEAR; @@ -377,18 +383,20 @@ bool Renderer::createPerFrameResources() { return false; } - // --- Create shadow framebuffer --- + // --- Create per-frame shadow framebuffers --- VkFramebufferCreateInfo fbCI{}; fbCI.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; fbCI.renderPass = shadowRenderPass; fbCI.attachmentCount = 1; - fbCI.pAttachments = &shadowDepthView; fbCI.width = SHADOW_MAP_SIZE; fbCI.height = SHADOW_MAP_SIZE; fbCI.layers = 1; - if (vkCreateFramebuffer(device, &fbCI, nullptr, &shadowFramebuffer) != VK_SUCCESS) { - LOG_ERROR("Failed to create shadow framebuffer"); - return false; + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + fbCI.pAttachments = &shadowDepthView[i]; + if (vkCreateFramebuffer(device, &fbCI, nullptr, &shadowFramebuffer[i]) != VK_SUCCESS) { + LOG_ERROR("Failed to create shadow framebuffer [", i, "]"); + return false; + } } // --- Create descriptor set layout for set 0 (per-frame UBO + shadow sampler) --- @@ -470,7 +478,7 @@ bool Renderer::createPerFrameResources() { VkDescriptorImageInfo shadowImgInfo{}; shadowImgInfo.sampler = shadowSampler; - shadowImgInfo.imageView = shadowDepthView; + shadowImgInfo.imageView = shadowDepthView[i]; shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; VkWriteDescriptorSet writes[2]{}; @@ -527,7 +535,7 @@ bool Renderer::createPerFrameResources() { VkDescriptorImageInfo shadowImgInfo{}; shadowImgInfo.sampler = shadowSampler; - shadowImgInfo.imageView = shadowDepthView; + shadowImgInfo.imageView = shadowDepthView[0]; // reflection uses frame 0 shadow view shadowImgInfo.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; VkWriteDescriptorSet writes[2]{}; @@ -576,13 +584,15 @@ void Renderer::destroyPerFrameResources() { perFrameSetLayout = VK_NULL_HANDLE; } - // Destroy shadow resources - if (shadowFramebuffer) { vkDestroyFramebuffer(device, shadowFramebuffer, nullptr); shadowFramebuffer = VK_NULL_HANDLE; } + // Destroy per-frame shadow resources + for (uint32_t i = 0; i < MAX_FRAMES; i++) { + if (shadowFramebuffer[i]) { vkDestroyFramebuffer(device, shadowFramebuffer[i], nullptr); shadowFramebuffer[i] = VK_NULL_HANDLE; } + if (shadowDepthView[i]) { vkDestroyImageView(device, shadowDepthView[i], nullptr); shadowDepthView[i] = VK_NULL_HANDLE; } + if (shadowDepthImage[i]) { vmaDestroyImage(vkCtx->getAllocator(), shadowDepthImage[i], shadowDepthAlloc[i]); shadowDepthImage[i] = VK_NULL_HANDLE; shadowDepthAlloc[i] = VK_NULL_HANDLE; } + shadowDepthLayout_[i] = VK_IMAGE_LAYOUT_UNDEFINED; + } if (shadowRenderPass) { vkDestroyRenderPass(device, shadowRenderPass, nullptr); shadowRenderPass = VK_NULL_HANDLE; } - if (shadowDepthView) { vkDestroyImageView(device, shadowDepthView, nullptr); shadowDepthView = VK_NULL_HANDLE; } - if (shadowDepthImage) { vmaDestroyImage(vkCtx->getAllocator(), shadowDepthImage, shadowDepthAlloc); shadowDepthImage = VK_NULL_HANDLE; shadowDepthAlloc = VK_NULL_HANDLE; } if (shadowSampler) { vkDestroySampler(device, shadowSampler, nullptr); shadowSampler = VK_NULL_HANDLE; } - shadowDepthLayout_ = VK_IMAGE_LAYOUT_UNDEFINED; } void Renderer::updatePerFrameUBO() { @@ -1088,7 +1098,7 @@ void Renderer::beginFrame() { } // Shadow pre-pass (before main render pass) - if (shadowsEnabled && shadowDepthImage != VK_NULL_HANDLE) { + if (shadowsEnabled && shadowDepthImage[0] != VK_NULL_HANDLE) { renderShadowPass(); } @@ -5669,7 +5679,7 @@ void Renderer::renderReflectionPass() { void Renderer::renderShadowPass() { static const bool skipShadows = (std::getenv("WOWEE_SKIP_SHADOWS") != nullptr); if (skipShadows) return; - if (!shadowsEnabled || shadowDepthImage == VK_NULL_HANDLE) return; + if (!shadowsEnabled || shadowDepthImage[0] == VK_NULL_HANDLE) return; if (currentCmd == VK_NULL_HANDLE) return; // Shadows render every frame — throttling causes visible flicker on player/NPCs @@ -5686,21 +5696,21 @@ void Renderer::renderShadowPass() { ubo->shadowParams.y = 0.8f; } - // Barrier 1: transition shadow map into writable depth layout. + // Barrier 1: transition this frame's shadow map into writable depth layout. VkImageMemoryBarrier b1{}; b1.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; - b1.oldLayout = shadowDepthLayout_; + b1.oldLayout = shadowDepthLayout_[frame]; b1.newLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; b1.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; b1.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; - b1.srcAccessMask = (shadowDepthLayout_ == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) + b1.srcAccessMask = (shadowDepthLayout_[frame] == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) ? VK_ACCESS_SHADER_READ_BIT : 0; b1.dstAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; - b1.image = shadowDepthImage; + b1.image = shadowDepthImage[frame]; b1.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; - VkPipelineStageFlags srcStage = (shadowDepthLayout_ == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) + VkPipelineStageFlags srcStage = (shadowDepthLayout_[frame] == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) ? VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT : VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; vkCmdPipelineBarrier(currentCmd, @@ -5711,7 +5721,7 @@ void Renderer::renderShadowPass() { VkRenderPassBeginInfo rpInfo{}; rpInfo.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; rpInfo.renderPass = shadowRenderPass; - rpInfo.framebuffer = shadowFramebuffer; + rpInfo.framebuffer = shadowFramebuffer[frame]; rpInfo.renderArea = {{0, 0}, {SHADOW_MAP_SIZE, SHADOW_MAP_SIZE}}; VkClearValue clear{}; clear.depthStencil = {1.0f, 0}; @@ -5750,12 +5760,12 @@ void Renderer::renderShadowPass() { b2.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; b2.srcAccessMask = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; b2.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; - b2.image = shadowDepthImage; + b2.image = shadowDepthImage[frame]; b2.subresourceRange = {VK_IMAGE_ASPECT_DEPTH_BIT, 0, 1, 0, 1}; vkCmdPipelineBarrier(currentCmd, VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, nullptr, 1, &b2); - shadowDepthLayout_ = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + shadowDepthLayout_[frame] = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; } } // namespace rendering diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index e186ed96..579a909a 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -885,13 +885,15 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { } case FinalizationPhase::M2_INSTANCES: { - // Create all M2 instances (lightweight struct allocation, no GPU work) - if (m2Renderer) { - int loadedDoodads = 0; - int skippedDedup = 0; - for (const auto& p : pending->m2Placements) { + // Create M2 instances incrementally to avoid main-thread stalls. + // createInstance includes an O(n) bone-sibling scan that becomes expensive + // on dense tiles with many placements and a large existing instance list. + if (m2Renderer && ft.m2InstanceIndex < pending->m2Placements.size()) { + constexpr size_t kInstancesPerStep = 32; + size_t created = 0; + while (ft.m2InstanceIndex < pending->m2Placements.size() && created < kInstancesPerStep) { + const auto& p = pending->m2Placements[ft.m2InstanceIndex++]; if (p.uniqueId != 0 && placedDoodadIds.count(p.uniqueId)) { - skippedDedup++; continue; } uint32_t instId = m2Renderer->createInstance(p.modelId, p.position, p.rotation, p.scale); @@ -901,12 +903,14 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { placedDoodadIds.insert(p.uniqueId); ft.tileUniqueIds.push_back(p.uniqueId); } - loadedDoodads++; + created++; } } + if (ft.m2InstanceIndex < pending->m2Placements.size()) { + return false; // More instances to create — yield + } LOG_DEBUG(" Loaded doodads for tile [", x, ",", y, "]: ", - loadedDoodads, " instances (", ft.uploadedM2ModelIds.size(), " new models, ", - skippedDedup, " dedup skipped)"); + ft.m2InstanceIds.size(), " instances (", ft.uploadedM2ModelIds.size(), " new models)"); } ft.phase = FinalizationPhase::WMO_MODELS; return false; @@ -948,17 +952,15 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { } case FinalizationPhase::WMO_INSTANCES: { - // Create all WMO instances + load WMO liquids - if (wmoRenderer) { - int loadedWMOs = 0; - int loadedLiquids = 0; - int skippedWmoDedup = 0; - for (auto& wmoReady : pending->wmoModels) { + // Create WMO instances incrementally to avoid stalls on tiles with many WMOs. + if (wmoRenderer && ft.wmoInstanceIndex < pending->wmoModels.size()) { + constexpr size_t kWmoInstancesPerStep = 4; + size_t created = 0; + while (ft.wmoInstanceIndex < pending->wmoModels.size() && created < kWmoInstancesPerStep) { + auto& wmoReady = pending->wmoModels[ft.wmoInstanceIndex++]; if (wmoReady.uniqueId != 0 && placedWmoIds.count(wmoReady.uniqueId)) { - skippedWmoDedup++; continue; } - uint32_t wmoInstId = wmoRenderer->createInstance(wmoReady.modelId, wmoReady.position, wmoReady.rotation); if (wmoInstId) { ft.wmoInstanceIds.push_back(wmoInstId); @@ -966,8 +968,6 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { placedWmoIds.insert(wmoReady.uniqueId); ft.tileWmoUniqueIds.push_back(wmoReady.uniqueId); } - loadedWMOs++; - // Load WMO liquids (canals, pools, etc.) if (waterRenderer) { glm::mat4 modelMatrix = glm::mat4(1.0f); @@ -977,25 +977,21 @@ bool TerrainManager::advanceFinalization(FinalizingTile& ft) { modelMatrix = glm::rotate(modelMatrix, wmoReady.rotation.x, glm::vec3(1.0f, 0.0f, 0.0f)); for (const auto& group : wmoReady.model.groups) { if (!group.liquid.hasLiquid()) continue; - // Skip interior water/ocean but keep magma/slime (e.g. Ironforge lava) if (group.flags & 0x2000) { uint16_t lt = group.liquid.materialId; uint8_t basicType = (lt == 0) ? 0 : ((lt - 1) % 4); if (basicType < 2) continue; } waterRenderer->loadFromWMO(group.liquid, modelMatrix, wmoInstId); - loadedLiquids++; } } + created++; } } - if (loadedWMOs > 0 || skippedWmoDedup > 0) { - LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", - loadedWMOs, " instances, ", skippedWmoDedup, " dedup skipped"); - } - if (loadedLiquids > 0) { - LOG_DEBUG(" Loaded WMO liquids for tile [", x, ",", y, "]: ", loadedLiquids); + if (ft.wmoInstanceIndex < pending->wmoModels.size()) { + return false; // More WMO instances to create — yield } + LOG_DEBUG(" Loaded WMOs for tile [", x, ",", y, "]: ", ft.wmoInstanceIds.size(), " instances"); } ft.phase = FinalizationPhase::WMO_DOODADS; return false; @@ -2213,10 +2209,16 @@ void TerrainManager::streamTiles() { return false; }; - // Enqueue tiles in radius around current tile for async loading + // Enqueue tiles in radius around current tile for async loading. + // Collect all newly-needed tiles, then sort by distance so the closest + // (most visible) tiles get loaded first. This is critical during taxi + // flight where new tiles enter the radius faster than they can load. { std::lock_guard lock(queueMutex); + struct PendingEntry { TileCoord coord; int distSq; }; + std::vector newTiles; + for (int dy = -loadRadius; dy <= loadRadius; dy++) { for (int dx = -loadRadius; dx <= loadRadius; dx++) { int tileX = currentTile.x + dx; @@ -2240,10 +2242,19 @@ void TerrainManager::streamTiles() { if (failedTiles.find(coord) != failedTiles.end()) continue; if (shouldSkipMissingAdt(coord)) continue; - loadQueue.push_back(coord); + newTiles.push_back({coord, dx*dx + dy*dy}); pendingTiles[coord] = true; } } + + // Sort nearest tiles first so workers service the most visible tiles + std::sort(newTiles.begin(), newTiles.end(), + [](const PendingEntry& a, const PendingEntry& b) { return a.distSq < b.distSq; }); + + // Insert at front so new close tiles preempt any distant tiles already queued + for (auto it = newTiles.rbegin(); it != newTiles.rend(); ++it) { + loadQueue.push_front(it->coord); + } } // Notify workers that there's work diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index 51d8c2a2..4d52fd76 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -414,6 +414,7 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { modelData.id = id; modelData.boundingBoxMin = model.boundingBoxMin; modelData.boundingBoxMax = model.boundingBoxMax; + modelData.wmoAmbientColor = model.ambientColor; { glm::vec3 ext = model.boundingBoxMax - model.boundingBoxMin; float horiz = std::max(ext.x, ext.y); @@ -681,6 +682,9 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { matData.heightMapVariance = mb.heightMapVariance; matData.normalMapStrength = normalMapStrength_; matData.isLava = mb.isLava ? 1 : 0; + matData.wmoAmbientR = modelData.wmoAmbientColor.r; + matData.wmoAmbientG = modelData.wmoAmbientColor.g; + matData.wmoAmbientB = modelData.wmoAmbientColor.b; if (matBuf.info.pMappedData) { memcpy(matBuf.info.pMappedData, &matData, sizeof(matData)); } diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 06e6b4b5..50c2a062 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -401,8 +401,10 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderMirrorTimers(gameHandler); renderQuestObjectiveTracker(gameHandler); if (showNameplates_) renderNameplates(gameHandler); + renderBattlegroundScore(gameHandler); renderCombatText(gameHandler); renderPartyFrames(gameHandler); + renderBossFrames(gameHandler); renderGroupInvitePopup(gameHandler); renderDuelRequestPopup(gameHandler); renderLootRollPopup(gameHandler); @@ -432,6 +434,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { // renderQuestMarkers(gameHandler); // Disabled - using 3D billboard markers now renderMinimapMarkers(gameHandler); renderDeathScreen(gameHandler); + renderReclaimCorpseButton(gameHandler); renderResurrectDialog(gameHandler); renderChatBubbles(gameHandler); renderEscapeMenu(); @@ -1865,6 +1868,37 @@ void GameScreen::renderPlayerFrame(game::GameHandler& gameHandler) { } 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)); + } + } + } } ImGui::End(); @@ -2090,6 +2124,22 @@ void GameScreen::renderTargetFrame(game::GameHandler& gameHandler) { } } + // 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(); + const std::string& castName = (tspell != 0) ? gameHandler.getSpellName(tspell) : ""; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + char castLabel[72]; + if (!castName.empty()) + snprintf(castLabel, sizeof(castLabel), "%s (%.1fs)", castName.c_str(), castLeft); + else + snprintf(castLabel, sizeof(castLabel), "Casting... (%.1fs)", castLeft); + ImGui::ProgressBar(castPct, ImVec2(-1, 14), castLabel); + ImGui::PopStyleColor(); + } + // Distance const auto& movement = gameHandler.getMovementInfo(); float dx = target->getX() - movement.x; @@ -4670,6 +4720,10 @@ void GameScreen::renderCombatText(game::GameHandler& gameHandler) { snprintf(text, sizeof(text), "+%d XP", entry.amount); color = ImVec4(0.7f, 0.3f, 1.0f, alpha); // Purple for XP break; + case game::CombatTextEntry::IMMUNE: + snprintf(text, sizeof(text), "Immune!"); + color = ImVec4(0.9f, 0.9f, 0.9f, alpha); // White for immune + break; default: snprintf(text, sizeof(text), "%d", entry.amount); color = ImVec4(1.0f, 1.0f, 1.0f, alpha); @@ -4883,6 +4937,21 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleColor(); } + // Party member cast bar — shows when the party member is casting + if (auto* cs = gameHandler.getUnitCastState(member.guid)) { + float castPct = (cs->timeTotal > 0.0f) + ? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.8f, 0.8f, 0.2f, 1.0f)); + char pcastLabel[48]; + const std::string& spellNm = gameHandler.getSpellName(cs->spellId); + if (!spellNm.empty()) + snprintf(pcastLabel, sizeof(pcastLabel), "%s (%.1fs)", spellNm.c_str(), cs->timeRemaining); + else + snprintf(pcastLabel, sizeof(pcastLabel), "Casting... (%.1fs)", cs->timeRemaining); + ImGui::ProgressBar(castPct, ImVec2(-1, 10), pcastLabel); + ImGui::PopStyleColor(); + } + ImGui::Separator(); ImGui::PopID(); } @@ -4893,6 +4962,97 @@ void GameScreen::renderPartyFrames(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +// ============================================================ +// Boss Encounter Frames +// ============================================================ + +void GameScreen::renderBossFrames(game::GameHandler& gameHandler) { + // Collect active boss unit slots + struct BossSlot { uint32_t slot; uint64_t guid; }; + std::vector active; + for (uint32_t s = 0; s < game::GameHandler::kMaxEncounterSlots; ++s) { + uint64_t g = gameHandler.getEncounterUnitGuid(s); + if (g != 0) active.push_back({s, g}); + } + if (active.empty()) return; + + const float frameW = 200.0f; + const float startX = ImGui::GetIO().DisplaySize.x - frameW - 10.0f; + float frameY = 120.0f; + + ImGuiWindowFlags flags = ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_AlwaysAutoResize; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 4.0f); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.15f, 0.05f, 0.05f, 0.85f)); + + ImGui::SetNextWindowPos(ImVec2(startX, frameY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, 0.0f), ImGuiCond_Always); + + if (ImGui::Begin("##BossFrames", nullptr, flags)) { + for (const auto& bs : active) { + ImGui::PushID(static_cast(bs.guid)); + + // Try to resolve name and health from entity manager + std::string name = "Boss"; + uint32_t hp = 0, maxHp = 0; + auto entity = gameHandler.getEntityManager().getEntity(bs.guid); + if (entity && (entity->getType() == game::ObjectType::UNIT || + entity->getType() == game::ObjectType::PLAYER)) { + auto unit = std::static_pointer_cast(entity); + const auto& n = unit->getName(); + if (!n.empty()) name = n; + hp = unit->getHealth(); + maxHp = unit->getMaxHealth(); + } + + // Clickable name to target + if (ImGui::Selectable(name.c_str(), gameHandler.getTargetGuid() == bs.guid)) { + gameHandler.setTarget(bs.guid); + } + + if (maxHp > 0) { + float pct = static_cast(hp) / static_cast(maxHp); + // Boss health bar in red shades + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, + pct > 0.5f ? ImVec4(0.8f, 0.2f, 0.2f, 1.0f) : + pct > 0.2f ? ImVec4(0.9f, 0.5f, 0.1f, 1.0f) : + ImVec4(1.0f, 0.8f, 0.1f, 1.0f)); + char label[32]; + std::snprintf(label, sizeof(label), "%u / %u", hp, maxHp); + ImGui::ProgressBar(pct, ImVec2(-1, 14), label); + ImGui::PopStyleColor(); + } + + // Boss cast bar — shown when the boss is casting (critical for interrupt) + if (auto* cs = gameHandler.getUnitCastState(bs.guid)) { + float castPct = (cs->timeTotal > 0.0f) + ? (cs->timeTotal - cs->timeRemaining) / cs->timeTotal : 0.0f; + uint32_t bspell = cs->spellId; + const std::string& bcastName = (bspell != 0) + ? gameHandler.getSpellName(bspell) : ""; + ImGui::PushStyleColor(ImGuiCol_PlotHistogram, ImVec4(0.9f, 0.3f, 0.2f, 1.0f)); + char bcastLabel[72]; + if (!bcastName.empty()) + snprintf(bcastLabel, sizeof(bcastLabel), "%s (%.1fs)", + bcastName.c_str(), cs->timeRemaining); + else + snprintf(bcastLabel, sizeof(bcastLabel), "Casting... (%.1fs)", cs->timeRemaining); + ImGui::ProgressBar(castPct, ImVec2(-1, 12), bcastLabel); + ImGui::PopStyleColor(); + } + + ImGui::PopID(); + ImGui::Spacing(); + } + } + ImGui::End(); + + ImGui::PopStyleColor(); + ImGui::PopStyleVar(); +} + // ============================================================ // Group Invite Popup (Phase 4) // ============================================================ @@ -6935,6 +7095,34 @@ void GameScreen::renderDeathScreen(game::GameHandler& gameHandler) { ImGui::PopStyleVar(); } +void GameScreen::renderReclaimCorpseButton(game::GameHandler& gameHandler) { + if (!gameHandler.isPlayerGhost() || !gameHandler.canReclaimCorpse()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + float btnW = 220.0f, btnH = 36.0f; + ImGui::SetNextWindowPos(ImVec2(screenW / 2 - btnW / 2, screenH * 0.72f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(btnW + 16.0f, btnH + 16.0f), ImGuiCond_Always); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8.0f, 8.0f)); + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.7f)); + if (ImGui::Begin("##ReclaimCorpse", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoBringToFrontOnFocus)) { + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.25f, 0.55f, 0.25f, 1.0f)); + if (ImGui::Button("Resurrect from Corpse", ImVec2(btnW, btnH))) { + gameHandler.reclaimCorpse(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + ImGui::PopStyleColor(); + ImGui::PopStyleVar(2); +} + void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) { if (!gameHandler.showResurrectDialog()) return; @@ -6956,10 +7144,13 @@ void GameScreen::renderResurrectDialog(game::GameHandler& gameHandler) { ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoTitleBar)) { ImGui::Spacing(); - const char* text = "Return to life?"; - float textW = ImGui::CalcTextSize(text).x; - ImGui::SetCursorPosX((dlgW - textW) / 2); - ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text); + const std::string& casterName = gameHandler.getResurrectCasterName(); + std::string text = casterName.empty() + ? "Return to life?" + : casterName + " wishes to resurrect you."; + float textW = ImGui::CalcTextSize(text.c_str()).x; + ImGui::SetCursorPosX(std::max(4.0f, (dlgW - textW) / 2)); + ImGui::TextColored(ImVec4(0.8f, 0.9f, 1.0f, 1.0f), "%s", text.c_str()); ImGui::Spacing(); ImGui::Spacing(); @@ -7106,9 +7297,19 @@ void GameScreen::renderSettingsWindow() { saveSettings(); } } - if (ImGui::Checkbox("Water Refraction", &pendingWaterRefraction)) { - if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); - saveSettings(); + { + bool fsrActive = renderer && (renderer->isFSREnabled() || renderer->isFSR2Enabled()); + if (!fsrActive && pendingWaterRefraction) { + // FSR was disabled while refraction was on — auto-disable + pendingWaterRefraction = false; + if (renderer) renderer->setWaterRefractionEnabled(false); + } + if (!fsrActive) ImGui::BeginDisabled(); + if (ImGui::Checkbox("Water Refraction (requires FSR)", &pendingWaterRefraction)) { + if (renderer) renderer->setWaterRefractionEnabled(pendingWaterRefraction); + saveSettings(); + } + if (!fsrActive) ImGui::EndDisabled(); } { const char* aaLabels[] = { "Off", "2x MSAA", "4x MSAA", "8x MSAA" }; @@ -7962,6 +8163,25 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { } } + // 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); + } + auto applyMuteState = [&]() { auto* activeRenderer = core::Application::getInstance().getRenderer(); float masterScale = soundMuted_ ? 0.0f : static_cast(pendingMasterVolume) / 100.0f; @@ -10157,4 +10377,122 @@ void GameScreen::renderInstanceLockouts(game::GameHandler& gameHandler) { ImGui::End(); } +// ============================================================================ +// Battleground score frame +// +// Displays the current score for the player's battleground using world states. +// Shown in the top-centre of the screen whenever SMSG_INIT_WORLD_STATES has +// been received for a known BG map. The layout adapts per battleground: +// +// WSG 489 – Alliance / Horde flag captures (max 3) +// AB 529 – Alliance / Horde resource scores (max 1600) +// AV 30 – Alliance / Horde reinforcements +// EotS 566 – Alliance / Horde resource scores (max 1600) +// ============================================================================ +void GameScreen::renderBattlegroundScore(game::GameHandler& gameHandler) { + // Only show when in a recognised battleground map + uint32_t mapId = gameHandler.getWorldStateMapId(); + + // World state key sets per battleground + // Keys from the WoW 3.3.5a WorldState.dbc / client source + struct BgScoreDef { + uint32_t mapId; + const char* name; + uint32_t allianceKey; // world state key for Alliance value + uint32_t hordeKey; // world state key for Horde value + uint32_t maxKey; // max score world state key (0 = use hardcoded) + uint32_t hardcodedMax; // used when maxKey == 0 + const char* unit; // suffix label (e.g. "flags", "resources") + }; + + static constexpr BgScoreDef kBgDefs[] = { + // Warsong Gulch: 3 flag captures wins + { 489, "Warsong Gulch", 1581, 1582, 0, 3, "flags" }, + // Arathi Basin: 1600 resources wins + { 529, "Arathi Basin", 1218, 1219, 0, 1600, "resources" }, + // Alterac Valley: reinforcements count down from 600 / 800 etc. + { 30, "Alterac Valley", 1322, 1323, 0, 600, "reinforcements" }, + // Eye of the Storm: 1600 resources wins + { 566, "Eye of the Storm", 2757, 2758, 0, 1600, "resources" }, + // Strand of the Ancients (WotLK) + { 607, "Strand of the Ancients", 3476, 3477, 0, 4, "" }, + }; + + const BgScoreDef* def = nullptr; + for (const auto& d : kBgDefs) { + if (d.mapId == mapId) { def = &d; break; } + } + if (!def) return; + + auto allianceOpt = gameHandler.getWorldState(def->allianceKey); + auto hordeOpt = gameHandler.getWorldState(def->hordeKey); + if (!allianceOpt && !hordeOpt) return; + + uint32_t allianceScore = allianceOpt.value_or(0); + uint32_t hordeScore = hordeOpt.value_or(0); + uint32_t maxScore = def->hardcodedMax; + if (def->maxKey != 0) { + if (auto mv = gameHandler.getWorldState(def->maxKey)) maxScore = *mv; + } + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + + // Width scales with screen but stays reasonable + float frameW = 260.0f; + float frameH = 60.0f; + float posX = screenW / 2.0f - frameW / 2.0f; + float posY = 4.0f; + + ImGui::SetNextWindowPos(ImVec2(posX, posY), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(frameW, frameH), ImGuiCond_Always); + ImGui::SetNextWindowBgAlpha(0.75f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 4.0f)); + + if (ImGui::Begin("##BGScore", nullptr, + ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoMove | + ImGuiWindowFlags_NoNav | ImGuiWindowFlags_NoBringToFrontOnFocus | + ImGuiWindowFlags_NoSavedSettings)) { + + // BG name centred at top + float nameW = ImGui::CalcTextSize(def->name).x; + ImGui::SetCursorPosX((frameW - nameW) / 2.0f); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "%s", def->name); + + // Alliance score | separator | Horde score + float innerW = frameW - 12.0f; + float halfW = innerW / 2.0f - 4.0f; + + ImGui::SetCursorPosX(6.0f); + ImGui::BeginGroup(); + { + // Alliance (blue) + char aBuf[32]; + if (maxScore > 0 && strlen(def->unit) > 0) + snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u / %u", allianceScore, maxScore); + else + snprintf(aBuf, sizeof(aBuf), "\xF0\x9F\x94\xB5 %u", allianceScore); + ImGui::TextColored(ImVec4(0.4f, 0.6f, 1.0f, 1.0f), "%s", aBuf); + } + ImGui::EndGroup(); + + ImGui::SameLine(halfW + 16.0f); + + ImGui::BeginGroup(); + { + // Horde (red) + char hBuf[32]; + if (maxScore > 0 && strlen(def->unit) > 0) + snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u / %u", hordeScore, maxScore); + else + snprintf(hBuf, sizeof(hBuf), "\xF0\x9F\x94\xB4 %u", hordeScore); + ImGui::TextColored(ImVec4(1.0f, 0.35f, 0.35f, 1.0f), "%s", hBuf); + } + ImGui::EndGroup(); + } + ImGui::End(); + ImGui::PopStyleVar(2); +} + }} // namespace wowee::ui