diff --git a/Data/expansions/classic/update_fields.json b/Data/expansions/classic/update_fields.json index bb269d8a..4f340df5 100644 --- a/Data/expansions/classic/update_fields.json +++ b/Data/expansions/classic/update_fields.json @@ -14,6 +14,7 @@ "UNIT_FIELD_DISPLAYID": 131, "UNIT_FIELD_MOUNTDISPLAYID": 133, "UNIT_FIELD_AURAS": 50, + "UNIT_FIELD_AURAFLAGS": 98, "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, diff --git a/Data/expansions/turtle/update_fields.json b/Data/expansions/turtle/update_fields.json index 74b873ae..a27e84f7 100644 --- a/Data/expansions/turtle/update_fields.json +++ b/Data/expansions/turtle/update_fields.json @@ -14,6 +14,7 @@ "UNIT_FIELD_DISPLAYID": 131, "UNIT_FIELD_MOUNTDISPLAYID": 133, "UNIT_FIELD_AURAS": 50, + "UNIT_FIELD_AURAFLAGS": 98, "UNIT_NPC_FLAGS": 147, "UNIT_DYNAMIC_FLAGS": 143, "UNIT_FIELD_RESISTANCES": 154, diff --git a/Data/expansions/wotlk/update_fields.json b/Data/expansions/wotlk/update_fields.json index 1532f628..b35422a3 100644 --- a/Data/expansions/wotlk/update_fields.json +++ b/Data/expansions/wotlk/update_fields.json @@ -37,6 +37,7 @@ "PLAYER_FIELD_BANKBAG_SLOT_1": 458, "PLAYER_SKILL_INFO_START": 636, "PLAYER_EXPLORED_ZONES_START": 1041, + "PLAYER_CHOSEN_TITLE": 1349, "GAMEOBJECT_DISPLAYID": 8, "ITEM_FIELD_STACK_COUNT": 14, "ITEM_FIELD_DURABILITY": 60, diff --git a/assets/shaders/fsr2_sharpen.frag.glsl b/assets/shaders/fsr2_sharpen.frag.glsl index 9cd1271c..392a3a37 100644 --- a/assets/shaders/fsr2_sharpen.frag.glsl +++ b/assets/shaders/fsr2_sharpen.frag.glsl @@ -10,9 +10,7 @@ layout(push_constant) uniform PushConstants { } pc; void main() { - // Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay, - // but we need standard UV coords for texture sampling) - vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y); + vec2 tc = TexCoord; vec2 texelSize = pc.params.xy; float sharpness = pc.params.z; diff --git a/assets/shaders/fsr2_sharpen.frag.spv b/assets/shaders/fsr2_sharpen.frag.spv index 20672a9e..24519b93 100644 Binary files a/assets/shaders/fsr2_sharpen.frag.spv and b/assets/shaders/fsr2_sharpen.frag.spv differ diff --git a/assets/shaders/fsr_easu.frag.glsl b/assets/shaders/fsr_easu.frag.glsl index 20e5ed32..6a36be75 100644 --- a/assets/shaders/fsr_easu.frag.glsl +++ b/assets/shaders/fsr_easu.frag.glsl @@ -21,9 +21,7 @@ vec3 fsrFetch(vec2 p, vec2 off) { } void main() { - // Undo the vertex shader Y flip (postprocess.vert flips for Vulkan overlay, - // but we need standard UV coords for texture sampling) - vec2 tc = vec2(TexCoord.x, 1.0 - TexCoord.y); + vec2 tc = TexCoord; // Map output pixel to input space vec2 pp = tc * fsr.con2.xy; // output pixel position diff --git a/assets/shaders/fsr_easu.frag.spv b/assets/shaders/fsr_easu.frag.spv index 5ddc2ea8..12780757 100644 Binary files a/assets/shaders/fsr_easu.frag.spv and b/assets/shaders/fsr_easu.frag.spv differ diff --git a/assets/shaders/fxaa.frag.glsl b/assets/shaders/fxaa.frag.glsl index df35aaa0..3da10854 100644 --- a/assets/shaders/fxaa.frag.glsl +++ b/assets/shaders/fxaa.frag.glsl @@ -2,7 +2,7 @@ // FXAA 3.11 — Fast Approximate Anti-Aliasing post-process pass. // Reads the resolved scene color and outputs a smoothed result. -// Push constant: rcpFrame = vec2(1/width, 1/height). +// Push constant: rcpFrame = vec2(1/width, 1/height), sharpness (0=off, 2=max), desaturate (1=ghost grayscale). layout(set = 0, binding = 0) uniform sampler2D uScene; @@ -10,7 +10,9 @@ layout(location = 0) in vec2 TexCoord; layout(location = 0) out vec4 outColor; layout(push_constant) uniform PC { - vec2 rcpFrame; + vec2 rcpFrame; + float sharpness; // 0 = no sharpen, 2 = max (matches FSR2 RCAS range) + float desaturate; // 1 = full grayscale (ghost mode), 0 = normal color } pc; // Quality tuning @@ -128,5 +130,26 @@ void main() { if ( horzSpan) finalUV.y += pixelOffsetFinal * lengthSign; if (!horzSpan) finalUV.x += pixelOffsetFinal * lengthSign; - outColor = vec4(texture(uScene, finalUV).rgb, 1.0); + vec3 fxaaResult = texture(uScene, finalUV).rgb; + + // Post-FXAA contrast-adaptive sharpening (unsharp mask). + // Counteracts FXAA's sub-pixel blur when sharpness > 0. + if (pc.sharpness > 0.0) { + vec2 r = pc.rcpFrame; + vec3 blur = (texture(uScene, uv + vec2(-r.x, 0)).rgb + + texture(uScene, uv + vec2( r.x, 0)).rgb + + texture(uScene, uv + vec2(0, -r.y)).rgb + + texture(uScene, uv + vec2(0, r.y)).rgb) * 0.25; + // scale sharpness from [0,2] to a modest [0, 0.3] boost factor + float s = pc.sharpness * 0.15; + fxaaResult = clamp(fxaaResult + s * (fxaaResult - blur), 0.0, 1.0); + } + + // Ghost mode: desaturate to grayscale (with a slight cool blue tint). + if (pc.desaturate > 0.5) { + float gray = dot(fxaaResult, vec3(0.299, 0.587, 0.114)); + fxaaResult = mix(fxaaResult, vec3(gray, gray, gray * 1.05), pc.desaturate); + } + + outColor = vec4(fxaaResult, 1.0); } diff --git a/assets/shaders/fxaa.frag.spv b/assets/shaders/fxaa.frag.spv new file mode 100644 index 00000000..b87b3dee Binary files /dev/null and b/assets/shaders/fxaa.frag.spv differ diff --git a/assets/shaders/m2_ribbon.frag.glsl b/assets/shaders/m2_ribbon.frag.glsl new file mode 100644 index 00000000..4e0e483e --- /dev/null +++ b/assets/shaders/m2_ribbon.frag.glsl @@ -0,0 +1,25 @@ +#version 450 + +// M2 ribbon emitter fragment shader. +// Samples the ribbon texture, multiplied by vertex color and alpha. +// Uses additive blending (pipeline-level) for magic/spell trails. + +layout(set = 1, binding = 0) uniform sampler2D uTexture; + +layout(location = 0) in vec3 vColor; +layout(location = 1) in float vAlpha; +layout(location = 2) in vec2 vUV; +layout(location = 3) in float vFogFactor; + +layout(location = 0) out vec4 outColor; + +void main() { + vec4 tex = texture(uTexture, vUV); + // For additive ribbons alpha comes from texture luminance; multiply by vertex alpha. + float a = tex.a * vAlpha; + if (a < 0.01) discard; + vec3 rgb = tex.rgb * vColor; + // Ribbons fade slightly with fog (additive blend attenuated toward black = invisible in fog). + rgb *= vFogFactor; + outColor = vec4(rgb, a); +} diff --git a/assets/shaders/m2_ribbon.frag.spv b/assets/shaders/m2_ribbon.frag.spv new file mode 100644 index 00000000..9b0a3fe9 Binary files /dev/null and b/assets/shaders/m2_ribbon.frag.spv differ diff --git a/assets/shaders/m2_ribbon.vert.glsl b/assets/shaders/m2_ribbon.vert.glsl new file mode 100644 index 00000000..492a295e --- /dev/null +++ b/assets/shaders/m2_ribbon.vert.glsl @@ -0,0 +1,43 @@ +#version 450 + +// M2 ribbon emitter vertex shader. +// Ribbon geometry is generated CPU-side as a triangle strip. +// Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats. + +layout(set = 0, binding = 0) uniform PerFrame { + mat4 view; + mat4 projection; + mat4 lightSpaceMatrix; + vec4 lightDir; + vec4 lightColor; + vec4 ambientColor; + vec4 viewPos; + vec4 fogColor; + vec4 fogParams; + vec4 shadowParams; +}; + +layout(location = 0) in vec3 aPos; +layout(location = 1) in vec3 aColor; +layout(location = 2) in float aAlpha; +layout(location = 3) in vec2 aUV; + +layout(location = 0) out vec3 vColor; +layout(location = 1) out float vAlpha; +layout(location = 2) out vec2 vUV; +layout(location = 3) out float vFogFactor; + +void main() { + vec4 worldPos = vec4(aPos, 1.0); + vec4 viewPos4 = view * worldPos; + gl_Position = projection * viewPos4; + + float dist = length(viewPos4.xyz); + float fogStart = fogParams.x; + float fogEnd = fogParams.y; + vFogFactor = clamp((fogEnd - dist) / max(fogEnd - fogStart, 0.001), 0.0, 1.0); + + vColor = aColor; + vAlpha = aAlpha; + vUV = aUV; +} diff --git a/assets/shaders/m2_ribbon.vert.spv b/assets/shaders/m2_ribbon.vert.spv new file mode 100644 index 00000000..d74ba750 Binary files /dev/null and b/assets/shaders/m2_ribbon.vert.spv differ diff --git a/assets/shaders/postprocess.vert.glsl b/assets/shaders/postprocess.vert.glsl index aa78b1b5..2ed8f784 100644 --- a/assets/shaders/postprocess.vert.glsl +++ b/assets/shaders/postprocess.vert.glsl @@ -6,5 +6,7 @@ void main() { // Fullscreen triangle trick: 3 vertices, no vertex buffer TexCoord = vec2((gl_VertexIndex << 1) & 2, gl_VertexIndex & 2); gl_Position = vec4(TexCoord * 2.0 - 1.0, 0.0, 1.0); - TexCoord.y = 1.0 - TexCoord.y; // flip Y for Vulkan + // No Y-flip: scene textures use Vulkan convention (v=0 at top), + // and NDC y=-1 already maps to framebuffer top, so the triangle + // naturally samples the correct row without any inversion. } diff --git a/assets/shaders/postprocess.vert.spv b/assets/shaders/postprocess.vert.spv index afc10472..89065a80 100644 Binary files a/assets/shaders/postprocess.vert.spv and b/assets/shaders/postprocess.vert.spv differ diff --git a/include/core/application.hpp b/include/core/application.hpp index 7da1469b..85339c04 100644 --- a/include/core/application.hpp +++ b/include/core/application.hpp @@ -271,6 +271,7 @@ private: }; std::unordered_map gameObjectDisplayIdToPath_; std::unordered_map gameObjectDisplayIdModelCache_; // displayId → M2 modelId + std::unordered_set gameObjectDisplayIdFailedCache_; // displayIds that permanently fail to load std::unordered_map gameObjectDisplayIdWmoCache_; // displayId → WMO modelId std::unordered_map gameObjectInstances_; // guid → instance info struct PendingTransportMove { diff --git a/include/game/game_handler.hpp b/include/game/game_handler.hpp index 1f6a029b..3481285b 100644 --- a/include/game/game_handler.hpp +++ b/include/game/game_handler.hpp @@ -339,6 +339,16 @@ public: // Inspection void inspectTarget(); + struct InspectArenaTeam { + uint32_t teamId = 0; + uint8_t type = 0; // bracket size: 2, 3, or 5 + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + std::string name; + uint32_t personalRating = 0; + }; struct InspectResult { uint64_t guid = 0; std::string playerName; @@ -348,6 +358,7 @@ public: uint8_t activeTalentGroup = 0; std::array itemEntries{}; // 0=head…18=ranged std::array enchantIds{}; // permanent enchant per slot (0 = none) + std::vector arenaTeams; // from MSG_INSPECT_ARENA_TEAMS (WotLK) }; const InspectResult* getInspectResult() const { return inspectResult_.guid ? &inspectResult_ : nullptr; @@ -394,11 +405,22 @@ public: std::chrono::steady_clock::time_point inviteReceivedTime{}; }; + // Available BG list (populated by SMSG_BATTLEFIELD_LIST) + struct AvailableBgInfo { + uint32_t bgTypeId = 0; + bool isRegistered = false; + bool isHoliday = false; + uint32_t minLevel = 0; + uint32_t maxLevel = 0; + std::vector instanceIds; + }; + // Battleground bool hasPendingBgInvite() const; void acceptBattlefield(uint32_t queueSlot = 0xFFFFFFFF); void declineBattlefield(uint32_t queueSlot = 0xFFFFFFFF); const std::array& getBgQueues() const { return bgQueues_; } + const std::vector& getAvailableBgs() const { return availableBgs_; } // BG scoreboard (MSG_PVP_LOG_DATA) struct BgPlayerScore { @@ -411,11 +433,18 @@ public: uint32_t bonusHonor = 0; std::vector> bgStats; // BG-specific fields }; + struct ArenaTeamScore { + std::string teamName; + uint32_t ratingChange = 0; // signed delta packed as uint32 + uint32_t newRating = 0; + }; struct BgScoreboardData { std::vector players; bool hasWinner = false; uint8_t winner = 0; // 0=Horde, 1=Alliance bool isArena = false; + // Arena-only fields (valid when isArena=true) + ArenaTeamScore arenaTeams[2]; // team 0 = first, team 1 = second }; void requestPvpLog(); const BgScoreboardData* getBgScoreboard() const { @@ -472,6 +501,24 @@ public: // GM Ticket void submitGmTicket(const std::string& text); void deleteGmTicket(); + void requestGmTicket(); ///< Send CMSG_GMTICKET_GETTICKET to query open ticket + + // GM ticket status accessors + bool hasActiveGmTicket() const { return gmTicketActive_; } + const std::string& getGmTicketText() const { return gmTicketText_; } + bool isGmSupportAvailable() const { return gmSupportAvailable_; } + float getGmTicketWaitHours() const { return gmTicketWaitHours_; } + + // Battlefield Manager (Wintergrasp) + bool hasBfMgrInvite() const { return bfMgrInvitePending_; } + bool isInBfMgrZone() const { return bfMgrActive_; } + uint32_t getBfMgrZoneId() const { return bfMgrZoneId_; } + void acceptBfMgrInvite(); + void declineBfMgrInvite(); + + // WotLK Calendar + uint32_t getCalendarPendingInvites() const { return calendarPendingInvites_; } + void requestCalendar(); ///< Send CMSG_CALENDAR_GET_CALENDAR to the server void queryGuildInfo(uint32_t guildId); void createGuild(const std::string& guildName); void addGuildRank(const std::string& rankName); @@ -1059,8 +1106,10 @@ public: /** Distance (yards) from ghost to corpse, or -1 if no corpse data. */ float getCorpseDistance() const { if (corpseMapId_ == 0 || currentMapId_ != corpseMapId_) return -1.0f; - float dx = movementInfo.x - corpseX_; - float dy = movementInfo.y - corpseY_; + // movementInfo is canonical (x=north=server_y, y=west=server_x); + // corpse coords are raw server (x=west, y=north) — swap to compare. + float dx = movementInfo.x - corpseY_; + float dy = movementInfo.y - corpseX_; float dz = movementInfo.z - corpseZ_; return std::sqrt(dx*dx + dy*dy + dz*dz); } @@ -1247,6 +1296,29 @@ public: }; const std::vector& getArenaTeamStats() const { return arenaTeamStats_; } + // ---- Arena Team Roster ---- + struct ArenaTeamMember { + uint64_t guid = 0; + std::string name; + bool online = false; + uint32_t weekGames = 0; + uint32_t weekWins = 0; + uint32_t seasonGames = 0; + uint32_t seasonWins = 0; + uint32_t personalRating = 0; + }; + struct ArenaTeamRoster { + uint32_t teamId = 0; + std::vector members; + }; + // Returns roster for the given teamId, or nullptr if not yet received + const ArenaTeamRoster* getArenaTeamRoster(uint32_t teamId) const { + for (const auto& r : arenaTeamRosters_) { + if (r.teamId == teamId) return &r; + } + return nullptr; + } + // ---- Phase 5: Loot ---- void lootTarget(uint64_t guid); void lootItem(uint8_t slotIndex); @@ -1421,12 +1493,115 @@ public: }; const std::array& getPlayerRunes() const { return playerRunes_; } + // Talent-driven spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER) + // SpellModOp matches WotLK SpellModOp enum (server-side). + enum class SpellModOp : uint8_t { + Damage = 0, + Duration = 1, + Threat = 2, + Effect1 = 3, + Charges = 4, + Range = 5, + Radius = 6, + CritChance = 7, + AllEffects = 8, + NotLoseCastingTime = 9, + CastingTime = 10, + Cooldown = 11, + Effect2 = 12, + IgnoreArmor = 13, + Cost = 14, + CritDamageBonus = 15, + ResistMissChance = 16, + JumpTargets = 17, + ChanceOfSuccess = 18, + ActivationTime = 19, + Efficiency = 20, + MultipleValue = 21, + ResistDispelChance = 22, + Effect3 = 23, + BonusMultiplier = 24, + ProcPerMinute = 25, + ValueMultiplier = 26, + ResistPushback = 27, + MechanicDuration = 28, + StartCooldown = 29, + PeriodicBonus = 30, + AttackPower = 31, + }; + static constexpr int SPELL_MOD_OP_COUNT = 32; + + // Key: (SpellModOp, groupIndex) — value: accumulated flat or pct modifier + // pct values are stored in integer percent (e.g. -20 means -20% reduction). + struct SpellModKey { + SpellModOp op; + uint8_t group; + bool operator==(const SpellModKey& o) const { + return op == o.op && group == o.group; + } + }; + struct SpellModKeyHash { + std::size_t operator()(const SpellModKey& k) const { + return std::hash()( + (static_cast(static_cast(k.op)) << 8) | k.group); + } + }; + + // Returns the sum of all flat modifiers for a given op across all groups. + // (Callers that need per-group resolution can use getSpellFlatMods() directly.) + int32_t getSpellFlatMod(SpellModOp op) const { + int32_t total = 0; + for (const auto& [k, v] : spellFlatMods_) + if (k.op == op) total += v; + return total; + } + // Returns the sum of all pct modifiers for a given op across all groups (in %). + int32_t getSpellPctMod(SpellModOp op) const { + int32_t total = 0; + for (const auto& [k, v] : spellPctMods_) + if (k.op == op) total += v; + return total; + } + + // Convenience: apply flat+pct modifier to a base value. + // result = (base + flatMod) * (1.0 + pctMod/100.0), clamped to >= 0. + static int32_t applySpellMod(int32_t base, int32_t flat, int32_t pct) { + int64_t v = static_cast(base) + flat; + if (pct != 0) v = v + (v * pct + 50) / 100; // round half-up + return static_cast(v < 0 ? 0 : v); + } + struct FactionStandingInit { uint8_t flags = 0; int32_t standing = 0; }; + // Faction flag bitmask constants (from Faction.dbc ReputationFlags / SMSG_INITIALIZE_FACTIONS) + static constexpr uint8_t FACTION_FLAG_VISIBLE = 0x01; // shown in reputation list + static constexpr uint8_t FACTION_FLAG_AT_WAR = 0x02; // player is at war + static constexpr uint8_t FACTION_FLAG_HIDDEN = 0x04; // never shown + static constexpr uint8_t FACTION_FLAG_INVISIBLE_FORCED = 0x08; + static constexpr uint8_t FACTION_FLAG_PEACE_FORCED = 0x10; + const std::vector& getInitialFactions() const { return initialFactions_; } const std::unordered_map& getFactionStandings() const { return factionStandings_; } + + // Returns true if the player has "at war" toggled for the faction at repListId + bool isFactionAtWar(uint32_t repListId) const { + if (repListId >= initialFactions_.size()) return false; + return (initialFactions_[repListId].flags & FACTION_FLAG_AT_WAR) != 0; + } + // Returns true if the faction is visible in the reputation list + bool isFactionVisible(uint32_t repListId) const { + if (repListId >= initialFactions_.size()) return false; + const uint8_t f = initialFactions_[repListId].flags; + if (f & FACTION_FLAG_HIDDEN) return false; + if (f & FACTION_FLAG_INVISIBLE_FORCED) return false; + return (f & FACTION_FLAG_VISIBLE) != 0; + } + // Returns the faction ID for a given repListId (0 if unknown) + uint32_t getFactionIdByRepListId(uint32_t repListId) const; + // Returns the repListId for a given faction ID (0xFFFFFFFF if not found) + uint32_t getRepListIdByFactionId(uint32_t factionId) const; // Shaman totems (4 slots: 0=Earth, 1=Fire, 2=Water, 3=Air) struct TotemSlot { uint32_t spellId = 0; @@ -1505,6 +1680,14 @@ public: void setAchievementEarnedCallback(AchievementEarnedCallback cb) { achievementEarnedCallback_ = std::move(cb); } const std::unordered_set& getEarnedAchievements() const { return earnedAchievements_; } + // Title system — earned title bits and the currently displayed title + const std::unordered_set& getKnownTitleBits() const { return knownTitleBits_; } + int32_t getChosenTitleBit() const { return chosenTitleBit_; } + /// Returns the formatted title string for a given bit (replaces %s with player name), or empty. + std::string getFormattedTitle(uint32_t bit) const; + /// Send CMSG_SET_TITLE to activate a title (bit >= 0) or clear it (bit = -1). + void sendSetTitle(int32_t bit); + // Area discovery callback — fires when SMSG_EXPLORATION_EXPERIENCE is received using AreaDiscoveryCallback = std::function; void setAreaDiscoveryCallback(AreaDiscoveryCallback cb) { areaDiscoveryCallback_ = std::move(cb); } @@ -1540,6 +1723,12 @@ public: auto it = achievementPointsCache_.find(id); return (it != achievementPointsCache_.end()) ? it->second : 0u; } + /// Returns the set of achievement IDs earned by an inspected player (via SMSG_RESPOND_INSPECT_ACHIEVEMENTS). + /// Returns nullptr if no inspect data is available for the given GUID. + const std::unordered_set* getInspectedPlayerAchievements(uint64_t guid) const { + auto it = inspectedPlayerAchievements_.find(guid); + return (it != inspectedPlayerAchievements_.end()) ? &it->second : nullptr; + } // Server-triggered music callback — fires when SMSG_PLAY_MUSIC is received. // The soundId corresponds to a SoundEntries.dbc record. The receiver is @@ -1594,6 +1783,10 @@ public: using TaxiFlightStartCallback = std::function; void setTaxiFlightStartCallback(TaxiFlightStartCallback cb) { taxiFlightStartCallback_ = std::move(cb); } + // Callback fired when server sends SMSG_OPEN_LFG_DUNGEON_FINDER (open dungeon finder UI) + using OpenLfgCallback = std::function; + void setOpenLfgCallback(OpenLfgCallback cb) { openLfgCallback_ = std::move(cb); } + bool isMounted() const { return currentMountDisplayId_ != 0; } bool isHostileAttacker(uint64_t guid) const { return hostileAttackers_.count(guid) > 0; } float getServerRunSpeed() const { return serverRunSpeed_; } @@ -2006,6 +2199,7 @@ private: // ---- Other player movement (MSG_MOVE_* from server) ---- void handleOtherPlayerMovement(network::Packet& packet); + void handleMoveSetSpeed(network::Packet& packet); // ---- Phase 5 handlers ---- void handleLootResponse(network::Packet& packet); @@ -2072,6 +2266,7 @@ private: void handleInstanceDifficulty(network::Packet& packet); void handleArenaTeamCommandResult(network::Packet& packet); void handleArenaTeamQueryResponse(network::Packet& packet); + void handleArenaTeamRoster(network::Packet& packet); void handleArenaTeamInvite(network::Packet& packet); void handleArenaTeamEvent(network::Packet& packet); void handleArenaTeamStats(network::Packet& packet); @@ -2428,6 +2623,10 @@ private: // ---- Battleground queue state ---- std::array bgQueues_{}; + // ---- Available battleground list (SMSG_BATTLEFIELD_LIST) ---- + std::vector availableBgs_; + void handleBattlefieldList(network::Packet& packet); + // Instance difficulty uint32_t instanceDifficulty_ = 0; bool instanceIsHeroic_ = false; @@ -2446,7 +2645,9 @@ private: std::vector instanceLockouts_; // Arena team stats (indexed by team slot, updated by SMSG_ARENA_TEAM_STATS) - std::vector arenaTeamStats_; + std::vector arenaTeamStats_; + // Arena team rosters (updated by SMSG_ARENA_TEAM_ROSTER) + std::vector arenaTeamRosters_; // BG scoreboard (MSG_PVP_LOG_DATA) BgScoreboardData bgScoreboard_; @@ -2478,6 +2679,10 @@ private: std::unordered_map factionStandings_; // Faction name cache (factionId → name), populated lazily from Faction.dbc std::unordered_map factionNameCache_; + // repListId → factionId mapping (populated with factionNameCache) + std::unordered_map factionRepListToId_; + // factionId → repListId reverse mapping + std::unordered_map factionIdToRepList_; bool factionNameCacheLoaded_ = false; void loadFactionNameCache(); std::string getFactionName(uint32_t factionId) const; @@ -2734,6 +2939,10 @@ private: std::unordered_map titleNameCache_; bool titleNameCacheLoaded_ = false; void loadTitleNameCache(); + // Set of title bit-indices known to the player (from SMSG_TITLE_EARNED). + std::unordered_set knownTitleBits_; + // Currently selected title bit, or -1 for no title. Updated from PLAYER_CHOSEN_TITLE. + int32_t chosenTitleBit_ = -1; // Achievement caches (lazy-loaded from Achievement.dbc on first earned event) std::unordered_map achievementNameCache_; @@ -2749,6 +2958,11 @@ private: std::unordered_map criteriaProgress_; void handleAllAchievementData(network::Packet& packet); + // Per-player achievement data from SMSG_RESPOND_INSPECT_ACHIEVEMENTS + // Key: inspected player's GUID; value: set of earned achievement IDs + std::unordered_map> inspectedPlayerAchievements_; + void handleRespondInspectAchievements(network::Packet& packet); + // Area name cache (lazy-loaded from WorldMapArea.dbc; maps AreaTable ID → display name) std::unordered_map areaNameCache_; bool areaNameCacheLoaded_ = false; @@ -2878,6 +3092,7 @@ private: TaxiPrecacheCallback taxiPrecacheCallback_; TaxiOrientationCallback taxiOrientationCallback_; TaxiFlightStartCallback taxiFlightStartCallback_; + OpenLfgCallback openLfgCallback_; uint32_t currentMountDisplayId_ = 0; uint32_t mountAuraSpellId_ = 0; // Spell ID of the aura that caused mounting (for CMSG_CANCEL_AURA fallback) float serverRunSpeed_ = 7.0f; @@ -2952,6 +3167,25 @@ private: // ---- Quest completion callback ---- QuestCompleteCallback questCompleteCallback_; + + // ---- GM Ticket state (SMSG_GMTICKET_GETTICKET / SMSG_GMTICKET_SYSTEMSTATUS) ---- + bool gmTicketActive_ = false; ///< True when an open ticket exists on the server + std::string gmTicketText_; ///< Text of the open ticket (from SMSG_GMTICKET_GETTICKET) + float gmTicketWaitHours_ = 0.0f; ///< Server-estimated wait time in hours + bool gmSupportAvailable_ = true; ///< GM support system online (SMSG_GMTICKET_SYSTEMSTATUS) + + // ---- Battlefield Manager state (WotLK Wintergrasp / outdoor battlefields) ---- + bool bfMgrInvitePending_ = false; ///< True when an entry/queue invite is pending acceptance + bool bfMgrActive_ = false; ///< True while the player is inside an outdoor battlefield + uint32_t bfMgrZoneId_ = 0; ///< Zone ID of the pending/active battlefield + + // ---- WotLK Calendar: pending invite counter ---- + uint32_t calendarPendingInvites_ = 0; ///< Unacknowledged calendar invites (SMSG_CALENDAR_SEND_NUM_PENDING) + + // ---- Spell modifiers (SMSG_SET_FLAT_SPELL_MODIFIER / SMSG_SET_PCT_SPELL_MODIFIER) ---- + // Keyed by (SpellModOp, groupIndex); cleared on logout/character change. + std::unordered_map spellFlatMods_; + std::unordered_map spellPctMods_; }; } // namespace game diff --git a/include/game/update_field_table.hpp b/include/game/update_field_table.hpp index 07c735fd..bc8a53f6 100644 --- a/include/game/update_field_table.hpp +++ b/include/game/update_field_table.hpp @@ -31,6 +31,7 @@ enum class UF : uint16_t { UNIT_FIELD_DISPLAYID, UNIT_FIELD_MOUNTDISPLAYID, UNIT_FIELD_AURAS, // Start of aura spell ID array (48 consecutive uint32 slots, classic/vanilla only) + UNIT_FIELD_AURAFLAGS, // Aura flags packed 4-per-uint32 (12 uint32 slots); 0x01=cancelable,0x02=harmful,0x04=helpful UNIT_NPC_FLAGS, UNIT_DYNAMIC_FLAGS, UNIT_FIELD_RESISTANCES, // Physical armor (index 0 of the resistance array) @@ -56,6 +57,7 @@ enum class UF : uint16_t { PLAYER_FIELD_BANKBAG_SLOT_1, PLAYER_SKILL_INFO_START, PLAYER_EXPLORED_ZONES_START, + PLAYER_CHOSEN_TITLE, // Active title index (-1 = no title) // GameObject fields GAMEOBJECT_DISPLAYID, diff --git a/include/game/world_packets.hpp b/include/game/world_packets.hpp index 71be1501..e5c7e63c 100644 --- a/include/game/world_packets.hpp +++ b/include/game/world_packets.hpp @@ -1448,6 +1448,12 @@ public: static network::Packet build(uint64_t targetGuid); }; +/** CMSG_QUERY_INSPECT_ACHIEVEMENTS packet builder (WotLK 3.3.5a) */ +class QueryInspectAchievementsPacket { +public: + static network::Packet build(uint64_t targetGuid); +}; + /** CMSG_NAME_QUERY packet builder */ class NameQueryPacket { public: @@ -2727,5 +2733,13 @@ public: static network::Packet build(uint64_t petGuid, const std::string& name, uint8_t isDeclined = 0); }; +/** CMSG_SET_TITLE packet builder. + * titleBit >= 0: activate the title with that bit index. + * titleBit == -1: clear the current title (show no title). */ +class SetTitlePacket { +public: + static network::Packet build(int32_t titleBit); +}; + } // namespace game } // namespace wowee diff --git a/include/pipeline/m2_loader.hpp b/include/pipeline/m2_loader.hpp index d3949f88..185ca653 100644 --- a/include/pipeline/m2_loader.hpp +++ b/include/pipeline/m2_loader.hpp @@ -165,6 +165,29 @@ struct M2ParticleEmitter { bool enabled = true; }; +// Ribbon emitter definition parsed from M2 (WotLK format) +struct M2RibbonEmitter { + int32_t ribbonId = 0; + uint32_t bone = 0; // Bone that drives the ribbon spine + glm::vec3 position{0.0f}; // Offset from bone pivot + + uint16_t textureIndex = 0; // First texture lookup index + uint16_t materialIndex = 0; // First material lookup index (blend mode) + + // Animated tracks + M2AnimationTrack colorTrack; // RGB 0..1 + M2AnimationTrack alphaTrack; // float 0..1 (stored as fixed16 on disk) + M2AnimationTrack heightAboveTrack; // Half-width above bone + M2AnimationTrack heightBelowTrack; // Half-width below bone + M2AnimationTrack visibilityTrack; // 0=hidden, 1=visible + + float edgesPerSecond = 15.0f; // How many edge points are generated per second + float edgeLifetime = 0.5f; // Seconds before edges expire + float gravity = 0.0f; // Downward pull on edges per s² + uint16_t textureRows = 1; + uint16_t textureCols = 1; +}; + // Complete M2 model structure struct M2Model { // Model metadata @@ -213,6 +236,9 @@ struct M2Model { // Particle emitters std::vector particleEmitters; + // Ribbon emitters + std::vector ribbonEmitters; + // Collision mesh (simplified geometry for physics) std::vector collisionVertices; std::vector collisionIndices; // 3 per triangle diff --git a/include/rendering/camera_controller.hpp b/include/rendering/camera_controller.hpp index fbddd523..e219e917 100644 --- a/include/rendering/camera_controller.hpp +++ b/include/rendering/camera_controller.hpp @@ -162,7 +162,7 @@ private: // Mouse settings float mouseSensitivity = 0.2f; - bool invertMouse = false; + bool invertMouse = true; bool mouseButtonDown = false; bool leftMouseDown = false; bool rightMouseDown = false; @@ -186,7 +186,7 @@ private: static constexpr float COLLISION_FOCUS_RADIUS_THIRD_PERSON = 20.0f; // Reduced for performance static constexpr float COLLISION_FOCUS_RADIUS_FREE_FLY = 20.0f; static constexpr float MIN_PITCH = -88.0f; // Look almost straight down - static constexpr float MAX_PITCH = 35.0f; // Limited upward look + static constexpr float MAX_PITCH = 88.0f; // Look almost straight up (WoW standard) glm::vec3* followTarget = nullptr; glm::vec3 smoothedCamPos = glm::vec3(0.0f); // For smooth camera movement float smoothedCollisionDist_ = -1.0f; // Asymmetrically-smoothed WMO collision limit (-1 = uninitialised) diff --git a/include/rendering/m2_renderer.hpp b/include/rendering/m2_renderer.hpp index 3d79379f..4ddea931 100644 --- a/include/rendering/m2_renderer.hpp +++ b/include/rendering/m2_renderer.hpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -130,6 +131,11 @@ struct M2ModelGPU { std::vector particleTextures; // Resolved Vulkan textures per emitter std::vector particleTexSets; // Pre-allocated descriptor sets per emitter (stable, avoids per-frame alloc) + // Ribbon emitter data (kept from M2Model) + std::vector ribbonEmitters; + std::vector ribbonTextures; // Resolved texture per ribbon emitter + std::vector ribbonTexSets; // Descriptor sets per ribbon emitter + // Texture transform data for UV animation std::vector textureTransforms; std::vector textureTransformLookup; @@ -180,6 +186,19 @@ struct M2Instance { std::vector emitterAccumulators; // fractional particle counter per emitter std::vector particles; + // Ribbon emitter state + struct RibbonEdge { + glm::vec3 worldPos; // Spine world position when this edge was born + glm::vec3 color; // Interpolated color at birth + float alpha; // Interpolated alpha at birth + float heightAbove;// Half-width above spine + float heightBelow;// Half-width below spine + float age; // Seconds since spawned + }; + // One deque of edges per ribbon emitter on this instance + std::vector> ribbonEdges; + std::vector ribbonEdgeAccumulators; // fractional edge counter per emitter + // Cached model flags (set at creation to avoid per-frame hash lookups) bool cachedHasAnimation = false; bool cachedDisableAnimation = false; @@ -295,6 +314,11 @@ public: */ void renderSmokeParticles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + /** + * Render M2 ribbon emitters (spell trails / wing effects) + */ + void renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet); + void setInstancePosition(uint32_t instanceId, const glm::vec3& position); void setInstanceTransform(uint32_t instanceId, const glm::mat4& transform); void setInstanceAnimationFrozen(uint32_t instanceId, bool frozen); @@ -374,6 +398,11 @@ private: VkPipeline smokePipeline_ = VK_NULL_HANDLE; // Smoke particles VkPipelineLayout smokePipelineLayout_ = VK_NULL_HANDLE; + // Ribbon pipelines (additive + alpha-blend) + VkPipeline ribbonPipeline_ = VK_NULL_HANDLE; // Alpha-blend ribbons + VkPipeline ribbonAdditivePipeline_ = VK_NULL_HANDLE; // Additive ribbons + VkPipelineLayout ribbonPipelineLayout_ = VK_NULL_HANDLE; + // Descriptor set layouts VkDescriptorSetLayout materialSetLayout_ = VK_NULL_HANDLE; // set 1 VkDescriptorSetLayout boneSetLayout_ = VK_NULL_HANDLE; // set 2 @@ -385,6 +414,12 @@ private: static constexpr uint32_t MAX_MATERIAL_SETS = 8192; static constexpr uint32_t MAX_BONE_SETS = 8192; + // Dynamic ribbon vertex buffer (CPU-written triangle strip) + static constexpr size_t MAX_RIBBON_VERTS = 2048; // 9 floats each + ::VkBuffer ribbonVB_ = VK_NULL_HANDLE; + VmaAllocation ribbonVBAlloc_ = VK_NULL_HANDLE; + void* ribbonVBMapped_ = nullptr; + // Dynamic particle buffers ::VkBuffer smokeVB_ = VK_NULL_HANDLE; VmaAllocation smokeVBAlloc_ = VK_NULL_HANDLE; @@ -535,6 +570,7 @@ private: glm::vec3 interpFBlockVec3(const pipeline::M2FBlock& fb, float lifeRatio); void emitParticles(M2Instance& inst, const M2ModelGPU& gpu, float dt); void updateParticles(M2Instance& inst, float dt); + void updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt); // Helper to allocate descriptor sets VkDescriptorSet allocateMaterialSet(); diff --git a/include/rendering/renderer.hpp b/include/rendering/renderer.hpp index 07d8091f..a198b0c7 100644 --- a/include/rendering/renderer.hpp +++ b/include/rendering/renderer.hpp @@ -638,6 +638,8 @@ private: bool terrainEnabled = true; bool terrainLoaded = false; + bool ghostMode_ = false; // set each frame from gameHandler->isPlayerGhost() + // CPU timing stats (last frame/update). double lastUpdateMs = 0.0; double lastRenderMs = 0.0; diff --git a/include/rendering/wmo_renderer.hpp b/include/rendering/wmo_renderer.hpp index 08108dc0..7f6728af 100644 --- a/include/rendering/wmo_renderer.hpp +++ b/include/rendering/wmo_renderer.hpp @@ -69,6 +69,12 @@ public: */ bool loadModel(const pipeline::WMOModel& model, uint32_t id); + /** + * Check if a WMO model is currently resident in the renderer + * @param id WMO model identifier + */ + bool isModelLoaded(uint32_t id) const; + /** * Unload WMO model and free GPU resources * @param id WMO model identifier diff --git a/include/ui/game_screen.hpp b/include/ui/game_screen.hpp index 56e133cd..e8fbca0f 100644 --- a/include/ui/game_screen.hpp +++ b/include/ui/game_screen.hpp @@ -158,6 +158,8 @@ private: ImVec2 chatWindowPos_ = ImVec2(0.0f, 0.0f); bool chatWindowPosInit_ = false; ImVec2 questTrackerPos_ = ImVec2(-1.0f, -1.0f); // <0 = use default + ImVec2 questTrackerSize_ = ImVec2(220.0f, 200.0f); // saved size + float questTrackerRightOffset_ = -1.0f; // pixels from right edge; <0 = use default bool questTrackerPosInit_ = false; bool showEscapeMenu = false; bool showEscapeSettingsNotice = false; @@ -361,6 +363,7 @@ private: void renderGuildInvitePopup(game::GameHandler& gameHandler); void renderReadyCheckPopup(game::GameHandler& gameHandler); void renderBgInvitePopup(game::GameHandler& gameHandler); + void renderBfMgrInvitePopup(game::GameHandler& gameHandler); void renderLfgProposalPopup(game::GameHandler& gameHandler); void renderChatBubbles(game::GameHandler& gameHandler); void renderMailWindow(game::GameHandler& gameHandler); @@ -429,8 +432,17 @@ private: char achievementSearchBuf_[128] = {}; void renderAchievementWindow(game::GameHandler& gameHandler); + // Titles window + bool showTitlesWindow_ = false; + void renderTitlesWindow(game::GameHandler& gameHandler); + + // Equipment Set Manager window + bool showEquipSetWindow_ = false; + void renderEquipSetWindow(game::GameHandler& gameHandler); + // GM Ticket window - bool showGmTicketWindow_ = false; + bool showGmTicketWindow_ = false; + bool gmTicketWindowWasOpen_ = false; ///< Previous frame state; used to fire one-shot query char gmTicketBuf_[2048] = {}; void renderGmTicketWindow(game::GameHandler& gameHandler); @@ -633,6 +645,7 @@ public: uint32_t str = 0, uint32_t agi = 0, uint32_t sta = 0, uint32_t intel = 0, uint32_t spi = 0); void triggerAchievementToast(uint32_t achievementId, std::string name = {}); + void openDungeonFinder() { showDungeonFinder_ = true; } }; } // namespace ui diff --git a/src/core/application.cpp b/src/core/application.cpp index 396c260f..83886782 100644 --- a/src/core/application.cpp +++ b/src/core/application.cpp @@ -2550,6 +2550,11 @@ void Application::setupUICallbacks() { } }); + // Open dungeon finder callback — server sends SMSG_OPEN_LFG_DUNGEON_FINDER + gameHandler->setOpenLfgCallback([this]() { + if (uiManager) uiManager->getGameScreen().openDungeonFinder(); + }); + // Creature move callback (online mode) - update creature positions gameHandler->setCreatureMoveCallback([this](uint64_t guid, float x, float y, float z, uint32_t durationMs) { if (!renderer || !renderer->getCharacterRenderer()) return; @@ -4108,6 +4113,8 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float gameObjectInstances_.clear(); gameObjectDisplayIdModelCache_.clear(); + gameObjectDisplayIdWmoCache_.clear(); + gameObjectDisplayIdFailedCache_.clear(); // Force player character re-spawn on new map playerCharacterSpawned = false; @@ -4458,7 +4465,7 @@ void Application::loadOnlineWorldTerrain(uint32_t mapId, float x, float y, float glm::vec3 worldPos = glm::vec3(worldMatrix[3]); uint32_t doodadModelId = static_cast(std::hash{}(m2Path)); - m2Renderer->loadModel(m2Model, doodadModelId); + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; uint32_t doodadInstId = m2Renderer->createInstanceWithMatrix(doodadModelId, worldMatrix, worldPos); if (doodadInstId) m2Renderer->setSkipCollision(doodadInstId, true); loadedDoodads++; @@ -6606,7 +6613,7 @@ void Application::spawnOnlinePlayer(uint64_t guid, } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); - if (!model.isValid() || model.vertices.empty()) { + if (model.vertices.empty()) { LOG_WARNING("spawnOnlinePlayer: failed to parse M2: ", m2Path); return; } @@ -6618,6 +6625,12 @@ void Application::spawnOnlinePlayer(uint64_t guid, pipeline::M2Loader::loadSkin(skinData, model); } + // After skin loading, full model must be valid (vertices + indices) + if (!model.isValid()) { + LOG_WARNING("spawnOnlinePlayer: failed to load skin for M2: ", m2Path); + return; + } + // Load only core external animations (stand/walk/run) to avoid stalls for (uint32_t si = 0; si < model.sequences.size(); si++) { if (!(model.sequences[si].flags & 0x20)) { @@ -7103,8 +7116,15 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto itCache = gameObjectDisplayIdWmoCache_.find(displayId); if (itCache != gameObjectDisplayIdWmoCache_.end()) { modelId = itCache->second; - loadedAsWmo = true; - } else { + // Only use cached entry if the model is still resident in the renderer + if (wmoRenderer->isModelLoaded(modelId)) { + loadedAsWmo = true; + } else { + gameObjectDisplayIdWmoCache_.erase(itCache); + modelId = 0; + } + } + if (!loadedAsWmo && modelId == 0) { auto wmoData = assetManager->readFile(modelPath); if (!wmoData.empty()) { pipeline::WMOModel wmoModel = pipeline::WMOLoader::load(wmoData); @@ -7229,6 +7249,11 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto* m2Renderer = renderer->getM2Renderer(); if (!m2Renderer) return; + // Skip displayIds that permanently failed to load (e.g. empty/unsupported M2s). + // Without this guard the same empty model is re-parsed every frame, causing + // sustained log spam and wasted CPU. + if (gameObjectDisplayIdFailedCache_.count(displayId)) return; + uint32_t modelId = 0; auto itCache = gameObjectDisplayIdModelCache_.find(displayId); if (itCache != gameObjectDisplayIdModelCache_.end()) { @@ -7247,12 +7272,14 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t auto m2Data = assetManager->readFile(modelPath); if (m2Data.empty()) { LOG_WARNING("Failed to read gameobject M2: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } pipeline::M2Model model = pipeline::M2Loader::load(m2Data); if (model.vertices.empty()) { LOG_WARNING("Failed to parse gameobject M2: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } @@ -7264,6 +7291,7 @@ void Application::spawnOnlineGameObject(uint64_t guid, uint32_t entry, uint32_t if (!m2Renderer->loadModel(model, modelId)) { LOG_WARNING("Failed to load gameobject model: ", modelPath); + gameObjectDisplayIdFailedCache_.insert(displayId); return; } @@ -8184,6 +8212,13 @@ void Application::processPendingTransportDoodads() { auto startTime = std::chrono::steady_clock::now(); static constexpr float kDoodadBudgetMs = 4.0f; + // Batch all GPU uploads into a single async command buffer submission so that + // N doodads with multiple textures each don't each block on vkQueueSubmit + + // vkWaitForFences. Without batching, 30+ doodads × several textures = hundreds + // of sync GPU submits → the 490ms stall that preceded the VK_ERROR_DEVICE_LOST. + auto* vkCtx = renderer->getVkContext(); + if (vkCtx) vkCtx->beginUploadBatch(); + size_t budgetLeft = MAX_TRANSPORT_DOODADS_PER_FRAME; for (auto it = pendingTransportDoodadBatches_.begin(); it != pendingTransportDoodadBatches_.end() && budgetLeft > 0;) { @@ -8227,7 +8262,7 @@ void Application::processPendingTransportDoodads() { } if (!m2Model.isValid()) continue; - m2Renderer->loadModel(m2Model, doodadModelId); + if (!m2Renderer->loadModel(m2Model, doodadModelId)) continue; uint32_t m2InstanceId = m2Renderer->createInstance(doodadModelId, glm::vec3(0.0f), glm::vec3(0.0f), 1.0f); if (m2InstanceId == 0) continue; m2Renderer->setSkipCollision(m2InstanceId, true); @@ -8251,6 +8286,9 @@ void Application::processPendingTransportDoodads() { ++it; } } + + // Finalize the upload batch — submit all GPU copies in one shot (async, no wait). + if (vkCtx) vkCtx->endUploadBatch(); } void Application::processPendingMount() { diff --git a/src/game/game_handler.cpp b/src/game/game_handler.cpp index f3d3eb2c..90fa6a12 100644 --- a/src/game/game_handler.cpp +++ b/src/game/game_handler.cpp @@ -1646,6 +1646,29 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; + case Opcode::SMSG_WHOIS: { + // GM/admin response to /whois command: cstring with account/IP info + // Format: string (the whois result text, typically "Name: ...\nAccount: ...\nIP: ...") + if (packet.getReadPos() < packet.getSize()) { + std::string whoisText = packet.readString(); + if (!whoisText.empty()) { + // Display each line of the whois response in system chat + std::string line; + for (char c : whoisText) { + if (c == '\n') { + if (!line.empty()) addSystemChatMessage("[Whois] " + line); + line.clear(); + } else { + line += c; + } + } + if (!line.empty()) addSystemChatMessage("[Whois] " + line); + LOG_INFO("SMSG_WHOIS: ", whoisText); + } + } + break; + } + case Opcode::SMSG_FRIEND_STATUS: if (state == WorldState::IN_WORLD) { handleFriendStatus(packet); @@ -2136,9 +2159,19 @@ void GameHandler::handlePacket(network::Packet& packet) { titleBit); msg = buf; } + // Track in known title set + if (isLost) { + knownTitleBits_.erase(titleBit); + } else { + knownTitleBits_.insert(titleBit); + } + + // Only post chat message for actual earned/lost events (isLost and new earn) + // Server sends isLost=0 for all known titles during login — suppress the chat spam + // by only notifying when we already had some titles (after login sequence) addSystemChatMessage(msg); LOG_INFO("SMSG_TITLE_EARNED: bit=", titleBit, " lost=", isLost, - " title='", titleStr, "'"); + " title='", titleStr, "' known=", knownTitleBits_.size()); break; } @@ -2297,10 +2330,16 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_DAMAGE_CALC_LOG: case Opcode::SMSG_DYNAMIC_DROP_ROLL_RESULT: case Opcode::SMSG_DESTRUCTIBLE_BUILDING_DAMAGE: - case Opcode::SMSG_FORCED_DEATH_UPDATE: // Consume — handled by broader object update or not yet implemented packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_FORCED_DEATH_UPDATE: + // Server forces player into dead state (GM command, scripted event, etc.) + playerDead_ = true; + if (ghostStateCallback_) ghostStateCallback_(false); // dead but not ghost yet + LOG_INFO("SMSG_FORCED_DEATH_UPDATE: player force-killed"); + packet.setReadPos(packet.getSize()); + break; // ---- Zone defense messages ---- case Opcode::SMSG_DEFENSE_MESSAGE: { @@ -2382,11 +2421,27 @@ void GameHandler::handlePacket(network::Packet& packet) { // Time bias — consume without processing packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_ACHIEVEMENT_DELETED: - case Opcode::SMSG_CRITERIA_DELETED: - // Consume achievement/criteria removal notifications + case Opcode::SMSG_ACHIEVEMENT_DELETED: { + // uint32 achievementId — remove from local earned set + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t achId = packet.readUInt32(); + earnedAchievements_.erase(achId); + achievementDates_.erase(achId); + LOG_DEBUG("SMSG_ACHIEVEMENT_DELETED: id=", achId); + } packet.setReadPos(packet.getSize()); break; + } + case Opcode::SMSG_CRITERIA_DELETED: { + // uint32 criteriaId — remove from local criteria progress + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t critId = packet.readUInt32(); + criteriaProgress_.erase(critId); + LOG_DEBUG("SMSG_CRITERIA_DELETED: id=", critId); + } + packet.setReadPos(packet.getSize()); + break; + } // ---- Combat clearing ---- case Opcode::SMSG_ATTACKSWING_DEADTARGET: @@ -3665,18 +3720,64 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_SET_FACTION_ATWAR: - case Opcode::SMSG_SET_FACTION_VISIBLE: - // uint32 factionId [+ uint8 flags for ATWAR] — consume; hostility is tracked via update fields + case Opcode::SMSG_SET_FACTION_ATWAR: { + // uint32 repListId + uint8 set (1=set at-war, 0=clear at-war) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t repListId = packet.readUInt32(); + uint8_t setAtWar = packet.readUInt8(); + if (repListId < initialFactions_.size()) { + if (setAtWar) + initialFactions_[repListId].flags |= FACTION_FLAG_AT_WAR; + else + initialFactions_[repListId].flags &= ~FACTION_FLAG_AT_WAR; + LOG_DEBUG("SMSG_SET_FACTION_ATWAR: repListId=", repListId, + " atWar=", (int)setAtWar); + } + break; + } + case Opcode::SMSG_SET_FACTION_VISIBLE: { + // uint32 repListId + uint8 visible (1=show, 0=hide) + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t repListId = packet.readUInt32(); + uint8_t visible = packet.readUInt8(); + if (repListId < initialFactions_.size()) { + if (visible) + initialFactions_[repListId].flags |= FACTION_FLAG_VISIBLE; + else + initialFactions_[repListId].flags &= ~FACTION_FLAG_VISIBLE; + LOG_DEBUG("SMSG_SET_FACTION_VISIBLE: repListId=", repListId, + " visible=", (int)visible); + } + break; + } + + case Opcode::SMSG_FEATURE_SYSTEM_STATUS: packet.setReadPos(packet.getSize()); break; - case Opcode::SMSG_FEATURE_SYSTEM_STATUS: case Opcode::SMSG_SET_FLAT_SPELL_MODIFIER: - case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: - // Different formats than SMSG_SPELL_DELAYED — consume and ignore + case Opcode::SMSG_SET_PCT_SPELL_MODIFIER: { + // WotLK format: one or more (uint8 groupIndex, uint8 modOp, int32 value) tuples + // Each tuple is 6 bytes; iterate until packet is consumed. + const bool isFlat = (*logicalOp == Opcode::SMSG_SET_FLAT_SPELL_MODIFIER); + auto& modMap = isFlat ? spellFlatMods_ : spellPctMods_; + while (packet.getSize() - packet.getReadPos() >= 6) { + uint8_t groupIndex = packet.readUInt8(); + uint8_t modOpRaw = packet.readUInt8(); + int32_t value = static_cast(packet.readUInt32()); + if (groupIndex > 5 || modOpRaw >= SPELL_MOD_OP_COUNT) continue; + SpellModKey key{ static_cast(modOpRaw), groupIndex }; + modMap[key] = value; + LOG_DEBUG(isFlat ? "SMSG_SET_FLAT_SPELL_MODIFIER" : "SMSG_SET_PCT_SPELL_MODIFIER", + ": group=", (int)groupIndex, " op=", (int)modOpRaw, " value=", value); + } packet.setReadPos(packet.getSize()); break; + } case Opcode::SMSG_SPELL_DELAYED: { // WotLK: packed_guid (caster) + uint32 delayMs @@ -4880,9 +4981,23 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_QUESTGIVER_OFFER_REWARD: handleQuestOfferReward(packet); break; - case Opcode::SMSG_GROUP_SET_LEADER: - LOG_DEBUG("Ignoring known opcode: 0x", std::hex, opcode, std::dec); + case Opcode::SMSG_GROUP_SET_LEADER: { + // SMSG_GROUP_SET_LEADER: string leaderName (null-terminated) + if (packet.getSize() > packet.getReadPos()) { + std::string leaderName = packet.readString(); + // Update leaderGuid by name lookup in party members + for (const auto& m : partyData.members) { + if (m.name == leaderName) { + partyData.leaderGuid = m.guid; + break; + } + } + if (!leaderName.empty()) + addSystemChatMessage(leaderName + " is now the group leader."); + LOG_INFO("SMSG_GROUP_SET_LEADER: ", leaderName); + } break; + } // ---- Teleport / Transfer ---- case Opcode::MSG_MOVE_TELEPORT_ACK: @@ -4955,7 +5070,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleBattlefieldStatus(packet); break; case Opcode::SMSG_BATTLEFIELD_LIST: - LOG_INFO("Received SMSG_BATTLEFIELD_LIST"); + handleBattlefieldList(packet); break; case Opcode::SMSG_BATTLEFIELD_PORT_DENIED: addSystemChatMessage("Battlefield port denied."); @@ -4973,12 +5088,30 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_JOINED_BATTLEGROUND_QUEUE: addSystemChatMessage("You have joined the battleground queue."); break; - case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED: - LOG_INFO("Battleground player joined"); + case Opcode::SMSG_BATTLEGROUND_PLAYER_JOINED: { + // SMSG_BATTLEGROUND_PLAYER_JOINED: uint64 guid + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t guid = packet.readUInt64(); + auto it = playerNameCache.find(guid); + std::string name = (it != playerNameCache.end()) ? it->second : ""; + if (!name.empty()) + addSystemChatMessage(name + " has entered the battleground."); + LOG_INFO("SMSG_BATTLEGROUND_PLAYER_JOINED: guid=0x", std::hex, guid, std::dec); + } break; - case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: - LOG_INFO("Battleground player left"); + } + case Opcode::SMSG_BATTLEGROUND_PLAYER_LEFT: { + // SMSG_BATTLEGROUND_PLAYER_LEFT: uint64 guid + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t guid = packet.readUInt64(); + auto it = playerNameCache.find(guid); + std::string name = (it != playerNameCache.end()) ? it->second : ""; + if (!name.empty()) + addSystemChatMessage(name + " has left the battleground."); + LOG_INFO("SMSG_BATTLEGROUND_PLAYER_LEFT: guid=0x", std::hex, guid, std::dec); + } break; + } case Opcode::SMSG_INSTANCE_DIFFICULTY: case Opcode::MSG_SET_DUNGEON_DIFFICULTY: handleInstanceDifficulty(packet); @@ -5091,10 +5224,14 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_UPDATE_LFG_LIST: case Opcode::SMSG_LFG_PLAYER_INFO: case Opcode::SMSG_LFG_PARTY_INFO: - case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER: // Informational LFG packets not yet surfaced in UI — consume silently. packet.setReadPos(packet.getSize()); break; + case Opcode::SMSG_OPEN_LFG_DUNGEON_FINDER: + // Server requests client to open the dungeon finder UI + packet.setReadPos(packet.getSize()); // consume any payload + if (openLfgCallback_) openLfgCallback_(); + break; case Opcode::SMSG_ARENA_TEAM_COMMAND_RESULT: handleArenaTeamCommandResult(packet); @@ -5103,7 +5240,7 @@ void GameHandler::handlePacket(network::Packet& packet) { handleArenaTeamQueryResponse(packet); break; case Opcode::SMSG_ARENA_TEAM_ROSTER: - LOG_INFO("Received SMSG_ARENA_TEAM_ROSTER"); + handleArenaTeamRoster(packet); break; case Opcode::SMSG_ARENA_TEAM_INVITE: handleArenaTeamInvite(packet); @@ -5120,9 +5257,37 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::MSG_PVP_LOG_DATA: handlePvpLogData(packet); break; - case Opcode::MSG_INSPECT_ARENA_TEAMS: - LOG_INFO("Received MSG_INSPECT_ARENA_TEAMS"); + case Opcode::MSG_INSPECT_ARENA_TEAMS: { + // WotLK: uint64 playerGuid + uint8 teamCount + per-team fields + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); + break; + } + uint64_t inspGuid = packet.readUInt64(); + uint8_t teamCount = packet.readUInt8(); + if (teamCount > 3) teamCount = 3; // 2v2, 3v3, 5v5 + if (inspGuid == inspectResult_.guid || inspectResult_.guid == 0) { + inspectResult_.guid = inspGuid; + inspectResult_.arenaTeams.clear(); + for (uint8_t t = 0; t < teamCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 21) break; + InspectArenaTeam team; + team.teamId = packet.readUInt32(); + team.type = packet.readUInt8(); + team.weekGames = packet.readUInt32(); + team.weekWins = packet.readUInt32(); + team.seasonGames = packet.readUInt32(); + team.seasonWins = packet.readUInt32(); + team.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 4) break; + team.personalRating = packet.readUInt32(); + inspectResult_.arenaTeams.push_back(std::move(team)); + } + } + LOG_DEBUG("MSG_INSPECT_ARENA_TEAMS: guid=0x", std::hex, inspGuid, std::dec, + " teams=", (int)teamCount); break; + } case Opcode::MSG_TALENT_WIPE_CONFIRM: { // Server sends: uint64 npcGuid + uint32 cost // Client must respond with the same opcode containing uint64 npcGuid to confirm. @@ -5173,6 +5338,22 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; + // ---- Broadcast speed changes (server→client, no ACK) ---- + // Format: PackedGuid (mover) + MovementInfo (variable) + float speed + // MovementInfo is complex (optional transport/fall/spline blocks based on flags). + // We consume the packet to suppress "Unhandled world opcode" warnings. + case Opcode::MSG_MOVE_SET_RUN_SPEED: + case Opcode::MSG_MOVE_SET_RUN_BACK_SPEED: + case Opcode::MSG_MOVE_SET_WALK_SPEED: + case Opcode::MSG_MOVE_SET_SWIM_SPEED: + case Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED: + case Opcode::MSG_MOVE_SET_FLIGHT_SPEED: + case Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED: + if (state == WorldState::IN_WORLD) { + handleMoveSetSpeed(packet); + } + break; + // ---- Mail ---- case Opcode::SMSG_SHOW_MAILBOX: handleShowMailbox(packet); @@ -5366,7 +5547,9 @@ void GameHandler::handlePacket(network::Packet& packet) { while (auraList->size() <= slot) auraList->push_back(AuraSlot{}); AuraSlot& a = (*auraList)[slot]; a.spellId = spellId; - a.flags = flags; + // TBC uses same flag convention as Classic: 0x02=harmful, 0x04=beneficial. + // Normalize to WotLK SMSG_AURA_UPDATE convention: 0x80=debuff, 0=buff. + a.flags = (flags & 0x02) ? 0x80u : 0u; a.durationMs = (durationMs == 0xFFFFFFFF) ? -1 : static_cast(durationMs); a.maxDurationMs= (maxDurMs == 0xFFFFFFFF) ? -1 : static_cast(maxDurMs); a.receivedAtMs = nowMs; @@ -5511,6 +5694,110 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + // ---- LFG error/timeout states ---- + case Opcode::SMSG_LFG_TIMEDOUT: + // Server-side LFG invite timed out (no response within time limit) + addSystemChatMessage("Dungeon Finder: Invite timed out."); + if (openLfgCallback_) openLfgCallback_(); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_OTHER_TIMEDOUT: + // Another party member failed to respond to a LFG role-check in time + addSystemChatMessage("Dungeon Finder: Another player's invite timed out."); + if (openLfgCallback_) openLfgCallback_(); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_AUTOJOIN_FAILED: { + // uint32 result — LFG auto-join attempt failed (player selected auto-join at queue time) + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t result = packet.readUInt32(); + (void)result; + } + addSystemChatMessage("Dungeon Finder: Auto-join failed."); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_LFG_AUTOJOIN_FAILED_NO_PLAYER: + // No eligible players found for auto-join + addSystemChatMessage("Dungeon Finder: No players available for auto-join."); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_LFG_LEADER_IS_LFM: + // Party leader is currently set to Looking for More (LFM) mode + addSystemChatMessage("Your party leader is currently Looking for More."); + packet.setReadPos(packet.getSize()); + break; + + // ---- Meeting stone (Classic/TBC group-finding via summon stone) ---- + case Opcode::SMSG_MEETINGSTONE_SETQUEUE: { + // uint32 zoneId + uint8 level_min + uint8 level_max — player queued for meeting stone + if (packet.getSize() - packet.getReadPos() >= 6) { + uint32_t zoneId = packet.readUInt32(); + uint8_t levelMin = packet.readUInt8(); + uint8_t levelMax = packet.readUInt8(); + char buf[128]; + std::snprintf(buf, sizeof(buf), + "You are now in the Meeting Stone queue for zone %u (levels %u-%u).", + zoneId, levelMin, levelMax); + addSystemChatMessage(buf); + LOG_INFO("SMSG_MEETINGSTONE_SETQUEUE: zone=", zoneId, + " levels=", (int)levelMin, "-", (int)levelMax); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_MEETINGSTONE_COMPLETE: + // Server confirms group found and teleport summon is ready + addSystemChatMessage("Meeting Stone: Your group is ready! Use the Meeting Stone to summon."); + LOG_INFO("SMSG_MEETINGSTONE_COMPLETE"); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_MEETINGSTONE_IN_PROGRESS: + // Meeting stone search is still ongoing + addSystemChatMessage("Meeting Stone: Searching for group members..."); + LOG_DEBUG("SMSG_MEETINGSTONE_IN_PROGRESS"); + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_MEETINGSTONE_MEMBER_ADDED: { + // uint64 memberGuid — a player was added to your group via meeting stone + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t memberGuid = packet.readUInt64(); + auto nit = playerNameCache.find(memberGuid); + if (nit != playerNameCache.end() && !nit->second.empty()) { + addSystemChatMessage("Meeting Stone: " + nit->second + + " has been added to your group."); + } else { + addSystemChatMessage("Meeting Stone: A new player has been added to your group."); + } + LOG_INFO("SMSG_MEETINGSTONE_MEMBER_ADDED: guid=0x", std::hex, memberGuid, std::dec); + } + break; + } + case Opcode::SMSG_MEETINGSTONE_JOINFAILED: { + // uint8 reason — failed to join group via meeting stone + // 0=target_not_in_lfg, 1=target_in_party, 2=target_invalid_map, 3=target_not_available + static const char* kMeetingstoneErrors[] = { + "Target player is not using the Meeting Stone.", + "Target player is already in a group.", + "You are not in a valid zone for that Meeting Stone.", + "Target player is not available.", + }; + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t reason = packet.readUInt8(); + const char* msg = (reason < 4) ? kMeetingstoneErrors[reason] + : "Meeting Stone: Could not join group."; + addSystemChatMessage(msg); + LOG_INFO("SMSG_MEETINGSTONE_JOINFAILED: reason=", (int)reason); + } + break; + } + case Opcode::SMSG_MEETINGSTONE_LEAVE: + // Player was removed from the meeting stone queue (left, or group disbanded) + addSystemChatMessage("You have left the Meeting Stone queue."); + LOG_DEBUG("SMSG_MEETINGSTONE_LEAVE"); + packet.setReadPos(packet.getSize()); + break; + // ---- GM Ticket responses ---- case Opcode::SMSG_GMTICKET_CREATE: { if (packet.getSize() - packet.getReadPos() >= 1) { @@ -5536,10 +5823,70 @@ void GameHandler::handlePacket(network::Packet& packet) { } break; } - case Opcode::SMSG_GMTICKET_GETTICKET: - case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: + case Opcode::SMSG_GMTICKET_GETTICKET: { + // WotLK 3.3.5a format: + // uint8 status — 1=no ticket, 6=has open ticket, 3=closed, 10=suspended + // If status == 6 (GMTICKET_STATUS_HASTEXT): + // cstring ticketText + // uint32 ticketAge (seconds old) + // uint32 daysUntilOld (days remaining before escalation) + // float waitTimeHours (estimated GM wait time) + if (packet.getSize() - packet.getReadPos() < 1) { packet.setReadPos(packet.getSize()); break; } + uint8_t gmStatus = packet.readUInt8(); + // Status 6 = GMTICKET_STATUS_HASTEXT — open ticket with text + if (gmStatus == 6 && packet.getSize() - packet.getReadPos() >= 1) { + gmTicketText_ = packet.readString(); + uint32_t ageSec = (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; + /*uint32_t daysLeft =*/ (packet.getSize() - packet.getReadPos() >= 4) ? packet.readUInt32() : 0; + gmTicketWaitHours_ = (packet.getSize() - packet.getReadPos() >= 4) + ? packet.readFloat() : 0.0f; + gmTicketActive_ = true; + char buf[256]; + if (ageSec < 60) { + std::snprintf(buf, sizeof(buf), + "You have an open GM ticket (submitted %us ago). Estimated wait: %.1f hours.", + ageSec, gmTicketWaitHours_); + } else { + uint32_t ageMin = ageSec / 60; + std::snprintf(buf, sizeof(buf), + "You have an open GM ticket (submitted %um ago). Estimated wait: %.1f hours.", + ageMin, gmTicketWaitHours_); + } + addSystemChatMessage(buf); + LOG_INFO("SMSG_GMTICKET_GETTICKET: open ticket age=", ageSec, + "s wait=", gmTicketWaitHours_, "h"); + } else if (gmStatus == 3) { + gmTicketActive_ = false; + gmTicketText_.clear(); + addSystemChatMessage("Your GM ticket has been closed."); + LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket closed"); + } else if (gmStatus == 10) { + gmTicketActive_ = false; + gmTicketText_.clear(); + addSystemChatMessage("Your GM ticket has been suspended."); + LOG_INFO("SMSG_GMTICKET_GETTICKET: ticket suspended"); + } else { + // Status 1 = no open ticket (default/no ticket) + gmTicketActive_ = false; + gmTicketText_.clear(); + LOG_DEBUG("SMSG_GMTICKET_GETTICKET: no open ticket (status=", (int)gmStatus, ")"); + } packet.setReadPos(packet.getSize()); break; + } + case Opcode::SMSG_GMTICKET_SYSTEMSTATUS: { + // uint32 status: 1 = GM support available, 0 = offline/unavailable + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t sysStatus = packet.readUInt32(); + gmSupportAvailable_ = (sysStatus != 0); + addSystemChatMessage(gmSupportAvailable_ + ? "GM support is currently available." + : "GM support is currently unavailable."); + LOG_INFO("SMSG_GMTICKET_SYSTEMSTATUS: available=", gmSupportAvailable_); + } + packet.setReadPos(packet.getSize()); + break; + } // ---- DK rune tracking ---- case Opcode::SMSG_CONVERT_RUNE: { @@ -5750,8 +6097,159 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; } - case Opcode::SMSG_SPELLINSTAKILLLOG: - case Opcode::SMSG_SPELLLOGEXECUTE: + case Opcode::SMSG_SPELLINSTAKILLLOG: { + // Sent when a unit is killed by a spell with SPELL_ATTR_EX2_INSTAKILL (e.g. Execute, Obliterate, etc.) + // WotLK: packed_guid caster + packed_guid victim + uint32 spellId + // TBC/Classic: full uint64 caster + full uint64 victim + uint32 spellId + const bool ikTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + auto ik_rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t ikCaster = ikTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (ik_rem() < (ikTbcLike ? 8u : 1u)) { packet.setReadPos(packet.getSize()); break; } + uint64_t ikVictim = ikTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + uint32_t ikSpell = (ik_rem() >= 4) ? packet.readUInt32() : 0; + // Show kill/death feedback for the local player + if (ikCaster == playerGuid) { + // We killed a target instantly — show a KILL combat text hit + addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, true); + } else if (ikVictim == playerGuid) { + // We were instantly killed — show a large incoming hit + addCombatText(CombatTextEntry::MELEE_DAMAGE, 0, ikSpell, false); + addSystemChatMessage("You were killed by an instant-kill effect."); + } + LOG_DEBUG("SMSG_SPELLINSTAKILLLOG: caster=0x", std::hex, ikCaster, + " victim=0x", ikVictim, std::dec, " spell=", ikSpell); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_SPELLLOGEXECUTE: { + // WotLK: packed_guid caster + uint32 spellId + uint32 effectCount + // TBC/Classic: uint64 caster + uint32 spellId + uint32 effectCount + // Per-effect: uint8 effectType + uint32 effectLogCount + effect-specific data + // Effect 10 = POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier + // Effect 11 = HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier + // Effect 24 = CREATE_ITEM: uint32 itemEntry + // Effect 26 = INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id + // Effect 49 = FEED_PET: uint32 itemEntry + // Effect 114= CREATE_ITEM2: uint32 itemEntry (same layout as CREATE_ITEM) + const bool exeTbcLike = isClassicLikeExpansion() || isActiveExpansion("tbc"); + if (packet.getSize() - packet.getReadPos() < (exeTbcLike ? 8u : 1u)) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t exeCaster = exeTbcLike + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t exeSpellId = packet.readUInt32(); + uint32_t exeEffectCount = packet.readUInt32(); + exeEffectCount = std::min(exeEffectCount, 32u); // sanity + + const bool isPlayerCaster = (exeCaster == playerGuid); + for (uint32_t ei = 0; ei < exeEffectCount; ++ei) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint8_t effectType = packet.readUInt8(); + uint32_t effectLogCount = packet.readUInt32(); + effectLogCount = std::min(effectLogCount, 64u); // sanity + if (effectType == 10) { + // SPELL_EFFECT_POWER_DRAIN: packed_guid target + uint32 amount + uint32 powerType + float multiplier + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t drainTarget = exeTbcLike + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 12) { packet.setReadPos(packet.getSize()); break; } + uint32_t drainAmount = packet.readUInt32(); + uint32_t drainPower = packet.readUInt32(); // 0=mana,1=rage,3=energy,6=runic + /*float drainMult =*/ packet.readFloat(); + if (drainAmount > 0) { + if (drainTarget == playerGuid) + addCombatText(CombatTextEntry::PERIODIC_DAMAGE, static_cast(drainAmount), exeSpellId, false); + else if (isPlayerCaster) + addCombatText(CombatTextEntry::ENERGIZE, static_cast(drainAmount), exeSpellId, true); + } + LOG_DEBUG("SMSG_SPELLLOGEXECUTE POWER_DRAIN: spell=", exeSpellId, + " power=", drainPower, " amount=", drainAmount); + } + } else if (effectType == 11) { + // SPELL_EFFECT_HEALTH_LEECH: packed_guid target + uint32 amount + float multiplier + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t leechTarget = exeTbcLike + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 8) { packet.setReadPos(packet.getSize()); break; } + uint32_t leechAmount = packet.readUInt32(); + /*float leechMult =*/ packet.readFloat(); + if (leechAmount > 0) { + if (leechTarget == playerGuid) + addCombatText(CombatTextEntry::SPELL_DAMAGE, static_cast(leechAmount), exeSpellId, false); + else if (isPlayerCaster) + addCombatText(CombatTextEntry::HEAL, static_cast(leechAmount), exeSpellId, true); + } + LOG_DEBUG("SMSG_SPELLLOGEXECUTE HEALTH_LEECH: spell=", exeSpellId, " amount=", leechAmount); + } + } else if (effectType == 24 || effectType == 114) { + // SPELL_EFFECT_CREATE_ITEM / CREATE_ITEM2: uint32 itemEntry per log entry + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t itemEntry = packet.readUInt32(); + if (isPlayerCaster && itemEntry != 0) { + ensureItemInfo(itemEntry); + const ItemQueryResponseData* info = getItemInfo(itemEntry); + std::string itemName = info && !info->name.empty() + ? info->name : ("item #" + std::to_string(itemEntry)); + loadSpellNameCache(); + auto spellIt = spellNameCache_.find(exeSpellId); + std::string spellName = (spellIt != spellNameCache_.end() && !spellIt->second.name.empty()) + ? spellIt->second.name : ""; + std::string msg = spellName.empty() + ? ("You create: " + itemName + ".") + : ("You create " + itemName + " using " + spellName + "."); + addSystemChatMessage(msg); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE CREATE_ITEM: spell=", exeSpellId, + " item=", itemEntry, " name=", itemName); + } + } + } else if (effectType == 26) { + // SPELL_EFFECT_INTERRUPT_CAST: packed_guid target + uint32 interrupted_spell_id + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint64_t icTarget = exeTbcLike + ? (packet.getSize() - packet.getReadPos() >= 8 ? packet.readUInt64() : 0) + : UpdateObjectParser::readPackedGuid(packet); + if (packet.getSize() - packet.getReadPos() < 4) { packet.setReadPos(packet.getSize()); break; } + uint32_t icSpellId = packet.readUInt32(); + // Clear the interrupted unit's cast bar immediately + unitCastStates_.erase(icTarget); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE INTERRUPT_CAST: spell=", exeSpellId, + " interrupted=", icSpellId, " target=0x", std::hex, icTarget, std::dec); + } + } else if (effectType == 49) { + // SPELL_EFFECT_FEED_PET: uint32 itemEntry per log entry + for (uint32_t li = 0; li < effectLogCount; ++li) { + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t feedItem = packet.readUInt32(); + if (isPlayerCaster && feedItem != 0) { + ensureItemInfo(feedItem); + const ItemQueryResponseData* info = getItemInfo(feedItem); + std::string itemName = info && !info->name.empty() + ? info->name : ("item #" + std::to_string(feedItem)); + addSystemChatMessage("You feed your pet " + itemName + "."); + LOG_DEBUG("SMSG_SPELLLOGEXECUTE FEED_PET: item=", feedItem, " name=", itemName); + } + } + } else { + // Unknown effect type — stop parsing to avoid misalignment + packet.setReadPos(packet.getSize()); + break; + } + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_SPELL_CHANCE_RESIST_PUSHBACK: case Opcode::SMSG_SPELL_UPDATE_CHAIN_TARGETS: packet.setReadPos(packet.getSize()); @@ -5823,10 +6321,21 @@ void GameHandler::handlePacket(network::Packet& packet) { LOG_DEBUG("SMSG_ITEM_ENCHANT_TIME_UPDATE: slot=", enchSlot, " dur=", durationSec, "s"); break; } - case Opcode::SMSG_COMPLAIN_RESULT: + case Opcode::SMSG_COMPLAIN_RESULT: { + // uint8 result: 0=success, 1=failed, 2=disabled + if (packet.getSize() - packet.getReadPos() >= 1) { + uint8_t result = packet.readUInt8(); + if (result == 0) + addSystemChatMessage("Your complaint has been submitted."); + else if (result == 2) + addUIError("Report a Player is currently disabled."); + } + packet.setReadPos(packet.getSize()); + break; + } case Opcode::SMSG_ITEM_REFUND_INFO_RESPONSE: case Opcode::SMSG_LOOT_LIST: - // Consume — not yet processed + // Consume silently — informational, no UI action needed packet.setReadPos(packet.getSize()); break; @@ -6428,7 +6937,7 @@ void GameHandler::handlePacket(network::Packet& packet) { break; } - // ---- Misc consume ---- + // ---- Misc consume (no state change needed) ---- case Opcode::SMSG_SET_PLAYER_DECLINED_NAMES_RESULT: case Opcode::SMSG_PROPOSE_LEVEL_GRANT: case Opcode::SMSG_REFER_A_FRIEND_EXPIRED: @@ -6437,8 +6946,12 @@ void GameHandler::handlePacket(network::Packet& packet) { case Opcode::SMSG_REDIRECT_CLIENT: case Opcode::SMSG_PVP_QUEUE_STATS: case Opcode::SMSG_NOTIFY_DEST_LOC_SPELL_CAST: - case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: case Opcode::SMSG_PLAYER_SKINNED: + packet.setReadPos(packet.getSize()); + break; + case Opcode::SMSG_RESPOND_INSPECT_ACHIEVEMENTS: + handleRespondInspectAchievements(packet); + break; case Opcode::SMSG_QUEST_POI_QUERY_RESPONSE: handleQuestPoiQueryResponse(packet); break; @@ -6487,6 +7000,41 @@ void GameHandler::handlePacket(network::Packet& packet) { packet.setReadPos(packet.getSize()); break; + // ---- Mirror image data (WotLK: Mage ability Mirror Image) ---- + case Opcode::SMSG_MIRRORIMAGE_DATA: { + // WotLK 3.3.5a format: + // uint64 mirrorGuid — GUID of the mirror image unit + // uint32 displayId — display ID to render the image with + // uint8 raceId — race of caster + // uint8 genderFlag — gender of caster + // uint8 classId — class of caster + // uint64 casterGuid — GUID of the player who cast the spell + // Followed by equipped item display IDs (11 × uint32) if casterGuid != 0 + // Purpose: tells client how to render the image (same appearance as caster). + // We parse the GUIDs so units render correctly via their existing display IDs. + if (packet.getSize() - packet.getReadPos() < 8) break; + uint64_t mirrorGuid = packet.readUInt64(); + if (packet.getSize() - packet.getReadPos() < 4) break; + uint32_t displayId = packet.readUInt32(); + if (packet.getSize() - packet.getReadPos() < 3) break; + /*uint8_t raceId =*/ packet.readUInt8(); + /*uint8_t gender =*/ packet.readUInt8(); + /*uint8_t classId =*/ packet.readUInt8(); + // Apply display ID to the mirror image unit so it renders correctly + if (mirrorGuid != 0 && displayId != 0) { + auto entity = entityManager.getEntity(mirrorGuid); + if (entity) { + auto unit = std::dynamic_pointer_cast(entity); + if (unit && unit->getDisplayId() == 0) + unit->setDisplayId(displayId); + } + } + LOG_DEBUG("SMSG_MIRRORIMAGE_DATA: mirrorGuid=0x", std::hex, mirrorGuid, + " displayId=", std::dec, displayId); + packet.setReadPos(packet.getSize()); + break; + } + // ---- Player movement flag changes (server-pushed) ---- case Opcode::SMSG_MOVE_GRAVITY_DISABLE: handleForceMoveFlagChange(packet, "GRAVITY_DISABLE", Opcode::CMSG_MOVE_GRAVITY_DISABLE_ACK, @@ -6524,6 +7072,477 @@ void GameHandler::handlePacket(network::Packet& packet) { static_cast(MovementFlags::FLYING), false); break; + // ---- Battlefield Manager (WotLK outdoor battlefields: Wintergrasp, Tol Barad) ---- + case Opcode::SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: { + // uint64 battlefieldGuid + uint32 zoneId + uint64 expireUnixTime (seconds) + if (packet.getSize() - packet.getReadPos() < 20) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t bfGuid = packet.readUInt64(); + uint32_t bfZoneId = packet.readUInt32(); + uint64_t expireTime = packet.readUInt64(); + (void)bfGuid; (void)expireTime; + // Store the invitation so the UI can show a prompt + bfMgrInvitePending_ = true; + bfMgrZoneId_ = bfZoneId; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "You are invited to the outdoor battlefield in zone %u. Click to enter.", bfZoneId); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTRY_INVITE: zoneId=", bfZoneId); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_ENTERED: { + // uint64 battlefieldGuid + uint8 isSafe (1=pvp zones enabled) + uint8 onQueue + if (packet.getSize() - packet.getReadPos() >= 8) { + uint64_t bfGuid2 = packet.readUInt64(); + (void)bfGuid2; + uint8_t isSafe = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + uint8_t onQueue = (packet.getSize() - packet.getReadPos() >= 1) ? packet.readUInt8() : 0; + bfMgrInvitePending_ = false; + bfMgrActive_ = true; + addSystemChatMessage(isSafe ? "You are in the battlefield zone (safe area)." + : "You have entered the battlefield!"); + if (onQueue) addSystemChatMessage("You are in the battlefield queue."); + LOG_INFO("SMSG_BATTLEFIELD_MGR_ENTERED: isSafe=", (int)isSafe, " onQueue=", (int)onQueue); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: { + // uint64 battlefieldGuid + uint32 battlefieldId + uint64 expireTime + if (packet.getSize() - packet.getReadPos() < 20) { + packet.setReadPos(packet.getSize()); break; + } + uint64_t bfGuid3 = packet.readUInt64(); + uint32_t bfId = packet.readUInt32(); + uint64_t expTime = packet.readUInt64(); + (void)bfGuid3; (void)expTime; + bfMgrInvitePending_ = true; + bfMgrZoneId_ = bfId; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "A spot has opened in the battlefield queue (battlefield %u).", bfId); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_INVITE: bfId=", bfId); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: { + // uint32 battlefieldId + uint32 teamId + uint8 accepted + uint8 loggingEnabled + uint8 result + // result: 0=queued, 1=not_in_group, 2=too_high_level, 3=too_low_level, + // 4=in_cooldown, 5=queued_other_bf, 6=bf_full + if (packet.getSize() - packet.getReadPos() < 11) { + packet.setReadPos(packet.getSize()); break; + } + uint32_t bfId2 = packet.readUInt32(); + /*uint32_t teamId =*/ packet.readUInt32(); + uint8_t accepted = packet.readUInt8(); + /*uint8_t logging =*/ packet.readUInt8(); + uint8_t result = packet.readUInt8(); + (void)bfId2; + if (accepted) { + addSystemChatMessage("You have joined the battlefield queue."); + } else { + static const char* kBfQueueErrors[] = { + "Queued for battlefield.", "Not in a group.", "Level too high.", + "Level too low.", "Battlefield in cooldown.", "Already queued for another battlefield.", + "Battlefield is full." + }; + const char* msg = (result < 7) ? kBfQueueErrors[result] + : "Battlefield queue request failed."; + addSystemChatMessage(std::string("Battlefield: ") + msg); + } + LOG_INFO("SMSG_BATTLEFIELD_MGR_QUEUE_REQUEST_RESPONSE: accepted=", (int)accepted, + " result=", (int)result); + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_EJECT_PENDING: { + // uint64 battlefieldGuid + uint8 remove + if (packet.getSize() - packet.getReadPos() >= 9) { + uint64_t bfGuid4 = packet.readUInt64(); + uint8_t remove = packet.readUInt8(); + (void)bfGuid4; + if (remove) { + addSystemChatMessage("You will be removed from the battlefield shortly."); + } + LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECT_PENDING: remove=", (int)remove); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_EJECTED: { + // uint64 battlefieldGuid + uint32 reason + uint32 battleStatus + uint8 relocated + if (packet.getSize() - packet.getReadPos() >= 17) { + uint64_t bfGuid5 = packet.readUInt64(); + uint32_t reason = packet.readUInt32(); + /*uint32_t status =*/ packet.readUInt32(); + uint8_t relocated = packet.readUInt8(); + (void)bfGuid5; + static const char* kEjectReasons[] = { + "Removed from battlefield.", "Transported from battlefield.", + "Left battlefield voluntarily.", "Offline.", + }; + const char* msg = (reason < 4) ? kEjectReasons[reason] + : "You have been ejected from the battlefield."; + addSystemChatMessage(msg); + if (relocated) addSystemChatMessage("You have been relocated outside the battlefield."); + LOG_INFO("SMSG_BATTLEFIELD_MGR_EJECTED: reason=", reason, " relocated=", (int)relocated); + } + bfMgrActive_ = false; + bfMgrInvitePending_ = false; + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_BATTLEFIELD_MGR_STATE_CHANGE: { + // uint32 oldState + uint32 newState + // States: 0=Waiting, 1=Starting, 2=InProgress, 3=Ending, 4=Cooldown + if (packet.getSize() - packet.getReadPos() >= 8) { + /*uint32_t oldState =*/ packet.readUInt32(); + uint32_t newState = packet.readUInt32(); + static const char* kBfStates[] = { + "waiting", "starting", "in progress", "ending", "in cooldown" + }; + const char* stateStr = (newState < 5) ? kBfStates[newState] : "unknown state"; + char buf[128]; + std::snprintf(buf, sizeof(buf), "Battlefield is now %s.", stateStr); + addSystemChatMessage(buf); + LOG_INFO("SMSG_BATTLEFIELD_MGR_STATE_CHANGE: newState=", newState); + } + packet.setReadPos(packet.getSize()); + break; + } + + // ---- WotLK Calendar system (pending invites, event notifications, command results) ---- + case Opcode::SMSG_CALENDAR_SEND_NUM_PENDING: { + // uint32 numPending — number of unacknowledged calendar invites + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t numPending = packet.readUInt32(); + calendarPendingInvites_ = numPending; + if (numPending > 0) { + char buf[64]; + std::snprintf(buf, sizeof(buf), + "You have %u pending calendar invite%s.", + numPending, numPending == 1 ? "" : "s"); + addSystemChatMessage(buf); + } + LOG_DEBUG("SMSG_CALENDAR_SEND_NUM_PENDING: ", numPending, " pending invites"); + } + break; + } + case Opcode::SMSG_CALENDAR_COMMAND_RESULT: { + // uint32 command + uint8 result + cstring info + // result 0 = success; non-zero = error code + // command values: 0=add,1=get,2=guild_filter,3=arena_team,4=update,5=remove, + // 6=copy,7=invite,8=rsvp,9=remove_invite,10=status,11=moderator_status + if (packet.getSize() - packet.getReadPos() < 5) { + packet.setReadPos(packet.getSize()); break; + } + /*uint32_t command =*/ packet.readUInt32(); + uint8_t result = packet.readUInt8(); + std::string info = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + if (result != 0) { + // Map common calendar error codes to friendly strings + static const char* kCalendarErrors[] = { + "", + "Calendar: Internal error.", // 1 = CALENDAR_ERROR_INTERNAL + "Calendar: Guild event limit reached.",// 2 + "Calendar: Event limit reached.", // 3 + "Calendar: You cannot invite that player.", // 4 + "Calendar: No invites remaining.", // 5 + "Calendar: Invalid date.", // 6 + "Calendar: Cannot invite yourself.", // 7 + "Calendar: Cannot modify this event.", // 8 + "Calendar: Not invited.", // 9 + "Calendar: Already invited.", // 10 + "Calendar: Player not found.", // 11 + "Calendar: Not enough focus.", // 12 + "Calendar: Event locked.", // 13 + "Calendar: Event deleted.", // 14 + "Calendar: Not a moderator.", // 15 + }; + const char* errMsg = (result < 16) ? kCalendarErrors[result] + : "Calendar: Command failed."; + if (errMsg && errMsg[0] != '\0') addSystemChatMessage(errMsg); + else if (!info.empty()) addSystemChatMessage("Calendar: " + info); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_EVENT_INVITE_ALERT: { + // Rich notification: eventId(8) + title(cstring) + eventTime(8) + flags(4) + + // eventType(1) + dungeonId(4) + inviteId(8) + status(1) + rank(1) + + // isGuildEvent(1) + inviterGuid(8) + if (packet.getSize() - packet.getReadPos() < 9) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t eventId =*/ packet.readUInt64(); + std::string title = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + packet.setReadPos(packet.getSize()); // consume remaining fields + if (!title.empty()) { + addSystemChatMessage("Calendar invite: " + title); + } else { + addSystemChatMessage("You have a new calendar invite."); + } + if (calendarPendingInvites_ < 255) ++calendarPendingInvites_; + LOG_INFO("SMSG_CALENDAR_EVENT_INVITE_ALERT: title='", title, "'"); + break; + } + // Remaining calendar informational packets — parse title where possible and consume + case Opcode::SMSG_CALENDAR_EVENT_STATUS: { + // Sent when an event invite's RSVP status changes for the local player + // Format: inviteId(8) + eventId(8) + eventType(1) + flags(4) + + // inviteTime(8) + status(1) + rank(1) + isGuildEvent(1) + title(cstring) + if (packet.getSize() - packet.getReadPos() < 31) { + packet.setReadPos(packet.getSize()); break; + } + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + /*uint8_t evType =*/ packet.readUInt8(); + /*uint32_t flags =*/ packet.readUInt32(); + /*uint64_t invTime =*/ packet.readUInt64(); + uint8_t status = packet.readUInt8(); + /*uint8_t rank =*/ packet.readUInt8(); + /*uint8_t isGuild =*/ packet.readUInt8(); + std::string evTitle = (packet.getReadPos() < packet.getSize()) ? packet.readString() : ""; + // status: 0=Invited,1=Accepted,2=Declined,3=Confirmed,4=Out,5=Standby,6=SignedUp,7=Not Signed Up,8=Tentative + static const char* kRsvpStatus[] = { + "invited", "accepted", "declined", "confirmed", + "out", "on standby", "signed up", "not signed up", "tentative" + }; + const char* statusStr = (status < 9) ? kRsvpStatus[status] : "unknown"; + if (!evTitle.empty()) { + char buf[256]; + std::snprintf(buf, sizeof(buf), "Calendar event '%s': your RSVP is %s.", + evTitle.c_str(), statusStr); + addSystemChatMessage(buf); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_ADDED: { + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + uint64 resetTime + if (packet.getSize() - packet.getReadPos() >= 28) { + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t difficulty = packet.readUInt32(); + /*uint64_t resetTime =*/ packet.readUInt64(); + char buf[128]; + std::snprintf(buf, sizeof(buf), + "Calendar: Raid lockout added for map %u (difficulty %u).", mapId, difficulty); + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_ADDED: mapId=", mapId, " difficulty=", difficulty); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: { + // uint64 inviteId + uint64 eventId + uint32 mapId + uint32 difficulty + if (packet.getSize() - packet.getReadPos() >= 20) { + /*uint64_t inviteId =*/ packet.readUInt64(); + /*uint64_t eventId =*/ packet.readUInt64(); + uint32_t mapId = packet.readUInt32(); + uint32_t difficulty = packet.readUInt32(); + (void)mapId; (void)difficulty; + LOG_DEBUG("SMSG_CALENDAR_RAID_LOCKOUT_REMOVED: mapId=", mapId, + " difficulty=", difficulty); + } + packet.setReadPos(packet.getSize()); + break; + } + case Opcode::SMSG_CALENDAR_RAID_LOCKOUT_UPDATED: { + // Same format as LOCKOUT_ADDED; consume + packet.setReadPos(packet.getSize()); + break; + } + // Remaining calendar opcodes: safe consume — data surfaced via SEND_CALENDAR/SEND_EVENT + case Opcode::SMSG_CALENDAR_SEND_CALENDAR: + case Opcode::SMSG_CALENDAR_SEND_EVENT: + case Opcode::SMSG_CALENDAR_ARENA_TEAM: + case Opcode::SMSG_CALENDAR_FILTER_GUILD: + case Opcode::SMSG_CALENDAR_CLEAR_PENDING_ACTION: + case Opcode::SMSG_CALENDAR_EVENT_INVITE: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_NOTES_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_REMOVED_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_INVITE_STATUS_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_MODERATOR_STATUS_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_REMOVED_ALERT: + case Opcode::SMSG_CALENDAR_EVENT_UPDATED_ALERT: + packet.setReadPos(packet.getSize()); + break; + + case Opcode::SMSG_SERVERTIME: { + // uint32 unixTime — server's current unix timestamp; use to sync gameTime_ + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t srvTime = packet.readUInt32(); + if (srvTime > 0) { + gameTime_ = static_cast(srvTime); + LOG_DEBUG("SMSG_SERVERTIME: serverTime=", srvTime); + } + } + break; + } + + case Opcode::SMSG_KICK_REASON: { + // uint64 kickerGuid + uint32 kickReasonType + null-terminated reason string + // kickReasonType: 0=other, 1=afk, 2=vote kick + if (packet.getSize() - packet.getReadPos() < 12) { + packet.setReadPos(packet.getSize()); + break; + } + uint64_t kickerGuid = packet.readUInt64(); + uint32_t reasonType = packet.readUInt32(); + std::string reason; + if (packet.getSize() - packet.getReadPos() > 0) + reason = packet.readString(); + (void)kickerGuid; + (void)reasonType; + std::string msg = "You have been removed from the group."; + if (!reason.empty()) + msg = "You have been removed from the group: " + reason; + else if (reasonType == 1) + msg = "You have been removed from the group for being AFK."; + addSystemChatMessage(msg); + addUIError(msg); + LOG_INFO("SMSG_KICK_REASON: reasonType=", reasonType, + " reason='", reason, "'"); + break; + } + + case Opcode::SMSG_GROUPACTION_THROTTLED: { + // uint32 throttleMs — rate-limited group action; notify the player + if (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t throttleMs = packet.readUInt32(); + char buf[128]; + if (throttleMs > 0) { + std::snprintf(buf, sizeof(buf), + "Group action throttled. Please wait %.1f seconds.", + throttleMs / 1000.0f); + } else { + std::snprintf(buf, sizeof(buf), "Group action throttled."); + } + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_GROUPACTION_THROTTLED: throttleMs=", throttleMs); + } + break; + } + + case Opcode::SMSG_GMRESPONSE_RECEIVED: { + // WotLK 3.3.5a: uint32 ticketId + string subject + string body + uint32 count + // per count: string responseText + if (packet.getSize() - packet.getReadPos() < 4) { + packet.setReadPos(packet.getSize()); + break; + } + uint32_t ticketId = packet.readUInt32(); + std::string subject; + std::string body; + if (packet.getSize() - packet.getReadPos() > 0) subject = packet.readString(); + if (packet.getSize() - packet.getReadPos() > 0) body = packet.readString(); + uint32_t responseCount = 0; + if (packet.getSize() - packet.getReadPos() >= 4) + responseCount = packet.readUInt32(); + std::string responseText; + for (uint32_t i = 0; i < responseCount && i < 10; ++i) { + if (packet.getSize() - packet.getReadPos() > 0) { + std::string t = packet.readString(); + if (i == 0) responseText = t; + } + } + (void)ticketId; + std::string msg; + if (!responseText.empty()) + msg = "[GM Response] " + responseText; + else if (!body.empty()) + msg = "[GM Response] " + body; + else if (!subject.empty()) + msg = "[GM Response] " + subject; + else + msg = "[GM Response] Your ticket has been answered."; + addSystemChatMessage(msg); + addUIError(msg); + LOG_INFO("SMSG_GMRESPONSE_RECEIVED: ticketId=", ticketId, + " subject='", subject, "'"); + break; + } + + case Opcode::SMSG_GMRESPONSE_STATUS_UPDATE: { + // uint32 ticketId + uint8 status (1=open, 2=surveyed, 3=need_more_help) + if (packet.getSize() - packet.getReadPos() >= 5) { + uint32_t ticketId = packet.readUInt32(); + uint8_t status = packet.readUInt8(); + const char* statusStr = (status == 1) ? "open" + : (status == 2) ? "answered" + : (status == 3) ? "needs more info" + : "updated"; + char buf[128]; + std::snprintf(buf, sizeof(buf), + "[GM Ticket #%u] Status: %s.", ticketId, statusStr); + addSystemChatMessage(buf); + LOG_DEBUG("SMSG_GMRESPONSE_STATUS_UPDATE: ticketId=", ticketId, + " status=", static_cast(status)); + } + break; + } + + // ---- Voice chat (WotLK built-in voice) — consume silently ---- + case Opcode::SMSG_VOICE_SESSION_ROSTER_UPDATE: + case Opcode::SMSG_VOICE_SESSION_LEAVE: + case Opcode::SMSG_VOICE_SESSION_ADJUST_PRIORITY: + case Opcode::SMSG_VOICE_SET_TALKER_MUTED: + case Opcode::SMSG_VOICE_SESSION_ENABLE: + case Opcode::SMSG_VOICE_PARENTAL_CONTROLS: + case Opcode::SMSG_AVAILABLE_VOICE_CHANNEL: + case Opcode::SMSG_VOICE_CHAT_STATUS: + packet.setReadPos(packet.getSize()); + break; + + // ---- Dance / custom emote system (WotLK) — consume silently ---- + case Opcode::SMSG_NOTIFY_DANCE: + case Opcode::SMSG_PLAY_DANCE: + case Opcode::SMSG_STOP_DANCE: + case Opcode::SMSG_DANCE_QUERY_RESPONSE: + case Opcode::SMSG_INVALIDATE_DANCE: + packet.setReadPos(packet.getSize()); + break; + + // ---- Commentator / spectator mode — consume silently ---- + case Opcode::SMSG_COMMENTATOR_STATE_CHANGED: + case Opcode::SMSG_COMMENTATOR_MAP_INFO: + case Opcode::SMSG_COMMENTATOR_GET_PLAYER_INFO: + case Opcode::SMSG_COMMENTATOR_PLAYER_INFO: + case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT1: + case Opcode::SMSG_COMMENTATOR_SKIRMISH_QUEUE_RESULT2: + packet.setReadPos(packet.getSize()); + break; + + // ---- Debug / cheat / GM-only opcodes — consume silently ---- + case Opcode::SMSG_DBLOOKUP: + case Opcode::SMSG_CHECK_FOR_BOTS: + case Opcode::SMSG_GODMODE: + case Opcode::SMSG_PETGODMODE: + case Opcode::SMSG_DEBUG_AISTATE: + case Opcode::SMSG_DEBUGAURAPROC: + case Opcode::SMSG_TEST_DROP_RATE_RESULT: + case Opcode::SMSG_COOLDOWN_CHEAT: + case Opcode::SMSG_GM_PLAYER_INFO: + case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE: + case Opcode::SMSG_CHEAT_DUMP_ITEMS_DEBUG_ONLY_RESPONSE_WRITE_FILE: + case Opcode::SMSG_CHEAT_PLAYER_LOOKUP: + case Opcode::SMSG_IGNORE_REQUIREMENTS_CHEAT: + case Opcode::SMSG_IGNORE_DIMINISHING_RETURNS_CHEAT: + case Opcode::SMSG_DEBUG_LIST_TARGETS: + case Opcode::SMSG_DEBUG_SERVER_GEO: + case Opcode::SMSG_DUMP_OBJECTS_DATA: + case Opcode::SMSG_AFK_MONITOR_INFO_RESPONSE: + case Opcode::SMSG_FORCEACTIONSHOW: + case Opcode::SMSG_MOVE_CHARACTER_CHEAT: + packet.setReadPos(packet.getSize()); + break; + default: // In pre-world states we need full visibility (char create/login handshakes). // In-world we keep de-duplication to avoid heavy log I/O in busy areas. @@ -6944,6 +7963,8 @@ void GameHandler::selectCharacter(uint64_t characterGuid) { std::fill(std::begin(playerStats_), std::end(playerStats_), -1); knownSpells.clear(); spellCooldowns.clear(); + spellFlatMods_.clear(); + spellPctMods_.clear(); actionBar = {}; playerAuras.clear(); targetAuras.clear(); @@ -7084,6 +8105,9 @@ void GameHandler::handleLoginVerifyWorld(network::Packet& packet) { encounterUnitGuids_.fill(0); raidTargetGuids_.fill(0); + // Clear inspect caches on world entry to avoid showing stale data + inspectedPlayerAchievements_.clear(); + // Reset talent initialization so the first SMSG_TALENTS_INFO after login // correctly sets the active spec (static locals don't reset across logins) talentsInitialized_ = false; @@ -8882,6 +9906,48 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (ghostStateCallback_) ghostStateCallback_(true); } } + // Classic: rebuild playerAuras from UNIT_FIELD_AURAS on initial object create + if (block.guid == playerGuid && isClassicLikeExpansion()) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras != 0xFFFF) { + bool hasAuraField = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraField = true; break; } + } + if (hasAuraField) { + playerAuras.clear(); + playerAuras.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = playerAuras[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + // Classic flags: 0x01=cancelable, 0x02=harmful, 0x04=helpful + // Normalize to WotLK convention: 0x80 = negative (debuff) + uint8_t classicFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + classicFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + // Map Classic harmful bit (0x02) → WotLK debuff bit (0x80) + a.flags = (classicFlag & 0x02) ? 0x80u : 0u; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (CREATE_OBJECT)"); + } + } + } // Determine hostility from faction template for online creatures. // Always call isHostileFaction — factionTemplate=0 defaults to hostile // in the lookup rather than silently staying at the struct default (false). @@ -8996,6 +10062,27 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { go->getX(), go->getY(), go->getZ(), go->getOrientation()); } } + // Detect player's own corpse object so we have the position even when + // SMSG_DEATH_RELEASE_LOC hasn't been received (e.g. login as ghost). + if (block.objectType == ObjectType::CORPSE && block.hasMovement) { + // CORPSE_FIELD_OWNER is at index 6 (uint64, low word at 6, high at 7) + uint16_t ownerLowIdx = 6; + auto ownerLowIt = block.fields.find(ownerLowIdx); + uint32_t ownerLow = (ownerLowIt != block.fields.end()) ? ownerLowIt->second : 0; + auto ownerHighIt = block.fields.find(ownerLowIdx + 1); + uint32_t ownerHigh = (ownerHighIt != block.fields.end()) ? ownerHighIt->second : 0; + uint64_t ownerGuid = (static_cast(ownerHigh) << 32) | ownerLow; + if (ownerGuid == playerGuid || ownerLow == static_cast(playerGuid)) { + // Server coords from movement block + corpseX_ = block.x; + corpseY_ = block.y; + corpseZ_ = block.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Corpse object detected: server=(", block.x, ", ", block.y, ", ", block.z, + ") map=", corpseMapId_); + } + } + // Track online item objects (CONTAINER = bags, also tracked as items) if (block.objectType == ObjectType::ITEM || block.objectType == ObjectType::CONTAINER) { auto entryIt = block.fields.find(fieldIndex(UF::OBJECT_FIELD_ENTRY)); @@ -9046,6 +10133,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufCoinage = fieldIndex(UF::PLAYER_FIELD_COINAGE); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2 = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); const uint16_t ufStats[5] = { fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), @@ -9082,6 +10170,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { uint8_t restStateByte = static_cast((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE from update fields: ", chosenTitleBit_); + } else { for (int si = 0; si < 5; ++si) { if (ufStats[si] != 0xFFFF && key == ufStats[si]) { @@ -9174,7 +10266,18 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerDead_ = true; releasedSpirit_ = false; stopAutoAttack(); - LOG_INFO("Player died!"); + // Cache death position as corpse location. + // Classic WoW does not send SMSG_DEATH_RELEASE_LOC, so + // this is the primary source for canReclaimCorpse(). + // movementInfo is canonical (x=north, y=west); corpseX_/Y_ + // are raw server coords (x=west, y=north) — swap axes. + corpseX_ = movementInfo.y; // canonical west = server X + corpseY_ = movementInfo.x; // canonical north = server Y + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died! Corpse position cached at server=(", + corpseX_, ",", corpseY_, ",", corpseZ_, + ") map=", corpseMapId_); } if (entity->getType() == ObjectType::UNIT && npcDeathCallback_) { npcDeathCallback_(block.guid); @@ -9209,7 +10312,11 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { if (!wasDead && nowDead) { playerDead_ = true; releasedSpirit_ = false; - LOG_INFO("Player died (dynamic flags)"); + corpseX_ = movementInfo.y; + corpseY_ = movementInfo.x; + corpseZ_ = movementInfo.z; + corpseMapId_ = currentMapId_; + LOG_INFO("Player died (dynamic flags). Corpse cached map=", corpseMapId_); } else if (wasDead && !nowDead) { playerDead_ = false; releasedSpirit_ = false; @@ -9290,6 +10397,46 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { } } + // Classic: sync playerAuras from UNIT_FIELD_AURAS when those fields are updated + if (block.guid == playerGuid && isClassicLikeExpansion()) { + const uint16_t ufAuras = fieldIndex(UF::UNIT_FIELD_AURAS); + const uint16_t ufAuraFlags = fieldIndex(UF::UNIT_FIELD_AURAFLAGS); + if (ufAuras != 0xFFFF) { + bool hasAuraUpdate = false; + for (const auto& [fk, fv] : block.fields) { + if (fk >= ufAuras && fk < ufAuras + 48) { hasAuraUpdate = true; break; } + } + if (hasAuraUpdate) { + playerAuras.clear(); + playerAuras.resize(48); + uint64_t nowMs = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count()); + const auto& allFields = entity->getFields(); + for (int slot = 0; slot < 48; ++slot) { + auto it = allFields.find(static_cast(ufAuras + slot)); + if (it != allFields.end() && it->second != 0) { + AuraSlot& a = playerAuras[slot]; + a.spellId = it->second; + // Read aura flag byte: packed 4-per-uint32 at ufAuraFlags + uint8_t aFlag = 0; + if (ufAuraFlags != 0xFFFF) { + auto fit = allFields.find(static_cast(ufAuraFlags + slot / 4)); + if (fit != allFields.end()) + aFlag = static_cast((fit->second >> ((slot % 4) * 8)) & 0xFF); + } + a.flags = aFlag; + a.durationMs = -1; + a.maxDurationMs = -1; + a.casterGuid = playerGuid; + a.receivedAtMs = nowMs; + } + } + LOG_DEBUG("[Classic] Rebuilt playerAuras from UNIT_FIELD_AURAS (VALUES)"); + } + } + } + // Some units/players are created without displayId and get it later via VALUES. if ((entity->getType() == ObjectType::UNIT || entity->getType() == ObjectType::PLAYER) && displayIdChanged && @@ -9378,6 +10525,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { const uint16_t ufPlayerFlags = fieldIndex(UF::PLAYER_FLAGS); const uint16_t ufArmor = fieldIndex(UF::UNIT_FIELD_RESISTANCES); const uint16_t ufPBytes2v = fieldIndex(UF::PLAYER_BYTES_2); + const uint16_t ufChosenTitle = fieldIndex(UF::PLAYER_CHOSEN_TITLE); const uint16_t ufStatsV[5] = { fieldIndex(UF::UNIT_FIELD_STAT0), fieldIndex(UF::UNIT_FIELD_STAT1), fieldIndex(UF::UNIT_FIELD_STAT2), fieldIndex(UF::UNIT_FIELD_STAT3), @@ -9425,6 +10573,10 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { uint8_t restStateByte = static_cast((val >> 24) & 0xFF); isResting_ = (restStateByte != 0); } + else if (ufChosenTitle != 0xFFFF && key == ufChosenTitle) { + chosenTitleBit_ = static_cast(val); + LOG_DEBUG("PLAYER_CHOSEN_TITLE updated: ", chosenTitleBit_); + } else if (key == ufPlayerFlags) { constexpr uint32_t PLAYER_FLAGS_GHOST = 0x00000010; bool wasGhost = releasedSpirit_; @@ -9438,6 +10590,7 @@ void GameHandler::handleUpdateObject(network::Packet& packet) { playerDead_ = false; repopPending_ = false; resurrectPending_ = false; + corpseMapId_ = 0; // corpse reclaimed LOG_INFO("Player resurrected (PLAYER_FLAGS ghost cleared)"); if (ghostStateCallback_) ghostStateCallback_(false); } @@ -10254,6 +11407,12 @@ void GameHandler::inspectTarget() { auto packet = InspectPacket::build(targetGuid); socket->send(packet); + // WotLK: also query the player's achievement data so the inspect UI can display it + if (isActiveExpansion("wotlk")) { + auto achPkt = QueryInspectAchievementsPacket::build(targetGuid); + socket->send(achPkt); + } + auto player = std::static_pointer_cast(target); std::string name = player->getName().empty() ? "Target" : player->getName(); addSystemChatMessage("Inspecting " + name + "..."); @@ -11086,9 +12245,11 @@ 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_; + // movementInfo.x/y are canonical (x=north=server_y, y=west=server_x). + // corpseX_/Y_ are raw server coords (x=west, y=north). + // Convert corpse to canonical before comparing. + float dx = movementInfo.x - corpseY_; // canonical north - server.y + float dy = movementInfo.y - corpseX_; // canonical west - server.x float dz = movementInfo.z - corpseZ_; return (dx*dx + dy*dy + dz*dz) <= (40.0f * 40.0f); } @@ -11556,7 +12717,7 @@ void GameHandler::handleInspectResults(network::Packet& packet) { if (packet.getSize() - packet.getReadPos() < 5) break; uint32_t talentId = packet.readUInt32(); uint8_t rank = packet.readUInt8(); - learnedTalents_[g][talentId] = rank; + learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed } if (packet.getSize() - packet.getReadPos() < 1) break; learnedGlyphs_[g].fill(0); @@ -13019,6 +14180,80 @@ void GameHandler::handleBattlefieldStatus(network::Packet& packet) { } } +void GameHandler::handleBattlefieldList(network::Packet& packet) { + // SMSG_BATTLEFIELD_LIST wire format by expansion: + // + // Classic 1.12 (vmangos/cmangos): + // bgTypeId(4) isRegistered(1) count(4) [instanceId(4)...] + // + // TBC 2.4.3: + // bgTypeId(4) isRegistered(1) isHoliday(1) count(4) [instanceId(4)...] + // + // WotLK 3.3.5a: + // bgTypeId(4) isRegistered(1) isHoliday(1) minLevel(4) maxLevel(4) count(4) [instanceId(4)...] + + if (packet.getSize() - packet.getReadPos() < 5) return; + + AvailableBgInfo info; + info.bgTypeId = packet.readUInt32(); + info.isRegistered = packet.readUInt8() != 0; + + const bool isWotlk = isActiveExpansion("wotlk"); + const bool isTbc = isActiveExpansion("tbc"); + + if (isTbc || isWotlk) { + if (packet.getSize() - packet.getReadPos() < 1) return; + info.isHoliday = packet.readUInt8() != 0; + } + + if (isWotlk) { + if (packet.getSize() - packet.getReadPos() < 8) return; + info.minLevel = packet.readUInt32(); + info.maxLevel = packet.readUInt32(); + } + + if (packet.getSize() - packet.getReadPos() < 4) return; + uint32_t count = packet.readUInt32(); + + // Sanity cap to avoid OOM from malformed packets + constexpr uint32_t kMaxInstances = 256; + count = std::min(count, kMaxInstances); + info.instanceIds.reserve(count); + + for (uint32_t i = 0; i < count; ++i) { + if (packet.getSize() - packet.getReadPos() < 4) break; + info.instanceIds.push_back(packet.readUInt32()); + } + + // Update or append the entry for this BG type + bool updated = false; + for (auto& existing : availableBgs_) { + if (existing.bgTypeId == info.bgTypeId) { + existing = std::move(info); + updated = true; + break; + } + } + if (!updated) { + availableBgs_.push_back(std::move(info)); + } + + const auto& stored = availableBgs_.back(); + static const std::unordered_map kBgNames = { + {1, "Alterac Valley"}, {2, "Warsong Gulch"}, {3, "Arathi Basin"}, + {4, "Nagrand Arena"}, {5, "Blade's Edge Arena"}, {6, "All Arenas"}, + {7, "Eye of the Storm"}, {8, "Ruins of Lordaeron"}, + {9, "Strand of the Ancients"}, {10, "Dalaran Sewers"}, + {11, "The Ring of Valor"}, {30, "Isle of Conquest"}, + }; + auto nameIt = kBgNames.find(stored.bgTypeId); + const char* bgName = (nameIt != kBgNames.end()) ? nameIt->second : "Unknown Battleground"; + + LOG_INFO("SMSG_BATTLEFIELD_LIST: ", bgName, " bgType=", stored.bgTypeId, + " registered=", stored.isRegistered ? "yes" : "no", + " instances=", stored.instanceIds.size()); +} + void GameHandler::declineBattlefield(uint32_t queueSlot) { if (state != WorldState::IN_WORLD) return; if (!socket) return; @@ -13672,6 +14907,70 @@ void GameHandler::handleArenaTeamQueryResponse(network::Packet& packet) { LOG_INFO("Arena team query response: id=", teamId, " name=", teamName); } +void GameHandler::handleArenaTeamRoster(network::Packet& packet) { + // SMSG_ARENA_TEAM_ROSTER (WotLK 3.3.5a): + // uint32 teamId + // uint8 unk (0 = not captainship packet) + // uint32 memberCount + // For each member: + // uint64 guid + // uint8 online (1=online, 0=offline) + // string name (null-terminated) + // uint32 gamesWeek + // uint32 winsWeek + // uint32 gamesSeason + // uint32 winsSeason + // uint32 personalRating + // float modDay (unused here) + // float modWeek (unused here) + if (packet.getSize() - packet.getReadPos() < 9) return; + + uint32_t teamId = packet.readUInt32(); + /*uint8_t unk =*/ packet.readUInt8(); + uint32_t memberCount = packet.readUInt32(); + + // Sanity cap to avoid huge allocations from malformed packets + if (memberCount > 100) memberCount = 100; + + ArenaTeamRoster roster; + roster.teamId = teamId; + roster.members.reserve(memberCount); + + for (uint32_t i = 0; i < memberCount; ++i) { + if (packet.getSize() - packet.getReadPos() < 12) break; + + ArenaTeamMember m; + m.guid = packet.readUInt64(); + m.online = (packet.readUInt8() != 0); + m.name = packet.readString(); + if (packet.getSize() - packet.getReadPos() < 20) break; + m.weekGames = packet.readUInt32(); + m.weekWins = packet.readUInt32(); + m.seasonGames = packet.readUInt32(); + m.seasonWins = packet.readUInt32(); + m.personalRating = packet.readUInt32(); + // skip 2 floats (modDay, modWeek) + if (packet.getSize() - packet.getReadPos() >= 8) { + packet.readFloat(); + packet.readFloat(); + } + roster.members.push_back(std::move(m)); + } + + // Replace existing roster for this team or append + for (auto& r : arenaTeamRosters_) { + if (r.teamId == teamId) { + r = std::move(roster); + LOG_INFO("SMSG_ARENA_TEAM_ROSTER: updated teamId=", teamId, + " members=", r.members.size()); + return; + } + } + LOG_INFO("SMSG_ARENA_TEAM_ROSTER: new teamId=", teamId, + " members=", roster.members.size()); + arenaTeamRosters_.push_back(std::move(roster)); +} + void GameHandler::handleArenaTeamInvite(network::Packet& packet) { std::string playerName = packet.readString(); std::string teamName = packet.readString(); @@ -13767,12 +15066,19 @@ void GameHandler::handlePvpLogData(network::Packet& packet) { bgScoreboard_.isArena = (packet.readUInt8() != 0); if (bgScoreboard_.isArena) { - // Skip arena-specific header (two teams × (rating change uint32 + name string + 5×uint32)) - // Rather than hardcoding arena parse we skip gracefully up to playerCount - // Each arena team block: uint32 + string + uint32*5 — variable length due to string. - // Skip by scanning for the uint32 playerCount heuristically; simply consume rest. - packet.setReadPos(packet.getSize()); - return; + // WotLK 3.3.5a MSG_PVP_LOG_DATA arena header: + // two team blocks × (uint32 ratingChange + uint32 newRating + uint32 unk1 + uint32 unk2 + uint32 unk3 + CString teamName) + // After both team blocks: same player list and winner fields as battleground. + for (int t = 0; t < 2; ++t) { + if (remaining() < 20) { packet.setReadPos(packet.getSize()); return; } + bgScoreboard_.arenaTeams[t].ratingChange = packet.readUInt32(); + bgScoreboard_.arenaTeams[t].newRating = packet.readUInt32(); + packet.readUInt32(); // unk1 + packet.readUInt32(); // unk2 + packet.readUInt32(); // unk3 + bgScoreboard_.arenaTeams[t].teamName = remaining() > 0 ? packet.readString() : ""; + } + // Fall through to parse player list and winner fields below (same layout as BG) } if (remaining() < 4) return; @@ -13821,8 +15127,51 @@ void GameHandler::handlePvpLogData(network::Packet& packet) { bgScoreboard_.winner = packet.readUInt8(); } - LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", - bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); + if (bgScoreboard_.isArena) { + LOG_INFO("Arena log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner, + " team0='", bgScoreboard_.arenaTeams[0].teamName, + "' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[0].ratingChange, + " team1='", bgScoreboard_.arenaTeams[1].teamName, + "' ratingChange=", (int32_t)bgScoreboard_.arenaTeams[1].ratingChange); + } else { + LOG_INFO("PvP log: ", bgScoreboard_.players.size(), " players, hasWinner=", + bgScoreboard_.hasWinner, " winner=", (int)bgScoreboard_.winner); + } +} + +void GameHandler::handleMoveSetSpeed(network::Packet& packet) { + // MSG_MOVE_SET_*_SPEED: PackedGuid (WotLK) / full uint64 (Classic/TBC) + MovementInfo + float speed. + // The MovementInfo block is variable-length; rather than fully parsing it, we read the + // fixed prefix, skip over optional blocks by consuming remaining bytes until 4 remain, + // then read the speed float. This is safe because the speed is always the last field. + const bool useFull = isClassicLikeExpansion() || isActiveExpansion("tbc"); + uint64_t moverGuid = useFull + ? packet.readUInt64() : UpdateObjectParser::readPackedGuid(packet); + + // Skip to the last 4 bytes — the speed float — by advancing past the MovementInfo. + // This avoids duplicating the full variable-length MovementInfo parser here. + const size_t remaining = packet.getSize() - packet.getReadPos(); + if (remaining < 4) return; + if (remaining > 4) { + // Advance past all MovementInfo bytes (flags, time, position, optional blocks). + // Speed is always the last 4 bytes in the packet. + packet.setReadPos(packet.getSize() - 4); + } + + float speed = packet.readFloat(); + if (!std::isfinite(speed) || speed <= 0.01f || speed > 200.0f) return; + + // Update local player speed state if this broadcast targets us. + if (moverGuid != playerGuid) return; + const uint16_t wireOp = packet.getOpcode(); + if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_SPEED)) serverRunSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_RUN_BACK_SPEED)) serverRunBackSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_WALK_SPEED)) serverWalkSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_SWIM_SPEED)) serverSwimSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_SWIM_BACK_SPEED)) serverSwimBackSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_FLIGHT_SPEED)) serverFlightSpeed_ = speed; + else if (wireOp == wireOpcode(Opcode::MSG_MOVE_SET_FLIGHT_BACK_SPEED))serverFlightBackSpeed_= speed; } void GameHandler::handleOtherPlayerMovement(network::Packet& packet) { @@ -15175,44 +16524,67 @@ void GameHandler::handleUnlearnSpells(network::Packet& packet) { // ============================================================ void GameHandler::handleTalentsInfo(network::Packet& packet) { - TalentsInfoData data; - if (!TalentsInfoParser::parse(packet, data)) return; + // SMSG_TALENTS_INFO (WotLK 3.3.5a) correct wire format: + // uint8 talentType (0 = own talents, 1 = inspect result — own talent packets always 0) + // uint32 unspentTalents + // uint8 talentGroupCount + // uint8 activeTalentGroup + // Per group: uint8 talentCount, [uint32 talentId + uint8 rank] × count, + // uint8 glyphCount, [uint16 glyphId] × count + + if (packet.getSize() - packet.getReadPos() < 1) return; + uint8_t talentType = packet.readUInt8(); + if (talentType != 0) { + // type 1 = inspect result; handled by handleInspectResults — ignore here + return; + } + if (packet.getSize() - packet.getReadPos() < 6) { + LOG_WARNING("handleTalentsInfo: packet too short for header"); + return; + } + + uint32_t unspentTalents = packet.readUInt32(); + uint8_t talentGroupCount = packet.readUInt8(); + uint8_t activeTalentGroup = packet.readUInt8(); + if (activeTalentGroup > 1) activeTalentGroup = 0; // Ensure talent DBCs are loaded loadTalentDbc(); - // Validate spec number - if (data.talentSpec > 1) { - LOG_WARNING("Invalid talent spec: ", (int)data.talentSpec); - return; + activeTalentSpec_ = activeTalentGroup; + + for (uint8_t g = 0; g < talentGroupCount && g < 2; ++g) { + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t talentCount = packet.readUInt8(); + learnedTalents_[g].clear(); + for (uint8_t t = 0; t < talentCount; ++t) { + if (packet.getSize() - packet.getReadPos() < 5) break; + uint32_t talentId = packet.readUInt32(); + uint8_t rank = packet.readUInt8(); + learnedTalents_[g][talentId] = rank + 1u; // wire sends 0-indexed; store 1-indexed + } + learnedGlyphs_[g].fill(0); + if (packet.getSize() - packet.getReadPos() < 1) break; + uint8_t glyphCount = packet.readUInt8(); + for (uint8_t gl = 0; gl < glyphCount; ++gl) { + if (packet.getSize() - packet.getReadPos() < 2) break; + uint16_t glyphId = packet.readUInt16(); + if (gl < MAX_GLYPH_SLOTS) learnedGlyphs_[g][gl] = glyphId; + } } - // Store talents for this spec - unspentTalentPoints_[data.talentSpec] = data.unspentPoints; + unspentTalentPoints_[activeTalentGroup] = + static_cast(unspentTalents > 255 ? 255 : unspentTalents); - // Clear and rebuild learned talents map for this spec - // Note: If a talent appears in the packet, it's learned (ranks are 0-indexed) - learnedTalents_[data.talentSpec].clear(); - for (const auto& talent : data.talents) { - learnedTalents_[data.talentSpec][talent.talentId] = talent.currentRank; - } + LOG_INFO("handleTalentsInfo: unspent=", unspentTalents, + " groups=", (int)talentGroupCount, " active=", (int)activeTalentGroup, + " learned=", learnedTalents_[activeTalentGroup].size()); - LOG_INFO("Talents loaded: spec=", (int)data.talentSpec, - " unspent=", (int)unspentTalentPoints_[data.talentSpec], - " learned=", learnedTalents_[data.talentSpec].size()); - - // If this is the first spec received after login, set it as the active spec if (!talentsInitialized_) { talentsInitialized_ = true; - activeTalentSpec_ = data.talentSpec; - - // Show message to player about active spec - if (unspentTalentPoints_[data.talentSpec] > 0) { - std::string msg = "You have " + std::to_string(unspentTalentPoints_[data.talentSpec]) + - " unspent talent point"; - if (unspentTalentPoints_[data.talentSpec] > 1) msg += "s"; - msg += " in spec " + std::to_string(data.talentSpec + 1); - addSystemChatMessage(msg); + if (unspentTalents > 0) { + addSystemChatMessage("You have " + std::to_string(unspentTalents) + + " unspent talent point" + (unspentTalents != 1 ? "s" : "") + "."); } } } @@ -15652,9 +17024,19 @@ void GameHandler::deleteGmTicket() { if (state != WorldState::IN_WORLD || !socket) return; network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_DELETETICKET)); socket->send(pkt); + gmTicketActive_ = false; + gmTicketText_.clear(); LOG_INFO("Deleting GM ticket"); } +void GameHandler::requestGmTicket() { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_GMTICKET_GETTICKET has no payload — server responds with SMSG_GMTICKET_GETTICKET + network::Packet pkt(wireOpcode(Opcode::CMSG_GMTICKET_GETTICKET)); + socket->send(pkt); + LOG_DEBUG("Sent CMSG_GMTICKET_GETTICKET — querying open ticket status"); +} + void GameHandler::queryGuildInfo(uint32_t guildId) { if (state != WorldState::IN_WORLD || !socket) return; auto packet = GuildQueryPacket::build(guildId); @@ -17425,8 +18807,20 @@ void GameHandler::handleGossipComplete(network::Packet& packet) { } void GameHandler::handleListInventory(network::Packet& packet) { - bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set before openVendor() + bool savedCanRepair = currentVendorItems.canRepair; // preserve armorer flag set via gossip path if (!ListInventoryParser::parse(packet, currentVendorItems)) return; + + // Check NPC_FLAG_REPAIR (0x40) on the vendor entity — this handles vendors that open + // directly without going through the gossip armorer option. + if (!savedCanRepair && currentVendorItems.vendorGuid != 0) { + auto entity = entityManager.getEntity(currentVendorItems.vendorGuid); + if (entity && entity->getType() == ObjectType::UNIT) { + auto unit = std::static_pointer_cast(entity); + if (unit->getNpcFlags() & 0x40) { // NPC_FLAG_REPAIR + savedCanRepair = true; + } + } + } currentVendorItems.canRepair = savedCanRepair; vendorWindowOpen = true; gossipWindowOpen = false; // Close gossip if vendor opens @@ -18012,6 +19406,7 @@ void GameHandler::handleNewWorld(network::Packet& packet) { repopPending_ = false; pendingSpiritHealerGuid_ = 0; resurrectCasterGuid_ = 0; + corpseMapId_ = 0; hostileAttackers_.clear(); stopAutoAttack(); tabCycleStale = true; @@ -20727,6 +22122,31 @@ void GameHandler::loadTitleNameCache() { LOG_INFO("CharTitles: loaded ", titleNameCache_.size(), " title names from DBC"); } +std::string GameHandler::getFormattedTitle(uint32_t bit) const { + const_cast(this)->loadTitleNameCache(); + auto it = titleNameCache_.find(bit); + if (it == titleNameCache_.end() || it->second.empty()) return {}; + + static const std::string kUnknown = "unknown"; + auto nameIt = playerNameCache.find(playerGuid); + const std::string& pName = (nameIt != playerNameCache.end()) ? nameIt->second : kUnknown; + + const std::string& fmt = it->second; + size_t pos = fmt.find("%s"); + if (pos != std::string::npos) { + return fmt.substr(0, pos) + pName + fmt.substr(pos + 2); + } + return fmt; +} + +void GameHandler::sendSetTitle(int32_t bit) { + if (state != WorldState::IN_WORLD || !socket) return; + auto packet = SetTitlePacket::build(bit); + socket->send(packet); + chosenTitleBit_ = bit; + LOG_INFO("sendSetTitle: bit=", bit); +} + void GameHandler::loadAchievementNameCache() { if (achievementNameCacheLoaded_) return; achievementNameCacheLoaded_ = true; @@ -20857,6 +22277,55 @@ void GameHandler::handleAllAchievementData(network::Packet& packet) { " achievements, ", criteriaProgress_.size(), " criteria"); } +// --------------------------------------------------------------------------- +// SMSG_RESPOND_INSPECT_ACHIEVEMENTS (WotLK 3.3.5a) +// Wire format: packed_guid (inspected player) + same achievement/criteria +// blocks as SMSG_ALL_ACHIEVEMENT_DATA: +// Achievement records: repeated { uint32 id, uint32 packedDate } until 0xFFFFFFFF sentinel +// Criteria records: repeated { uint32 id, uint64 counter, uint32 date, uint32 unk } +// until 0xFFFFFFFF sentinel +// We store only the earned achievement IDs (not criteria) per inspected player. +// --------------------------------------------------------------------------- +void GameHandler::handleRespondInspectAchievements(network::Packet& packet) { + loadAchievementNameCache(); + + // Read the inspected player's packed guid + if (packet.getSize() - packet.getReadPos() < 1) return; + uint64_t inspectedGuid = UpdateObjectParser::readPackedGuid(packet); + if (inspectedGuid == 0) { + packet.setReadPos(packet.getSize()); + return; + } + + std::unordered_set achievements; + + // Achievement records: { uint32 id, uint32 packedDate } until sentinel 0xFFFFFFFF + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + if (packet.getSize() - packet.getReadPos() < 4) break; + /*uint32_t date =*/ packet.readUInt32(); + achievements.insert(id); + } + + // Criteria records: { uint32 id, uint64 counter, uint32 date, uint32 unk } + // until sentinel 0xFFFFFFFF — consume but don't store for inspect use + while (packet.getSize() - packet.getReadPos() >= 4) { + uint32_t id = packet.readUInt32(); + if (id == 0xFFFFFFFF) break; + // counter(8) + date(4) + unk(4) = 16 bytes + if (packet.getSize() - packet.getReadPos() < 16) break; + packet.readUInt64(); // counter + packet.readUInt32(); // date + packet.readUInt32(); // unk + } + + inspectedPlayerAchievements_[inspectedGuid] = std::move(achievements); + + LOG_INFO("SMSG_RESPOND_INSPECT_ACHIEVEMENTS: guid=0x", std::hex, inspectedGuid, std::dec, + " achievements=", inspectedPlayerAchievements_[inspectedGuid].size()); +} + // --------------------------------------------------------------------------- // Faction name cache (lazily loaded from Faction.dbc) // --------------------------------------------------------------------------- @@ -20873,32 +22342,60 @@ void GameHandler::loadFactionNameCache() { // Faction.dbc WotLK 3.3.5a field layout: // 0: ID - // 1-4: ReputationRaceMask[4] - // 5-8: ReputationClassMask[4] - // 9-12: ReputationBase[4] - // 13-16: ReputationFlags[4] - // 17: ParentFactionID - // 18-19: Spillover rates (floats) - // 20-21: MaxRank - // 22: Name (English locale, string ref) - constexpr uint32_t ID_FIELD = 0; - constexpr uint32_t NAME_FIELD = 22; // enUS name string + // 1: ReputationListID (-1 / 0xFFFFFFFF = no reputation tracking) + // 2-5: ReputationRaceMask[4] + // 6-9: ReputationClassMask[4] + // 10-13: ReputationBase[4] + // 14-17: ReputationFlags[4] + // 18: ParentFactionID + // 19-20: SpilloverRateIn, SpilloverRateOut (floats) + // 21-22: SpilloverMaxRankIn, SpilloverMaxRankOut + // 23: Name (English locale, string ref) + constexpr uint32_t ID_FIELD = 0; + constexpr uint32_t REPLIST_FIELD = 1; + constexpr uint32_t NAME_FIELD = 23; // enUS name string + // Classic/TBC have fewer fields; fall back gracefully + const bool hasRepListField = dbc->getFieldCount() > REPLIST_FIELD; if (dbc->getFieldCount() <= NAME_FIELD) { LOG_WARNING("Faction.dbc: unexpected field count ", dbc->getFieldCount()); - return; + // Don't abort — still try to load names from a shorter layout } + const uint32_t nameField = (dbc->getFieldCount() > NAME_FIELD) ? NAME_FIELD : 22u; uint32_t count = dbc->getRecordCount(); for (uint32_t i = 0; i < count; ++i) { uint32_t factionId = dbc->getUInt32(i, ID_FIELD); if (factionId == 0) continue; - std::string name = dbc->getString(i, NAME_FIELD); - if (!name.empty()) { - factionNameCache_[factionId] = std::move(name); + if (dbc->getFieldCount() > nameField) { + std::string name = dbc->getString(i, nameField); + if (!name.empty()) { + factionNameCache_[factionId] = std::move(name); + } + } + // Build repListId ↔ factionId mapping (WotLK field 1) + if (hasRepListField) { + uint32_t repListId = dbc->getUInt32(i, REPLIST_FIELD); + if (repListId != 0xFFFFFFFFu) { + factionRepListToId_[repListId] = factionId; + factionIdToRepList_[factionId] = repListId; + } } } - LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names"); + LOG_INFO("Faction.dbc: loaded ", factionNameCache_.size(), " faction names, ", + factionRepListToId_.size(), " with reputation tracking"); +} + +uint32_t GameHandler::getFactionIdByRepListId(uint32_t repListId) const { + const_cast(this)->loadFactionNameCache(); + auto it = factionRepListToId_.find(repListId); + return (it != factionRepListToId_.end()) ? it->second : 0u; +} + +uint32_t GameHandler::getRepListIdByFactionId(uint32_t factionId) const { + const_cast(this)->loadFactionNameCache(); + auto it = factionIdToRepList_.find(factionId); + return (it != factionIdToRepList_.end()) ? it->second : 0xFFFFFFFFu; } std::string GameHandler::getFactionName(uint32_t factionId) const { @@ -21031,5 +22528,40 @@ void GameHandler::handleSetForcedReactions(network::Packet& packet) { LOG_INFO("SMSG_SET_FORCED_REACTIONS: ", forcedReactions_.size(), " faction overrides"); } +// ---- Battlefield Manager (WotLK Wintergrasp / outdoor battlefields) ---- + +void GameHandler::acceptBfMgrInvite() { + if (!bfMgrInvitePending_ || state != WorldState::IN_WORLD || !socket) return; + // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 1 + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); + pkt.writeUInt8(1); // accepted + socket->send(pkt); + bfMgrInvitePending_ = false; + LOG_INFO("acceptBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=1"); +} + +void GameHandler::declineBfMgrInvite() { + if (!bfMgrInvitePending_ || state != WorldState::IN_WORLD || !socket) return; + // CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE: uint8 accepted = 0 + network::Packet pkt(wireOpcode(Opcode::CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE)); + pkt.writeUInt8(0); // declined + socket->send(pkt); + bfMgrInvitePending_ = false; + LOG_INFO("declineBfMgrInvite: sent CMSG_BATTLEFIELD_MGR_ENTRY_INVITE_RESPONSE accepted=0"); +} + +// ---- WotLK Calendar ---- + +void GameHandler::requestCalendar() { + if (state != WorldState::IN_WORLD || !socket) return; + // CMSG_CALENDAR_GET_CALENDAR has no payload + network::Packet pkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_CALENDAR)); + socket->send(pkt); + LOG_INFO("requestCalendar: sent CMSG_CALENDAR_GET_CALENDAR"); + // Also request pending invite count + network::Packet numPkt(wireOpcode(Opcode::CMSG_CALENDAR_GET_NUM_PENDING)); + socket->send(numPkt); +} + } // namespace game } // namespace wowee diff --git a/src/game/world_packets.cpp b/src/game/world_packets.cpp index 98ddd9d3..14dc7a20 100644 --- a/src/game/world_packets.cpp +++ b/src/game/world_packets.cpp @@ -1031,6 +1031,7 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock // Legacy UPDATE_OBJECT spline layout used by many servers: // timePassed, duration, splineId, durationMod, durationModNext, + // [ANIMATION: animType(1)+animTime(4) if SPLINEFLAG_ANIMATION(0x00400000)], // verticalAccel, effectStartTime, pointCount, points, splineMode, endPoint. const size_t legacyStart = packet.getReadPos(); if (!bytesAvailable(12 + 8 + 8 + 4)) return false; @@ -1039,6 +1040,12 @@ bool UpdateObjectParser::parseMovementBlock(network::Packet& packet, UpdateBlock /*uint32_t splineId =*/ packet.readUInt32(); /*float durationMod =*/ packet.readFloat(); /*float durationModNext =*/ packet.readFloat(); + // Animation flag inserts 5 bytes (uint8 type + int32 time) before verticalAccel + if (splineFlags & 0x00400000) { // SPLINEFLAG_ANIMATION + if (!bytesAvailable(5)) return false; + packet.readUInt8(); // animationType + packet.readUInt32(); // animTime + } /*float verticalAccel =*/ packet.readFloat(); /*uint32_t effectStartTime =*/ packet.readUInt32(); uint32_t pointCount = packet.readUInt32(); @@ -1722,6 +1729,15 @@ network::Packet InspectPacket::build(uint64_t targetGuid) { return packet; } +network::Packet QueryInspectAchievementsPacket::build(uint64_t targetGuid) { + // CMSG_QUERY_INSPECT_ACHIEVEMENTS: uint64 targetGuid + uint8 unk (always 0) + network::Packet packet(wireOpcode(Opcode::CMSG_QUERY_INSPECT_ACHIEVEMENTS)); + packet.writeUInt64(targetGuid); + packet.writeUInt8(0); // unk / achievementSlot — always 0 for WotLK + LOG_DEBUG("Built CMSG_QUERY_INSPECT_ACHIEVEMENTS: target=0x", std::hex, targetGuid, std::dec); + return packet; +} + // ============================================================ // Server Info Commands // ============================================================ @@ -3225,17 +3241,25 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda data.totalDamage = static_cast(packet.readUInt32()); data.subDamageCount = packet.readUInt8(); - // Cap subDamageCount to prevent OOM (each entry is 20 bytes: 4+4+4+4+4) - if (data.subDamageCount > 64) { - LOG_WARNING("AttackerStateUpdate: subDamageCount capped (requested=", (int)data.subDamageCount, ")"); - data.subDamageCount = 64; + // Cap subDamageCount: each entry is 20 bytes. If the claimed count + // exceeds what the remaining bytes can hold, a GUID was mis-parsed + // (off by one byte), causing the school-mask byte to be read as count. + // In that case silently clamp to the number of full entries that fit. + { + size_t remaining = packet.getSize() - packet.getReadPos(); + size_t maxFit = remaining / 20; + if (data.subDamageCount > maxFit) { + data.subDamageCount = static_cast(maxFit > 0 ? 1 : 0); + } else if (data.subDamageCount > 64) { + data.subDamageCount = 64; + } } + if (data.subDamageCount == 0) return false; data.subDamages.reserve(data.subDamageCount); for (uint8_t i = 0; i < data.subDamageCount; ++i) { // Each sub-damage entry needs 20 bytes: schoolMask(4) + damage(4) + intDamage(4) + absorbed(4) + resisted(4) if (packet.getSize() - packet.getReadPos() < 20) { - LOG_WARNING("AttackerStateUpdate: truncated subDamage at index ", (int)i, "/", (int)data.subDamageCount); data.subDamageCount = i; break; } @@ -3250,21 +3274,25 @@ bool AttackerStateUpdateParser::parse(network::Packet& packet, AttackerStateUpda // Validate victimState + overkill fields (8 bytes) if (packet.getSize() - packet.getReadPos() < 8) { - LOG_WARNING("AttackerStateUpdate: truncated victimState/overkill"); data.victimState = 0; data.overkill = 0; return !data.subDamages.empty(); } data.victimState = packet.readUInt32(); - data.overkill = static_cast(packet.readUInt32()); + // WotLK (AzerothCore): two unknown uint32 fields follow victimState before overkill. + // Older parsers omitted these, reading overkill from the wrong offset. + auto rem = [&]() { return packet.getSize() - packet.getReadPos(); }; + if (rem() >= 4) packet.readUInt32(); // unk1 (always 0) + if (rem() >= 4) packet.readUInt32(); // unk2 (melee spell ID, 0 for auto-attack) + data.overkill = (rem() >= 4) ? static_cast(packet.readUInt32()) : -1; - // Read blocked amount (optional, 4 bytes) - if (packet.getSize() - packet.getReadPos() >= 4) { - data.blocked = packet.readUInt32(); - } else { - data.blocked = 0; - } + // hitInfo-conditional fields: HITINFO_BLOCK(0x2000), RAGE_GAIN(0x20000), FAKE_DAMAGE(0x40) + if ((data.hitInfo & 0x2000) && rem() >= 4) data.blocked = packet.readUInt32(); + else data.blocked = 0; + // RAGE_GAIN and FAKE_DAMAGE both add a uint32 we can skip + if ((data.hitInfo & 0x20000) && rem() >= 4) packet.readUInt32(); // rage gain + if ((data.hitInfo & 0x40) && rem() >= 4) packet.readUInt32(); // fake damage total LOG_DEBUG("Melee hit: ", data.totalDamage, " damage", data.isCrit() ? " (CRIT)" : "", @@ -3962,13 +3990,27 @@ network::Packet LootReleasePacket::build(uint64_t lootGuid) { bool LootResponseParser::parse(network::Packet& packet, LootResponseData& data, bool isWotlkFormat) { data = LootResponseData{}; - if (packet.getSize() - packet.getReadPos() < 14) { - LOG_WARNING("LootResponseParser: packet too short"); + size_t avail = packet.getSize() - packet.getReadPos(); + + // Minimum is guid(8)+lootType(1) = 9 bytes. Servers send a short packet with + // lootType=0 (LOOT_NONE) when loot is unavailable (e.g. chest not yet opened, + // needs a key, or another player is looting). We treat this as an empty-loot + // signal and return false so the caller knows not to open the loot window. + if (avail < 9) { + LOG_WARNING("LootResponseParser: packet too short (", avail, " bytes)"); return false; } data.lootGuid = packet.readUInt64(); data.lootType = packet.readUInt8(); + + // Short failure packet — no gold/item data follows. + avail = packet.getSize() - packet.getReadPos(); + if (avail < 5) { + LOG_DEBUG("LootResponseParser: lootType=", (int)data.lootType, " (empty/failure response)"); + return false; + } + data.gold = packet.readUInt32(); uint8_t itemCount = packet.readUInt8(); @@ -5429,5 +5471,12 @@ network::Packet PetRenamePacket::build(uint64_t petGuid, const std::string& name return p; } +network::Packet SetTitlePacket::build(int32_t titleBit) { + // CMSG_SET_TITLE: int32 titleBit (-1 = remove active title) + network::Packet p(wireOpcode(Opcode::CMSG_SET_TITLE)); + p.writeUInt32(static_cast(titleBit)); + return p; +} + } // namespace game } // namespace wowee diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index b3d057d6..b1f82973 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -1258,6 +1258,125 @@ M2Model M2Loader::load(const std::vector& m2Data) { } // end size check } + // Parse ribbon emitters (WotLK only; vanilla format TBD). + // WotLK M2RibbonEmitter = 0xAC (172) bytes per entry. + static constexpr uint32_t RIBBON_SIZE_WOTLK = 0xAC; + if (header.nRibbonEmitters > 0 && header.ofsRibbonEmitters > 0 && + header.nRibbonEmitters < 64 && header.version >= 264) { + + if (static_cast(header.ofsRibbonEmitters) + + static_cast(header.nRibbonEmitters) * RIBBON_SIZE_WOTLK <= m2Data.size()) { + + // Build sequence flags for parseAnimTrack + std::vector ribSeqFlags; + ribSeqFlags.reserve(model.sequences.size()); + for (const auto& seq : model.sequences) { + ribSeqFlags.push_back(seq.flags); + } + + for (uint32_t ri = 0; ri < header.nRibbonEmitters; ri++) { + uint32_t base = header.ofsRibbonEmitters + ri * RIBBON_SIZE_WOTLK; + + M2RibbonEmitter rib; + rib.ribbonId = readValue(m2Data, base + 0x00); + rib.bone = readValue(m2Data, base + 0x04); + rib.position.x = readValue(m2Data, base + 0x08); + rib.position.y = readValue(m2Data, base + 0x0C); + rib.position.z = readValue(m2Data, base + 0x10); + + // textureIndices M2Array (0x14): count + offset → first element = texture lookup index + { + uint32_t nTex = readValue(m2Data, base + 0x14); + uint32_t ofsTex = readValue(m2Data, base + 0x18); + if (nTex > 0 && ofsTex + sizeof(uint16_t) <= m2Data.size()) { + rib.textureIndex = readValue(m2Data, ofsTex); + } + } + + // materialIndices M2Array (0x1C): count + offset → first element = material index + { + uint32_t nMat = readValue(m2Data, base + 0x1C); + uint32_t ofsMat = readValue(m2Data, base + 0x20); + if (nMat > 0 && ofsMat + sizeof(uint16_t) <= m2Data.size()) { + rib.materialIndex = readValue(m2Data, ofsMat); + } + } + + // colorTrack M2TrackDisk at 0x24 (vec3 RGB 0..1) + if (base + 0x24 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x24); + parseAnimTrack(m2Data, disk, rib.colorTrack, TrackType::VEC3, ribSeqFlags); + } + + // alphaTrack M2TrackDisk at 0x38 (fixed16: int16/32767) + // Same nested-array layout as parseAnimTrack but keys are int16. + if (base + 0x38 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x38); + auto& track = rib.alphaTrack; + track.interpolationType = disk.interpolationType; + track.globalSequence = disk.globalSequence; + uint32_t nSeqs = disk.nTimestamps; + if (nSeqs > 0 && nSeqs <= 4096) { + track.sequences.resize(nSeqs); + for (uint32_t s = 0; s < nSeqs; s++) { + if (s < ribSeqFlags.size() && !(ribSeqFlags[s] & 0x20)) continue; + uint32_t tsHdr = disk.ofsTimestamps + s * 8; + uint32_t keyHdr = disk.ofsKeys + s * 8; + if (tsHdr + 8 > m2Data.size() || keyHdr + 8 > m2Data.size()) continue; + uint32_t tsCount = readValue(m2Data, tsHdr); + uint32_t tsOfs = readValue(m2Data, tsHdr + 4); + uint32_t kCount = readValue(m2Data, keyHdr); + uint32_t kOfs = readValue(m2Data, keyHdr + 4); + if (tsCount == 0 || kCount == 0) continue; + if (tsOfs + tsCount * 4 > m2Data.size()) continue; + if (kOfs + kCount * sizeof(int16_t) > m2Data.size()) continue; + track.sequences[s].timestamps = readArray(m2Data, tsOfs, tsCount); + auto raw = readArray(m2Data, kOfs, kCount); + track.sequences[s].floatValues.reserve(raw.size()); + for (auto v : raw) { + track.sequences[s].floatValues.push_back( + static_cast(v) / 32767.0f); + } + } + } + } + + // heightAboveTrack M2TrackDisk at 0x4C (float) + if (base + 0x4C + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x4C); + parseAnimTrack(m2Data, disk, rib.heightAboveTrack, TrackType::FLOAT, ribSeqFlags); + } + + // heightBelowTrack M2TrackDisk at 0x60 (float) + if (base + 0x60 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x60); + parseAnimTrack(m2Data, disk, rib.heightBelowTrack, TrackType::FLOAT, ribSeqFlags); + } + + rib.edgesPerSecond = readValue(m2Data, base + 0x74); + rib.edgeLifetime = readValue(m2Data, base + 0x78); + rib.gravity = readValue(m2Data, base + 0x7C); + rib.textureRows = readValue(m2Data, base + 0x80); + rib.textureCols = readValue(m2Data, base + 0x82); + if (rib.textureRows == 0) rib.textureRows = 1; + if (rib.textureCols == 0) rib.textureCols = 1; + + // Clamp to sane values + if (rib.edgesPerSecond < 1.0f || rib.edgesPerSecond > 200.0f) rib.edgesPerSecond = 15.0f; + if (rib.edgeLifetime < 0.05f || rib.edgeLifetime > 10.0f) rib.edgeLifetime = 0.5f; + + // visibilityTrack M2TrackDisk at 0x98 (uint8, treat as float 0/1) + if (base + 0x98 + sizeof(M2TrackDisk) <= m2Data.size()) { + M2TrackDisk disk = readValue(m2Data, base + 0x98); + parseAnimTrack(m2Data, disk, rib.visibilityTrack, TrackType::FLOAT, ribSeqFlags); + } + + model.ribbonEmitters.push_back(std::move(rib)); + } + core::Logger::getInstance().debug(" Ribbon emitters: ", model.ribbonEmitters.size()); + } + } + // Read collision mesh (bounding triangles/vertices/normals) if (header.nBoundingVertices > 0 && header.ofsBoundingVertices > 0) { struct Vec3Disk { float x, y, z; }; diff --git a/src/rendering/camera_controller.cpp b/src/rendering/camera_controller.cpp index a34f05f1..22c5304f 100644 --- a/src/rendering/camera_controller.cpp +++ b/src/rendering/camera_controller.cpp @@ -1903,7 +1903,9 @@ void CameraController::processMouseMotion(const SDL_MouseMotionEvent& event) { // Directly update stored yaw/pitch (no lossy forward-vector derivation) yaw -= event.xrel * mouseSensitivity; - float invert = invertMouse ? -1.0f : 1.0f; + // SDL yrel > 0 = mouse moved DOWN. In WoW, mouse-down = look down = pitch decreases. + // invertMouse flips to flight-sim style (mouse-down = look up). + float invert = invertMouse ? 1.0f : -1.0f; pitch += event.yrel * mouseSensitivity * invert; // WoW-style pitch limits: can look almost straight down, limited upward diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 96659828..d7ae0b2a 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -540,6 +540,54 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout .build(device); } + // --- Build ribbon pipelines --- + // Vertex format: pos(3) + color(3) + alpha(1) + uv(2) = 9 floats = 36 bytes + { + rendering::VkShaderModule ribVert, ribFrag; + ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + // Reuse particleTexLayout_ for set 1 (single texture sampler) + VkDescriptorSetLayout ribLayouts[] = {perFrameLayout, particleTexLayout_}; + VkPipelineLayoutCreateInfo lci{VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO}; + lci.setLayoutCount = 2; + lci.pSetLayouts = ribLayouts; + vkCreatePipelineLayout(device, &lci, nullptr, &ribbonPipelineLayout_); + + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, // pos + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, // color + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, // alpha + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, // uv + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + // Clean up shader modules m2Vert.destroy(); m2Frag.destroy(); particleVert.destroy(); particleFrag.destroy(); @@ -570,6 +618,11 @@ bool M2Renderer::initialize(VkContext* ctx, VkDescriptorSetLayout perFrameLayout bci.size = MAX_GLOW_SPRITES * 9 * sizeof(float); vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &glowVB_, &glowVBAlloc_, &allocInfo); glowVBMapped_ = allocInfo.pMappedData; + + // Ribbon vertex buffer — triangle strip: pos(3)+color(3)+alpha(1)+uv(2)=9 floats/vert + bci.size = MAX_RIBBON_VERTS * 9 * sizeof(float); + vmaCreateBuffer(vkCtx_->getAllocator(), &bci, &aci, &ribbonVB_, &ribbonVBAlloc_, &allocInfo); + ribbonVBMapped_ = allocInfo.pMappedData; } // --- Create white fallback texture --- @@ -666,10 +719,11 @@ void M2Renderer::shutdown() { whiteTexture_.reset(); glowTexture_.reset(); - // Clean up particle buffers + // Clean up particle/ribbon buffers if (smokeVB_) { vmaDestroyBuffer(alloc, smokeVB_, smokeVBAlloc_); smokeVB_ = VK_NULL_HANDLE; } if (m2ParticleVB_) { vmaDestroyBuffer(alloc, m2ParticleVB_, m2ParticleVBAlloc_); m2ParticleVB_ = VK_NULL_HANDLE; } if (glowVB_) { vmaDestroyBuffer(alloc, glowVB_, glowVBAlloc_); glowVB_ = VK_NULL_HANDLE; } + if (ribbonVB_) { vmaDestroyBuffer(alloc, ribbonVB_, ribbonVBAlloc_); ribbonVB_ = VK_NULL_HANDLE; } smokeParticles.clear(); // Destroy pipelines @@ -681,10 +735,13 @@ void M2Renderer::shutdown() { destroyPipeline(particlePipeline_); destroyPipeline(particleAdditivePipeline_); destroyPipeline(smokePipeline_); + destroyPipeline(ribbonPipeline_); + destroyPipeline(ribbonAdditivePipeline_); if (pipelineLayout_) { vkDestroyPipelineLayout(device, pipelineLayout_, nullptr); pipelineLayout_ = VK_NULL_HANDLE; } if (particlePipelineLayout_) { vkDestroyPipelineLayout(device, particlePipelineLayout_, nullptr); particlePipelineLayout_ = VK_NULL_HANDLE; } if (smokePipelineLayout_) { vkDestroyPipelineLayout(device, smokePipelineLayout_, nullptr); smokePipelineLayout_ = VK_NULL_HANDLE; } + if (ribbonPipelineLayout_) { vkDestroyPipelineLayout(device, ribbonPipelineLayout_, nullptr); ribbonPipelineLayout_ = VK_NULL_HANDLE; } // Destroy descriptor pools and layouts if (materialDescPool_) { vkDestroyDescriptorPool(device, materialDescPool_, nullptr); materialDescPool_ = VK_NULL_HANDLE; } @@ -719,6 +776,11 @@ void M2Renderer::destroyModelGPU(M2ModelGPU& model) { if (pSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &pSet); pSet = VK_NULL_HANDLE; } } model.particleTexSets.clear(); + // Free ribbon texture descriptor sets + for (auto& rSet : model.ribbonTexSets) { + if (rSet) { vkFreeDescriptorSets(device, materialDescPool_, 1, &rSet); rSet = VK_NULL_HANDLE; } + } + model.ribbonTexSets.clear(); } void M2Renderer::destroyInstanceBones(M2Instance& inst) { @@ -882,8 +944,9 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { bool hasGeometry = !model.vertices.empty() && !model.indices.empty(); bool hasParticles = !model.particleEmitters.empty(); - if (!hasGeometry && !hasParticles) { - LOG_WARNING("M2 model has no geometry and no particles: ", model.name); + bool hasRibbons = !model.ribbonEmitters.empty(); + if (!hasGeometry && !hasParticles && !hasRibbons) { + LOG_WARNING("M2 model has no renderable content: ", model.name); return false; } @@ -1345,6 +1408,43 @@ bool M2Renderer::loadModel(const pipeline::M2Model& model, uint32_t modelId) { } } + // Copy ribbon emitter data and resolve textures + gpuModel.ribbonEmitters = model.ribbonEmitters; + if (!model.ribbonEmitters.empty()) { + VkDevice device = vkCtx_->getDevice(); + gpuModel.ribbonTextures.resize(model.ribbonEmitters.size(), whiteTexture_.get()); + gpuModel.ribbonTexSets.resize(model.ribbonEmitters.size(), VK_NULL_HANDLE); + for (size_t ri = 0; ri < model.ribbonEmitters.size(); ri++) { + // Resolve texture via textureLookup table + uint16_t texLookupIdx = model.ribbonEmitters[ri].textureIndex; + uint32_t texIdx = (texLookupIdx < model.textureLookup.size()) + ? model.textureLookup[texLookupIdx] : UINT32_MAX; + if (texIdx < allTextures.size() && allTextures[texIdx] != nullptr) { + gpuModel.ribbonTextures[ri] = allTextures[texIdx]; + } + // Allocate descriptor set (reuse particleTexLayout_ = single sampler) + if (particleTexLayout_ && materialDescPool_) { + VkDescriptorSetAllocateInfo ai{VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO}; + ai.descriptorPool = materialDescPool_; + ai.descriptorSetCount = 1; + ai.pSetLayouts = &particleTexLayout_; + if (vkAllocateDescriptorSets(device, &ai, &gpuModel.ribbonTexSets[ri]) == VK_SUCCESS) { + VkTexture* tex = gpuModel.ribbonTextures[ri]; + VkDescriptorImageInfo imgInfo = tex->descriptorInfo(); + VkWriteDescriptorSet write{VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET}; + write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + write.dstSet = gpuModel.ribbonTexSets[ri]; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &imgInfo; + vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); + } + } + } + LOG_DEBUG(" Ribbon emitters loaded: ", model.ribbonEmitters.size()); + } + // Copy texture transform data for UV animation gpuModel.textureTransforms = model.textureTransforms; gpuModel.textureTransformLookup = model.textureTransformLookup; @@ -2241,6 +2341,9 @@ void M2Renderer::update(float deltaTime, const glm::vec3& cameraPos, const glm:: if (!instance.cachedModel) continue; emitParticles(instance, *instance.cachedModel, deltaTime); updateParticles(instance, deltaTime); + if (!instance.cachedModel->ribbonEmitters.empty()) { + updateRibbons(instance, *instance.cachedModel, deltaTime); + } } } @@ -3375,6 +3478,214 @@ void M2Renderer::updateParticles(M2Instance& inst, float dt) { } } +// --------------------------------------------------------------------------- +// Ribbon emitter simulation +// --------------------------------------------------------------------------- +void M2Renderer::updateRibbons(M2Instance& inst, const M2ModelGPU& gpu, float dt) { + const auto& emitters = gpu.ribbonEmitters; + if (emitters.empty()) return; + + // Grow per-instance state arrays if needed + if (inst.ribbonEdges.size() != emitters.size()) { + inst.ribbonEdges.resize(emitters.size()); + } + if (inst.ribbonEdgeAccumulators.size() != emitters.size()) { + inst.ribbonEdgeAccumulators.resize(emitters.size(), 0.0f); + } + + for (size_t ri = 0; ri < emitters.size(); ri++) { + const auto& em = emitters[ri]; + auto& edges = inst.ribbonEdges[ri]; + auto& accum = inst.ribbonEdgeAccumulators[ri]; + + // Determine bone world position for spine + glm::vec3 spineWorld = inst.position; + if (em.bone < inst.boneMatrices.size()) { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * inst.boneMatrices[em.bone] * local); + } else { + glm::vec4 local(em.position.x, em.position.y, em.position.z, 1.0f); + spineWorld = glm::vec3(inst.modelMatrix * local); + } + + // Evaluate animated tracks (use first available sequence key, or fallback value) + auto getFloatVal = [&](const pipeline::M2AnimationTrack& track, float fallback) -> float { + for (const auto& seq : track.sequences) { + if (!seq.floatValues.empty()) return seq.floatValues[0]; + } + return fallback; + }; + auto getVec3Val = [&](const pipeline::M2AnimationTrack& track, glm::vec3 fallback) -> glm::vec3 { + for (const auto& seq : track.sequences) { + if (!seq.vec3Values.empty()) return seq.vec3Values[0]; + } + return fallback; + }; + + float visibility = getFloatVal(em.visibilityTrack, 1.0f); + float heightAbove = getFloatVal(em.heightAboveTrack, 0.5f); + float heightBelow = getFloatVal(em.heightBelowTrack, 0.5f); + glm::vec3 color = getVec3Val(em.colorTrack, glm::vec3(1.0f)); + float alpha = getFloatVal(em.alphaTrack, 1.0f); + + // Age existing edges and remove expired ones + for (auto& e : edges) { + e.age += dt; + // Apply gravity + if (em.gravity != 0.0f) { + e.worldPos.z -= em.gravity * dt * dt * 0.5f; + } + } + while (!edges.empty() && edges.front().age >= em.edgeLifetime) { + edges.pop_front(); + } + + // Emit new edges based on edgesPerSecond + if (visibility > 0.5f) { + accum += em.edgesPerSecond * dt; + while (accum >= 1.0f) { + accum -= 1.0f; + M2Instance::RibbonEdge e; + e.worldPos = spineWorld; + e.color = color; + e.alpha = alpha; + e.heightAbove = heightAbove; + e.heightBelow = heightBelow; + e.age = 0.0f; + edges.push_back(e); + // Cap trail length + if (edges.size() > 128) edges.pop_front(); + } + } else { + accum = 0.0f; + } + } +} + +// --------------------------------------------------------------------------- +// Ribbon rendering +// --------------------------------------------------------------------------- +void M2Renderer::renderM2Ribbons(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { + if (!ribbonPipeline_ || !ribbonAdditivePipeline_ || !ribbonVB_ || !ribbonVBMapped_) return; + + // Build camera right vector for billboard orientation + // For ribbons we orient the quad strip along the spine with screen-space up. + // Simple approach: use world-space Z=up for the ribbon cross direction. + const glm::vec3 upWorld(0.0f, 0.0f, 1.0f); + + float* dst = static_cast(ribbonVBMapped_); + size_t written = 0; + + struct DrawCall { + VkDescriptorSet texSet; + VkPipeline pipeline; + uint32_t firstVertex; + uint32_t vertexCount; + }; + std::vector draws; + + for (const auto& inst : instances) { + if (!inst.cachedModel) continue; + const auto& gpu = *inst.cachedModel; + if (gpu.ribbonEmitters.empty()) continue; + + for (size_t ri = 0; ri < gpu.ribbonEmitters.size(); ri++) { + if (ri >= inst.ribbonEdges.size()) continue; + const auto& edges = inst.ribbonEdges[ri]; + if (edges.size() < 2) continue; + + const auto& em = gpu.ribbonEmitters[ri]; + + // Select blend pipeline based on material blend mode + bool additive = false; + if (em.materialIndex < gpu.batches.size()) { + additive = (gpu.batches[em.materialIndex].blendMode >= 3); + } + VkPipeline pipe = additive ? ribbonAdditivePipeline_ : ribbonPipeline_; + + // Descriptor set for texture + VkDescriptorSet texSet = (ri < gpu.ribbonTexSets.size()) + ? gpu.ribbonTexSets[ri] : VK_NULL_HANDLE; + if (!texSet) continue; + + uint32_t firstVert = static_cast(written); + + // Emit triangle strip: 2 verts per edge (top + bottom) + for (size_t ei = 0; ei < edges.size(); ei++) { + if (written + 2 > MAX_RIBBON_VERTS) break; + const auto& e = edges[ei]; + float t = (em.edgeLifetime > 0.0f) + ? 1.0f - (e.age / em.edgeLifetime) : 1.0f; + float a = e.alpha * t; + float u = static_cast(ei) / static_cast(edges.size() - 1); + + // Top vertex (above spine along upWorld) + glm::vec3 top = e.worldPos + upWorld * e.heightAbove; + dst[written * 9 + 0] = top.x; + dst[written * 9 + 1] = top.y; + dst[written * 9 + 2] = top.z; + dst[written * 9 + 3] = e.color.r; + dst[written * 9 + 4] = e.color.g; + dst[written * 9 + 5] = e.color.b; + dst[written * 9 + 6] = a; + dst[written * 9 + 7] = u; + dst[written * 9 + 8] = 0.0f; // v = top + written++; + + // Bottom vertex (below spine) + glm::vec3 bot = e.worldPos - upWorld * e.heightBelow; + dst[written * 9 + 0] = bot.x; + dst[written * 9 + 1] = bot.y; + dst[written * 9 + 2] = bot.z; + dst[written * 9 + 3] = e.color.r; + dst[written * 9 + 4] = e.color.g; + dst[written * 9 + 5] = e.color.b; + dst[written * 9 + 6] = a; + dst[written * 9 + 7] = u; + dst[written * 9 + 8] = 1.0f; // v = bottom + written++; + } + + uint32_t vertCount = static_cast(written) - firstVert; + if (vertCount >= 4) { + draws.push_back({texSet, pipe, firstVert, vertCount}); + } else { + // Rollback if too few verts + written = firstVert; + } + } + } + + if (draws.empty() || written == 0) return; + + VkExtent2D ext = vkCtx_->getSwapchainExtent(); + VkViewport vp{}; + vp.x = 0; vp.y = 0; + vp.width = static_cast(ext.width); + vp.height = static_cast(ext.height); + vp.minDepth = 0.0f; vp.maxDepth = 1.0f; + VkRect2D sc{}; + sc.offset = {0, 0}; + sc.extent = ext; + vkCmdSetViewport(cmd, 0, 1, &vp); + vkCmdSetScissor(cmd, 0, 1, &sc); + + VkPipeline lastPipe = VK_NULL_HANDLE; + for (const auto& dc : draws) { + if (dc.pipeline != lastPipe) { + vkCmdBindPipeline(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, dc.pipeline); + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + ribbonPipelineLayout_, 0, 1, &perFrameSet, 0, nullptr); + lastPipe = dc.pipeline; + } + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + ribbonPipelineLayout_, 1, 1, &dc.texSet, 0, nullptr); + VkDeviceSize offset = 0; + vkCmdBindVertexBuffers(cmd, 0, 1, &ribbonVB_, &offset); + vkCmdDraw(cmd, dc.vertexCount, 1, dc.firstVertex, 0); + } +} + void M2Renderer::renderM2Particles(VkCommandBuffer cmd, VkDescriptorSet perFrameSet) { if (!particlePipeline_ || !m2ParticleVB_) return; @@ -4505,6 +4816,8 @@ void M2Renderer::recreatePipelines() { if (particlePipeline_) { vkDestroyPipeline(device, particlePipeline_, nullptr); particlePipeline_ = VK_NULL_HANDLE; } if (particleAdditivePipeline_) { vkDestroyPipeline(device, particleAdditivePipeline_, nullptr); particleAdditivePipeline_ = VK_NULL_HANDLE; } if (smokePipeline_) { vkDestroyPipeline(device, smokePipeline_, nullptr); smokePipeline_ = VK_NULL_HANDLE; } + if (ribbonPipeline_) { vkDestroyPipeline(device, ribbonPipeline_, nullptr); ribbonPipeline_ = VK_NULL_HANDLE; } + if (ribbonAdditivePipeline_) { vkDestroyPipeline(device, ribbonAdditivePipeline_, nullptr); ribbonAdditivePipeline_ = VK_NULL_HANDLE; } // --- Load shaders --- rendering::VkShaderModule m2Vert, m2Frag; @@ -4624,6 +4937,46 @@ void M2Renderer::recreatePipelines() { .build(device); } + // --- Ribbon pipelines --- + { + rendering::VkShaderModule ribVert, ribFrag; + ribVert.loadFromFile(device, "assets/shaders/m2_ribbon.vert.spv"); + ribFrag.loadFromFile(device, "assets/shaders/m2_ribbon.frag.spv"); + if (ribVert.isValid() && ribFrag.isValid()) { + VkVertexInputBindingDescription rBind{}; + rBind.binding = 0; + rBind.stride = 9 * sizeof(float); + rBind.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + + std::vector rAttrs = { + {0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0}, + {1, 0, VK_FORMAT_R32G32B32_SFLOAT, 3 * sizeof(float)}, + {2, 0, VK_FORMAT_R32_SFLOAT, 6 * sizeof(float)}, + {3, 0, VK_FORMAT_R32G32_SFLOAT, 7 * sizeof(float)}, + }; + + auto buildRibbonPipeline = [&](VkPipelineColorBlendAttachmentState blend) -> VkPipeline { + return PipelineBuilder() + .setShaders(ribVert.stageInfo(VK_SHADER_STAGE_VERTEX_BIT), + ribFrag.stageInfo(VK_SHADER_STAGE_FRAGMENT_BIT)) + .setVertexInput({rBind}, rAttrs) + .setTopology(VK_PRIMITIVE_TOPOLOGY_TRIANGLE_STRIP) + .setRasterization(VK_POLYGON_MODE_FILL, VK_CULL_MODE_NONE) + .setDepthTest(true, false, VK_COMPARE_OP_LESS_OR_EQUAL) + .setColorBlendAttachment(blend) + .setMultisample(vkCtx_->getMsaaSamples()) + .setLayout(ribbonPipelineLayout_) + .setRenderPass(mainPass) + .setDynamicStates({VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR}) + .build(device); + }; + + ribbonPipeline_ = buildRibbonPipeline(PipelineBuilder::blendAlpha()); + ribbonAdditivePipeline_ = buildRibbonPipeline(PipelineBuilder::blendAdditive()); + } + ribVert.destroy(); ribFrag.destroy(); + } + m2Vert.destroy(); m2Frag.destroy(); particleVert.destroy(); particleFrag.destroy(); smokeVert.destroy(); smokeFrag.destroy(); diff --git a/src/rendering/renderer.cpp b/src/rendering/renderer.cpp index a7cbb3e7..9f85c3d5 100644 --- a/src/rendering/renderer.cpp +++ b/src/rendering/renderer.cpp @@ -3865,7 +3865,13 @@ void Renderer::setFSREnabled(bool enabled) { if (fsr_.enabled == enabled) return; fsr_.enabled = enabled; - if (!enabled) { + if (enabled) { + // FSR1 upscaling renders its own AA — disable MSAA to avoid redundant work + if (vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) { + pendingMsaaSamples_ = VK_SAMPLE_COUNT_1_BIT; + msaaChangePending_ = true; + } + } else { // Defer destruction to next beginFrame() — can't destroy mid-render fsr_.needsRecreate = true; } @@ -4962,11 +4968,11 @@ bool Renderer::initFXAAResources() { write.pImageInfo = &imgInfo; vkUpdateDescriptorSets(device, 1, &write, 0, nullptr); - // Pipeline layout — push constant holds vec2 rcpFrame + // Pipeline layout — push constant holds vec4(rcpFrame.xy, sharpness, pad) VkPushConstantRange pc{}; pc.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; pc.offset = 0; - pc.size = 8; // vec2 + pc.size = 16; // vec4 VkPipelineLayoutCreateInfo plCI{}; plCI.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; plCI.setLayoutCount = 1; @@ -5038,19 +5044,31 @@ void Renderer::renderFXAAPass() { vkCmdBindDescriptorSets(currentCmd, VK_PIPELINE_BIND_POINT_GRAPHICS, fxaa_.pipelineLayout, 0, 1, &fxaa_.descSet, 0, nullptr); - // Push rcpFrame = vec2(1/width, 1/height) - float rcpFrame[2] = { + // Pass rcpFrame + sharpness + desaturate (vec4, 16 bytes). + // When FSR2/FSR3 is active alongside FXAA, forward FSR2's sharpness so the + // post-FXAA unsharp-mask step restores the crispness that FXAA's blur removes. + float sharpness = fsr2_.enabled ? fsr2_.sharpness : 0.0f; + float pc[4] = { 1.0f / static_cast(ext.width), - 1.0f / static_cast(ext.height) + 1.0f / static_cast(ext.height), + sharpness, + ghostMode_ ? 1.0f : 0.0f // desaturate: 1=ghost grayscale, 0=normal }; vkCmdPushConstants(currentCmd, fxaa_.pipelineLayout, - VK_SHADER_STAGE_FRAGMENT_BIT, 0, 8, rcpFrame); + VK_SHADER_STAGE_FRAGMENT_BIT, 0, 16, pc); vkCmdDraw(currentCmd, 3, 1, 0, 0); // fullscreen triangle } void Renderer::setFXAAEnabled(bool enabled) { if (fxaa_.enabled == enabled) return; + // FXAA is a post-process AA pass intended to supplement FSR temporal output. + // It conflicts with MSAA (which resolves AA during the scene render pass), so + // refuse to enable FXAA when hardware MSAA is active. + if (enabled && vkCtx && vkCtx->getMsaaSamples() > VK_SAMPLE_COUNT_1_BIT) { + LOG_INFO("FXAA: blocked while MSAA is active — disable MSAA first"); + return; + } fxaa_.enabled = enabled; if (!enabled) { fxaa_.needsRecreate = true; // defer destruction to next beginFrame() @@ -5074,6 +5092,9 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { lastWMORenderMs = 0.0; lastM2RenderMs = 0.0; + // Cache ghost state for use in overlay and FXAA passes this frame. + ghostMode_ = (gameHandler && gameHandler->isPlayerGhost()); + uint32_t frameIdx = vkCtx->getCurrentFrame(); VkDescriptorSet perFrameSet = perFrameDescSets[frameIdx]; const glm::mat4& view = camera ? camera->getViewMatrix() : glm::mat4(1.0f); @@ -5138,6 +5159,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { m2Renderer->render(cmd, perFrameSet, *camera); m2Renderer->renderSmokeParticles(cmd, perFrameSet); m2Renderer->renderM2Particles(cmd, perFrameSet); + m2Renderer->renderM2Ribbons(cmd, perFrameSet); vkEndCommandBuffer(cmd); return std::chrono::duration( std::chrono::steady_clock::now() - t0).count(); @@ -5219,6 +5241,12 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint, cmd); } } + // Ghost mode desaturation overlay (non-FXAA path approximation). + // When FXAA is active the FXAA shader applies true per-pixel desaturation; + // otherwise a high-opacity gray overlay gives a similar washed-out effect. + if (ghostMode_ && overlayPipeline && !fxaa_.enabled) { + renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f), cmd); + } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) @@ -5228,14 +5256,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (cameraController) { float facingRad = glm::radians(characterYaw); glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); - minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + // atan2(-x,y) = canonical yaw (0=North); negate for shader convention. + minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - // Server orientation is in WoW space: π/2 = North, 0 = East. - // Minimap arrow expects render space: 0 = North, π/2 = East. - // Convert: minimap_angle = server_orientation - π/2 - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation - - static_cast(M_PI_2); + // movementInfo.orientation is canonical yaw: 0=North, π/2=East. + // Minimap shader: arrowRotation=0 points up (North), positive rotates CW + // (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw. + minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation; hasMinimapPlayerOrientation = true; } minimap->render(cmd, *camera, minimapCenter, @@ -5317,6 +5345,7 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { m2Renderer->render(currentCmd, perFrameSet, *camera); m2Renderer->renderSmokeParticles(currentCmd, perFrameSet); m2Renderer->renderM2Particles(currentCmd, perFrameSet); + m2Renderer->renderM2Ribbons(currentCmd, perFrameSet); lastM2RenderMs = std::chrono::duration( std::chrono::steady_clock::now() - m2Start).count(); } @@ -5351,6 +5380,10 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { renderOverlay(tint); } } + // Ghost mode desaturation overlay (non-FXAA path approximation). + if (ghostMode_ && overlayPipeline && !fxaa_.enabled) { + renderOverlay(glm::vec4(0.5f, 0.5f, 0.55f, 0.82f)); + } if (minimap && minimap->isEnabled() && camera && window) { glm::vec3 minimapCenter = camera->getPosition(); if (cameraController && cameraController->isThirdPerson()) @@ -5360,14 +5393,14 @@ void Renderer::renderWorld(game::World* world, game::GameHandler* gameHandler) { if (cameraController) { float facingRad = glm::radians(characterYaw); glm::vec3 facingFwd(std::cos(facingRad), std::sin(facingRad), 0.0f); - minimapPlayerOrientation = std::atan2(-facingFwd.x, facingFwd.y); + // atan2(-x,y) = canonical yaw (0=North); negate for shader convention. + minimapPlayerOrientation = -std::atan2(-facingFwd.x, facingFwd.y); hasMinimapPlayerOrientation = true; } else if (gameHandler) { - // Server orientation is in WoW space: π/2 = North, 0 = East. - // Minimap arrow expects render space: 0 = North, π/2 = East. - // Convert: minimap_angle = server_orientation - π/2 - minimapPlayerOrientation = gameHandler->getMovementInfo().orientation - - static_cast(M_PI_2); + // movementInfo.orientation is canonical yaw: 0=North, π/2=East. + // Minimap shader: arrowRotation=0 points up (North), positive rotates CW + // (π/2=West, -π/2=East). Correct mapping: arrowRotation = -canonical_yaw. + minimapPlayerOrientation = -gameHandler->getMovementInfo().orientation; hasMinimapPlayerOrientation = true; } minimap->render(currentCmd, *camera, minimapCenter, diff --git a/src/rendering/terrain_manager.cpp b/src/rendering/terrain_manager.cpp index 579a909a..340b242d 100644 --- a/src/rendering/terrain_manager.cpp +++ b/src/rendering/terrain_manager.cpp @@ -1377,6 +1377,10 @@ void TerrainManager::unloadTile(int x, int y) { // Water may have already been loaded in TERRAIN phase, so clean it up. for (auto fit = finalizingTiles_.begin(); fit != finalizingTiles_.end(); ++fit) { if (fit->pending && fit->pending->coord == coord) { + // If terrain chunks were already uploaded, free their descriptor sets + if (fit->terrainMeshDone && terrainRenderer) { + terrainRenderer->removeTile(x, y); + } // If past TERRAIN phase, water was already loaded — remove it if (fit->phase != FinalizationPhase::TERRAIN && waterRenderer) { waterRenderer->removeTile(x, y); diff --git a/src/rendering/wmo_renderer.cpp b/src/rendering/wmo_renderer.cpp index bc9aa362..7210d1be 100644 --- a/src/rendering/wmo_renderer.cpp +++ b/src/rendering/wmo_renderer.cpp @@ -805,6 +805,10 @@ bool WMORenderer::loadModel(const pipeline::WMOModel& model, uint32_t id) { return true; } +bool WMORenderer::isModelLoaded(uint32_t id) const { + return loadedModels.find(id) != loadedModels.end(); +} + void WMORenderer::unloadModel(uint32_t id) { auto it = loadedModels.find(id); if (it == loadedModels.end()) { @@ -2063,6 +2067,18 @@ void WMORenderer::getVisibleGroupsViaPortals(const ModelData& model, return; } + // If the camera group has no portal refs, it's a dead-end group (utility/transition group). + // Fall back to showing all groups to avoid the rest of the WMO going invisible. + if (cameraGroup < static_cast(model.groupPortalRefs.size())) { + auto [portalStart, portalCount] = model.groupPortalRefs[cameraGroup]; + if (portalCount == 0) { + for (size_t gi = 0; gi < model.groups.size(); gi++) { + outVisibleGroups.insert(static_cast(gi)); + } + return; + } + } + // BFS through portals from camera's group std::vector visited(model.groups.size(), false); std::vector queue; diff --git a/src/rendering/world_map.cpp b/src/rendering/world_map.cpp index 138d39db..7ee4f43a 100644 --- a/src/rendering/world_map.cpp +++ b/src/rendering/world_map.cpp @@ -842,45 +842,47 @@ void WorldMap::render(const glm::vec3& playerRenderPos, int screenWidth, int scr if (!zones.empty()) updateExploration(playerRenderPos); - if (open) { - if (input.isKeyJustPressed(SDL_SCANCODE_M) || - input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { - open = false; - return; + // game_screen owns the open/close toggle (via showWorldMap_ + TOGGLE_WORLD_MAP keybinding). + // render() is only called when showWorldMap_ is true, so treat each call as "should be open". + if (!open) { + // First time shown: load zones and navigate to player's location. + open = true; + if (zones.empty()) loadZonesFromDBC(); + + int bestContinent = findBestContinentForPlayer(playerRenderPos); + if (bestContinent >= 0 && bestContinent != continentIdx) { + continentIdx = bestContinent; + compositedIdx = -1; } + int playerZone = findZoneForPlayer(playerRenderPos); + if (playerZone >= 0 && continentIdx >= 0 && + zoneBelongsToContinent(playerZone, continentIdx)) { + loadZoneTextures(playerZone); + requestComposite(playerZone); + currentIdx = playerZone; + viewLevel = ViewLevel::ZONE; + } else if (continentIdx >= 0) { + loadZoneTextures(continentIdx); + requestComposite(continentIdx); + currentIdx = continentIdx; + viewLevel = ViewLevel::CONTINENT; + } + } + + // ESC closes the map; game_screen will sync showWorldMap_ via wm->isOpen() next frame. + if (input.isKeyJustPressed(SDL_SCANCODE_ESCAPE)) { + open = false; + return; + } + + { auto& io = ImGui::GetIO(); float wheelDelta = io.MouseWheel; if (std::abs(wheelDelta) < 0.001f) wheelDelta = input.getMouseWheelDelta(); if (wheelDelta > 0.0f) zoomIn(playerRenderPos); else if (wheelDelta < 0.0f) zoomOut(); - } else { - auto& io = ImGui::GetIO(); - if (!io.WantCaptureKeyboard && input.isKeyJustPressed(SDL_SCANCODE_M)) { - open = true; - if (zones.empty()) loadZonesFromDBC(); - - int bestContinent = findBestContinentForPlayer(playerRenderPos); - if (bestContinent >= 0 && bestContinent != continentIdx) { - continentIdx = bestContinent; - compositedIdx = -1; - } - - int playerZone = findZoneForPlayer(playerRenderPos); - if (playerZone >= 0 && continentIdx >= 0 && - zoneBelongsToContinent(playerZone, continentIdx)) { - loadZoneTextures(playerZone); - requestComposite(playerZone); - currentIdx = playerZone; - viewLevel = ViewLevel::ZONE; - } else if (continentIdx >= 0) { - loadZoneTextures(continentIdx); - requestComposite(continentIdx); - currentIdx = continentIdx; - viewLevel = ViewLevel::CONTINENT; - } - } } if (!open) return; diff --git a/src/ui/game_screen.cpp b/src/ui/game_screen.cpp index 6d86c1a1..92dccefe 100644 --- a/src/ui/game_screen.cpp +++ b/src/ui/game_screen.cpp @@ -687,6 +687,7 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderGuildInvitePopup(gameHandler); renderReadyCheckPopup(gameHandler); renderBgInvitePopup(gameHandler); + renderBfMgrInvitePopup(gameHandler); renderLfgProposalPopup(gameHandler); renderGuildRoster(gameHandler); renderSocialFrame(gameHandler); @@ -710,6 +711,8 @@ void GameScreen::render(game::GameHandler& gameHandler) { renderWhoWindow(gameHandler); renderCombatLog(gameHandler); renderAchievementWindow(gameHandler); + renderTitlesWindow(gameHandler); + renderEquipSetWindow(gameHandler); renderGmTicketWindow(gameHandler); renderInspectWindow(gameHandler); renderBookWindow(gameHandler); @@ -2333,6 +2336,12 @@ void GameScreen::processTargetInput(game::GameHandler& gameHandler) { showAchievementWindow_ = !showAchievementWindow_; } + // Toggle Titles window with H (hero/title screen — no conflicting keybinding) + if (input.isKeyJustPressed(SDL_SCANCODE_H) && !ImGui::GetIO().WantCaptureKeyboard) { + showTitlesWindow_ = !showTitlesWindow_; + } + + // Action bar keys (1-9, 0, -, =) static const SDL_Scancode actionBarKeys[] = { SDL_SCANCODE_1, SDL_SCANCODE_2, SDL_SCANCODE_3, SDL_SCANCODE_4, @@ -6374,6 +6383,9 @@ void GameScreen::renderWorldMap(game::GameHandler& gameHandler) { int screenW = window ? window->getWidth() : 1280; int screenH = window ? window->getHeight() : 720; wm->render(playerPos, screenW, screenH); + + // Sync showWorldMap_ if the map closed itself (e.g. ESC key inside the overlay). + if (!wm->isOpen()) showWorldMap_ = false; } // ============================================================ @@ -7899,18 +7911,25 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { float screenH = ImGui::GetIO().DisplaySize.y > 0.0f ? ImGui::GetIO().DisplaySize.y : 720.0f; - // Default position: top-right, below minimap + buff bar space - if (!questTrackerPosInit_ || questTrackerPos_.x < 0.0f) { - questTrackerPos_ = ImVec2(screenW - TRACKER_W - RIGHT_MARGIN, 320.0f); + // Default position: top-right, below minimap + buff bar space. + // questTrackerRightOffset_ stores pixels from the right edge so the tracker + // stays anchored to the right side when the window is resized. + if (!questTrackerPosInit_ || questTrackerRightOffset_ < 0.0f) { + questTrackerRightOffset_ = TRACKER_W + RIGHT_MARGIN; // default: right-aligned + questTrackerPos_.y = 320.0f; questTrackerPosInit_ = true; } + // Recompute X from right offset every frame (handles window resize) + questTrackerPos_.x = screenW - questTrackerRightOffset_; ImGui::SetNextWindowPos(questTrackerPos_, ImGuiCond_Always); - ImGui::SetNextWindowSize(ImVec2(TRACKER_W, 0), ImGuiCond_Always); + ImGui::SetNextWindowSize(questTrackerSize_, ImGuiCond_FirstUseEver); - ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | + ImGuiWindowFlags flags = ImGuiWindowFlags_NoTitleBar | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoCollapse | ImGuiWindowFlags_NoNav | - ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoBringToFrontOnFocus; + ImGuiWindowFlags_NoBringToFrontOnFocus; ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.0f, 0.0f, 0.0f, 0.55f)); ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(6.0f, 6.0f)); @@ -7926,7 +7945,7 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { : ImVec4(1.0f, 1.0f, 0.85f, 1.0f); ImGui::PushStyleColor(ImGuiCol_Text, titleCol); if (ImGui::Selectable(q.title.c_str(), false, - ImGuiSelectableFlags_DontClosePopups, ImVec2(TRACKER_W - 12.0f, 0))) { + ImGuiSelectableFlags_DontClosePopups, ImVec2(ImGui::GetContentRegionAvail().x, 0))) { questLogScreen.openAndSelectQuest(q.questId); } if (ImGui::IsItemHovered() && !ImGui::IsPopupOpen("##QTCtx")) { @@ -8049,15 +8068,28 @@ void GameScreen::renderQuestObjectiveTracker(game::GameHandler& gameHandler) { } } - // Capture position after drag - ImVec2 newPos = ImGui::GetWindowPos(); + // Capture position and size after drag/resize + ImVec2 newPos = ImGui::GetWindowPos(); + ImVec2 newSize = ImGui::GetWindowSize(); + bool changed = false; + + // Clamp within screen + newPos.x = std::clamp(newPos.x, 0.0f, screenW - newSize.x); + newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); + if (std::abs(newPos.x - questTrackerPos_.x) > 0.5f || std::abs(newPos.y - questTrackerPos_.y) > 0.5f) { - newPos.x = std::clamp(newPos.x, 0.0f, screenW - TRACKER_W); - newPos.y = std::clamp(newPos.y, 0.0f, screenH - 40.0f); questTrackerPos_ = newPos; - saveSettings(); + // Update right offset so resizes keep the new position anchored + questTrackerRightOffset_ = screenW - newPos.x; + changed = true; } + if (std::abs(newSize.x - questTrackerSize_.x) > 0.5f || + std::abs(newSize.y - questTrackerSize_.y) > 0.5f) { + questTrackerSize_ = newSize; + changed = true; + } + if (changed) saveSettings(); } ImGui::End(); @@ -10935,6 +10967,63 @@ void GameScreen::renderBgInvitePopup(game::GameHandler& gameHandler) { ImGui::PopStyleColor(3); } +void GameScreen::renderBfMgrInvitePopup(game::GameHandler& gameHandler) { + // Only shown on WotLK servers (outdoor battlefields like Wintergrasp use the BF Manager) + if (!gameHandler.hasBfMgrInvite()) return; + + auto* window = core::Application::getInstance().getWindow(); + float screenW = window ? static_cast(window->getWidth()) : 1280.0f; + float screenH = window ? static_cast(window->getHeight()) : 720.0f; + + ImGui::SetNextWindowPos(ImVec2(screenW / 2.0f - 190.0f, screenH / 2.0f - 55.0f), ImGuiCond_Always); + ImGui::SetNextWindowSize(ImVec2(380.0f, 0.0f), ImGuiCond_Always); + + ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.08f, 0.10f, 0.20f, 0.96f)); + ImGui::PushStyleColor(ImGuiCol_Border, ImVec4(0.5f, 0.5f, 1.0f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_TitleBgActive, ImVec4(0.15f, 0.15f, 0.45f, 1.0f)); + ImGui::PushStyleVar(ImGuiStyleVar_WindowRounding, 6.0f); + + const ImGuiWindowFlags flags = + ImGuiWindowFlags_NoResize | ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse; + + if (ImGui::Begin("Battlefield", nullptr, flags)) { + // Resolve zone name for Wintergrasp (zoneId 4197) + uint32_t zoneId = gameHandler.getBfMgrZoneId(); + const char* zoneName = nullptr; + if (zoneId == 4197) zoneName = "Wintergrasp"; + else if (zoneId == 5095) zoneName = "Tol Barad"; + + if (zoneName) { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "%s", zoneName); + } else { + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.2f, 1.0f), "Outdoor Battlefield"); + } + ImGui::Spacing(); + ImGui::TextWrapped("You are invited to join the outdoor battlefield. Do you want to enter?"); + ImGui::Spacing(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.15f, 0.5f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.2f, 0.7f, 0.2f, 1.0f)); + if (ImGui::Button("Enter Battlefield", ImVec2(178, 28))) { + gameHandler.acceptBfMgrInvite(); + } + ImGui::PopStyleColor(2); + + ImGui::SameLine(); + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.5f, 0.15f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.7f, 0.2f, 0.2f, 1.0f)); + if (ImGui::Button("Decline", ImVec2(175, 28))) { + gameHandler.declineBfMgrInvite(); + } + ImGui::PopStyleColor(2); + } + ImGui::End(); + + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); +} + void GameScreen::renderLfgProposalPopup(game::GameHandler& gameHandler) { using LfgState = game::GameHandler::LfgState; if (gameHandler.getLfgState() != LfgState::Proposal) return; @@ -11842,11 +11931,11 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ImGui::EndTabItem(); } - // ---- Arena tab (WotLK: shows per-team rating/record) ---- + // ---- Arena tab (WotLK: shows per-team rating/record + roster) ---- const auto& arenaStats = gameHandler.getArenaTeamStats(); if (!arenaStats.empty()) { if (ImGui::BeginTabItem("Arena")) { - ImGui::BeginChild("##ArenaList", ImVec2(200, 200), false); + ImGui::BeginChild("##ArenaList", ImVec2(0, 0), false); for (size_t ai = 0; ai < arenaStats.size(); ++ai) { const auto& ts = arenaStats[ai]; @@ -11875,6 +11964,49 @@ void GameScreen::renderSocialFrame(game::GameHandler& gameHandler) { ? ts.seasonGames - ts.seasonWins : 0; ImGui::Text("Season: %u W / %u L", ts.seasonWins, seasLosses); + // Roster members (from SMSG_ARENA_TEAM_ROSTER) + const auto* roster = gameHandler.getArenaTeamRoster(ts.teamId); + if (roster && !roster->members.empty()) { + ImGui::Spacing(); + ImGui::TextDisabled("-- Roster (%zu members) --", + roster->members.size()); + // Column headers + ImGui::Columns(4, "##arenaRosterCols", false); + ImGui::SetColumnWidth(0, 110.0f); + ImGui::SetColumnWidth(1, 60.0f); + ImGui::SetColumnWidth(2, 60.0f); + ImGui::SetColumnWidth(3, 60.0f); + ImGui::TextDisabled("Name"); ImGui::NextColumn(); + ImGui::TextDisabled("Rating"); ImGui::NextColumn(); + ImGui::TextDisabled("Week"); ImGui::NextColumn(); + ImGui::TextDisabled("Season"); ImGui::NextColumn(); + ImGui::Separator(); + + for (const auto& m : roster->members) { + // Name coloured green (online) or grey (offline) + if (m.online) + ImGui::TextColored(ImVec4(0.4f,1.0f,0.4f,1.0f), + "%s", m.name.c_str()); + else + ImGui::TextDisabled("%s", m.name.c_str()); + ImGui::NextColumn(); + + ImGui::Text("%u", m.personalRating); + ImGui::NextColumn(); + + uint32_t wL = m.weekGames > m.weekWins + ? m.weekGames - m.weekWins : 0; + ImGui::Text("%uW/%uL", m.weekWins, wL); + ImGui::NextColumn(); + + uint32_t sL = m.seasonGames > m.seasonWins + ? m.seasonGames - m.seasonWins : 0; + ImGui::Text("%uW/%uL", m.seasonWins, sL); + ImGui::NextColumn(); + } + ImGui::Columns(1); + } + ImGui::Unindent(8.0f); if (ai + 1 < arenaStats.size()) @@ -15443,7 +15575,9 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { float sinB = 0.0f; if (minimap->isRotateWithCamera()) { glm::vec3 fwd = camera->getForward(); - bearing = std::atan2(-fwd.x, fwd.y); + // Render space: +X=West, +Y=North. Camera fwd=(cos(yaw),sin(yaw)). + // Clockwise bearing from North: atan2(fwd.y, -fwd.x). + bearing = std::atan2(fwd.y, -fwd.x); cosB = std::cos(bearing); sinB = std::sin(bearing); } @@ -15481,7 +15615,7 @@ void GameScreen::renderMinimapMarkers(game::GameHandler& gameHandler) { // The player is always at centerX, centerY on the minimap. // Draw a yellow arrow pointing in the player's facing direction. glm::vec3 fwd = camera->getForward(); - float facing = std::atan2(-fwd.x, fwd.y); // bearing relative to north + float facing = std::atan2(fwd.y, -fwd.x); // clockwise bearing from North float cosF = std::cos(facing - bearing); float sinF = std::sin(facing - bearing); float arrowLen = 8.0f; @@ -16846,9 +16980,11 @@ void GameScreen::saveSettings() { out << "extended_zoom=" << (pendingExtendedZoom ? 1 : 0) << "\n"; out << "fov=" << pendingFov << "\n"; - // Quest tracker position - out << "quest_tracker_x=" << questTrackerPos_.x << "\n"; + // Quest tracker position/size + out << "quest_tracker_right_offset=" << questTrackerRightOffset_ << "\n"; out << "quest_tracker_y=" << questTrackerPos_.y << "\n"; + out << "quest_tracker_w=" << questTrackerSize_.x << "\n"; + out << "quest_tracker_h=" << questTrackerSize_.y << "\n"; // Chat out << "chat_active_tab=" << activeChatTab_ << "\n"; @@ -16994,15 +17130,25 @@ void GameScreen::loadSettings() { if (auto* camera = renderer->getCamera()) camera->setFov(pendingFov); } } - // Quest tracker position + // Quest tracker position/size else if (key == "quest_tracker_x") { - questTrackerPos_.x = std::stof(val); + // Legacy: ignore absolute X (right_offset supersedes it) + (void)val; + } + else if (key == "quest_tracker_right_offset") { + questTrackerRightOffset_ = std::stof(val); questTrackerPosInit_ = true; } else if (key == "quest_tracker_y") { questTrackerPos_.y = std::stof(val); questTrackerPosInit_ = true; } + else if (key == "quest_tracker_w") { + questTrackerSize_.x = std::max(100.0f, std::stof(val)); + } + else if (key == "quest_tracker_h") { + questTrackerSize_.y = std::max(60.0f, std::stof(val)); + } // Chat else if (key == "chat_active_tab") activeChatTab_ = std::clamp(std::stoi(val), 0, 3); else if (key == "chat_timestamps") chatShowTimestamps_ = (std::stoi(val) != 0); @@ -20107,9 +20253,15 @@ void GameScreen::renderAchievementWindow(game::GameHandler& gameHandler) { // ─── GM Ticket Window ───────────────────────────────────────────────────────── void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { + // Fire a one-shot query when the window first becomes visible + if (showGmTicketWindow_ && !gmTicketWindowWasOpen_) { + gameHandler.requestGmTicket(); + } + gmTicketWindowWasOpen_ = showGmTicketWindow_; + if (!showGmTicketWindow_) return; - ImGui::SetNextWindowSize(ImVec2(400, 260), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(440, 320), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(300, 200), ImGuiCond_FirstUseEver); if (!ImGui::Begin("GM Ticket", &showGmTicketWindow_, @@ -20118,10 +20270,33 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { return; } + // Show GM support availability + if (!gameHandler.isGmSupportAvailable()) { + ImGui::TextColored(ImVec4(1.0f, 0.4f, 0.4f, 1.0f), "GM support is currently unavailable."); + ImGui::Spacing(); + } + + // Show existing open ticket if any + if (gameHandler.hasActiveGmTicket()) { + ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "You have an open GM ticket."); + const std::string& existingText = gameHandler.getGmTicketText(); + if (!existingText.empty()) { + ImGui::TextWrapped("Current ticket: %s", existingText.c_str()); + } + float waitHours = gameHandler.getGmTicketWaitHours(); + if (waitHours > 0.0f) { + char waitBuf[64]; + std::snprintf(waitBuf, sizeof(waitBuf), "Estimated wait: %.1f hours", waitHours); + ImGui::TextColored(ImVec4(0.8f, 0.8f, 0.4f, 1.0f), "%s", waitBuf); + } + ImGui::Separator(); + ImGui::Spacing(); + } + ImGui::TextWrapped("Describe your issue and a Game Master will contact you."); ImGui::Spacing(); ImGui::InputTextMultiline("##gmticket_body", gmTicketBuf_, sizeof(gmTicketBuf_), - ImVec2(-1, 160)); + ImVec2(-1, 120)); ImGui::Spacing(); bool hasText = (gmTicketBuf_[0] != '\0'); @@ -20138,8 +20313,11 @@ void GameScreen::renderGmTicketWindow(game::GameHandler& gameHandler) { showGmTicketWindow_ = false; } ImGui::SameLine(); - if (ImGui::Button("Delete Ticket", ImVec2(100, 0))) { - gameHandler.deleteGmTicket(); + if (gameHandler.hasActiveGmTicket()) { + if (ImGui::Button("Delete Ticket", ImVec2(110, 0))) { + gameHandler.deleteGmTicket(); + showGmTicketWindow_ = false; + } } ImGui::End(); @@ -20255,7 +20433,8 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { ImGui::SetNextWindowSize(ImVec2(600, 400), ImGuiCond_FirstUseEver); ImGui::SetNextWindowPos(ImVec2(150, 100), ImGuiCond_FirstUseEver); - const char* title = "Battleground Score###BgScore"; + const char* title = data && data->isArena ? "Arena Score###BgScore" + : "Battleground Score###BgScore"; if (!ImGui::Begin(title, &showBgScoreboard_, ImGuiWindowFlags_NoCollapse)) { ImGui::End(); return; @@ -20263,16 +20442,46 @@ void GameScreen::renderBgScoreboard(game::GameHandler& gameHandler) { if (!data) { ImGui::TextDisabled("No score data yet."); - ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground."); + ImGui::TextDisabled("Use /score to request the scoreboard while in a battleground or arena."); ImGui::End(); return; } + // Arena team rating banner (shown only for arenas) + if (data->isArena) { + for (int t = 0; t < 2; ++t) { + const auto& at = data->arenaTeams[t]; + if (at.teamName.empty()) continue; + int32_t ratingDelta = static_cast(at.ratingChange); + ImVec4 teamCol = (t == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f) // team 0: red + : ImVec4(0.4f, 0.6f, 1.0f, 1.0f); // team 1: blue + ImGui::TextColored(teamCol, "%s", at.teamName.c_str()); + ImGui::SameLine(); + char ratingBuf[32]; + if (ratingDelta >= 0) + std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (+%d)", at.newRating, ratingDelta); + else + std::snprintf(ratingBuf, sizeof(ratingBuf), "Rating: %u (%d)", at.newRating, ratingDelta); + ImGui::TextDisabled("%s", ratingBuf); + } + ImGui::Separator(); + } + // Winner banner if (data->hasWinner) { - const char* winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; - ImVec4 winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f) - : ImVec4(1.0f, 0.35f, 0.35f, 1.0f); + const char* winnerStr; + ImVec4 winnerColor; + if (data->isArena) { + // For arenas, winner byte 0/1 refers to team index in arenaTeams[] + const auto& winTeam = data->arenaTeams[data->winner & 1]; + winnerStr = winTeam.teamName.empty() ? "Team 1" : winTeam.teamName.c_str(); + winnerColor = (data->winner == 0) ? ImVec4(1.0f, 0.35f, 0.35f, 1.0f) + : ImVec4(0.4f, 0.6f, 1.0f, 1.0f); + } else { + winnerStr = (data->winner == 1) ? "Alliance" : "Horde"; + winnerColor = (data->winner == 1) ? ImVec4(0.4f, 0.6f, 1.0f, 1.0f) + : ImVec4(1.0f, 0.35f, 0.35f, 1.0f); + } float textW = ImGui::CalcTextSize(winnerStr).x + ImGui::CalcTextSize(" Victory!").x; ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - textW) * 0.5f); ImGui::TextColored(winnerColor, "%s", winnerStr); @@ -20642,6 +20851,161 @@ void GameScreen::renderInspectWindow(game::GameHandler& gameHandler) { ImGui::EndChild(); } + // Arena teams (WotLK — from MSG_INSPECT_ARENA_TEAMS) + if (!result->arenaTeams.empty()) { + ImGui::Separator(); + ImGui::TextColored(ImVec4(1.0f, 0.75f, 0.2f, 1.0f), "Arena Teams"); + ImGui::Spacing(); + for (const auto& team : result->arenaTeams) { + const char* bracket = (team.type == 2) ? "2v2" + : (team.type == 3) ? "3v3" + : (team.type == 5) ? "5v5" : "?v?"; + ImGui::TextColored(ImVec4(0.9f, 0.9f, 0.9f, 1.0f), + "[%s] %s", bracket, team.name.c_str()); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.4f, 0.85f, 1.0f, 1.0f), + " Rating: %u", team.personalRating); + if (team.weekGames > 0 || team.seasonGames > 0) { + ImGui::TextDisabled(" Week: %u/%u Season: %u/%u", + team.weekWins, team.weekGames, + team.seasonWins, team.seasonGames); + } + } + } + + ImGui::End(); +} + +// ─── Titles Window ──────────────────────────────────────────────────────────── +void GameScreen::renderTitlesWindow(game::GameHandler& gameHandler) { + if (!showTitlesWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(320, 400), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(240, 170), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Titles", &showTitlesWindow_)) { + ImGui::End(); + return; + } + + const auto& knownBits = gameHandler.getKnownTitleBits(); + const int32_t chosen = gameHandler.getChosenTitleBit(); + + if (knownBits.empty()) { + ImGui::TextDisabled("No titles earned yet."); + ImGui::End(); + return; + } + + ImGui::TextUnformatted("Select a title to display:"); + ImGui::Separator(); + + // "No Title" option + bool noTitle = (chosen < 0); + if (ImGui::Selectable("(No Title)", noTitle)) { + if (!noTitle) gameHandler.sendSetTitle(-1); + } + if (noTitle) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.85f, 0.0f, 1.0f), "<-- active"); + } + + ImGui::Separator(); + + // Sort known bits for stable display order + std::vector sortedBits(knownBits.begin(), knownBits.end()); + std::sort(sortedBits.begin(), sortedBits.end()); + + ImGui::BeginChild("##titlelist", ImVec2(0, 0), false); + for (uint32_t bit : sortedBits) { + const std::string title = gameHandler.getFormattedTitle(bit); + const std::string display = title.empty() + ? ("Title #" + std::to_string(bit)) : title; + + bool isActive = (chosen >= 0 && static_cast(chosen) == bit); + ImGui::PushID(static_cast(bit)); + + if (isActive) { + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.85f, 0.0f, 1.0f)); + } + if (ImGui::Selectable(display.c_str(), isActive)) { + if (!isActive) gameHandler.sendSetTitle(static_cast(bit)); + } + if (isActive) { + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextDisabled("<-- active"); + } + + ImGui::PopID(); + } + ImGui::EndChild(); + + ImGui::End(); +} + +// ─── Equipment Set Manager Window ───────────────────────────────────────────── +void GameScreen::renderEquipSetWindow(game::GameHandler& gameHandler) { + if (!showEquipSetWindow_) return; + + ImGui::SetNextWindowSize(ImVec2(280, 320), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowPos(ImVec2(260, 180), ImGuiCond_FirstUseEver); + + if (!ImGui::Begin("Equipment Sets##equipsets", &showEquipSetWindow_)) { + ImGui::End(); + return; + } + + const auto& sets = gameHandler.getEquipmentSets(); + + if (sets.empty()) { + ImGui::TextDisabled("No equipment sets saved."); + ImGui::Spacing(); + ImGui::TextWrapped("Create equipment sets in-game using the default WoW equipment manager (Shift+click the Equipment Sets button)."); + ImGui::End(); + return; + } + + ImGui::TextUnformatted("Click a set to equip it:"); + ImGui::Separator(); + ImGui::Spacing(); + + ImGui::BeginChild("##equipsetlist", ImVec2(0, 0), false); + for (const auto& set : sets) { + ImGui::PushID(static_cast(set.setId)); + + // Icon placeholder (use a coloured square if no icon texture available) + ImVec2 iconSize(32.0f, 32.0f); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.25f, 0.20f, 0.10f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.40f, 0.30f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.60f, 0.45f, 0.20f, 1.0f)); + if (ImGui::Button("##icon", iconSize)) { + gameHandler.useEquipmentSet(set.setId); + } + ImGui::PopStyleColor(3); + + if (ImGui::IsItemHovered()) { + ImGui::SetTooltip("Equip set: %s", set.name.c_str()); + } + + ImGui::SameLine(); + + // Name and equip button + ImGui::BeginGroup(); + ImGui::TextUnformatted(set.name.c_str()); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.20f, 0.35f, 0.15f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.30f, 0.50f, 0.22f, 1.0f)); + if (ImGui::SmallButton("Equip")) { + gameHandler.useEquipmentSet(set.setId); + } + ImGui::PopStyleColor(2); + ImGui::EndGroup(); + + ImGui::Spacing(); + ImGui::PopID(); + } + ImGui::EndChild(); + ImGui::End(); } diff --git a/src/ui/inventory_screen.cpp b/src/ui/inventory_screen.cpp index 1c029217..cfea2be4 100644 --- a/src/ui/inventory_screen.cpp +++ b/src/ui/inventory_screen.cpp @@ -1420,10 +1420,13 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { ImGui::BeginChild("##ReputationList", ImVec2(0, 0), true); - // Sort factions alphabetically by name + // Sort: watched faction first, then alphabetically by name + uint32_t watchedFactionId = gameHandler.getWatchedFactionId(); std::vector> sortedFactions(standings.begin(), standings.end()); std::sort(sortedFactions.begin(), sortedFactions.end(), [&](const auto& a, const auto& b) { + if (a.first == watchedFactionId) return true; + if (b.first == watchedFactionId) return false; const std::string& na = gameHandler.getFactionNamePublic(a.first); const std::string& nb = gameHandler.getFactionNamePublic(b.first); return na < nb; @@ -1435,10 +1438,25 @@ void InventoryScreen::renderReputationPanel(game::GameHandler& gameHandler) { const std::string& factionName = gameHandler.getFactionNamePublic(factionId); const char* displayName = factionName.empty() ? "Unknown Faction" : factionName.c_str(); - // Faction name + tier label on same line + // Determine at-war status via repListId lookup + uint32_t repListId = gameHandler.getRepListIdByFactionId(factionId); + bool atWar = (repListId != 0xFFFFFFFFu) && gameHandler.isFactionAtWar(repListId); + bool isWatched = (factionId == watchedFactionId); + + // Faction name + tier label on same line; mark at-war and watched factions ImGui::TextColored(tier.color, "[%s]", tier.name); ImGui::SameLine(90.0f); - ImGui::Text("%s", displayName); + if (atWar) { + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "%s", displayName); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(1.0f, 0.3f, 0.3f, 1.0f), "(At War)"); + } else if (isWatched) { + ImGui::TextColored(ImVec4(1.0f, 0.9f, 0.5f, 1.0f), "%s", displayName); + ImGui::SameLine(); + ImGui::TextDisabled("(Tracked)"); + } else { + ImGui::Text("%s", displayName); + } // Progress bar showing position within current tier float ratio = 0.0f; @@ -1594,6 +1612,8 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play // Secondary stat sums from extraStats int32_t itemAP = 0, itemSP = 0, itemHit = 0, itemCrit = 0, itemHaste = 0; int32_t itemResil = 0, itemExpertise = 0, itemMp5 = 0, itemHp5 = 0; + int32_t itemDefense = 0, itemDodge = 0, itemParry = 0, itemBlock = 0, itemBlockVal = 0; + int32_t itemArmorPen = 0, itemSpellPen = 0; for (int s = 0; s < game::Inventory::NUM_EQUIP_SLOTS; s++) { const auto& slot = inventory.getEquipSlot(static_cast(s)); if (slot.empty()) continue; @@ -1604,15 +1624,22 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play itemSpi += slot.item.spirit; for (const auto& es : slot.item.extraStats) { switch (es.statType) { - case 16: case 17: case 18: case 31: itemHit += es.statValue; break; - case 19: case 20: case 21: case 32: itemCrit += es.statValue; break; - case 28: case 29: case 30: case 36: itemHaste += es.statValue; break; - case 35: itemResil += es.statValue; break; + case 12: itemDefense += es.statValue; break; + case 13: itemDodge += es.statValue; break; + case 14: itemParry += es.statValue; break; + case 15: itemBlock += es.statValue; break; + case 16: case 17: case 18: case 31: itemHit += es.statValue; break; + case 19: case 20: case 21: case 32: itemCrit += es.statValue; break; + case 28: case 29: case 30: case 36: itemHaste += es.statValue; break; + case 35: itemResil += es.statValue; break; case 37: itemExpertise += es.statValue; break; - case 38: case 39: itemAP += es.statValue; break; - case 41: case 42: case 45: itemSP += es.statValue; break; - case 43: itemMp5 += es.statValue; break; - case 46: itemHp5 += es.statValue; break; + case 38: case 39: itemAP += es.statValue; break; + case 41: case 42: case 45: itemSP += es.statValue; break; + case 43: itemMp5 += es.statValue; break; + case 44: itemArmorPen += es.statValue; break; + case 46: itemHp5 += es.statValue; break; + case 47: itemSpellPen += es.statValue; break; + case 48: itemBlockVal += es.statValue; break; default: break; } } @@ -1699,7 +1726,9 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play // Secondary stats from equipped items bool hasSecondary = itemAP || itemSP || itemHit || itemCrit || itemHaste || - itemResil || itemExpertise || itemMp5 || itemHp5; + itemResil || itemExpertise || itemMp5 || itemHp5 || + itemDefense || itemDodge || itemParry || itemBlock || itemBlockVal || + itemArmorPen || itemSpellPen; if (hasSecondary) { ImGui::Spacing(); ImGui::Separator(); @@ -1708,15 +1737,22 @@ void InventoryScreen::renderStatsPanel(game::Inventory& inventory, uint32_t play ImGui::TextColored(green, "+%d %s", val, name); } }; - renderSecondary("Attack Power", itemAP); - renderSecondary("Spell Power", itemSP); - renderSecondary("Hit Rating", itemHit); - renderSecondary("Crit Rating", itemCrit); - renderSecondary("Haste Rating", itemHaste); - renderSecondary("Resilience", itemResil); - renderSecondary("Expertise", itemExpertise); - renderSecondary("Mana per 5 sec", itemMp5); - renderSecondary("Health per 5 sec",itemHp5); + renderSecondary("Attack Power", itemAP); + renderSecondary("Spell Power", itemSP); + renderSecondary("Hit Rating", itemHit); + renderSecondary("Crit Rating", itemCrit); + renderSecondary("Haste Rating", itemHaste); + renderSecondary("Resilience", itemResil); + renderSecondary("Expertise", itemExpertise); + renderSecondary("Defense Rating", itemDefense); + renderSecondary("Dodge Rating", itemDodge); + renderSecondary("Parry Rating", itemParry); + renderSecondary("Block Rating", itemBlock); + renderSecondary("Block Value", itemBlockVal); + renderSecondary("Armor Penetration",itemArmorPen); + renderSecondary("Spell Penetration",itemSpellPen); + renderSecondary("Mana per 5 sec", itemMp5); + renderSecondary("Health per 5 sec", itemHp5); } // Elemental resistances from server update fields diff --git a/src/ui/keybinding_manager.cpp b/src/ui/keybinding_manager.cpp index 5ac79927..fbe70e33 100644 --- a/src/ui/keybinding_manager.cpp +++ b/src/ui/keybinding_manager.cpp @@ -22,15 +22,15 @@ void KeybindingManager::initializeDefaults() { bindings_[static_cast(Action::TOGGLE_SPELLBOOK)] = ImGuiKey_P; // WoW standard key bindings_[static_cast(Action::TOGGLE_TALENTS)] = ImGuiKey_N; // WoW standard key bindings_[static_cast(Action::TOGGLE_QUESTS)] = ImGuiKey_L; - bindings_[static_cast(Action::TOGGLE_MINIMAP)] = ImGuiKey_M; + bindings_[static_cast(Action::TOGGLE_MINIMAP)] = ImGuiKey_None; // minimap is always visible; no default toggle bindings_[static_cast(Action::TOGGLE_SETTINGS)] = ImGuiKey_Escape; bindings_[static_cast(Action::TOGGLE_CHAT)] = ImGuiKey_Enter; bindings_[static_cast(Action::TOGGLE_GUILD_ROSTER)] = ImGuiKey_O; bindings_[static_cast(Action::TOGGLE_DUNGEON_FINDER)] = ImGuiKey_J; // Originally I, reassigned to avoid conflict - bindings_[static_cast(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_W; + bindings_[static_cast(Action::TOGGLE_WORLD_MAP)] = ImGuiKey_M; // WoW standard: M opens world map bindings_[static_cast(Action::TOGGLE_NAMEPLATES)] = ImGuiKey_V; bindings_[static_cast(Action::TOGGLE_RAID_FRAMES)] = ImGuiKey_F; // Reassigned from R (now camera reset) - bindings_[static_cast(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_Q; + bindings_[static_cast(Action::TOGGLE_QUEST_LOG)] = ImGuiKey_None; // Q conflicts with strafe-left; quest log accessible via TOGGLE_QUESTS (L) bindings_[static_cast(Action::TOGGLE_ACHIEVEMENTS)] = ImGuiKey_Y; // WoW standard key (Shift+Y in retail) } diff --git a/src/ui/spellbook_screen.cpp b/src/ui/spellbook_screen.cpp index 8c78ab7d..3d2ceeed 100644 --- a/src/ui/spellbook_screen.cpp +++ b/src/ui/spellbook_screen.cpp @@ -525,7 +525,7 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle // Resource cost + cast time on same row (WoW style) if (!info->isPassive()) { - // Left: resource cost + // Left: resource cost (with talent flat/pct modifier applied) char costBuf[64] = ""; if (info->manaCost > 0) { const char* powerName = "Mana"; @@ -535,16 +535,26 @@ void SpellbookScreen::renderSpellTooltip(const SpellInfo* info, game::GameHandle case 4: powerName = "Focus"; break; default: break; } - std::snprintf(costBuf, sizeof(costBuf), "%u %s", info->manaCost, powerName); + // Apply SMSG_SET_FLAT/PCT_SPELL_MODIFIER Cost modifier (SpellModOp::Cost = 14) + int32_t flatCost = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::Cost); + int32_t pctCost = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::Cost); + uint32_t displayCost = static_cast( + game::GameHandler::applySpellMod(static_cast(info->manaCost), flatCost, pctCost)); + std::snprintf(costBuf, sizeof(costBuf), "%u %s", displayCost, powerName); } - // Right: cast time + // Right: cast time (with talent CastingTime modifier applied) char castBuf[32] = ""; if (info->castTimeMs == 0) { std::snprintf(castBuf, sizeof(castBuf), "Instant cast"); } else { - float secs = info->castTimeMs / 1000.0f; - std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs); + // Apply SpellModOp::CastingTime (10) modifiers + int32_t flatCT = gameHandler.getSpellFlatMod(game::GameHandler::SpellModOp::CastingTime); + int32_t pctCT = gameHandler.getSpellPctMod(game::GameHandler::SpellModOp::CastingTime); + int32_t modCT = game::GameHandler::applySpellMod( + static_cast(info->castTimeMs), flatCT, pctCT); + float secs = static_cast(modCT) / 1000.0f; + std::snprintf(castBuf, sizeof(castBuf), "%.1f sec cast", secs > 0.0f ? secs : 0.0f); } if (costBuf[0] || castBuf[0]) { diff --git a/src/ui/talent_screen.cpp b/src/ui/talent_screen.cpp index b1231f24..bed817ab 100644 --- a/src/ui/talent_screen.cpp +++ b/src/ui/talent_screen.cpp @@ -201,20 +201,23 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab return a->column < b->column; }); - // Find grid dimensions - uint8_t maxRow = 0, maxCol = 0; + // Find grid dimensions — use int to avoid uint8_t wrap-around infinite loops + int maxRow = 0, maxCol = 0; for (const auto* talent : talents) { - maxRow = std::max(maxRow, talent->row); - maxCol = std::max(maxCol, talent->column); + maxRow = std::max(maxRow, (int)talent->row); + maxCol = std::max(maxCol, (int)talent->column); } + // Sanity-cap to prevent runaway loops from corrupt/unexpected DBC data + maxRow = std::min(maxRow, 15); + maxCol = std::min(maxCol, 15); // WoW talent grids are always 4 columns wide if (maxCol < 3) maxCol = 3; const float iconSize = 40.0f; const float spacing = 8.0f; const float cellSize = iconSize + spacing; - const float gridWidth = (maxCol + 1) * cellSize + spacing; - const float gridHeight = (maxRow + 1) * cellSize + spacing; + const float gridWidth = (float)(maxCol + 1) * cellSize + spacing; + const float gridHeight = (float)(maxRow + 1) * cellSize + spacing; // Points in this tree uint32_t pointsInTree = 0; @@ -300,7 +303,7 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab if (fromIt == talentPositions.end() || toIt == talentPositions.end()) continue; uint8_t prereqRank = gameHandler.getTalentRank(talent->prereqTalent[i]); - bool met = prereqRank >= talent->prereqRank[i]; + bool met = prereqRank > talent->prereqRank[i]; // storage 1-indexed, DBC 0-indexed ImU32 lineCol = met ? IM_COL32(100, 220, 100, 200) : IM_COL32(120, 120, 120, 150); ImVec2 from = fromIt->second.center; @@ -322,8 +325,8 @@ void TalentScreen::renderTalentTree(game::GameHandler& gameHandler, uint32_t tab } // Render talent icons - for (uint8_t row = 0; row <= maxRow; ++row) { - for (uint8_t col = 0; col <= maxCol; ++col) { + for (int row = 0; row <= maxRow; ++row) { + for (int col = 0; col <= maxCol; ++col) { const game::GameHandler::TalentEntry* talent = nullptr; for (const auto* t : talents) { if (t->row == row && t->column == col) { @@ -371,7 +374,7 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, for (int i = 0; i < 3; ++i) { if (talent.prereqTalent[i] != 0) { uint8_t prereqRank = gameHandler.getTalentRank(talent.prereqTalent[i]); - if (prereqRank < talent.prereqRank[i]) { + if (prereqRank <= talent.prereqRank[i]) { // storage 1-indexed, DBC 0-indexed prereqsMet = false; canLearn = false; break; @@ -538,14 +541,15 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, if (!prereq || prereq->rankSpells[0] == 0) continue; uint8_t prereqCurrentRank = gameHandler.getTalentRank(talent.prereqTalent[i]); - bool met = prereqCurrentRank >= talent.prereqRank[i]; + bool met = prereqCurrentRank > talent.prereqRank[i]; // storage 1-indexed, DBC 0-indexed ImVec4 pColor = met ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(1.0f, 0.3f, 0.3f, 1); const std::string& prereqName = gameHandler.getSpellName(prereq->rankSpells[0]); ImGui::Spacing(); + const uint8_t reqRankDisplay = talent.prereqRank[i] + 1u; // DBC 0-indexed → display 1-indexed ImGui::TextColored(pColor, "Requires %u point%s in %s", - talent.prereqRank[i], - talent.prereqRank[i] > 1 ? "s" : "", + reqRankDisplay, + reqRankDisplay > 1 ? "s" : "", prereqName.empty() ? "prerequisite" : prereqName.c_str()); } @@ -570,16 +574,10 @@ void TalentScreen::renderTalent(game::GameHandler& gameHandler, ImGui::EndTooltip(); } - // Handle click + // Handle click — currentRank is 1-indexed (0=not learned, 1=rank1, ...) + // CMSG_LEARN_TALENT requestedRank must equal current count of learned ranks (same value) if (clicked && canLearn && prereqsMet) { - const auto& learned = gameHandler.getLearnedTalents(); - uint8_t desiredRank; - if (learned.find(talent.talentId) == learned.end()) { - desiredRank = 0; // First rank (0-indexed on wire) - } else { - desiredRank = currentRank; // currentRank is already the next 0-indexed rank to learn - } - gameHandler.learnTalent(talent.talentId, desiredRank); + gameHandler.learnTalent(talent.talentId, currentRank); } ImGui::PopID();