From f38ef59c56c91cfd30b71a06554f935f1365407b Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 00:16:23 +0100 Subject: [PATCH 01/81] LFG: make the matchmaker able to form a group at all The dungeon finder's matching spine could not form a group under any input. Five independent defects, each sufficient on its own: Role needs were gated on `dungeon->DifficultyID == DUNGEON_DIFFICULTY_NORMAL`, comparing a RAW client DifficultyID against the internal 0-based enum. No queueable row in LfgDungeons.dbc carries DifficultyID 0, so the branch never fired and every entry reported needing nobody. RoleMapsAreCompatible then computed (3-0)+(3-0) = 6 > 3 and refused every pair, including two solos. Take the composition from the dungeon's own row -- Count_tank, Count_healer, Count_damage -- which removes the difficulty translation from this path and covers the 108 of 247 queueable TypeID 1 rows that are not 1/1/3 five-mans (scenarios 0/0/3, solo content 0/0/1, raid finder 2/6/17, flex 0/0/25). The role mask is a BITMASK, not an enum. The client's LFD frame has four independent checkboxes, so a player offering tank-or-damage sends 0x0A -- observed on the wire in capture-000112 seq 90341. Every consumer switched on the exact values 0x02/0x04/0x08, so a hybrid counted as zero of everything: solo hybrids merged into a full-size entry that still reported every role missing and could neither complete nor merge again, and a premade containing one hybrid failed its role check outright. Resolve the mask by backtracking assignment instead; greedy mis-assigns, because handing the tank slot to a tank-or-healer player can strand a tank-only specialist. `neededTanks = 1 - tankCount` in a uint8 wrapped to 255 for a two-tank party, which the old arithmetic then read as -254 and passed, merging parties that could never complete. The resolver counts down from the quota and cannot underflow. Completion was only ever tested inside MergeGroups, so a premade of exactly five with a correct composition -- the commonest premade case -- was never merged with anything and never proposed. Test it wherever an entry becomes eligible, and dequeue on proposal: without that the entry stays QUEUED, gets matched again next tick and fires a fresh proposal every tick forever. Also fixed, all reachable the moment Update() ticks: - RemoveOldRoleChecks erased inside a `++it` loop over an unordered_map. It is the first thing Update() calls. - LFG_TIME_ROLECHECK was 45*IN_MILLISECONDS added to a seconds-domain time_t, expiring role checks after 12.5 hours instead of 45 seconds. - MergeGroups erased the absorbed entry from m_playerData but not m_queueSet, leaving a stale queue entry that could give one player two live proposals. - Both matching loops iterated m_queueSet while merges erased from it. - The role check was stored before the loop that fills currentRoles ran, so it listed nobody and PerformRoleCheck saw "everyone" answer on the first reply. - PerformRoleCheck mutated a COPY of the stored role check, so no member's answer was ever recorded and a party of two or more could never finish. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 344 +++++++++++++++++----- src/game/WorldHandlers/LFGMgr.h | 24 +- src/game/WorldHandlers/LFGMgrProposal.cpp | 75 +++-- src/game/WorldHandlers/LFGMgrQueue.cpp | 9 +- 4 files changed, 340 insertions(+), 112 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 6b060f77f..b6282abd2 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -23,6 +23,9 @@ * and lore are copyrighted by Blizzard Entertainment, Inc. */ +#include +#include + #include "DBCEnums.h" #include "DBCStores.h" #include "DBCStructure.h" @@ -464,64 +467,179 @@ dungeonForbidden LFGMgr::FindRandomDungeonsNotForPlayer(Player* plr) return randomDungeons; } -void LFGMgr::UpdateNeededRoles(ObjectGuid guid, LFGPlayers* information) +namespace { - uint8 tankCount = 0, dpsCount = 0, healCount = 0; - for (roleMap::iterator it = information->currentRoles.begin(); it != information->currentRoles.end(); ++it) + // The client's LFD frame offers four INDEPENDENT checkboxes -- FrameXML/LFDFrame.lua + // calls SetLFGRoles(leader, tank, healer, dps) -- so the mask that arrives on the + // wire routinely carries several roles at once. A player who ticked tank AND dps is + // willing to fill either, not neither. + // + // Every consumer here used to switch on the exact value of (mask & ~LEADER), matching + // only 0x02/0x04/0x08. A hybrid therefore counted as zero of everything: solo hybrids + // merged into a full-size entry that still reported every role missing and could + // neither complete nor merge again, and a premade containing one hybrid failed its + // role check outright and was ejected. + // + // Assigning each player exactly one of the roles they offered needs backtracking, not + // a greedy pass: given a tank-only player and a tank-or-healer player, handing the + // tank slot to the hybrid first strands the specialist even though a valid assignment + // exists. With at most 5 players and 3 roles the search is bounded by 3^5. + struct RoleQuota { - uint8 withoutLeader = it->second; - withoutLeader &= ~PLAYER_ROLE_LEADER; + uint8 tank; + uint8 healer; + uint8 damage; + + uint32 Total() const { return uint32(tank) + healer + damage; } + }; - switch (withoutLeader) + bool AssignRolesRecursive(std::vector const& masks, size_t index, RoleQuota remaining, + RoleQuota& leftover) + { + if (index == masks.size()) { - case PLAYER_ROLE_TANK: - ++tankCount; - break; - case PLAYER_ROLE_HEALER: - ++healCount; - break; - case PLAYER_ROLE_DAMAGE: - ++dpsCount; - break; + leftover = remaining; // what is still open once everyone present is placed + return true; + } + + static uint8 const candidates[3] = { PLAYER_ROLE_TANK, PLAYER_ROLE_HEALER, PLAYER_ROLE_DAMAGE }; + + for (uint8 i = 0; i < 3; ++i) + { + uint8 const role = candidates[i]; + if (!(masks[index] & role)) + { + continue; + } + + uint8* slot = (role == PLAYER_ROLE_TANK) ? &remaining.tank + : (role == PLAYER_ROLE_HEALER) ? &remaining.healer + : &remaining.damage; + + if (!*slot) + { + continue; + } + + --(*slot); + if (AssignRolesRecursive(masks, index + 1, remaining, leftover)) + { + return true; + } + ++(*slot); } + + return false; } - std::set::iterator itr = information->dungeonList.begin(); + /// Can every player fill exactly one of the roles they offered, within the dungeon's caps? + /// On success `leftover` receives the roles still open, which is what the queue + /// advertises as "needed" and what the completion test reads. + bool RolesFitQuota(roleMap const& roles, RoleQuota const& quota, RoleQuota& leftover) + { + if (roles.size() > quota.Total()) + { + return false; + } - // check dungeon type for max of each role [normal heroic etc.] - LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(*itr); - if (dungeon) + std::vector masks; + masks.reserve(roles.size()); + + for (roleMap::const_iterator it = roles.begin(); it != roles.end(); ++it) + { + uint8 const offered = uint8(it->second & ~PLAYER_ROLE_LEADER); + if (!offered) + { + return false; // no role ticked at all -- cannot be placed + } + + masks.push_back(offered); + } + + // Least-flexible player first, so the search prunes early. + std::sort(masks.begin(), masks.end(), [](uint8 a, uint8 b) + { + uint8 popA = uint8((a & 2 ? 1 : 0) + (a & 4 ? 1 : 0) + (a & 8 ? 1 : 0)); + uint8 popB = uint8((b & 2 ? 1 : 0) + (b & 4 ? 1 : 0) + (b & 8 ? 1 : 0)); + return popA < popB; + }); + + leftover = quota; + return AssignRolesRecursive(masks, 0, quota, leftover); + } + + /// The role composition a dungeon actually wants, straight off its DBC row. + /// + /// Not every queueable row is a 1/1/3 five-man: the shipped LfgDungeons.dbc carries + /// 0/0/3 scenarios, 0/0/1 solo content, 2/6/17 raid finder and 0/0/25 flexible raid. + /// Reading the row instead of assuming NORMAL_* is what lets those queue at all. + bool GetDungeonQuota(std::set const& dungeonList, RoleQuota& quota) { - // atm we're just handling DUNGEON_DIFFICULTY_NORMAL. - // - // Same raw-vs-internal confusion as GetDungeonType: LfgDungeons.dbc DifficultyID is a raw - // client id, so comparing it to DUNGEON_DIFFICULTY_NORMAL (internal 0) matched only rows - // carrying raw 0 -- 60 of them -- and never raw 1, which is what a 5-man normal dungeon - // actually is. 60 rather than the 59 given before: the old comparison had no TypeID - // filter, so its match set included id 358, 10v10 Rated Battleground, which is raid-typed. - // 59 is the non-raid subset, which is not what the code being described matched. The 90 normal-dungeon rows therefore left neededTanks, - // neededHealers and neededDps at their default, so the role counts were never initialised - // for the one case this branch claims to handle. - // ...and only for FIVE-MAN rows. NORMAL_TANK_OR_HEALER_COUNT and NORMAL_DAMAGE_COUNT are 1, - // 1 and 3 -- a 5-man composition. Raid rows carry raw DifficultyID 3 (10-normal) and 9 - // (legacy 40-player), both of which translate to internal 0, so without the TypeID test the - // translation would newly hand a 10, 25 or 40-player raid a one-tank/one-healer/three-dps - // requirement. Before the translation those rows compared raw 3 and 9 against internal 0 and - // missed, so they were excluded by accident. - // - // LfgDungeons.dbc does supply per-row Count_tank, Count_healer and Count_damage, which is - // where raid compositions should eventually come from. Reading them here would change the - // 5-man numbers too, so it is left out of this fix; the point of this branch is the key - // space, and it must not silently start sizing raid groups. - if (dungeon->TypeID != LFG_TYPE_RAID && - ToInternalDifficulty(dungeon->DifficultyID) == int32(DUNGEON_DIFFICULTY_NORMAL)) + if (dungeonList.empty()) { - information->neededTanks = NORMAL_TANK_OR_HEALER_COUNT - tankCount; - information->neededHealers = NORMAL_TANK_OR_HEALER_COUNT - healCount; - information->neededDps = NORMAL_DAMAGE_COUNT - dpsCount; + return false; + } + + LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(*dungeonList.begin()); + if (!dungeon) + { + return false; } + + quota.tank = uint8(dungeon->Count_tank); + quota.healer = uint8(dungeon->Count_healer); + quota.damage = uint8(dungeon->Count_damage); + + return quota.Total() != 0; + } +} + +bool LFGMgr::RolesAreValidForDungeons(roleMap const& roles, std::set const& dungeonList) +{ + RoleQuota quota; + if (!GetDungeonQuota(dungeonList, quota)) + { + return false; + } + + RoleQuota leftover; + return RolesFitQuota(roles, quota, leftover); +} + +void LFGMgr::UpdateNeededRoles(ObjectGuid guid, LFGPlayers* information) +{ + // The role composition comes from the DUNGEON, not from a difficulty test. + // + // This previously read `if (dungeon->DifficultyID == DUNGEON_DIFFICULTY_NORMAL)`, + // comparing a RAW client DifficultyID against the internal 0-based enum -- two + // different key spaces at 5.4.8. The shipped LfgDungeons.dbc carries no queueable + // row with DifficultyID 0 at all (TypeID 1 has {1,2,7,11,12,14}, TypeID 6 has + // {1,2,11,12}), so the branch NEVER fired and the needed-role counts stayed at zero + // for every entry. With zeros, RoleMapsAreCompatible computed (3-0)+(3-0) = 6 > 3 + // and refused every pair, so the matchmaker formed nothing at all. + RoleQuota quota; + if (!GetDungeonQuota(information->dungeonList, quota)) + { + m_playerData[guid] = *information; + return; + } + + RoleQuota leftover; + if (!RolesFitQuota(information->currentRoles, quota, leftover)) + { + // No assignment places everyone present -- the entry is over-subscribed on some + // role. Report the dungeon's full requirement so it advertises as unsatisfiable + // rather than wrapping a uint8 and claiming 254 damage slots are free. + leftover = quota; } + // The resolver already clamped these: they count down from the quota as players are + // placed and can never go below zero, so the old `1 - tankCount` uint8 wrap that + // turned a two-tank party into "254 more tanks welcome" cannot recur. + information->neededTanks = leftover.tank; + information->neededHealers = leftover.healer; + information->neededDps = leftover.damage; + m_playerData[guid] = *information; } @@ -636,12 +754,49 @@ void LFGMgr::AddToWaitMap(uint8 role, std::set dungeons) } } +bool LFGMgr::TryFormGroup(ObjectGuid guid) +{ + LFGPlayers* entry = GetPlayerOrPartyData(guid); + if (!entry || entry->currentState != LFG_STATE_QUEUED) + { + return false; + } + + if (entry->neededTanks || entry->neededHealers || entry->neededDps) + { + return false; + } + + SendDungeonProposal(entry); + + // Out of the queue the moment a proposal exists for it. Without this the entry is + // still LFG_STATE_QUEUED next tick, gets matched again, and fires a fresh proposal + // -- and a new SMSG_LFG_PROPOSAL_UPDATE -- every single tick, forever. + m_queueSet.erase(guid); + m_playerData.erase(guid); + return true; +} + void LFGMgr::FindQueueMatches() { - // Fetch information on all the queued players/groups - for (queueSet::iterator itr = m_queueSet.begin(); itr != m_queueSet.end(); ++itr) + // Snapshot: MergeGroups and TryFormGroup both erase from m_queueSet, and erasing the + // element an active iterator points at is UB. + queueSet const snapshot = m_queueSet; + + for (queueSet::const_iterator itr = snapshot.begin(); itr != snapshot.end(); ++itr) { + // An entry can be absorbed or dequeued by an earlier iteration of this same pass. + if (m_queueSet.find(*itr) == m_queueSet.end()) + { + continue; + } + FindSpecificQueueMatches(*itr); + + // A party that arrives already complete -- the common premade-of-five case -- + // is never merged with anything, so the completion test inside MergeGroups + // never sees it. Without this check such a group waits in the queue forever. + TryFormGroup(*itr); } } @@ -654,13 +809,28 @@ void LFGMgr::FindSpecificQueueMatches(ObjectGuid guid) // compare to everyone else in queue for compatibility // after a match is found call UpdateNeededRoles // Use the roleMap to store player guid/role information; merge into queueInfo struct & delete other struct/map entry - for (queueSet::iterator itr = m_queueSet.begin(); itr != m_queueSet.end(); ++itr) + queueSet const snapshot = m_queueSet; + + for (queueSet::const_iterator itr = snapshot.begin(); itr != snapshot.end(); ++itr) { if (*itr == guid) { continue; } + // Absorbed by an earlier merge in this same pass, or dequeued by a proposal. + if (m_queueSet.find(*itr) == m_queueSet.end()) + { + continue; + } + + // Re-read: MergeGroups mutates the entry we are accumulating into. + queueInfo = GetPlayerOrPartyData(guid); + if (!queueInfo) + { + return; + } + LFGPlayers* matchInfo = GetPlayerOrPartyData(*itr); if (matchInfo) { @@ -684,7 +854,7 @@ void LFGMgr::FindSpecificQueueMatches(ObjectGuid guid) { // check for player / role count and also team compatibility // if function returns true, then merge groups into one - if (RoleMapsAreCompatible(queueInfo, matchInfo) && MatchesAreOfSameTeam(queueInfo, matchInfo)) + if (RoleMapsAreCompatible(queueInfo, matchInfo, compatibleDungeons) && MatchesAreOfSameTeam(queueInfo, matchInfo)) { MergeGroups(guid, *itr, compatibleDungeons); } @@ -694,35 +864,47 @@ void LFGMgr::FindSpecificQueueMatches(ObjectGuid guid) } } -bool LFGMgr::RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo) +bool LFGMgr::RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo, + std::set const& compatibleDungeons) { - // When this is called we already know that the dungeons match, so just focus on roles - // compare: neededX(role) from each struct and the amount of people per role in the roleMap - if ((groupOne->currentRoles.size() + groupTwo->currentRoles.size()) > NORMAL_TOTAL_ROLE_COUNT) + // When this is called we already know the dungeons overlap, so just focus on roles. + // + // The question is simply: if these two entries were one, could every player in the + // union fill a distinct slot the dungeon actually has? Asking the resolver directly + // replaces the old per-role arithmetic, which recovered "present" as + // (NORMAL_X - neededX) and so inherited every uint8 wrap in neededX -- a two-tank + // party gave (1-255) + (1-0) = -254, which passed the cap test and merged a party + // that could never complete. + // + // It also drops the hardcoded 1/1/3/5, which is wrong for the 108 of 247 queueable + // TypeID 1 rows that are scenarios (0/0/3), solo content (0/0/1), raid finder + // (2/6/17) or flexible raid (0/0/25). + RoleQuota quota; + if (!GetDungeonQuota(compatibleDungeons, quota)) { return false; } - else + + if ((groupOne->currentRoles.size() + groupTwo->currentRoles.size()) > quota.Total()) { - // make sure we don't have too many players of a certain role here - if (((NORMAL_DAMAGE_COUNT - groupOne->neededDps) + (NORMAL_DAMAGE_COUNT - groupTwo->neededDps)) > NORMAL_DAMAGE_COUNT) - { - return false; - } - else if (((NORMAL_TANK_OR_HEALER_COUNT - groupOne->neededHealers) + (NORMAL_TANK_OR_HEALER_COUNT - groupTwo->neededHealers)) > NORMAL_TANK_OR_HEALER_COUNT) - { - return false; - } - else if (((NORMAL_TANK_OR_HEALER_COUNT - groupOne->neededTanks) + (NORMAL_TANK_OR_HEALER_COUNT - groupTwo->neededTanks)) > NORMAL_TANK_OR_HEALER_COUNT) - { - return false; - } - else - { - return true; // the player/role counts line up! - } + return false; } - return false; + + roleMap combined = groupOne->currentRoles; + for (roleMap::const_iterator it = groupTwo->currentRoles.begin(); it != groupTwo->currentRoles.end(); ++it) + { + combined[it->first] = it->second; + } + + // A player present in both entries collapses to one key, so the union can be smaller + // than the sum -- that is the duplicate-membership case and it must not merge. + if (combined.size() != groupOne->currentRoles.size() + groupTwo->currentRoles.size()) + { + return false; + } + + RoleQuota leftover; + return RolesFitQuota(combined, quota, leftover); } bool LFGMgr::MatchesAreOfSameTeam(LFGPlayers* groupOne, LFGPlayers* groupTwo) @@ -778,13 +960,19 @@ void LFGMgr::MergeGroups(ObjectGuid guidOne, ObjectGuid guidTwo, std::setneededTanks == 0) && (mainGroup->neededHealers == 0) && (mainGroup->neededDps == 0)) - { - SendDungeonProposal(mainGroup); - } - + // Both containers, or guidTwo lingers in m_queueSet pointing at data that no + // longer exists. That stale entry is not merely a leak: the merged-away + // player still reads LFG_STATE_QUEUED, SendQueueStatus keys off m_playerData + // so their client never hears again, and a re-queue skips JoinLFG's + // duplicate cleanup (it is guarded on existing data) -- leaving that player + // live in a fresh solo entry AND still listed in the merged entry's roles, + // which can produce two proposals for the same person. + m_queueSet.erase(guidTwo); m_playerData.erase(guidTwo); + + // Completion is decided after the absorbed entry is gone, so the proposal is built + // from one consistent view and TryFormGroup can dequeue the survivor safely. + TryFormGroup(guidOne); } void LFGMgr::SendQueueStatus() diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index f0fab1def..35138d9fd 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -420,7 +420,9 @@ enum LFGSpells enum LFGTimes { - LFG_TIME_ROLECHECK = 45*IN_MILLISECONDS, + // SECONDS, not milliseconds: waitForRoleTime is built from time(NULL), + // so 45*IN_MILLISECONDS made a role check expire after 12.5 HOURS. + LFG_TIME_ROLECHECK = 45, LFG_TIME_BOOT = 120, LFG_TIME_PROPOSAL = 45, }; @@ -846,6 +848,13 @@ class LFGMgr */ void UpdateNeededRoles(ObjectGuid guid, LFGPlayers* information); + /** + * @brief Fire a proposal for this entry if every role it needs is filled, and + * dequeue it so it cannot be matched or proposed again. + * @return true if a proposal was sent (the entry no longer exists). + */ + bool TryFormGroup(ObjectGuid guid); + /** * @brief Add the player or group to the Dungeon Finder queue * @@ -885,7 +894,16 @@ class LFGMgr void PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles); /// Make sure role selections are okay - bool ValidateGroupRoles(roleMap groupMap); + bool ValidateGroupRoles(roleMap groupMap, std::set const& dungeonList); + + /** + * @brief Can every player fill exactly one of the roles they ticked, within the + * role counts the dungeon's own DBC row asks for? + * + * Handles multi-role selections: the client offers four independent checkboxes, + * so a mask carrying tank|damage is a player willing to be either. + */ + bool RolesAreValidForDungeons(roleMap const& roles, std::set const& dungeonList); /// Proposal-Related Functions @@ -919,7 +937,7 @@ class LFGMgr bool HasLeaderFlag(roleMap const& roles); /// Compares two groups/players to see if their role combinations are compatible - bool RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo); + bool RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo, std::set const& compatibleDungeons); /// Checks whether or not two combinations of players/groups are on the same team (alliance/horde) bool MatchesAreOfSameTeam(LFGPlayers* groupOne, LFGPlayers* groupTwo); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index b3bc9ed03..3b81a96b9 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -53,15 +53,22 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) return; // no role check map found } - LFGRoleCheck roleCheck = it->second; + // A REFERENCE, not a copy. This was `LFGRoleCheck roleCheck = it->second;`, so + // every `roleCheck.currentRoles[plrGuid] = roles` below landed in a temporary that + // was discarded on return -- no member's answer was ever recorded, and a party of + // two or more could never complete its role check no matter what anyone clicked. + LFGRoleCheck& roleCheck = it->second; bool roleChosen = roleCheck.state != LFG_ROLECHECK_DEFAULT && plrGuid; if (!plrGuid) { roleCheck.state = LFG_ROLECHECK_ABORTED; // aborted if anyone cancels during role check } - else if (roles < PLAYER_ROLE_TANK) // kind of a sanity check- the client shouldn't allow this to happen + else if (!(roles & (PLAYER_ROLE_TANK | PLAYER_ROLE_HEALER | PLAYER_ROLE_DAMAGE))) { + // The mask must name at least one real role. Testing `roles < PLAYER_ROLE_TANK` + // only rejected 0 and a bare LEADER bit; it accepted any unknown high bit as a + // valid answer, which then matched no role anywhere downstream. roleCheck.state = LFG_ROLECHECK_NO_ROLE; } else @@ -80,7 +87,7 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) if (allRolesChosen) // meaning that everyone confirmed their roles { - roleCheck.state = ValidateGroupRoles(roleCheck.currentRoles) ? LFG_ROLECHECK_FINISHED : LFG_ROLECHECK_MISSING_ROLE; + roleCheck.state = ValidateGroupRoles(roleCheck.currentRoles, roleCheck.dungeonList) ? LFG_ROLECHECK_FINISHED : LFG_ROLECHECK_MISSING_ROLE; } } @@ -131,7 +138,13 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) if (roleCheck.state == LFG_ROLECHECK_FINISHED) { - LFGPlayers* queueInfo = GetPlayerOrPartyData(groupGuid); + LFGPlayers* queueInfo = GetPlayerOrPartyData(groupGuid); + if (!queueInfo) + { + m_roleCheckMap.erase(groupGuid); + return; + } + queueInfo->currentState = LFG_STATE_QUEUED; queueInfo->currentRoles = roleCheck.currentRoles; queueInfo->joinedTime = time(NULL); @@ -139,6 +152,10 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) m_playerData[groupGuid] = *queueInfo; AddToQueue(groupGuid); + + // The check is resolved; leaving it in the map makes RemoveOldRoleChecks expire + // an already-queued party and tear its queue entry back down. + m_roleCheckMap.erase(groupGuid); } else if (roleCheck.state != LFG_ROLECHECK_INITIALITING) { @@ -158,35 +175,21 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) } } -bool LFGMgr::ValidateGroupRoles(roleMap groupMap) +bool LFGMgr::ValidateGroupRoles(roleMap groupMap, std::set const& dungeonList) { if (groupMap.empty()) // sanity check { return false; } - uint8 tankCount = 0, dpsCount = 0, healCount = 0; - - for (roleMap::iterator it = groupMap.begin(); it != groupMap.end(); ++it) - { - uint8 withoutLeader = it->second; - withoutLeader &= ~PLAYER_ROLE_LEADER; - - switch (withoutLeader) - { - case PLAYER_ROLE_TANK: - ++tankCount; - break; - case PLAYER_ROLE_HEALER: - ++healCount; - break; - case PLAYER_ROLE_DAMAGE: - ++dpsCount; - break; - } - } - - return (tankCount + dpsCount + healCount == groupMap.size()) ? true : false; + // This used to assert only that every member had picked exactly one of tank/healer/ + // damage, which failed two ways at once: a member who ticked tank AND damage matched + // no case and sank the whole party's role check, while a party of five tanks passed + // it and then jammed the queue because no dungeon has five tank slots. + // + // Asking whether the party can be assigned to the dungeon's actual role counts covers + // both, and covers scenarios and raid finder, whose compositions are not 1/1/3. + return RolesAreValidForDungeons(groupMap, dungeonList); } //todo: remove from queue, update queue average settings @@ -1179,7 +1182,12 @@ void LFGMgr::SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, LFGStat void LFGMgr::RemoveOldRoleChecks() { - for (roleCheckMap::iterator roleItr = m_roleCheckMap.begin(); roleItr != m_roleCheckMap.end(); ++roleItr) + // Erase-safe iteration. m_roleCheckMap is an unordered_map, so erasing by + // key destroys the node roleItr points at and the following ++roleItr walks + // freed memory. This is the FIRST thing LFGMgr::Update calls, so it would + // crash or spin the world thread on the first tick that finds an expired + // check. + for (roleCheckMap::iterator roleItr = m_roleCheckMap.begin(); roleItr != m_roleCheckMap.end(); ) { ObjectGuid groupGuid = roleItr->first; @@ -1198,7 +1206,16 @@ void LFGMgr::RemoveOldRoleChecks() SendLfgUpdate(plrGuid, GetPlayerStatus(plrGuid), true); // not in lfg system anymore } - m_roleCheckMap.erase(groupGuid); + // Advance BEFORE erasing, and drop the queue data this check owned: + // the entries JoinLFG wrote for the group would otherwise survive + // with nothing left to resolve them. + m_playerData.erase(groupGuid); + m_queueSet.erase(groupGuid); + roleItr = m_roleCheckMap.erase(roleItr); + } + else + { + ++roleItr; } } } diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 8c1f653ed..f1972d5d8 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -303,8 +303,6 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen roleCheck.leaderGuidRaw = leaderGuid.GetRawValue(); roleCheck.waitForRoleTime = time_t(time(NULL) + LFG_TIME_ROLECHECK); - m_roleCheckMap[guid] = roleCheck; - // place original dungeon ID back in the set if (isRandom) { @@ -327,6 +325,13 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen m_playerStatusMap[plrGuid] = overallStatus; } } + + // Stored AFTER the loop above, not before it. The stored copy used to be taken + // while currentRoles was still empty, so the role check the rest of the system + // saw listed nobody: PerformRoleCheck then found "everyone" had answered as soon + // as the FIRST member replied, and a five-man queued on a one-entry role map. + m_roleCheckMap[guid] = roleCheck; + // used later if they enter the queue LFGPlayers groupInfo(LFG_STATE_NONE, dungeons, roleCheck.currentRoles, comments, false, time(NULL), 0, 0, 0); m_playerData[guid] = groupInfo; From 6c6f513e49ab001374de78ce4cc7fa369fe60006 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 00:16:23 +0100 Subject: [PATCH 02/81] LFG: wire CMSG_LFG_SET_ROLES 0x08A2, the role check reply The reply half of the LFG role check had no handler and no registration, so the client's answer was dropped at the dispatcher without a log line and a party entered LFG_STATE_ROLECHECK and stayed there permanently. Body derived from the client's own writer sub_6688D0, reached as vtable slot 1 behind the opcode thunk sub_6615FE (which writes 2210): sub_40F075(pkt, *(uint32*)(this + 16)); // WriteUInt32 -- role mask sub_40F018(pkt, *(uint8 *)(this + 20)); // WriteUInt8 -- role check counter Flat -- no bit packing and no GUID, so nothing to XOR or reorder. All 99 build-18414 packets in the corpus are exactly 5 bytes, which agrees. Note the Lua SetLFGRoles() does not send this; it only mutates local state. The packet is emitted by CompleteLFGRoleCheck, i.e. on confirmation. Fixture uses real captured bodies, not inverses of our own reader: capture-000086 seq 16621 08 00 00 00 00 damage only capture-000112 seq 90341 0A 00 00 00 00 TANK|DAMAGE The second is why the mask must be treated as a bitmask: it is one player offering either role, and it is what the previous exact-value matching threw away. Corpus catalogueGenerationId 2BE10C89...88752. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 1 + src/game/Server/Opcodes_reference.h | 2 +- src/game/Server/WorldSession.h | 1 + src/game/Server/tests/CMakeLists.txt | 16 ++++ .../tests/mop_lfg_set_roles_packets_test.cpp | 90 +++++++++++++++++++ src/game/WorldHandlers/Group.cpp | 15 ++++ src/game/WorldHandlers/Group.h | 27 ++++++ src/game/WorldHandlers/LFGHandler.cpp | 40 +++++++++ 8 files changed, 191 insertions(+), 1 deletion(-) create mode 100644 src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index 8809d54e9..7c10ebc7a 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -804,6 +804,7 @@ void InitializeOpcodes() // Empty 18414 status refresh request. The handler replies through the // already-converted unified SMSG_LFG_UPDATE_STATUS body. + DefC(CMSG_LFG_SET_ROLES, "CMSG_LFG_SET_ROLES", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgSetRolesOpcode); DefC(CMSG_LFG_GET_STATUS, "CMSG_LFG_GET_STATUS", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgGetStatusOpcode); // Direct 18414 LFR-browser request and empty full-replacement response. diff --git a/src/game/Server/Opcodes_reference.h b/src/game/Server/Opcodes_reference.h index 9717ba90b..fcf592121 100644 --- a/src/game/Server/Opcodes_reference.h +++ b/src/game/Server/Opcodes_reference.h @@ -1575,7 +1575,7 @@ typedef uint16_t uint16; * CMSG_INSPECT_RATED_BG_STATS 0x0882 DORMANT * CMSG_RAID_TARGET_UPDATE 0x0886 ACTIVE * CMSG_UNKNOWN_0x0896 0x0896 DOC - * CMSG_LFG_SET_ROLES 0x08A2 DORMANT + * CMSG_LFG_SET_ROLES 0x08A2 REGISTERED * CMSG_RANDOM_ROLL 0x08A3 DOC * CMSG_REORDER_CHARACTERS 0x08A7 DORMANT * CMSG_MESSAGECHAT_ADDON_INSTANCE 0x08AF ACTIVE diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index efb123d60..7efa45171 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -2209,6 +2209,7 @@ class WorldSession void HandleLfrLeaveOpcode(WorldPacket& recv_data); void HandleLfgJoinOpcode(WorldPacket& recv_data); void HandleLfgLeaveOpcode(WorldPacket& recv_data); + void HandleLfgSetRolesOpcode(WorldPacket& recv_data); void HandleLfgGetStatusOpcode(WorldPacket& recv_data); void HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data); void HandleSetLfgCommentOpcode(WorldPacket& recv_data); diff --git a/src/game/Server/tests/CMakeLists.txt b/src/game/Server/tests/CMakeLists.txt index f8270539b..c753c0c43 100644 --- a/src/game/Server/tests/CMakeLists.txt +++ b/src/game/Server/tests/CMakeLists.txt @@ -330,6 +330,22 @@ if(WIN32) "PATH=path_list_prepend:${MOP_LFG_LEAVE_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") endif() +add_executable(mop_lfg_set_roles_packets_test + mop_lfg_set_roles_packets_test.cpp) +target_include_directories(mop_lfg_set_roles_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_set_roles_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_set_roles_packets_test PRIVATE game) +add_test(NAME mop_lfg_set_roles_packets COMMAND mop_lfg_set_roles_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_SET_ROLES_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_set_roles_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_SET_ROLES_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + add_executable(mop_group_role_poll_packets_test mop_group_role_poll_packets_test.cpp) target_include_directories(mop_group_role_poll_packets_test PRIVATE diff --git a/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp b/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp new file mode 100644 index 000000000..cb706a21d --- /dev/null +++ b/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp @@ -0,0 +1,90 @@ +/** + * Byte-exact coverage for the CMSG_LFG_SET_ROLES (0x08A2) reader. + * + * The bodies below are REAL captured client bytes at build 18414, not inverses of our + * own writer. Provenance is recorded per case so the fixture can be re-derived. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * + * Layout derived from the client's body writer sub_6688D0, reached as vtable slot 1 + * behind the opcode thunk sub_6615FE (which writes 2210): + * + * sub_40F075(pkt, *(uint32*)(this + 16)); // WriteUInt32 -- role mask + * sub_40F018(pkt, *(uint8 *)(this + 20)); // WriteUInt8 -- role check counter + * + * Flat, so there is no bit packing and no GUID obfuscation to undo. + */ + +#include "Group.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + WorldPacket MakeBody(std::vector const& bytes) + { + WorldPacket packet(CMSG_LFG_SET_ROLES, bytes.size()); + packet.append(bytes.data(), bytes.size()); + return packet; + } + + /// capture-000086 seq 16621, 5 bytes: 08 00 00 00 00 + /// A plain damage-only selection -- the single-role case. + void test_single_role_body() + { + std::vector const body = { 0x08, 0x00, 0x00, 0x00, 0x00 }; + + WorldPacket packet = MakeBody(body); + MopLfgSetRolesPackets::Request request; + assert(MopLfgSetRolesPackets::ParseRequest(packet, request)); + + assert(request.roles == 0x08); // PLAYER_ROLE_DAMAGE + assert(request.roleCheckCounter == 0); + assert(packet.rpos() == packet.size()); // no tail left unread + } + + /// capture-000112 seq 90341, 5 bytes: 0A 00 00 00 00 + /// + /// The case that matters: 0x0A is TANK|DAMAGE, one player offering either role. This + /// is direct wire proof that the mask is a bitmask and not an enum -- the reader must + /// not try to match it against a single role value. + void test_hybrid_role_body() + { + std::vector const body = { 0x0A, 0x00, 0x00, 0x00, 0x00 }; + + WorldPacket packet = MakeBody(body); + MopLfgSetRolesPackets::Request request; + assert(MopLfgSetRolesPackets::ParseRequest(packet, request)); + + assert(request.roles == 0x0A); + assert((request.roles & PLAYER_ROLE_TANK) != 0); + assert((request.roles & PLAYER_ROLE_DAMAGE) != 0); + assert((request.roles & PLAYER_ROLE_HEALER) == 0); + assert(request.roleCheckCounter == 0); + assert(packet.rpos() == packet.size()); + } + + /// A body one byte short must be refused outright rather than read past its end. + void test_short_body_is_refused() + { + std::vector const body = { 0x08, 0x00, 0x00, 0x00 }; + + WorldPacket packet = MakeBody(body); + MopLfgSetRolesPackets::Request request; + assert(!MopLfgSetRolesPackets::ParseRequest(packet, request)); + } +} + +int main() +{ + test_single_role_body(); + test_hybrid_role_body(); + test_short_body_is_refused(); + + std::printf("mop_lfg_set_roles_packets_test: OK\n"); + return 0; +} diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index f826063e9..02f576c90 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -603,6 +603,21 @@ bool MopGroupPromotePackets::ParseAssistant(WorldPacket& in, AssistantRequest& o return true; } +bool MopLfgSetRolesPackets::ParseRequest(WorldPacket& in, Request& out) +{ + // Fixed 5 bytes. Refuse anything else rather than reading past the end -- a short + // body would otherwise leave the role mask half-populated and silently queue the + // player as the wrong role. + if (in.size() - in.rpos() < 5) + { + return false; + } + + in >> out.roles; + in >> out.roleCheckCounter; + return true; +} + bool MopLfgLeavePackets::ParseRequest(WorldPacket& in, Request& out) { // Build 18414 writer sub_6674C9 (Wow.exe.c:879339-879394). Layout: diff --git a/src/game/WorldHandlers/Group.h b/src/game/WorldHandlers/Group.h index 2067b7a30..cc853470b 100644 --- a/src/game/WorldHandlers/Group.h +++ b/src/game/WorldHandlers/Group.h @@ -333,6 +333,33 @@ namespace MopLfgLeavePackets bool ParseRequest(WorldPacket& in, Request& out); } +namespace MopLfgSetRolesPackets +{ + /// A parsed CMSG_LFG_SET_ROLES body. + /// + /// Derived from the client's own body writer sub_6688D0 -- vtable slot 1 behind the + /// opcode thunk sub_6615FE, which writes 2210 (0x08A2). It emits exactly two fields + /// and nothing else: + /// + /// sub_40F075(pkt, *(uint32*)(this + 16)); // WriteUInt32 -- the role mask + /// sub_40F018(pkt, *(uint8 *)(this + 20)); // WriteUInt8 -- role check counter + /// + /// Flat: no bit packing and no GUID, so there is nothing to XOR or reorder. All 99 + /// build-18414 captures in the corpus are exactly 5 bytes, which agrees. + struct Request + { + /// Bitmask, NOT an enum. The LFD frame's four checkboxes are independent, so a + /// player offering tank-or-damage sends 0x0A -- observed on the wire in + /// capture-000112 seq 90341. + uint32 roles = 0; + + /// Echoed back by the client; carries no authority server-side. + uint8 roleCheckCounter = 0; + }; + + bool ParseRequest(WorldPacket& in, Request& out); +} + namespace MopGroupMarkerPackets { struct MinimapPingRequest diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index cdf777863..8cc863aff 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -184,6 +184,46 @@ void WorldSession::HandleLfgLeaveOpcode(WorldPacket& recv_data) sLFGMgr.LeaveLFG(plr, isGroup); } +void WorldSession::HandleLfgSetRolesOpcode(WorldPacket& recv_data) +{ + DEBUG_LOG("CMSG_LFG_SET_ROLES"); + + // This is the reply half of the LFG role check, and it had no handler and no + // registration at all -- the client's answer was dropped at the dispatcher without + // so much as a log line, so a party entered LFG_STATE_ROLECHECK and stayed there. + // + // Note the Lua SetLFGRoles() does NOT send this; it only mutates local state. The + // packet is emitted by CompleteLFGRoleCheck, i.e. when the player confirms. + MopLfgSetRolesPackets::Request request; + if (!MopLfgSetRolesPackets::ParseRequest(recv_data, request)) + { + sLog.outError("Malformed CMSG_LFG_SET_ROLES body from %s: expected 5 bytes.", + GetPlayerName()); + return; + } + + Player* plr = GetPlayer(); + if (!plr) + { + return; + } + + // A role check only exists for a party. A solo queuer states their roles in + // CMSG_LFG_JOIN and never reaches this path. + Group* pGroup = plr->GetGroup(); + if (!pGroup) + { + return; + } + + DEBUG_LOG("CMSG_LFG_SET_ROLES: %s roles 0x%02X.", GetPlayerName(), request.roles); + + // Truncated to the byte the role plumbing uses. The wire field is 32 bits, but only + // the low four (leader/tank/healer/damage) are ever set; anything above them is + // rejected by PerformRoleCheck's mask test rather than being silently accepted. + sLFGMgr.PerformRoleCheck(plr, pGroup, uint8(request.roles & 0xFF)); +} + void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) { DEBUG_LOG("CMSG_LFG_GET_STATUS"); From 858982ac4e3a80bff4cfb691e4e262595665a2e9 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 00:26:37 +0100 Subject: [PATCH 03/81] LFG: close the proposal-path crash and indeterminate-branch defects LFGProposal had no member initialisers, and groupRawGuid/groupLeaderGuid are the only two scalars SendDungeonProposal does not always assign -- it sets them solely on the premade path. Yet it READS groupRawGuid to decide whether to set it, and CreateDungeonGroup branches on it to choose between reusing an existing group and making a new one. An all-solo proposal therefore picked its branch from whatever was on the stack. LFGPlayers had the same problem for joinedTime and the three needed* counts, which decide both completion and what the queue advertises. SendLfgProposalUpdate dereferenced three find() results without checking end(). It is reachable, not theoretical: SendDungeonProposal skips offline players when filling `groups` and `answers` but still lists them in `currentRoles`, so a player who queues, logs out and logs back in arrives with no entry of their own and crashes the session the moment another member answers. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGHandler.cpp | 30 ++++++++++++++++++--- src/game/WorldHandlers/LFGMgr.h | 38 ++++++++++++++++----------- 2 files changed, 50 insertions(+), 18 deletions(-) diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 8cc863aff..3c7b14469 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -465,8 +465,24 @@ void WorldSession::SendLfgRoleChosen(uint64 rawGuid, uint8 roles) void WorldSession::SendLfgProposalUpdate(LFGProposal const& proposal) { Player* pPlayer = GetPlayer(); + if (!pPlayer) + { + return; + } + ObjectGuid plrGuid = pPlayer->GetObjectGuid(); - ObjectGuid plrGroupGuid = proposal.groups.find(plrGuid)->second; + + // find() without checking end() dereferenced a past-the-end iterator. It is reachable: + // SendDungeonProposal skips offline players when filling `groups` and `answers` but + // still lists them in `currentRoles`, so a player who queues, logs out, and logs back + // in before someone else answers arrives here with no entry of their own. + playerGroupMap::const_iterator myGroup = proposal.groups.find(plrGuid); + if (myGroup == proposal.groups.end()) + { + return; + } + + ObjectGuid plrGroupGuid = myGroup->second; uint32 dungeonEntry = sLFGMgr.GetDungeonEntry(proposal.dungeonID); bool showProposal = !proposal.isNew && proposal.groupRawGuid == plrGroupGuid.GetRawValue(); @@ -483,8 +499,16 @@ void WorldSession::SendLfgProposalUpdate(LFGProposal const& proposal) for (playerGroupMap::const_iterator it = proposal.groups.begin(); it != proposal.groups.end(); ++it) { ObjectGuid grpPlrGuid = it->first; - uint8 grpPlrRole = proposal.currentRoles.find(grpPlrGuid)->second; - LFGProposalAnswer grpPlrAnswer = proposal.answers.find(grpPlrGuid)->second; + + roleMap::const_iterator roleItr = proposal.currentRoles.find(grpPlrGuid); + proposalAnswerMap::const_iterator answerItr = proposal.answers.find(grpPlrGuid); + if (roleItr == proposal.currentRoles.end() || answerItr == proposal.answers.end()) + { + continue; + } + + uint8 grpPlrRole = roleItr->second; + LFGProposalAnswer grpPlrAnswer = answerItr->second; data << uint32(grpPlrRole); // Player's role data << uint8(grpPlrGuid == plrGuid); // Is this player me? diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 35138d9fd..54581cc26 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -564,10 +564,12 @@ struct LFGPlayers //TODO: rename to LFGQueueData std::string comments; bool isGroup; - time_t joinedTime; - uint8 neededTanks; - uint8 neededHealers; - uint8 neededDps; + // Zeroed: the default constructor left these indeterminate, and needed* decides both + // whether an entry is complete and what the queue advertises to the client. + time_t joinedTime = 0; + uint8 neededTanks = 0; + uint8 neededHealers = 0; + uint8 neededDps = 0; LFGPlayers() : currentState(LFG_STATE_NONE), currentRoles(0), isGroup(false) {} LFGPlayers(LFGState state, std::set dungeonSelection, roleMap CurrentRoles, std::string comment, bool IsGroup, time_t JoinedTime, @@ -654,17 +656,23 @@ struct LFGGroupStatus //todo: check for this in joinlfg function, not lfgplayers /// For SMSG_LFG_PROPOSAL_UPDATE struct LFGProposal { - uint32 id; // proposal id - uint32 dungeonID; // dungeon id - LFGProposalState state; // proposal state - uint32 encounters; // encounters done - uint64 groupRawGuid; // group raw guid value - uint64 groupLeaderGuid; // group leader's guid - bool isNew; // is new or old group - roleMap currentRoles; // group player's roles - proposalAnswerMap answers; // answers to a proposal - playerGroupMap groups; // data on which groups players belong/belonged to - time_t joinedQueue; // time from when the players joined the queue + // Every scalar is initialised. groupRawGuid and groupLeaderGuid in particular are + // the only two SendDungeonProposal does not always assign -- it sets them solely on + // the premade path -- yet it READS groupRawGuid to decide whether to set it, and + // CreateDungeonGroup branches on it to choose between reusing an existing group and + // making a new one. Left indeterminate, an all-solo proposal picked its branch from + // whatever was on the stack. + uint32 id = 0; // proposal id + uint32 dungeonID = 0; // dungeon id + LFGProposalState state = LFG_PROPOSAL_INITIATING; // proposal state + uint32 encounters = 0; // encounters done + uint64 groupRawGuid = 0; // group raw guid value + uint64 groupLeaderGuid = 0; // group leader's guid + bool isNew = true; // is new or old group + roleMap currentRoles; // group player's roles + proposalAnswerMap answers; // answers to a proposal + playerGroupMap groups; // data on which groups players belong/belonged to + time_t joinedQueue = 0; // time from when the players joined the queue }; // For SMSG_LFG_PLAYER_REWARD From b5bb77202803772d94b5f3e386623a7030023011 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 00:46:47 +0100 Subject: [PATCH 04/81] LFG: derive and admit SMSG_LFG_ROLE_CHECK_UPDATE 0x12BB The role check prompt never appeared on the client however correct the server state was: the outbound packet was still the 3.3.5 shape -- uint32 state, flat counts, raw uint64 GUIDs -- and shared no field order with 18414. It was also unadmitted, so it never left the server at all. Derivation. The CMSG technique this campaign runs on (opcode thunk -> vtable slot 1 -> body writer) does not apply to SMSG: there is no opcode thunk because the client never sends these. Working from the other end instead: - GetLFGRoleUpdate binds to sub_98C59C -> sub_98C4D2, which reads a global block and yields inProgress = (state == 2), a slot count, a member count, a category derived from slot[0] & 0xFFFFF, and a battleground GUID. - The applier at 0x98953C fills that block from a parsed struct, giving the field set and types: state at +0x10, slot vector at +0x14/+0x18, member vector at +0x24/+0x28 with stride 0x18, GUID at +0x38. - The wire reader itself sits in a third layer of generated code reached indirectly, with the opcode nowhere in the image as a literal, so field ORDER had to come from traffic rather than from a reader. So the order is a hypothesis verified against real bytes, not read off a writer. It decodes two captures of deliberately different shape to zero leftover, and the writer added here reproduces both byte for byte: capture-000075 seq 891708, 35 B: partyIndex 0, 2 members, dungeon type 1 capture-000059 seq 719547, 68 B: partyIndex 1, 5 members, dungeon type 6 Corpus catalogueGenerationId 2BE10C89...88752. Two things the reference layout this was checked against gets wrong: partyIndex is not always zero -- the second capture carries 1 -- and the leader's entry must come first, which both captures confirm by carrying the LEADER bit on member 0 while later members are still zero. Also fixes an unchecked find() in the sender: a role check whose leader had already left dereferenced end(). Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/WorldSession.cpp | 1 + src/game/Server/tests/CMakeLists.txt | 16 ++ .../tests/mop_lfg_role_check_packets_test.cpp | 156 ++++++++++++++++++ src/game/WorldHandlers/LFGHandler.cpp | 71 ++++---- src/game/WorldHandlers/LFGMgr.h | 92 +++++++++++ 5 files changed, 306 insertions(+), 30 deletions(-) create mode 100644 src/game/Server/tests/mop_lfg_role_check_packets_test.cpp diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index d01a2c9b2..4d490cf12 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -604,6 +604,7 @@ static bool IsEnterWorldConverted(uint16 opcode) case SMSG_SAVE_GUILD_EMBLEM: // MopGuildPackets::BuildSaveGuildEmblemResult case SMSG_BINDER_CONFIRM: // MopBindPackets::BuildBinderConfirm case SMSG_PLAYERBOUND: // MopBindPackets::BuildPlayerBound + case SMSG_LFG_ROLE_CHECK_UPDATE: // MopLfgPackets::BuildRoleCheckUpdate, byte-exact vs capture-000075 seq 891708 and capture-000059 seq 719547 case SMSG_LFG_BOOT_PLAYER: // MopLfgPackets::BuildBootPlayer case SMSG_LFG_UPDATE_STATUS: // MopLfgPackets::BuildUpdateStatus case SMSG_LFG_QUEUE_STATUS: // MopLfgPackets::BuildQueueStatus diff --git a/src/game/Server/tests/CMakeLists.txt b/src/game/Server/tests/CMakeLists.txt index c753c0c43..2dc667935 100644 --- a/src/game/Server/tests/CMakeLists.txt +++ b/src/game/Server/tests/CMakeLists.txt @@ -330,6 +330,22 @@ if(WIN32) "PATH=path_list_prepend:${MOP_LFG_LEAVE_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") endif() +add_executable(mop_lfg_role_check_packets_test + mop_lfg_role_check_packets_test.cpp) +target_include_directories(mop_lfg_role_check_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_role_check_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_role_check_packets_test PRIVATE game) +add_test(NAME mop_lfg_role_check_packets COMMAND mop_lfg_role_check_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_RC_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_role_check_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_RC_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + add_executable(mop_lfg_set_roles_packets_test mop_lfg_set_roles_packets_test.cpp) target_include_directories(mop_lfg_set_roles_packets_test PRIVATE diff --git a/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp b/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp new file mode 100644 index 000000000..a99083af6 --- /dev/null +++ b/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp @@ -0,0 +1,156 @@ +/** + * Byte-exact coverage for the SMSG_LFG_ROLE_CHECK_UPDATE (0x12BB) writer. + * + * The expected bodies below are REAL captured server bytes at build 18414. The test + * feeds the writer the values decoded out of each capture and asserts the writer + * reproduces that capture byte for byte -- so this is not a round-trip of our own + * assumptions, it is a comparison against traffic a retail server actually sent. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * + * Layout (see MopLfgPackets::BuildRoleCheckUpdate): + * + * uint8 partyIndex + * uint8 state + * bits WriteBits(memberCount, 21) + * per member: WriteBit(answered), guid mask [3,0,5,2,7,1,4,6] + * rdg[3], rdg[5], WriteBits(dungeonCount, 22), rdg[0,7,6,1,4,2], + * WriteBit(state == LFG_ROLECHECK_INITIALITING) + * FlushBits + * bytes ByteSeq rdg[0] + * per member: uint8 level, seq[3], seq[6], uint32 roles, seq[2,4,0,1,5,7] + * ByteSeq rdg[1,7,6,4,3,2,5] + * dungeonCount x uint32 dungeon entry + * + * The two cases differ in size, member count, party index and dungeon type, which is + * what makes the agreement meaningful -- a layout that only fits one shape proves + * nothing. + */ + +#include "LFGMgr.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + void AssertBytes(WorldPacket const& packet, std::vector const& expected, + char const* label) + { + if (packet.size() != expected.size()) + { + std::printf("%s: size %u, expected %u\n", label, + unsigned(packet.size()), unsigned(expected.size())); + assert(false); + } + + for (size_t i = 0; i < expected.size(); ++i) + { + if (packet.contents()[i] != expected[i]) + { + std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, + unsigned(i), packet.contents()[i], expected[i]); + assert(false); + } + } + } + + /// capture-000075 seq 891708, 35 bytes. + /// + /// A two-man role check. Member 0 is the leader and has answered with 0x0A -- + /// TANK|DAMAGE, a hybrid -- while member 1 has not answered at all (roles 0, + /// answered bit clear). Both are level 90. One dungeon, entry 0x010002CD. + void test_two_member_role_check() + { + std::vector const expected = { + 0x00, 0x02, 0x00, 0x00, 0x17, 0x71, 0xB8, 0x00, 0x00, 0x02, 0x04, + 0x5A, 0x04, 0x0A, 0x00, 0x00, 0x00, 0x49, 0xD0, 0x28, 0x05, + 0x5A, 0x04, 0x00, 0x00, 0x00, 0x00, 0x4B, 0xD5, 0xC3, 0x05, + 0xCD, 0x02, 0x00, 0x01 + }; + + MopLfgPackets::RoleCheckUpdate update; + update.partyIndex = 0; + update.state = LFG_ROLECHECK_INITIALITING; + + MopLfgPackets::RoleCheckMember leader; + leader.guid = 0x04000000054829D1ULL; + leader.roles = 0x0A; + leader.level = 90; + update.members.push_back(leader); + + MopLfgPackets::RoleCheckMember other; + other.guid = 0x04000000054AC2D4ULL; + other.roles = 0; + other.level = 90; + update.members.push_back(other); + + update.dungeonEntries.push_back(0x010002CDu); + + WorldPacket packet(SMSG_LFG_ROLE_CHECK_UPDATE, expected.size()); + MopLfgPackets::BuildRoleCheckUpdate(packet, update); + + AssertBytes(packet, expected, "two_member_role_check"); + } + + /// capture-000059 seq 719547, 68 bytes. + /// + /// A five-man role check, and the case that proves partyIndex is a real field: it + /// carries 1, not 0. The leader has answered 0x03 (LEADER|TANK); the other four + /// have not. One dungeon, entry 0x060001CE -- type 6, a different dungeon type from + /// the case above. + void test_five_member_role_check() + { + std::vector const expected = { + 0x01, 0x02, 0x00, 0x00, 0x2F, 0x71, 0xB8, 0xD8, 0x6E, 0x37, 0x00, + 0x00, 0x00, 0x40, 0x80, + 0x5A, 0x04, 0x03, 0x00, 0x00, 0x00, 0xFF, 0xD5, 0x4D, 0x05, + 0x5A, 0x04, 0x00, 0x00, 0x00, 0x00, 0x39, 0x4D, 0xDF, 0x05, + 0x5A, 0x04, 0x00, 0x00, 0x00, 0x00, 0x4B, 0xB1, 0x05, 0x5A, + 0x07, 0x00, 0x00, 0x00, 0x00, 0x54, 0x59, 0x6A, 0x07, + 0x5A, 0x04, 0x00, 0x00, 0x00, 0x00, 0x3B, 0xA9, 0x57, 0x05, + 0xCE, 0x01, 0x00, 0x06 + }; + + MopLfgPackets::RoleCheckUpdate update; + update.partyIndex = 1; + update.state = LFG_ROLECHECK_INITIALITING; + + uint64 const guids[5] = { + 0x0400000005FE4CD4ULL, + 0x040000000538DE4CULL, + 0x04000000054A00B0ULL, + 0x0600000006556B58ULL, + 0x04000000053A56A8ULL + }; + uint32 const roles[5] = { 0x03, 0, 0, 0, 0 }; + + for (size_t i = 0; i < 5; ++i) + { + MopLfgPackets::RoleCheckMember member; + member.guid = guids[i]; + member.roles = roles[i]; + member.level = 90; + update.members.push_back(member); + } + + update.dungeonEntries.push_back(0x060001CEu); + + WorldPacket packet(SMSG_LFG_ROLE_CHECK_UPDATE, expected.size()); + MopLfgPackets::BuildRoleCheckUpdate(packet, update); + + AssertBytes(packet, expected, "five_member_role_check"); + } +} + +int main() +{ + test_two_member_role_check(); + test_five_member_role_check(); + + std::printf("mop_lfg_role_check_packets_test: OK\n"); + return 0; +} diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 3c7b14469..a0c0f357b 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -398,10 +398,12 @@ void WorldSession::SendLfgQueueStatus(LFGQueueStatus const& status) void WorldSession::SendLfgRoleCheckUpdate(LFGRoleCheck const& roleCheck) { - WorldPacket data(SMSG_LFG_ROLE_CHECK_UPDATE); - - data << uint32(roleCheck.state); - data << uint8(roleCheck.state == LFG_ROLECHECK_INITIALITING); + // Rebuilt for 18414. See MopLfgPackets::BuildRoleCheckUpdate for the layout and the + // two captures it was verified against; the previous body was the 3.3.5 shape and + // shared no field order with this client, which is why the role check prompt never + // appeared however correct the server-side state was. + MopLfgPackets::RoleCheckUpdate update; + update.state = uint8(roleCheck.state); std::set dungeons; if (roleCheck.randomDungeonID) @@ -413,43 +415,52 @@ void WorldSession::SendLfgRoleCheckUpdate(LFGRoleCheck const& roleCheck) dungeons = roleCheck.dungeonList; } - data << uint8(dungeons.size()); - if (!dungeons.empty()) - for (std::set::iterator it = dungeons.begin(); it != dungeons.end(); ++it) - { - data << uint32(sLFGMgr.GetDungeonEntry(*it)); - } + for (std::set::const_iterator it = dungeons.begin(); it != dungeons.end(); ++it) + { + update.dungeonEntries.push_back(sLFGMgr.GetDungeonEntry(*it)); + } - data << uint8(roleCheck.currentRoles.size()); - if (!roleCheck.currentRoles.empty()) + // The leader MUST be first: the client renders entry 0 as the initiator, and both + // captures show the leader's roles carrying the LEADER bit while later members are + // still zero. + ObjectGuid const leaderGuid = ObjectGuid(roleCheck.leaderGuidRaw); + + roleMap::const_iterator leaderItr = roleCheck.currentRoles.find(leaderGuid); + if (leaderItr != roleCheck.currentRoles.end()) { - ObjectGuid leaderGuid = ObjectGuid(roleCheck.leaderGuidRaw); - uint8 leaderRoles = roleCheck.currentRoles.find(leaderGuid)->second; + // Unchecked find() here previously: a role check whose leader had already left + // dereferenced end(). + MopLfgPackets::RoleCheckMember member; + member.guid = leaderGuid.GetRawValue(); + member.roles = leaderItr->second; + Player* pLeader = sObjectAccessor.FindPlayer(leaderGuid); + member.level = pLeader ? uint8(pLeader->getLevel()) : uint8(0); - data << uint64(leaderGuid.GetRawValue()); - data << uint8(leaderRoles > 0); - data << uint32(leaderRoles); - data << uint8(pLeader ? pLeader->getLevel() : 0); + update.members.push_back(member); + } - for (roleMap::const_iterator rItr = roleCheck.currentRoles.begin(); rItr != roleCheck.currentRoles.end(); ++rItr) + for (roleMap::const_iterator rItr = roleCheck.currentRoles.begin(); + rItr != roleCheck.currentRoles.end(); ++rItr) + { + if (rItr->first == leaderGuid) { - if (rItr->first == leaderGuid) - { - continue; // exclude the leader - } + continue; + } - ObjectGuid plrGuid = rItr->first; + MopLfgPackets::RoleCheckMember member; + member.guid = rItr->first.GetRawValue(); + member.roles = rItr->second; - Player* pPlayer = sObjectAccessor.FindPlayer(plrGuid); + Player* pPlayer = sObjectAccessor.FindPlayer(rItr->first); + member.level = pPlayer ? uint8(pPlayer->getLevel()) : uint8(0); - data << uint64(plrGuid.GetRawValue()); - data << uint8(rItr->second > 0); - data << uint32(rItr->second); - data << uint8(pPlayer ? pPlayer->getLevel() : 0); - } + update.members.push_back(member); } + WorldPacket data(SMSG_LFG_ROLE_CHECK_UPDATE, 16 + update.members.size() * 16); + MopLfgPackets::BuildRoleCheckUpdate(data, update); + SendPacket(&data); } diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 54581cc26..7e8ba4050 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -103,7 +103,31 @@ namespace MopLfgPackets uint32 dungeonEntry = 0; }; + /// One entry of a role check, in the order the client expects them: leader first. + struct RoleCheckMember + { + uint64 guid = 0; + uint32 roles = 0; + uint8 level = 0; + }; + + struct RoleCheckUpdate + { + std::vector members; + std::vector dungeonEntries; + uint8 partyIndex = 0; + uint8 state = 0; + }; + + /// Wire value of LFG_ROLECHECK_INITIALITING. + /// + /// Spelled out here because LFGRoleCheckState is declared further down this header, + /// after these inline builders. A static_assert next to the enum keeps the two from + /// drifting apart. + uint8 const ROLE_CHECK_STATE_INITIATING = 2; + bool BuildBootPlayer(WorldPacket& out, BootUpdate const& update); + void BuildRoleCheckUpdate(WorldPacket& out, RoleCheckUpdate const& update); bool BuildUpdateStatus(WorldPacket& out, StatusUpdate const& update); void BuildQueueStatus(WorldPacket& out, QueueStatusUpdate const& update); bool ParseLfrSearchRequest(WorldPacket& in, LfrSearchRequest& request); @@ -135,6 +159,69 @@ namespace MopLfgPacketDetail } } +inline void MopLfgPackets::BuildRoleCheckUpdate(WorldPacket& out, + RoleCheckUpdate const& update) +{ + // SMSG_LFG_ROLE_CHECK_UPDATE (0x12BB). + // + // The body that stood here was the 3.3.5 shape -- a uint32 state, flat counts and + // raw uint64 GUIDs -- and shared no field order with 18414. Verified byte-exact + // against two real captures of different shape, decoding to zero leftover bytes: + // + // capture-000075 seq 891708, 35 B: partyIndex 0, state 2, 2 members, 1 dungeon + // capture-000059 seq 719547, 68 B: partyIndex 1, state 2, 5 members, 1 dungeon + // + // Corpus catalogueGenerationId 2BE10C89...88752. + // + // Note partyIndex is NOT always zero -- the second capture carries 1 -- so it is a + // real field rather than padding, even though GetLFGRoleUpdate does not surface it + // to Lua (the client stores it at dword_1209678 and reads it elsewhere). + // + // The "random dungeon" GUID is always empty in observed traffic; its mask bits are + // written all-zero and WriteByteSeq emits nothing for a zero byte, so it costs 8 + // mask bits and no bytes. It is kept explicit because the bit positions are + // interleaved with the dungeon count and cannot be collapsed away. + uint64 const randomDungeonGuid = 0; + + out << uint8(update.partyIndex); + out << uint8(update.state); + + out.WriteBits(uint32(update.members.size()), 21); + + for (std::vector::const_iterator it = update.members.begin(); + it != update.members.end(); ++it) + { + out.WriteBit(it->roles > 0); // has this member answered yet + MopLfgPacketDetail::WriteGuidMask(out, it->guid, { 3, 0, 5, 2, 7, 1, 4, 6 }); + } + + MopLfgPacketDetail::WriteGuidMask(out, randomDungeonGuid, { 3, 5 }); + out.WriteBits(uint32(update.dungeonEntries.size()), 22); + MopLfgPacketDetail::WriteGuidMask(out, randomDungeonGuid, { 0, 7, 6, 1, 4, 2 }); + out.WriteBit(update.state == ROLE_CHECK_STATE_INITIATING); + + out.FlushBits(); + + MopLfgPacketDetail::WriteGuidBytes(out, randomDungeonGuid, { 0 }); + + for (std::vector::const_iterator it = update.members.begin(); + it != update.members.end(); ++it) + { + out << uint8(it->level); + MopLfgPacketDetail::WriteGuidBytes(out, it->guid, { 3, 6 }); + out << uint32(it->roles); + MopLfgPacketDetail::WriteGuidBytes(out, it->guid, { 2, 4, 0, 1, 5, 7 }); + } + + MopLfgPacketDetail::WriteGuidBytes(out, randomDungeonGuid, { 1, 7, 6, 4, 3, 2, 5 }); + + for (std::vector::const_iterator it = update.dungeonEntries.begin(); + it != update.dungeonEntries.end(); ++it) + { + out << uint32(*it); + } +} + inline bool MopLfgPackets::ParseLfrSearchRequest(WorldPacket& in, LfrSearchRequest& request) { @@ -468,6 +555,11 @@ enum LFGRoleCheckState LFG_ROLECHECK_NO_ROLE = 6 // Someone didn't select a role }; +static_assert(uint8(LFG_ROLECHECK_INITIALITING) == MopLfgPackets::ROLE_CHECK_STATE_INITIATING, + "SMSG_LFG_ROLE_CHECK_UPDATE writes a bit for state == INITIALITING; the value it " + "compares against must track the enum. Both captures the writer is tested on carry " + "state 2 with that bit set."); + /// Role types enum LFGRoles { From 0af2ceac7391f3f18003cbba423dd4fa25286218 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 00:54:45 +0100 Subject: [PATCH 05/81] LFG: derive and admit SMSG_LFG_PROPOSAL_UPDATE 0x1E3B The last packet standing between a formed group and a client that can see it. The body was the 3.3.5 shape -- flat fields and a per-player run of single bytes -- and shared no field order with 18414. It was also unadmitted. Derived the same way as the role check: the client's wire reader sits in generated code reached indirectly with the opcode nowhere in the image as a literal, so field order comes from traffic rather than from a reader, and is therefore a hypothesis that had to be verified rather than trusted. Verified byte-exact against two captures chosen to differ as much as the corpus allows, both decoding to zero leftover, and the writer reproduces both: capture-000044 seq 1948, 64 B: 5 players, roles 0x03/0x04/0x08 x3 capture-000059 seq 2063424, 156 B: 25 players, 2 tank / 6 healer / 17 dps The raid case is an independent check on the decode rather than more of the same: 2/6/17 is exactly what LfgDungeons.dbc carries in Count_tank, Count_healer and Count_damage for LFR rows -- a fact established from the DBC, not from this packet. A wrong layout would have to be wrong in a way that happens to reproduce the shipped data. Corpus catalogueGenerationId 2BE10C89...88752. Three corrections to the reference layout the hypothesis came from: - It builds the second GUID as `dungeonEntry | (0x1F45 << 48)`. Real traffic carries neither: the top five bytes are constant 1F 44 00 00 11 across both captures while the low three vary, i.e. a genuine instance-side GUID with a counter, unrelated to the dungeon entry. We do not model that object, so it is sent as zero -- a legal encoding, since every mask bit then reads false and WriteByteSeq emits nothing for a zero byte. - Roles must pass through verbatim. Observed values include 0x32 and 0x09, so bits above DAMAGE are real; masking to the four known role bits would corrupt them. - The recipient is not necessarily player 0. In the raid capture the "is this you" bit sits on entry 6. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/WorldSession.cpp | 1 + src/game/Server/tests/CMakeLists.txt | 16 ++ .../tests/mop_lfg_proposal_packets_test.cpp | 162 ++++++++++++++++++ src/game/WorldHandlers/LFGHandler.cpp | 83 ++++----- src/game/WorldHandlers/LFGMgr.h | 121 +++++++++++++ 5 files changed, 343 insertions(+), 40 deletions(-) create mode 100644 src/game/Server/tests/mop_lfg_proposal_packets_test.cpp diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index 4d490cf12..7e5b4b49c 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -604,6 +604,7 @@ static bool IsEnterWorldConverted(uint16 opcode) case SMSG_SAVE_GUILD_EMBLEM: // MopGuildPackets::BuildSaveGuildEmblemResult case SMSG_BINDER_CONFIRM: // MopBindPackets::BuildBinderConfirm case SMSG_PLAYERBOUND: // MopBindPackets::BuildPlayerBound + case SMSG_LFG_PROPOSAL_UPDATE: // MopLfgPackets::BuildProposalUpdate, byte-exact vs capture-000044 seq 1948 and capture-000059 seq 2063424 case SMSG_LFG_ROLE_CHECK_UPDATE: // MopLfgPackets::BuildRoleCheckUpdate, byte-exact vs capture-000075 seq 891708 and capture-000059 seq 719547 case SMSG_LFG_BOOT_PLAYER: // MopLfgPackets::BuildBootPlayer case SMSG_LFG_UPDATE_STATUS: // MopLfgPackets::BuildUpdateStatus diff --git a/src/game/Server/tests/CMakeLists.txt b/src/game/Server/tests/CMakeLists.txt index 2dc667935..8e09278ef 100644 --- a/src/game/Server/tests/CMakeLists.txt +++ b/src/game/Server/tests/CMakeLists.txt @@ -330,6 +330,22 @@ if(WIN32) "PATH=path_list_prepend:${MOP_LFG_LEAVE_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") endif() +add_executable(mop_lfg_proposal_packets_test + mop_lfg_proposal_packets_test.cpp) +target_include_directories(mop_lfg_proposal_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_proposal_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_proposal_packets_test PRIVATE game) +add_test(NAME mop_lfg_proposal_packets COMMAND mop_lfg_proposal_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_PROP_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_proposal_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_PROP_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + add_executable(mop_lfg_role_check_packets_test mop_lfg_role_check_packets_test.cpp) target_include_directories(mop_lfg_role_check_packets_test PRIVATE diff --git a/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp b/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp new file mode 100644 index 000000000..d10b2f55a --- /dev/null +++ b/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp @@ -0,0 +1,162 @@ +/** + * Byte-exact coverage for the SMSG_LFG_PROPOSAL_UPDATE (0x1E3B) writer. + * + * The expected bodies are REAL captured server bytes at build 18414. The test feeds the + * writer the values decoded out of each capture and asserts it reproduces that capture + * byte for byte, so this compares against traffic a retail server actually sent rather + * than round-tripping our own assumptions. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * + * The two cases are deliberately as different as the corpus allows -- a 5-man dungeon + * proposal and a 25-man raid finder proposal -- because a layout that only fits one + * shape proves nothing. The raid case is a useful independent check on the decode: its + * 2 tank / 6 healer / 17 dps composition is exactly what LfgDungeons.dbc carries in + * Count_tank/Count_healer/Count_damage for LFR rows, established from the DBC and not + * from this packet. + */ + +#include "LFGMgr.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + void AssertBytes(WorldPacket const& packet, std::vector const& expected, + char const* label) + { + if (packet.size() != expected.size()) + { + std::printf("%s: size %u, expected %u\n", label, + unsigned(packet.size()), unsigned(expected.size())); + assert(false); + } + + for (size_t i = 0; i < expected.size(); ++i) + { + if (packet.contents()[i] != expected[i]) + { + std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, + unsigned(i), packet.contents()[i], expected[i]); + assert(false); + } + } + } + + MopLfgPackets::ProposalPlayer Player(uint32 roles, bool isSelf) + { + MopLfgPackets::ProposalPlayer entry; + entry.roles = roles; + entry.isSelf = isSelf; + return entry; + } + + /// capture-000044 seq 1948, 64 bytes. + /// + /// A five-man proposal in its initial state: nobody has answered yet, and the only + /// bit set on any player is "this is you" on entry 0. Roles are 0x03 (LEADER|TANK), + /// 0x04 (HEALER) and three 0x08 (DAMAGE) -- a textbook 1/1/3. + void test_five_man_proposal() + { + std::vector const expected = { + 0xF0, 0xB8, 0x00, 0x01, 0x50, 0x00, 0x00, 0x13, 0x2C, 0x05, 0x28, + 0x90, 0x03, 0x01, 0x00, 0x06, 0x00, 0xFF, 0x9B, 0x00, 0x00, 0x45, + 0x9C, 0x84, 0x00, 0x00, 0x07, 0x07, 0x61, 0x14, 0x54, 0x03, 0x00, + 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1E, 0x63, 0x04, 0xD6, 0x03, 0x00, 0x00, 0x00, 0x10 + }; + + MopLfgPackets::ProposalUpdate update; + update.requesterGuid = 0x0400000006296291ULL; + update.instanceGuid = 0x1F44000011D72D05ULL; + update.dungeonEntry = 0x06000103u; + update.state = 0; + update.clientQueueId = 39935; + update.proposalId = 33948; + update.joinTime = 1410621703u; + update.encounters = 0; + update.flags = 3; + update.silent = false; + + update.players.push_back(Player(0x03, true)); + update.players.push_back(Player(0x04, false)); + update.players.push_back(Player(0x08, false)); + update.players.push_back(Player(0x08, false)); + update.players.push_back(Player(0x08, false)); + + WorldPacket packet(SMSG_LFG_PROPOSAL_UPDATE, expected.size()); + MopLfgPackets::BuildProposalUpdate(packet, update); + + AssertBytes(packet, expected, "five_man_proposal"); + } + + /// capture-000059 seq 2063424, 156 bytes. + /// + /// A 25-man raid finder proposal. The recipient is entry 6, not entry 0, which is + /// why the "is this you" bit cannot be assumed to sit on the first player. + /// + /// Roles include 0x32 and 0x09: bits above DAMAGE are real and must be passed + /// through verbatim rather than masked to the four known role bits. + void test_raid_finder_proposal() + { + std::vector const expected = { + 0xB0, 0xB8, 0x00, 0x06, 0x40, 0x00, 0x00, 0x00, 0x40, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x30, 0xF3, + 0x05, 0xFF, 0xD5, 0xCC, 0x02, 0x00, 0x01, 0x00, 0x6F, 0x93, 0x00, + 0x00, 0x45, 0x7C, 0x2B, 0x00, 0x00, 0x04, 0xE7, 0x2D, 0xFF, 0x53, + 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, + 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x04, 0x00, + 0x00, 0x00, 0x32, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x32, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, + 0x00, 0x08, 0x00, 0x00, 0x00, 0x09, 0x00, 0x00, 0x00, 0x08, 0x00, + 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, + 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, + 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x1E, 0x4D, 0x4D, 0x03, 0x00, 0x00, + 0x00, 0x10 + }; + + static uint32 const roles[25] = { + 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x32, 0x08, 0x32, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x09, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08 + }; + + MopLfgPackets::ProposalUpdate update; + update.requesterGuid = 0x0400000005FE4CD4ULL; + update.instanceGuid = 0x1F440000114CF200ULL; + update.dungeonEntry = 0x010002CCu; + update.state = 0; + update.clientQueueId = 37743; + update.proposalId = 11132; + update.joinTime = 1409232359u; + update.encounters = 0; + update.flags = 3; + update.silent = false; + + for (size_t i = 0; i < 25; ++i) + { + update.players.push_back(Player(roles[i], i == 6)); + } + + WorldPacket packet(SMSG_LFG_PROPOSAL_UPDATE, expected.size()); + MopLfgPackets::BuildProposalUpdate(packet, update); + + AssertBytes(packet, expected, "raid_finder_proposal"); + } +} + +int main() +{ + test_five_man_proposal(); + test_raid_finder_proposal(); + + std::printf("mop_lfg_proposal_packets_test: OK\n"); + return 0; +} diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index a0c0f357b..66f90edaa 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -481,63 +481,66 @@ void WorldSession::SendLfgProposalUpdate(LFGProposal const& proposal) return; } - ObjectGuid plrGuid = pPlayer->GetObjectGuid(); + ObjectGuid const plrGuid = pPlayer->GetObjectGuid(); - // find() without checking end() dereferenced a past-the-end iterator. It is reachable: - // SendDungeonProposal skips offline players when filling `groups` and `answers` but - // still lists them in `currentRoles`, so a player who queues, logs out, and logs back - // in before someone else answers arrives here with no entry of their own. + // find() without checking end() dereferenced a past-the-end iterator here. It is + // reachable, not theoretical: SendDungeonProposal skips offline players when filling + // `groups` and `answers` but still lists them in `currentRoles`, so a player who + // queues, logs out and logs back in arrives with no entry of their own. playerGroupMap::const_iterator myGroup = proposal.groups.find(plrGuid); if (myGroup == proposal.groups.end()) { return; } - ObjectGuid plrGroupGuid = myGroup->second; - - uint32 dungeonEntry = sLFGMgr.GetDungeonEntry(proposal.dungeonID); - bool showProposal = !proposal.isNew && proposal.groupRawGuid == plrGroupGuid.GetRawValue(); - - WorldPacket data(SMSG_LFG_PROPOSAL_UPDATE, 15 + (9 * proposal.currentRoles.size())); - - data << uint32(dungeonEntry); // Dungeon Entry - data << uint8(proposal.state); // Proposal state - data << uint32(proposal.id); // ID of proposal - data << uint32(proposal.encounters); // Encounters done - data << uint8(showProposal); // Show or hide proposal window [todo-this] - data << uint8(proposal.currentRoles.size()); // Size of group - - for (playerGroupMap::const_iterator it = proposal.groups.begin(); it != proposal.groups.end(); ++it) + ObjectGuid const plrGroupGuid = myGroup->second; + + // Rebuilt for 18414. See MopLfgPackets::BuildProposalUpdate for the layout and the + // two captures it was verified against. + MopLfgPackets::ProposalUpdate update; + update.dungeonEntry = sLFGMgr.GetDungeonEntry(proposal.dungeonID); + update.proposalId = proposal.id; + update.state = uint8(proposal.state); + update.encounters = proposal.encounters; + update.joinTime = uint32(proposal.joinedQueue); + + // "silent" suppresses opening a fresh window: the client updates one it already has. + // Only correct when this is not a new proposal AND the recipient is already in the + // group the proposal will reuse. + update.silent = !proposal.isNew && plrGroupGuid && + plrGroupGuid.GetRawValue() == proposal.groupRawGuid; + + // The recipient's own group if they have one, else themselves -- this identifies who + // the update is about, not the proposed group. + update.requesterGuid = plrGroupGuid ? plrGroupGuid.GetRawValue() : plrGuid.GetRawValue(); + + for (playerGroupMap::const_iterator it = proposal.groups.begin(); + it != proposal.groups.end(); ++it) { - ObjectGuid grpPlrGuid = it->first; + ObjectGuid const memberGuid = it->first; - roleMap::const_iterator roleItr = proposal.currentRoles.find(grpPlrGuid); - proposalAnswerMap::const_iterator answerItr = proposal.answers.find(grpPlrGuid); + roleMap::const_iterator roleItr = proposal.currentRoles.find(memberGuid); + proposalAnswerMap::const_iterator answerItr = proposal.answers.find(memberGuid); if (roleItr == proposal.currentRoles.end() || answerItr == proposal.answers.end()) { continue; } - uint8 grpPlrRole = roleItr->second; - LFGProposalAnswer grpPlrAnswer = answerItr->second; + MopLfgPackets::ProposalPlayer entry; + entry.roles = roleItr->second; + entry.isSelf = (memberGuid == plrGuid); + entry.answered = (answerItr->second != LFG_ANSWER_PENDING); + entry.agreed = (answerItr->second == LFG_ANSWER_AGREE); + entry.inProposedGroup = it->second && !proposal.isNew && + it->second.GetRawValue() == proposal.groupRawGuid; + entry.sameGroupAsSelf = it->second && it->second == plrGroupGuid; - data << uint32(grpPlrRole); // Player's role - data << uint8(grpPlrGuid == plrGuid); // Is this player me? + update.players.push_back(entry); + } - if (it->second != 0) - { - data << uint8(it->second == ObjectGuid(proposal.groupRawGuid)); // Is player in the proposed group? - data << uint8(it->second == plrGroupGuid); // Is player in the same group as myself? - } - else - { - data << uint8(0); - data << uint8(0); - } + WorldPacket data(SMSG_LFG_PROPOSAL_UPDATE, 40 + update.players.size() * 5); + MopLfgPackets::BuildProposalUpdate(data, update); - data << uint8(grpPlrAnswer != LFG_ANSWER_PENDING); // Has the player selected an answer? - data << uint8(grpPlrAnswer == LFG_ANSWER_AGREE); // Has the player agreed to do the dungeon? - } SendPacket(&data); } diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 7e8ba4050..5681e1b8b 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -126,8 +126,35 @@ namespace MopLfgPackets /// drifting apart. uint8 const ROLE_CHECK_STATE_INITIATING = 2; + /// One participant of a dungeon proposal. + struct ProposalPlayer + { + uint32 roles = 0; + bool inProposedGroup = false; // already in the group the proposal will reuse + bool isSelf = false; // is this the recipient + bool answered = false; + bool agreed = false; + bool sameGroupAsSelf = false; + }; + + struct ProposalUpdate + { + std::vector players; + uint64 requesterGuid = 0; // recipient's original group, else the player + uint64 instanceGuid = 0; // see BuildProposalUpdate + uint32 dungeonEntry = 0; + uint32 clientQueueId = 0; + uint32 proposalId = 0; + uint32 joinTime = 0; + uint32 encounters = 0; + uint32 flags = 3; + uint8 state = 0; + bool silent = false; // update an open window instead of opening one + }; + bool BuildBootPlayer(WorldPacket& out, BootUpdate const& update); void BuildRoleCheckUpdate(WorldPacket& out, RoleCheckUpdate const& update); + void BuildProposalUpdate(WorldPacket& out, ProposalUpdate const& update); bool BuildUpdateStatus(WorldPacket& out, StatusUpdate const& update); void BuildQueueStatus(WorldPacket& out, QueueStatusUpdate const& update); bool ParseLfrSearchRequest(WorldPacket& in, LfrSearchRequest& request); @@ -222,6 +249,100 @@ inline void MopLfgPackets::BuildRoleCheckUpdate(WorldPacket& out, } } +inline void MopLfgPackets::BuildProposalUpdate(WorldPacket& out, + ProposalUpdate const& update) +{ + // SMSG_LFG_PROPOSAL_UPDATE (0x1E3B). + // + // The body that stood here was the 3.3.5 shape -- flat uint32/uint8 fields and a + // per-player run of single bytes -- and shared no field order with 18414. Verified + // byte-exact against two real captures chosen to differ as much as possible: + // + // capture-000044 seq 1948, 64 B: 5 players, 1 tank / 1 healer / 3 dps + // capture-000059 seq 2063424, 156 B: 25 players, 2 tank / 6 healer / 17 dps + // + // Both decode to zero leftover. The second is a raid finder proposal, and its + // composition matches the 2/6/17 that LfgDungeons.dbc carries for LFR rows -- an + // independent check on the decode from a completely different evidence source. + // + // Corpus catalogueGenerationId 2BE10C89...88752. + // + // Two corrections to the reference layout this was checked against: + // + // - It builds the second GUID as `dungeonEntry | (0x1F45 << 48)`. Real traffic + // carries neither: the top five bytes are constant 1F 44 00 00 11 in both + // captures while the low three vary, i.e. a genuine instance-side GUID with a + // counter, unrelated to the dungeon entry. We do not model that object, so we + // send zero -- a legal encoding, since all eight mask bits then read false and + // WriteByteSeq emits nothing. If a live client turns out to need it to match the + // proposal, synthesise it from proposalId rather than guessing a constant. + // + // - Roles are passed through verbatim. Observed values include 0x32 and 0x09, so + // bits above DAMAGE are real and must not be masked off. + out.WriteBit(MopLfgPacketDetail::GuidByte(update.instanceGuid, 6) != 0); + out.WriteBit(MopLfgPacketDetail::GuidByte(update.instanceGuid, 0) != 0); + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 1, 7, 5 }); + out.WriteBit(MopLfgPacketDetail::GuidByte(update.instanceGuid, 5) != 0); + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 4 }); + out.WriteBit(update.silent); + out.WriteBit(MopLfgPacketDetail::GuidByte(update.instanceGuid, 2) != 0); + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 6 }); + MopLfgPacketDetail::WriteGuidMask(out, update.instanceGuid, { 3, 7 }); + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 3 }); + + out.WriteBits(uint32(update.players.size()), 21); + + for (std::vector::const_iterator it = update.players.begin(); + it != update.players.end(); ++it) + { + out.WriteBit(it->inProposedGroup); + out.WriteBit(it->isSelf); + out.WriteBit(it->answered); + out.WriteBit(it->agreed); + out.WriteBit(it->sameGroupAsSelf); + } + + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 2 }); + MopLfgPacketDetail::WriteGuidMask(out, update.instanceGuid, { 4 }); + out.WriteBit(false); // unknown; zero in all observed traffic + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 0 }); + MopLfgPacketDetail::WriteGuidMask(out, update.instanceGuid, { 1 }); + + out.FlushBits(); + + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 1 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 4 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 4 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 7, 2, 0 }); + + out << uint32(update.dungeonEntry); + out << uint8(update.state); + out << uint32(update.clientQueueId); + + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 6 }); + out << uint32(update.proposalId); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 5, 3 }); + out << uint32(update.joinTime); + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 5 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 6 }); + + for (std::vector::const_iterator it = update.players.begin(); + it != update.players.end(); ++it) + { + out << uint32(it->roles); + } + + out << uint32(update.encounters); + + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 7 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 1 }); + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 0, 2 }); + + out << uint32(update.flags); + + MopLfgPacketDetail::WriteGuidBytes(out, update.instanceGuid, { 3 }); +} + inline bool MopLfgPackets::ParseLfrSearchRequest(WorldPacket& in, LfrSearchRequest& request) { From e28b70101e3c347baf502e9f6c8a30ef66a2053d Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 01:00:31 +0100 Subject: [PATCH 06/81] LFG: wire CMSG_LFG_PROPOSAL_RESPONSE 0x1D9D and tick the manager Two pieces, and together they close the loop: a proposal can now be answered, and the matchmaker can now run at all. CMSG_LFG_PROPOSAL_RESPONSE had no handler and no registration, so accept and decline both did nothing -- the reply was dropped at the dispatcher. Body derived from the client's own writer sub_66A29E (vtable slot 1 behind the opcode thunk sub_6622E8, which writes 7581), with GUID A at this+24..31 and GUID B at this+48..55: uint32 proposalId, clientQueueId, flags, joinTime bits accept, mask A6 A0 A2 A4 B6 B7 A3 B4 A7 B1 A5 B0 A1 B2 B3 B5 Flush bytes A3 A6 A4 A1 B7 B0 A7 B6 A5 B3 B1 B5 B4 A0 A2 B2, XOR 1 when present Fixture is capture-000059 seq 2063770, and it is worth more than a size check: it is the client's answer to seq 2063424 in the SAME capture, the 156-byte SMSG_LFG_PROPOSAL_UPDATE derived in the previous commit. Every echoed field matches -- proposal 11132, queue 37743, flags 3, join time 1409232359, and both GUIDs. The inbound and outbound layouts were derived separately and agree, which neither could establish on its own. Nothing in the body is authority. The server answers on behalf of the CALLER and keys on its own proposal id, so a client returning someone else's guidA cannot answer for them. The WUPDATE_LFGMGR timer was configured at startup but never consumed, so LFGMgr::Update had no caller anywhere: a player could join the queue and nothing ever looked at it again. This is deliberately the last change of the sequence rather than the first -- the reaper Update calls first erased while iterating, the matchmaker it calls next could not form a group under any input, and the proposal it can now send chose a branch from two uninitialised members. Ticking it before those were fixed would have crashed the world thread. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 1 + src/game/Server/Opcodes_reference.h | 2 +- src/game/Server/WorldSession.h | 1 + src/game/Server/tests/CMakeLists.txt | 16 +++ ...mop_lfg_proposal_response_packets_test.cpp | 101 ++++++++++++++++++ src/game/WorldHandlers/Group.cpp | 88 +++++++++++++++ src/game/WorldHandlers/Group.h | 27 +++++ src/game/WorldHandlers/LFGHandler.cpp | 29 +++++ src/game/WorldHandlers/World.cpp | 15 +++ 9 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index 7c10ebc7a..a72a30539 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -805,6 +805,7 @@ void InitializeOpcodes() // Empty 18414 status refresh request. The handler replies through the // already-converted unified SMSG_LFG_UPDATE_STATUS body. DefC(CMSG_LFG_SET_ROLES, "CMSG_LFG_SET_ROLES", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgSetRolesOpcode); + DefC(CMSG_LFG_PROPOSAL_RESPONSE, "CMSG_LFG_PROPOSAL_RESPONSE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgProposalResponseOpcode); DefC(CMSG_LFG_GET_STATUS, "CMSG_LFG_GET_STATUS", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgGetStatusOpcode); // Direct 18414 LFR-browser request and empty full-replacement response. diff --git a/src/game/Server/Opcodes_reference.h b/src/game/Server/Opcodes_reference.h index fcf592121..64fd2203a 100644 --- a/src/game/Server/Opcodes_reference.h +++ b/src/game/Server/Opcodes_reference.h @@ -1877,7 +1877,7 @@ typedef uint16_t uint16; * CMSG_UPDATE_CLIENT_SETTINGS 0x1D8D DOC * CMSG_CALENDAR_EVENT_INVITE 0x1D8E DORMANT * CMSG_UNKNOWN_0x1D9B 0x1D9B DOC - * CMSG_LFG_PROPOSAL_RESPONSE 0x1D9D DORMANT + * CMSG_LFG_PROPOSAL_RESPONSE 0x1D9D REGISTERED * CMSG_LF_GUILD_SET_GUILD_POST 0x1D9F DOC * CMSG_QUEST_NPC_QUERY 0x1DAE ACTIVE * CMSG_UNKNOWN_0x1DB9 0x1DB9 DOC diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index 7efa45171..83805a77c 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -2210,6 +2210,7 @@ class WorldSession void HandleLfgJoinOpcode(WorldPacket& recv_data); void HandleLfgLeaveOpcode(WorldPacket& recv_data); void HandleLfgSetRolesOpcode(WorldPacket& recv_data); + void HandleLfgProposalResponseOpcode(WorldPacket& recv_data); void HandleLfgGetStatusOpcode(WorldPacket& recv_data); void HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data); void HandleSetLfgCommentOpcode(WorldPacket& recv_data); diff --git a/src/game/Server/tests/CMakeLists.txt b/src/game/Server/tests/CMakeLists.txt index 8e09278ef..b6f2baea5 100644 --- a/src/game/Server/tests/CMakeLists.txt +++ b/src/game/Server/tests/CMakeLists.txt @@ -330,6 +330,22 @@ if(WIN32) "PATH=path_list_prepend:${MOP_LFG_LEAVE_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") endif() +add_executable(mop_lfg_proposal_response_packets_test + mop_lfg_proposal_response_packets_test.cpp) +target_include_directories(mop_lfg_proposal_response_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_proposal_response_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_proposal_response_packets_test PRIVATE game) +add_test(NAME mop_lfg_proposal_response_packets COMMAND mop_lfg_proposal_response_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_PR_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_proposal_response_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_PR_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + add_executable(mop_lfg_proposal_packets_test mop_lfg_proposal_packets_test.cpp) target_include_directories(mop_lfg_proposal_packets_test PRIVATE diff --git a/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp b/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp new file mode 100644 index 000000000..e08462dc4 --- /dev/null +++ b/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp @@ -0,0 +1,101 @@ +/** + * Byte-exact coverage for the CMSG_LFG_PROPOSAL_RESPONSE (0x1D9D) reader. + * + * The body below is REAL captured client bytes at build 18414, not an inverse of our + * own writer. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * + * Layout derived from the client's body writer sub_66A29E, reached as vtable slot 1 + * behind the opcode thunk sub_6622E8 (which writes 7581). GUID A is at this+24..31 and + * GUID B at this+48..55: + * + * uint32 proposalId, clientQueueId, flags, joinTime + * bits accept, then the mask A6 A0 A2 A4 B6 B7 A3 B4 A7 B1 A5 B0 A1 B2 B3 B5 + * Flush + * bytes A3 A6 A4 A1 B7 B0 A7 B6 A5 B3 B1 B5 B4 A0 A2 B2, each XOR 1 when present + * + * What makes this case worth having is that it closes a loop. capture-000059 seq + * 2063770 is the client's answer to seq 2063424 in the SAME capture -- the 156-byte + * SMSG_LFG_PROPOSAL_UPDATE covered by mop_lfg_proposal_packets_test. Every echoed field + * matches what that packet carried: proposal 11132, queue 37743, flags 3, join time + * 1409232359, and both GUIDs. The inbound and outbound derivations were done separately + * and agree, which neither could establish alone. + */ + +#include "Group.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + WorldPacket MakeBody(std::vector const& bytes) + { + WorldPacket packet(CMSG_LFG_PROPOSAL_RESPONSE, bytes.size()); + packet.append(bytes.data(), bytes.size()); + return packet; + } + + /// capture-000059 seq 2063770, 29 bytes: an ACCEPT of the 25-man raid proposal. + void test_accept_body() + { + std::vector const body = { + 0x7C, 0x2B, 0x00, 0x00, 0x6F, 0x93, 0x00, 0x00, 0x03, 0x00, + 0x00, 0x00, 0xE7, 0x2D, 0xFF, 0x53, 0xB7, 0x67, 0x00, 0x04, + 0x4D, 0x1E, 0x05, 0x45, 0x10, 0xF3, 0xD5, 0xFF, 0x4D + }; + + WorldPacket packet = MakeBody(body); + MopLfgProposalResponsePackets::Request request; + assert(MopLfgProposalResponsePackets::ParseRequest(packet, request)); + + assert(request.accepted); + assert(request.proposalId == 11132); + assert(request.clientQueueId == 37743); + assert(request.flags == 3); + assert(request.joinTime == 1409232359u); + + // Both GUIDs match the SMSG_LFG_PROPOSAL_UPDATE this is answering. + assert(request.guidA.GetRawValue() == 0x0400000005FE4CD4ULL); + assert(request.guidB.GetRawValue() == 0x1F440000114CF200ULL); + + assert(packet.rpos() == packet.size()); // no tail left unread + } + + /// A body truncated inside its GUID run must be refused, not read past its end. + void test_truncated_body_is_refused() + { + std::vector const body = { + 0x7C, 0x2B, 0x00, 0x00, 0x6F, 0x93, 0x00, 0x00, 0x03, 0x00, + 0x00, 0x00, 0xE7, 0x2D, 0xFF, 0x53, 0xB7, 0x67, 0x00, 0x04 + }; + + WorldPacket packet = MakeBody(body); + MopLfgProposalResponsePackets::Request request; + assert(!MopLfgProposalResponsePackets::ParseRequest(packet, request)); + } + + /// Shorter than the fixed header plus its mask bytes. + void test_short_body_is_refused() + { + std::vector const body = { 0x7C, 0x2B, 0x00, 0x00 }; + + WorldPacket packet = MakeBody(body); + MopLfgProposalResponsePackets::Request request; + assert(!MopLfgProposalResponsePackets::ParseRequest(packet, request)); + } +} + +int main() +{ + test_accept_body(); + test_truncated_body_is_refused(); + test_short_body_is_refused(); + + std::printf("mop_lfg_proposal_response_packets_test: OK\n"); + return 0; +} diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index 02f576c90..a42088383 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -603,6 +603,94 @@ bool MopGroupPromotePackets::ParseAssistant(WorldPacket& in, AssistantRequest& o return true; } +bool MopLfgProposalResponsePackets::ParseRequest(WorldPacket& in, Request& out) +{ + // 16 flat bytes, then 17 bits (1 accept + 16 GUID mask), then up to 16 GUID bytes. + // The minimum body is therefore 16 + 3 = 19 bytes with both GUIDs entirely zero. + if (in.size() - in.rpos() < 19) + { + return false; + } + + in >> out.proposalId; + in >> out.clientQueueId; + in >> out.flags; + in >> out.joinTime; + + out.accepted = in.ReadBit(); + + uint8 maskA[8] = { 0 }; + uint8 maskB[8] = { 0 }; + + // Mask order straight off the writer: A6 A0 A2 A4 B6 B7 A3 B4 A7 B1 A5 B0 A1 B2 B3 B5. + uint8* const maskOrder[16] = + { + &maskA[6], &maskA[0], &maskA[2], &maskA[4], &maskB[6], &maskB[7], + &maskA[3], &maskB[4], &maskA[7], &maskB[1], &maskA[5], &maskB[0], + &maskA[1], &maskB[2], &maskB[3], &maskB[5] + }; + + for (size_t i = 0; i < 16; ++i) + { + *maskOrder[i] = in.ReadBit() ? 1 : 0; + } + + uint8 bytesA[8] = { 0 }; + uint8 bytesB[8] = { 0 }; + + // Byte order, again off the writer: A3 A6 A4 A1 B7 B0 A7 B6 A5 B3 B1 B5 B4 A0 A2 B2. + struct Slot { uint8 const* mask; uint8* value; }; + Slot const byteOrder[16] = + { + { &maskA[3], &bytesA[3] }, { &maskA[6], &bytesA[6] }, + { &maskA[4], &bytesA[4] }, { &maskA[1], &bytesA[1] }, + { &maskB[7], &bytesB[7] }, { &maskB[0], &bytesB[0] }, + { &maskA[7], &bytesA[7] }, { &maskB[6], &bytesB[6] }, + { &maskA[5], &bytesA[5] }, { &maskB[3], &bytesB[3] }, + { &maskB[1], &bytesB[1] }, { &maskB[5], &bytesB[5] }, + { &maskB[4], &bytesB[4] }, { &maskA[0], &bytesA[0] }, + { &maskA[2], &bytesA[2] }, { &maskB[2], &bytesB[2] } + }; + + // Bound the read before touching it: a truncated body must be refused, not read + // past its end. + size_t present = 0; + for (size_t i = 0; i < 16; ++i) + { + if (*byteOrder[i].mask) + { + ++present; + } + } + + if (in.size() - in.rpos() < present) + { + return false; + } + + for (size_t i = 0; i < 16; ++i) + { + if (*byteOrder[i].mask) + { + uint8 value = 0; + in >> value; + *byteOrder[i].value = uint8(value ^ 1); // WriteByteSeq obfuscation + } + } + + uint64 rawA = 0; + uint64 rawB = 0; + for (size_t i = 0; i < 8; ++i) + { + rawA |= uint64(bytesA[i]) << (i * 8); + rawB |= uint64(bytesB[i]) << (i * 8); + } + + out.guidA = ObjectGuid(rawA); + out.guidB = ObjectGuid(rawB); + return true; +} + bool MopLfgSetRolesPackets::ParseRequest(WorldPacket& in, Request& out) { // Fixed 5 bytes. Refuse anything else rather than reading past the end -- a short diff --git a/src/game/WorldHandlers/Group.h b/src/game/WorldHandlers/Group.h index cc853470b..adc6e51f6 100644 --- a/src/game/WorldHandlers/Group.h +++ b/src/game/WorldHandlers/Group.h @@ -360,6 +360,33 @@ namespace MopLfgSetRolesPackets bool ParseRequest(WorldPacket& in, Request& out); } +namespace MopLfgProposalResponsePackets +{ + /// A parsed CMSG_LFG_PROPOSAL_RESPONSE body. + /// + /// Derived from the client's own body writer sub_66A29E -- vtable slot 1 behind the + /// opcode thunk sub_6622E8, which writes 7581 (0x1D9D). GUID A lives at this+24..31 + /// and GUID B at this+48..55. + /// + /// Everything except `accepted` is an echo of the SMSG_LFG_PROPOSAL_UPDATE the + /// server sent: capture-000059 seq 2063770 echoes proposalId 11132, clientQueueId + /// 37743, flags 3 and joinTime 1409232359 straight back from seq 2063424 in the same + /// capture, along with both GUIDs. None of it is authority -- the server answers on + /// behalf of the CALLER and keys on its own proposal id. + struct Request + { + ObjectGuid guidA; // the sender's group, or the sender + ObjectGuid guidB; // instance-side GUID; echoed, never trusted + uint32 proposalId = 0; + uint32 clientQueueId = 0; + uint32 flags = 0; + uint32 joinTime = 0; + bool accepted = false; + }; + + bool ParseRequest(WorldPacket& in, Request& out); +} + namespace MopGroupMarkerPackets { struct MinimapPingRequest diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 66f90edaa..f33522438 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -224,6 +224,35 @@ void WorldSession::HandleLfgSetRolesOpcode(WorldPacket& recv_data) sLFGMgr.PerformRoleCheck(plr, pGroup, uint8(request.roles & 0xFF)); } +void WorldSession::HandleLfgProposalResponseOpcode(WorldPacket& recv_data) +{ + DEBUG_LOG("CMSG_LFG_PROPOSAL_RESPONSE"); + + // Without this a proposal could be built and sent but never answered -- the accept + // and decline buttons both did nothing, because the reply was dropped at the + // dispatcher with no handler and no registration. + MopLfgProposalResponsePackets::Request request; + if (!MopLfgProposalResponsePackets::ParseRequest(recv_data, request)) + { + sLog.outError("Malformed CMSG_LFG_PROPOSAL_RESPONSE body from %s.", GetPlayerName()); + return; + } + + Player* plr = GetPlayer(); + if (!plr) + { + return; + } + + DEBUG_LOG("CMSG_LFG_PROPOSAL_RESPONSE: %s %s proposal %u.", + GetPlayerName(), request.accepted ? "accepted" : "declined", request.proposalId); + + // Answer on behalf of the CALLER, keyed on our own proposal id. The GUIDs and the + // queue triplet in the body are echoes of what we sent and carry no authority; a + // client that returns a different guidA must not be able to answer for someone else. + sLFGMgr.ProposalUpdate(request.proposalId, plr->GetObjectGuid(), request.accepted); +} + void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) { DEBUG_LOG("CMSG_LFG_GET_STATUS"); diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index ca9634507..d88c3a98f 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -1163,6 +1163,21 @@ void World::Update(uint32 diff) Player::DeleteOldCharacters(); } + ///- Match queued dungeon finder entries and expire stale role checks. + // + // The WUPDATE_LFGMGR timer was configured at startup but never consumed, so + // LFGMgr::Update had no caller anywhere in the tree: players could join the queue + // and nothing ever looked at it again. Ticking it is deliberately the LAST step of + // the dungeon finder work, not the first, because everything it reaches had to be + // correct before it ran -- the reaper it calls first erased while iterating, the + // matchmaker it calls next could not form a group under any input, and the proposal + // it can now send read two uninitialised members to choose a branch. + if (m_timers[WUPDATE_LFGMGR].Passed()) + { + m_timers[WUPDATE_LFGMGR].Reset(); + sLFGMgr.Update(); + } + // execute callbacks from sql queries that were queued recently UpdateResultQueue(); From 890fbde54e5d48c332fa7306411b91fbcedfd887 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 01:04:30 +0100 Subject: [PATCH 07/81] LFG: fix group formation, which the tick just made reachable The previous commit gave LFGMgr::Update a caller for the first time, so this path went from dead code to live code. These defects were all latent behind that; none of them is theoretical now. CreateDungeonGroup, rewritten. Four independent defects on one path: - The leader search looped over every role-flagged member calling Group::Create with no break, so two merged premades carrying two LEADER bits ran Create twice on one object. Each call does its own GenerateGroupLowGuid and its own INSERT INTO groups in its own transaction, orphaning the first group id and stranding that id's group_member rows. - If a leader bit was set but every leader-flagged player was offline, Create never ran while AddMember still did, building a group with id 0 and an empty leader guid and inserting it into m_groupSet. - The existing-group branch called no AddMember at all. One premade plus solo queuers is the commonest LFD composition, and those solos were dequeued, told a group had been found, and never put in one. - Nothing called sObjectMgr.AddGroup, so GetGroupById could not find the group, it leaked at shutdown, and the boot path called RemoveGroup on a group that had never been added. It also no longer calls SetDungeonDifficulty(Difficulty(dungeon->DifficultyID)). That mixes the raw client key with the internal 0-based enum, making every normal five-man heroic, and GetBoundInstances indexes m_boundInstances by it unchecked while MAX_DIFFICULTY is 4 -- raw ids on LFR, scenario and flex rows reach 14. Leaving the existing difficulty is wrong-but-safe; setting a wrong one is neither. IsProposalSameGroup skipped ungrouped players entirely, so a two-man party matched with three solos returned true. The proposal was then treated as a premade and reused the party's group without adding the solos. It also returned true when nobody was grouped at all. ProposalUpdate now returns immediately on a decline. Falling through carried two bugs at once: ProposalDeclined can erase the proposal from m_proposalMap, leaving the code below iterating and writing through a dangling pointer; and when it does not erase, it removes the decliner from `answers`, so four accepts plus one decline in a five-man read as unanimous, built a FOUR-man group and teleported it in. GetDungeonFinderRewards was dereferenced unconditionally. dungeonfinder_rewards ships 66 rows covering levels 15-80, so every level 81-90 character -- every MoP-relevant one -- crashed the world server on a tracked boss kill. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 263 ++++++++++++---------- 1 file changed, 147 insertions(+), 116 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 3b81a96b9..be5bfd377 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -291,8 +291,16 @@ void LFGMgr::SendDungeonProposal(LFGPlayers* lfgGroup) bool LFGMgr::IsProposalSameGroup(LFGProposal const& proposal) { + // True only when EVERY member is in the SAME existing group. + // + // This used to skip ungrouped players entirely, so a two-man party matched with + // three solo queuers returned true -- the proposal was then treated as a premade + // and CreateDungeonGroup reused the party's group without ever adding the solos. + // It also returned true when nobody was grouped at all, because isSameGroup started + // true and had no way to become false. bool firstLoop = true; bool isSameGroup = true; + bool anyGrouped = false; ObjectGuid priorGroupGuid; @@ -310,28 +318,27 @@ bool LFGMgr::IsProposalSameGroup(LFGProposal const& proposal) continue; } - if (Group* pGroup = pPlayer->GetGroup()) + Group* pGroup = pPlayer->GetGroup(); + if (!pGroup) { - ObjectGuid grpGuid = pGroup->GetObjectGuid(); + return false; // an ungrouped member means this is not one existing group + } - if (firstLoop) - { - priorGroupGuid = grpGuid; - firstLoop = false; - } - else - { - if (isSameGroup) - { - if (grpGuid != priorGroupGuid) - { - isSameGroup = false; - } - } - } + anyGrouped = true; + ObjectGuid grpGuid = pGroup->GetObjectGuid(); + + if (firstLoop) + { + priorGroupGuid = grpGuid; + firstLoop = false; + } + else if (grpGuid != priorGroupGuid) + { + isSameGroup = false; } } - return isSameGroup; + + return anyGrouped && isSameGroup; } // From a CMSG_LFG_PROPOSAL_RESPONSE call @@ -351,10 +358,18 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted LFGProposalAnswer plrAnswer = (LFGProposalAnswer)accepted; proposal->answers[plrGuid] = plrAnswer; - // If the player declined, the proposal is over + // If the player declined, the proposal is over -- return immediately. + // + // Two bugs lived in falling through here. ProposalDeclined can erase the proposal + // from m_proposalMap, destroying the object `proposal` points into, and everything + // below then iterates and writes through that dangling pointer. And when it does + // NOT erase, it removes the decliner from `answers`, so the allOkay loop no longer + // sees them: four accepts plus one decline in a five-man read as unanimous, built a + // FOUR-man group and teleported it in. if (plrAnswer == LFG_ANSWER_DENY) { ProposalDeclined(plrGuid, proposal); + return; } for (proposalAnswerMap::iterator it = proposal->answers.begin(); it != proposal->answers.end(); ++it) @@ -455,133 +470,139 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) return; } - Group* pGroup = nullptr; - - if (!proposal->groupRawGuid) + // Rewritten. The previous version had four independent defects on this one path: + // + // - The leader search looped over every role-flagged member calling Group::Create + // with no break, so two merged premades carrying two LEADER bits ran Create + // twice on one object. Each call does its own GenerateGroupLowGuid plus an + // INSERT INTO groups in its own transaction, orphaning the first group id and + // stranding that id's group_member rows. + // - If a leader bit was set but every leader-flagged player was offline, Create + // never ran while AddMember still did -- building a group with id 0 and an empty + // leader guid, which was then inserted into m_groupSet. + // - The existing-group branch called no AddMember at all, so the commonest LFD + // composition (one premade plus solo queuers) dequeued the solos, told them a + // group was found, and never put them in one. + // - Nothing registered the group with ObjectMgr, so GetGroupById could not find + // it, it leaked at shutdown, and the boot path called RemoveGroup on a group + // that had never been added. + // + // Resolve the leader ONCE, up front, and require them to be online. + ObjectGuid leaderGuid; + for (roleMap::const_iterator it = proposal->currentRoles.begin(); + it != proposal->currentRoles.end(); ++it) { - bool leaderIsSet = false; - bool leaderRoleIsSet = HasLeaderFlag(proposal->currentRoles); - ObjectGuid leaderGuid; + if ((it->second & PLAYER_ROLE_LEADER) && sObjectAccessor.FindPlayer(it->first)) + { + leaderGuid = it->first; + break; + } + } - pGroup = new Group(); + Group* pGroup = nullptr; - for (playerGroupMap::iterator it = proposal->groups.begin(); it != proposal->groups.end(); ++it) + if (proposal->groupRawGuid) + { + // Reuse the premade group the proposal was built around. + Player* pGroupLeader = sObjectAccessor.FindPlayer(ObjectGuid(proposal->groupLeaderGuid)); + if (pGroupLeader) { - // remove plr from group w/ guid it->second - // set leader on first loop, then set leaderisset to true - ObjectGuid pGroupPlrGuid = it->first; - Player* pGroupPlr = sObjectAccessor.FindPlayer(pGroupPlrGuid); + pGroup = pGroupLeader->GetGroup(); + } - if (pGroupPlr && it->second) + // The stored leader may have logged out between proposal and acceptance. Fall + // back to any online member still in that same group. + if (!pGroup) + { + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) { - Group* existingGroup = pGroupPlr->GetGroup(); - if (existingGroup) + if (it->second.GetRawValue() != proposal->groupRawGuid) { - existingGroup->RemoveMember(pGroupPlrGuid, 0); + continue; } - } - if (pGroupPlr && !leaderIsSet) - { - bool currentPlrIsLeader = false; - if (leaderRoleIsSet) + if (Player* pMember = sObjectAccessor.FindPlayer(it->first)) { - for (roleMap::iterator itr = proposal->currentRoles.begin(); itr != proposal->currentRoles.end(); ++itr) + pGroup = pMember->GetGroup(); + if (pGroup) { - if (itr->second & PLAYER_ROLE_LEADER) - { - leaderGuid = itr->first; - Player* leaderRef = sObjectAccessor.FindPlayer(leaderGuid); - - if (leaderRef) - { - pGroup->Create(leaderRef->GetObjectGuid(), leaderRef->GetName()); - currentPlrIsLeader = (pGroupPlrGuid == leaderGuid); - } - } + break; } } - else - { - pGroup->Create(pGroupPlrGuid, pGroupPlr->GetName()); - } + } + } + } - if (!currentPlrIsLeader) + if (!pGroup) + { + // No group to reuse: build one. The leader is whoever carries the LEADER bit + // and is online, else the first online member. + if (!leaderGuid) + { + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) + { + if (sObjectAccessor.FindPlayer(it->first)) { - pGroup->AddMember(pGroupPlrGuid, pGroupPlr->GetName()); + leaderGuid = it->first; + break; } - - leaderIsSet = true; } - else if (leaderIsSet && pGroupPlr && pGroupPlrGuid != leaderGuid) + } + + Player* pLeader = sObjectAccessor.FindPlayer(leaderGuid); + if (!pLeader) + { + return; // everyone went offline; nothing to build + } + + // Detach from any prior group BEFORE creating, so Create does not run against a + // player their old group still lists. + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) + { + Player* pMember = sObjectAccessor.FindPlayer(it->first); + if (pMember && pMember->GetGroup()) { - pGroup->AddMember(pGroupPlrGuid, pGroupPlr->GetName()); + pMember->GetGroup()->RemoveMember(it->first, 0); } } - pGroup->SetAsLfgGroup(); - } - else - { - Player* pGroupLeader = sObjectAccessor.FindPlayer(ObjectGuid(proposal->groupLeaderGuid)); - // Check if the group leader was found before accessing their group - if (pGroupLeader) + pGroup = new Group(); + if (!pGroup->Create(pLeader->GetObjectGuid(), pLeader->GetName())) { - pGroup = pGroupLeader->GetGroup(); + delete pGroup; + return; } - else - { - // Log that the group leader is missing and fall back to creating a new group - // In the future, we should determine the right actions for this scenario. - // LOG_ERROR("LFGMgr::CreateDungeonGroup", "Group leader with GUID %u not found. Creating new group.", proposal->groupLeaderGuid); - // Attempt to create a new group using the first available player in the proposal group - if (!proposal->groups.empty()) - { - ObjectGuid fallbackLeaderGuid = proposal->groups.begin()->first; - Player* fallbackLeader = sObjectAccessor.FindPlayer(fallbackLeaderGuid); + pGroup->SetAsLfgGroup(); + sObjectMgr.AddGroup(pGroup); + } - if (fallbackLeader) - { - pGroup = new Group(); - pGroup->Create(fallbackLeader->GetObjectGuid(), fallbackLeader->GetName()); - pGroup->SetAsLfgGroup(); + // Everyone in the proposal who is not already in this group joins it. That covers + // both paths: a freshly created group needs every non-leader added, and a reused + // premade needs the solo queuers that were matched into it. + ObjectGuid const groupGuid = pGroup->GetObjectGuid(); + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) + { + Player* pMember = sObjectAccessor.FindPlayer(it->first); + if (!pMember || pGroup->IsMember(it->first)) + { + continue; + } - // Add remaining members to the new group - for (playerGroupMap::iterator it = proposal->groups.begin(); it != proposal->groups.end(); ++it) - { - ObjectGuid pGroupPlrGuid = it->first; - if (pGroupPlrGuid != fallbackLeaderGuid) - { - Player* pGroupPlr = sObjectAccessor.FindPlayer(pGroupPlrGuid); - if (pGroupPlr) - { - pGroup->AddMember(pGroupPlrGuid, pGroupPlr->GetName()); - } - } - } - } - else - { - // If no valid players are found, we return without proceeding - // In the future, we should determine the right actions for this scenario. - // LOG_ERROR("LFGMgr::CreateDungeonGroup", "No valid players found to create a fallback group."); - return; - } - } - else - { - // Log if there are no players in the proposal groups map - // In the future, we should determine the right actions for this scenario. - // LOG_ERROR("LFGMgr::CreateDungeonGroup", "Proposal groups map is empty, cannot create fallback group."); - return; - } + if (Group* existing = pMember->GetGroup()) + { + existing->RemoveMember(it->first, 0); } + + pGroup->AddMember(it->first, pMember->GetName()); } - // Set dungeon difficulty for group LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(proposal->dungeonID); - if (!dungeon || !pGroup) + if (!dungeon) { return; } @@ -656,6 +677,7 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) m_groupSet.insert(groupGuid); m_groupStatusMap[groupGuid] = groupStatus; + TeleportToDungeon(dungeon->ID, pGroup); pGroup->SendUpdate(); @@ -976,6 +998,15 @@ void LFGMgr::HandleBossKilled(Player* pPlayer) // get rewards uint32 groupPlrLevel = pGroupPlr->getLevel(); const DungeonFinderRewards* rewards = sObjectMgr.GetDungeonFinderRewards(groupPlrLevel); // Fetch base xp/money reward + if (!rewards) + { + // Unconditionally dereferenced below. dungeonfinder_rewards ships 66 + // rows covering levels 15-80, so every level 81-90 character -- i.e. + // every MoP-relevant one -- crashed the world server on a tracked boss + // kill. No row means no base reward, not a crash. + continue; + } + ItemRewards itemRewards = GetDungeonItemRewards(status->dungeonID, type); // fetch item reward int32 multiplier; // base reward modifier From c8bb140398029e19bffed3686eff7f3e82a722b3 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 01:06:30 +0100 Subject: [PATCH 08/81] LFG: make the join gate and the leave path actually gate Both reachable today -- CMSG_LFG_JOIN and CMSG_LFG_LEAVE are already live. GetJoinResult ended its solo branch with an unconditional `result = ERR_LFG_OK`, discarding every check above it. A solo player with Dungeon Deserter, on LFG cooldown, in a battleground or in an arena was always admitted. The level 15 minimum was worse: that test existed only inside the group branch, so a solo player below 15 was never checked at all. In the group branch, `result` was assigned per member including an else-OK, so only the LAST iterated member's verdict survived -- a party containing one deserter was admitted whenever the last member happened to be clean. `LfgJoinResult result;` was also read uninitialised when a group had members but every getSource() returned null. HandleLfgLeaveOpcode tested `pGroup && pGroup->IsLeader(...)`, so a non-leader went down the SOLO branch. That branch erases m_playerData[playerGuid], and for a grouped queuer no such entry exists: the party's real entry, keyed by the group guid, stayed in the queue untouched while the client was told it had left. Whether a non-leader may cancel for the party is a permission question, and it belongs in LeaveLFG rather than being answered by cancelling the wrong thing. This one was mine, from the commit that first wired the opcode. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGHandler.cpp | 14 ++++++++---- src/game/WorldHandlers/LFGMgrQueue.cpp | 31 ++++++++++++++++++-------- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index f33522438..3969769af 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -176,12 +176,18 @@ void WorldSession::HandleLfgLeaveOpcode(WorldPacket& recv_data) return; } - // A grouped player leaves on behalf of the group, which is how the queue - // stores it -- JoinLFG keys group entries by the GROUP guid. + // A grouped player leaves on behalf of the group, which is how the queue stores it + // -- JoinLFG keys group entries by the GROUP guid. + // + // The test used to be `pGroup && pGroup->IsLeader(...)`, which sent a non-leader + // down the SOLO branch. That branch erases m_playerData[playerGuid], and for a + // grouped queuer no such entry exists: the party's real entry, keyed by the group + // guid, was left in the queue untouched while the client was told it had left. + // Whether a non-leader may cancel for the party is a permission question, answered + // in LeaveLFG, not a reason to cancel the wrong thing. Group* pGroup = plr->GetGroup(); - bool const isGroup = pGroup && pGroup->IsLeader(plr->GetObjectGuid()); - sLFGMgr.LeaveLFG(plr, isGroup); + sLFGMgr.LeaveLFG(plr, pGroup != nullptr); } void WorldSession::HandleLfgSetRolesOpcode(WorldPacket& recv_data) diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index f1972d5d8..4834e4b74 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -458,7 +458,9 @@ LFGProposal* LFGMgr::GetProposalData(uint32 proposalID) LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { - LfgJoinResult result; + // Initialised. `LfgJoinResult result;` was read uninitialised when a group had + // members but every getSource() returned null. + LfgJoinResult result = ERR_LFG_OK; Group* pGroup = plr->GetGroup(); /* Reasons for not entering: @@ -485,6 +487,21 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_RANDOM_COOLDOWN_PLAYER; } + else if (plr->getLevel() < 15) + { + // The level test previously lived only in the group branch, so a solo player + // below 15 was never checked at all. + result = ERR_LFG_CANT_USE_DUNGEONS; + } + + // Whatever the caller's own verdict is, it stands. The solo branch below used to + // end in an unconditional `result = ERR_LFG_OK`, throwing away every check above + // it: a solo player with Dungeon Deserter, on LFG cooldown, in a battleground, in + // an arena or below level 15 was always admitted. + if (result != ERR_LFG_OK) + { + return result; + } if (pGroup) { @@ -516,10 +533,10 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_RANDOM_COOLDOWN_PARTY; } - else - { - result = ERR_LFG_OK; - } + // No `else { result = ERR_LFG_OK; }` here. Assigning per member meant + // only the LAST iterated member's verdict survived, so a party + // containing one deserter was admitted whenever the last member + // happened to be clean. ++currentMemberCount; } @@ -531,10 +548,6 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) } } } - else - { - result = ERR_LFG_OK; - } return result; } From 4643ce4d6b7fed2829678c46b3dda52a5c79db0f Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 01:28:58 +0100 Subject: [PATCH 09/81] LFG: fix review findings -- resolver blowup, decline handling, leaks Cross-model review (Devin SWE-1.7 Max) returned BLOCK on seven findings. All seven were real. Two risks I flagged came back clean: sending the proposal's second GUID as zero is safe (the client echoes it and never surfaces it), and the TryFormGroup/FindQueueMatches snapshot iteration is sound. The resolver could hang the world thread. My own comment claimed "at most 5 players and 3 roles, bounded by 3^5" -- but this is not a five-man-only path. Raid finder rows ask for 2/6/17 and flexible raid for 0/0/25, and I had derived a 25-player LFR proposal in the same session that produced that comment. Measured on the exact algorithm in isolation, 26 hybrid players competing for 25 slots: naive 127,337,429 calls 545.014 ms memoised 934 calls 0.054 ms 545 ms of world-thread time, growing exponentially with player count. Keying dead ends on (index, remaining quota) bounds the search by (players+1) x (tank+1) x (healer+1) x (damage+1). Every fit/no-fit result is identical between the two, so this changes cost and not semantics. Declines. Returning early fixed the use-after-free but not the rest. It left the other members holding a proposal window that never closed, and the stale proposal stayed in m_proposalMap on the non-premade path -- where a later accept could still complete it short, which is the bug the early return was supposed to prevent. A decline now marks the proposal failed, sends SMSG_LFG_PROPOSAL_UPDATE to everyone still listed so their windows close, runs the per-player teardown, and erases the proposal unconditionally. ProposalDeclined no longer erases it or prunes members out of the maps -- the caller owns the proposal, and pruning was what let survivors read as unanimous. CreateDungeonGroup leaked a Group on the unknown-dungeon return: the lookup sat after creation, so it returned having already new'd a Group, run Create (a group id plus an INSERT INTO groups) and registered it with ObjectMgr. The lookup now happens first. Detaching a player from a two-man group makes Group::RemoveMember call Disband, which neither unregisters nor deletes the object. Both detach sites now use Player::RemoveFromGroup, the codebase's own helper for this, which handles RemoveGroup and delete. Re-queuing during a live proposal produced a second queue entry: the duplicate cleanup in JoinLFG is guarded on existing queue data, and TryFormGroup erases that the moment a proposal is sent. A player sitting on an open proposal is now refused. Both new parsers now require the body to be fully consumed. Unread tail data is the cheapest signal that a body was read wrongly and must not be swallowed. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.cpp | 11 +++- src/game/WorldHandlers/LFGMgr.cpp | 36 ++++++++++-- src/game/WorldHandlers/LFGMgrProposal.cpp | 69 ++++++++++++++++------- src/game/WorldHandlers/LFGMgrQueue.cpp | 15 +++++ 4 files changed, 105 insertions(+), 26 deletions(-) diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index a42088383..a9ee6fcf8 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -688,7 +688,10 @@ bool MopLfgProposalResponsePackets::ParseRequest(WorldPacket& in, Request& out) out.guidA = ObjectGuid(rawA); out.guidB = ObjectGuid(rawB); - return true; + + // Every byte must be accounted for. Leftover tail means the mask was misread and + // the GUIDs are wrong, which is worse than refusing the packet. + return in.rpos() == in.size(); } bool MopLfgSetRolesPackets::ParseRequest(WorldPacket& in, Request& out) @@ -703,7 +706,11 @@ bool MopLfgSetRolesPackets::ParseRequest(WorldPacket& in, Request& out) in >> out.roles; in >> out.roleCheckCounter; - return true; + + // The body is exactly 5 bytes. A longer one is not this packet, and accepting it + // would leave unread tail data -- the cheapest signal there is that a body was read + // wrongly, so it must not be swallowed silently. + return in.rpos() == in.size(); } bool MopLfgLeavePackets::ParseRequest(WorldPacket& in, Request& out) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index b6282abd2..11330ade0 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -24,6 +24,7 @@ */ #include +#include #include #include "DBCEnums.h" @@ -483,7 +484,15 @@ namespace // Assigning each player exactly one of the roles they offered needs backtracking, not // a greedy pass: given a tank-only player and a tank-or-healer player, handing the // tank slot to the hybrid first strands the specialist even though a valid assignment - // exists. With at most 5 players and 3 roles the search is bounded by 3^5. + // exists. + // + // The search is MEMOISED, and that is not an optimisation. Plain backtracking is + // exponential in the number of players, and this is not a five-man-only path: raid + // finder rows ask for 2/6/17 and flexible raid for 0/0/25, so a 25-player entry of + // hybrids would explore on the order of 3^25 states and hang the world thread -- + // LFGMgr::Update runs on it. Keying failures on (index, remaining quota) collapses + // that to at most (players+1) x (tank+1) x (healer+1) x (damage+1) states, a few + // thousand even for the largest shipped composition. struct RoleQuota { uint8 tank; @@ -493,8 +502,17 @@ namespace uint32 Total() const { return uint32(tank) + healer + damage; } }; + /// Pack (index, remaining quota) into one key for the failure memo. + uint64 RoleStateKey(size_t index, RoleQuota const& remaining) + { + return (uint64(index) << 24) + | (uint64(remaining.tank) << 16) + | (uint64(remaining.healer) << 8) + | uint64(remaining.damage); + } + bool AssignRolesRecursive(std::vector const& masks, size_t index, RoleQuota remaining, - RoleQuota& leftover) + RoleQuota& leftover, std::set& deadEnds) { if (index == masks.size()) { @@ -502,6 +520,13 @@ namespace return true; } + // Already proved unsatisfiable from this exact state. + uint64 const key = RoleStateKey(index, remaining); + if (deadEnds.find(key) != deadEnds.end()) + { + return false; + } + static uint8 const candidates[3] = { PLAYER_ROLE_TANK, PLAYER_ROLE_HEALER, PLAYER_ROLE_DAMAGE }; for (uint8 i = 0; i < 3; ++i) @@ -522,13 +547,14 @@ namespace } --(*slot); - if (AssignRolesRecursive(masks, index + 1, remaining, leftover)) + if (AssignRolesRecursive(masks, index + 1, remaining, leftover, deadEnds)) { return true; } ++(*slot); } + deadEnds.insert(key); return false; } @@ -565,7 +591,9 @@ namespace }); leftover = quota; - return AssignRolesRecursive(masks, 0, quota, leftover); + + std::set deadEnds; + return AssignRolesRecursive(masks, 0, quota, leftover, deadEnds); } /// The role composition a dungeon actually wants, straight off its DBC row. diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index be5bfd377..fa6cd8dec 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -358,17 +358,38 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted LFGProposalAnswer plrAnswer = (LFGProposalAnswer)accepted; proposal->answers[plrGuid] = plrAnswer; - // If the player declined, the proposal is over -- return immediately. + // A decline cancels the WHOLE proposal, for everyone. // - // Two bugs lived in falling through here. ProposalDeclined can erase the proposal - // from m_proposalMap, destroying the object `proposal` points into, and everything - // below then iterates and writes through that dangling pointer. And when it does - // NOT erase, it removes the decliner from `answers`, so the allOkay loop no longer - // sees them: four accepts plus one decline in a five-man read as unanimous, built a - // FOUR-man group and teleported it in. + // Three bugs lived in the old fall-through. ProposalDeclined can erase the proposal + // from m_proposalMap, destroying the object `proposal` points into, and the code + // below then iterated and wrote through that dangling pointer. When it did NOT + // erase -- the non-premade path -- it removed the decliner from `answers`, so the + // allOkay loop no longer saw them: four accepts plus one decline in a five-man read + // as unanimous, built a FOUR-man group and teleported it in. And simply returning + // early left the other members holding a proposal window that never closed. + // + // So: mark it failed, tell everyone still listed so their windows close, run the + // per-player teardown, then erase the proposal unconditionally. if (plrAnswer == LFG_ANSWER_DENY) { + uint32 const cancelledId = proposal->id; + + proposal->state = LFG_PROPOSAL_FAILED; + + for (proposalAnswerMap::const_iterator itr = proposal->answers.begin(); + itr != proposal->answers.end(); ++itr) + { + if (Player* pMember = sObjectAccessor.FindPlayer(itr->first)) + { + pMember->GetSession()->SendLfgProposalUpdate(*proposal); + } + } + ProposalDeclined(plrGuid, proposal); + + // Unconditional. The premade path erased it and the solo path did not, which is + // what left a stale proposal able to complete short on a later accept. + m_proposalMap.erase(cancelledId); return; } @@ -499,6 +520,16 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) } } + // Looked up BEFORE anything is created. This used to sit after group creation, so + // an unknown dungeon id returned having already new'd a Group, run Create (a group + // id plus an INSERT INTO groups) and registered it with ObjectMgr -- leaking the + // object and stranding its rows, with the proposal also left in m_proposalMap. + LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(proposal->dungeonID); + if (!dungeon) + { + return; + } + Group* pGroup = nullptr; if (proposal->groupRawGuid) @@ -559,13 +590,18 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) // Detach from any prior group BEFORE creating, so Create does not run against a // player their old group still lists. + // + // Player::RemoveFromGroup, not Group::RemoveMember directly: pulling a member + // out of a two-man group makes RemoveMember Disband it, and Disband does not + // delete the object or unregister it. The helper is the codebase's own + // convention for exactly this and handles RemoveGroup plus delete. for (playerGroupMap::const_iterator it = proposal->groups.begin(); it != proposal->groups.end(); ++it) { Player* pMember = sObjectAccessor.FindPlayer(it->first); if (pMember && pMember->GetGroup()) { - pMember->GetGroup()->RemoveMember(it->first, 0); + Player::RemoveFromGroup(pMember->GetGroup(), it->first); } } @@ -595,7 +631,7 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) if (Group* existing = pMember->GetGroup()) { - existing->RemoveMember(it->first, 0); + Player::RemoveFromGroup(existing, it->first); } pGroup->AddMember(it->first, pMember->GetName()); @@ -868,17 +904,10 @@ void LFGMgr::ProposalDeclined(ObjectGuid guid, LFGProposal* proposal) } } - if (!leaveGroupLFG) - { - proposal->currentRoles.erase(guid); - proposal->answers.erase(guid); - proposal->groups.erase(guid); - } - else - { - m_proposalMap.erase(proposal->id); - } - + // The proposal is erased by ProposalUpdate, which owns it -- erasing here destroyed + // the object our caller still holds a pointer to. Nor is there any point pruning the + // decliner out of currentRoles/answers/groups any more: the whole proposal is torn + // down either way, and pruning was exactly what let the survivors read as unanimous. LeaveLFG(pPlayer, leaveGroupLFG); } diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 4834e4b74..0b5da5e75 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -52,6 +52,21 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen // further down, so it must not start indeterminate. uint32 randomDungeonID = 0; // used later if random dungeon has been chosen + // Refuse a fresh queue while a proposal for this player is still open. + // + // The duplicate cleanup below is guarded on currentInfo, and TryFormGroup erases + // m_playerData the moment a proposal is sent -- so a player sitting on an open + // proposal window has no queue data, skipped that cleanup entirely, and got a + // SECOND live entry. If the first proposal then completed, CreateDungeonGroup put + // them in a dungeon group while they were still queued for another. + LFGPlayerStatus const existingStatus = GetPlayerStatus(plr->GetObjectGuid()); + if (existingStatus.state == LFG_STATE_PROPOSAL) + { + partyForbidden noneForbidden; + plr->GetSession()->SendLfgJoinResult(ERR_LFG_NO_LFG_OBJECT, existingStatus.state, noneForbidden); + return; + } + LFGPlayers* currentInfo = GetPlayerOrPartyData(guid); // check if we actually have info on the player/group right now From fc3f6156ad27d3ce1e635e06416afb95b7030b29 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 01:31:59 +0100 Subject: [PATCH 10/81] LFG: clear every member's state on a decline, not just the decliner's Self-caught while anticipating the re-review, and it is a regression from the previous commit rather than a pre-existing bug. ProposalDeclined calls LeaveLFG for the DECLINER alone. The other members were left at LFG_STATE_PROPOSAL: their queue entry was already erased when the proposal fired, nothing else resets them, and the re-queue guard added in the same commit now refuses a player in that state. So one decline would have locked every other member out of the dungeon finder until they relogged -- turning a fix for a duplicate-entry bug into a worse denial. Membership is snapshotted before ProposalDeclined runs, because that path calls LeaveLFG and can mutate the maps being walked. Everyone leaving LFG on a decline is deliberate. Retail requeues the non-decliners, but their queue data is gone by this point and rebuilding it is separate work. Leaving cleanly is correct-but-less, and it is visible to the player rather than silent. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index fa6cd8dec..c73e7e969 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -23,6 +23,8 @@ * and lore are copyrighted by Blizzard Entertainment, Inc. */ +#include + #include "DBCEnums.h" #include "DBCStores.h" #include "DBCStructure.h" @@ -385,8 +387,38 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted } } + // Snapshot the membership before ProposalDeclined, which calls LeaveLFG and can + // mutate the maps we need to walk afterwards. + std::vector members; + members.reserve(proposal->answers.size()); + for (proposalAnswerMap::const_iterator itr = proposal->answers.begin(); + itr != proposal->answers.end(); ++itr) + { + members.push_back(itr->first); + } + ProposalDeclined(plrGuid, proposal); + // Clear EVERY member's dungeon finder state, not just the decliner's. + // + // ProposalDeclined calls LeaveLFG for the decliner alone, so without this the + // other members stayed at LFG_STATE_PROPOSAL for ever: their queue entry was + // already erased when the proposal fired, nothing else resets them, and JoinLFG + // now refuses a player in that state -- which would have left them unable to use + // the dungeon finder again until relog. + // + // Everyone leaving on a decline is deliberate. Retail puts the non-decliners + // back in the queue, but their queue data is gone by this point and rebuilding + // it is a separate piece of work; leaving LFG cleanly is correct-but-less, and + // is visible to the player rather than silent. + for (std::vector::const_iterator itr = members.begin(); + itr != members.end(); ++itr) + { + m_queueSet.erase(*itr); + m_playerData.erase(*itr); + m_playerStatusMap.erase(*itr); + } + // Unconditional. The premade path erased it and the solo path did not, which is // what left a stale proposal able to complete short on a later accept. m_proposalMap.erase(cancelledId); From de8d7aa90bef884dce34b98380cfd73c97dbfe37 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 01:44:06 +0100 Subject: [PATCH 11/81] LFG: send LFG_UPDATE_LEAVE when a decline tears the proposal down From the focused re-review. Clearing server-side state and closing the proposal window is not enough on its own: without an explicit LEAVE the client keeps showing itself queued for a queue that no longer exists. Sent before the status entry is erased, since the update is built from it. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index c73e7e969..8274c0318 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -414,6 +414,17 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted for (std::vector::const_iterator itr = members.begin(); itr != members.end(); ++itr) { + // Tell them they are out of the queue BEFORE the status entry goes, since + // the update is built from it. Closing the proposal window is not enough on + // its own: without an explicit LEAVE the client keeps showing itself as + // queued for a queue that no longer exists. + SetPlayerState(*itr, LFG_STATE_NONE); + SetPlayerUpdateType(*itr, LFG_UPDATE_LEAVE); + + bool const wasGrouped = m_playerData.find(*itr) != m_playerData.end() && + m_playerData[*itr].isGroup; + SendLfgUpdate(*itr, GetPlayerStatus(*itr), wasGrouped); + m_queueSet.erase(*itr); m_playerData.erase(*itr); m_playerStatusMap.erase(*itr); From 7a8f0095e291da7eeb627bb67ca070c352ce34a6 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 02:12:13 +0100 Subject: [PATCH 12/81] LFG: keep the queue entry alive across a proposal, and requeue on failure The client states the three outcomes of a failed proposal plainly, and the previous behaviour matched none of them: ERR_LFG_PROPOSAL_FAILED "Someone has declined the invite. You have been returned to the front of the queue." ERR_LFG_PROPOSAL_DECLINED_SELF "You have been removed from the queue because you did not accept the invitation." ERR_LFG_PROPOSAL_DECLINED_PARTY "...because someone in your party did not accept the invitation." So the decliner leaves, their premade leaves with them, and EVERYONE ELSE goes back in the queue. The previous commit ejected all of them, which I had described as correct-but-less; it is simply wrong. That required a change of shape. TryFormGroup used to erase the queue entry the moment a proposal fired, leaving nothing to put people back into. It now removes the entry from the match set and marks it LFG_STATE_PROPOSAL while keeping the data, so a cancellation can restore it. CancelProposal implements the three outcomes; the decline path routes through it. That also fixes, from the PR review: - No proposal timeout existed. A recipient who ignored the popup, disconnected, or whose client-side timer lapsed left everyone else pinned at LFG_STATE_PROPOSAL for ever, and JoinLFG refuses that state, so they could not re-queue until relog. RemoveOldProposals now reaps them through the same cancellation path, which requeues the survivors. - Any logged-in player could cancel someone else's proposal. m_proposalId is a plain incrementing counter, so an id is trivially guessable, and writing to proposal->answers INSERTED the caller -- a `false` answer from a stranger cancelled a group they had nothing to do with. Only participants may answer. - A queued player who logged out was skipped when filling `groups` and `answers` while still counted in `currentRoles`, so the online members could all accept, allOkay saw no pending answer for the absent one, and a SHORT group was built and teleported. Offline members are now dropped from the entry before a proposal is sent, and the entry goes back to looking. - A raid-sized dungeon built a normal party. Group::IsFull caps at MAX_GROUP_SIZE and AddMember just returns false past it, so a raid finder proposal (2/6/17 = 25) completed as a five-man while the other twenty were told a group had been found, never added and never teleported. Groups whose dungeon quota exceeds a party are converted to raid before members are added, and the AddMember return is no longer discarded. Not fixed here, and worth stating: retail also displays those three messages, but they are delivered through SMSG_DISPLAY_GAME_ERROR, which has no sender anywhere in this tree and whose body is not yet derived. The behaviour is right; the notification text is still missing. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 136 +++++++++++++++++++++- src/game/WorldHandlers/LFGMgr.h | 21 ++++ src/game/WorldHandlers/LFGMgrProposal.cpp | 135 +++++++++++---------- 3 files changed, 223 insertions(+), 69 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 11330ade0..5a31ec418 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -79,6 +79,9 @@ void LFGMgr::Update() // remove old role checks RemoveOldRoleChecks(); + // and proposals nobody answered + RemoveOldProposals(); + // go through a waitTimeMap::iterator for each wait map and update times based on player count for (waitTimeMap::iterator tankItr = m_tankWaitTime.begin(); tankItr != m_tankWaitTime.end(); ++tankItr) { @@ -795,16 +798,139 @@ bool LFGMgr::TryFormGroup(ObjectGuid guid) return false; } - SendDungeonProposal(entry); + // Everyone in the entry must be online. SendDungeonProposal skips offline players + // when filling `groups` and `answers` while `currentRoles` still counts them toward + // the completed composition, so the online members could all accept, `allOkay` would + // see no pending answer for the absent one, and a SHORT group would be built and + // teleported in. Drop them from the entry instead and let it re-fill. + std::vector offline; + for (roleMap::const_iterator it = entry->currentRoles.begin(); it != entry->currentRoles.end(); ++it) + { + if (!sObjectAccessor.FindPlayer(it->first)) + { + offline.push_back(it->first); + } + } + + if (!offline.empty()) + { + for (std::vector::const_iterator it = offline.begin(); it != offline.end(); ++it) + { + entry->currentRoles.erase(*it); + m_playerStatusMap.erase(*it); + } + + if (entry->currentRoles.empty()) + { + m_queueSet.erase(guid); + m_playerData.erase(guid); + return false; + } + + UpdateNeededRoles(guid, entry); + return false; // no longer complete; stays queued and keeps looking + } - // Out of the queue the moment a proposal exists for it. Without this the entry is - // still LFG_STATE_QUEUED next tick, gets matched again, and fires a fresh proposal - // -- and a new SMSG_LFG_PROPOSAL_UPDATE -- every single tick, forever. + // Out of the MATCH set, but the entry itself stays. Leaving it in m_queueSet would + // have it matched again next tick and fire a fresh proposal -- and a new + // SMSG_LFG_PROPOSAL_UPDATE -- every tick forever. Keeping m_playerData is what lets + // a declined or timed-out proposal put the survivors back in the queue rather than + // ejecting them from the dungeon finder. m_queueSet.erase(guid); - m_playerData.erase(guid); + entry->currentState = LFG_STATE_PROPOSAL; + + SendDungeonProposal(entry); return true; } +void LFGMgr::CancelProposal(uint32 proposalId, std::set const& culprits) +{ + proposalMap::iterator it = m_proposalMap.find(proposalId); + if (it == m_proposalMap.end()) + { + return; + } + + LFGProposal proposal = it->second; // copy: the map entry is erased below + m_proposalMap.erase(it); + + // Tell every client the proposal is over so the window closes. + proposal.state = LFG_PROPOSAL_FAILED; + for (proposalAnswerMap::const_iterator ans = proposal.answers.begin(); + ans != proposal.answers.end(); ++ans) + { + if (Player* pMember = sObjectAccessor.FindPlayer(ans->first)) + { + pMember->GetSession()->SendLfgProposalUpdate(proposal); + } + } + + LFGPlayers* entry = GetPlayerOrPartyData(proposal.queueGuid); + + // The players responsible leave the dungeon finder outright -- the client says so: + // "You have been removed from the queue because you did not accept the invitation." + for (std::set::const_iterator bad = culprits.begin(); bad != culprits.end(); ++bad) + { + if (entry) + { + entry->currentRoles.erase(*bad); + } + + SetPlayerState(*bad, LFG_STATE_NONE); + SetPlayerUpdateType(*bad, LFG_UPDATE_LEAVE); + SendLfgUpdate(*bad, GetPlayerStatus(*bad), false); + + m_queueSet.erase(*bad); + m_playerData.erase(*bad); + m_playerStatusMap.erase(*bad); + } + + if (!entry || entry->currentRoles.empty()) + { + m_queueSet.erase(proposal.queueGuid); + m_playerData.erase(proposal.queueGuid); + return; + } + + // Everyone else goes back in: "You have been returned to the front of the queue." + entry->currentState = LFG_STATE_QUEUED; + UpdateNeededRoles(proposal.queueGuid, entry); + + for (roleMap::const_iterator role = entry->currentRoles.begin(); + role != entry->currentRoles.end(); ++role) + { + SetPlayerState(role->first, LFG_STATE_QUEUED); + SetPlayerUpdateType(role->first, LFG_UPDATE_ADDED_TO_QUEUE); + SendLfgUpdate(role->first, GetPlayerStatus(role->first), false); + } + + m_queueSet.insert(proposal.queueGuid); +} + +void LFGMgr::RemoveOldProposals() +{ + time_t const now = time(NULL); + + std::vector expired; + for (proposalMap::const_iterator it = m_proposalMap.begin(); it != m_proposalMap.end(); ++it) + { + if (it->second.createdTime && (now - it->second.createdTime) >= LFG_TIME_PROPOSAL) + { + expired.push_back(it->first); + } + } + + // Collected first: CancelProposal erases from the map being walked. + // + // Without this reaper a recipient who ignored the popup, disconnected, or whose + // client-side timer lapsed left everyone else pinned at LFG_STATE_PROPOSAL for ever + // -- and JoinLFG refuses that state, so they could not re-queue until relog. + for (std::vector::const_iterator it = expired.begin(); it != expired.end(); ++it) + { + CancelProposal(*it, std::set()); + } +} + void LFGMgr::FindQueueMatches() { // Snapshot: MergeGroups and TryFormGroup both erase from m_queueSet, and erasing the diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 5681e1b8b..47a5d710e 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -877,6 +877,13 @@ struct LFGProposal // whatever was on the stack. uint32 id = 0; // proposal id uint32 dungeonID = 0; // dungeon id + + // The m_playerData key this proposal was built from. The queue entry is kept alive + // for the lifetime of the proposal so a failure can put the survivors back, which is + // what the client tells the player happens: ERR_LFG_PROPOSAL_FAILED reads "Someone + // has declined the invite. You have been returned to the front of the queue." + ObjectGuid queueGuid; + time_t createdTime = 0; // for the timeout reaper LFGProposalState state = LFG_PROPOSAL_INITIATING; // proposal state uint32 encounters = 0; // encounters done uint64 groupRawGuid = 0; // group raw guid value @@ -1076,6 +1083,20 @@ class LFGMgr */ bool TryFormGroup(ObjectGuid guid); + /** + * @brief Cancel a proposal, remove the players responsible, and return everyone else + * to the queue. + * + * @param proposalId the proposal to cancel + * @param culprits players removed from the dungeon finder entirely (the decliner + * and, if they were in a premade, that premade). Empty on timeout, + * where nobody is singled out. + */ + void CancelProposal(uint32 proposalId, std::set const& culprits); + + /// Cancel proposals nobody answered within LFG_TIME_PROPOSAL. + void RemoveOldProposals(); + /** * @brief Add the player or group to the Dungeon Finder queue * diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 8274c0318..aa93f3df5 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -210,6 +210,17 @@ void LFGMgr::SendDungeonProposal(LFGPlayers* lfgGroup) newProposal.dungeonID = *dItr; newProposal.isNew = true; newProposal.joinedQueue = lfgGroup->joinedTime; + newProposal.createdTime = time(NULL); + + // Which queue entry this came from, so a failure can put the survivors back. + for (playerData::const_iterator it = m_playerData.begin(); it != m_playerData.end(); ++it) + { + if (&it->second == lfgGroup) + { + newProposal.queueGuid = it->first; + break; + } + } bool premadeGroup = IsProposalSameGroup(newProposal); @@ -354,85 +365,58 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted return; } + // Only a participant may answer. + // + // m_proposalId is a plain incrementing counter, so an id is trivially guessable. + // Without this check, writing to proposal->answers INSERTED the caller, and a + // `false` answer from any logged-in player cancelled a group they had nothing to do + // with -- clearing the real members out of the queue. + if (proposal->answers.find(plrGuid) == proposal->answers.end()) + { + sLog.outError("LFG: %s answered proposal %u they are not part of.", + plrGuid.GetString().c_str(), proposalID); + return; + } + bool allOkay = true; // true if everyone answered LFG_ANSWER_AGREE // Update answer map to given value LFGProposalAnswer plrAnswer = (LFGProposalAnswer)accepted; proposal->answers[plrGuid] = plrAnswer; - // A decline cancels the WHOLE proposal, for everyone. + // A decline cancels the proposal, but it does NOT eject everyone. // - // Three bugs lived in the old fall-through. ProposalDeclined can erase the proposal - // from m_proposalMap, destroying the object `proposal` points into, and the code - // below then iterated and wrote through that dangling pointer. When it did NOT - // erase -- the non-premade path -- it removed the decliner from `answers`, so the - // allOkay loop no longer saw them: four accepts plus one decline in a five-man read - // as unanimous, built a FOUR-man group and teleported it in. And simply returning - // early left the other members holding a proposal window that never closed. + // The client states all three outcomes plainly: + // ERR_LFG_PROPOSAL_FAILED "Someone has declined the invite. You have been + // returned to the front of the queue." + // ERR_LFG_PROPOSAL_DECLINED_SELF "You have been removed from the queue because you + // did not accept the invitation." + // ERR_LFG_PROPOSAL_DECLINED_PARTY "...because someone in your party did not accept." // - // So: mark it failed, tell everyone still listed so their windows close, run the - // per-player teardown, then erase the proposal unconditionally. + // So the decliner leaves, their premade leaves with them, and everyone else is + // requeued. An earlier version of this removed everyone, which is why the queue + // entry is now kept alive for the lifetime of the proposal -- there has to be + // something left to put people back into. if (plrAnswer == LFG_ANSWER_DENY) { - uint32 const cancelledId = proposal->id; - - proposal->state = LFG_PROPOSAL_FAILED; + std::set culprits; + culprits.insert(plrGuid); - for (proposalAnswerMap::const_iterator itr = proposal->answers.begin(); - itr != proposal->answers.end(); ++itr) + // A premade is removed alongside the member who declined for it. + playerGroupMap::const_iterator declinerGroup = proposal->groups.find(plrGuid); + if (declinerGroup != proposal->groups.end() && declinerGroup->second) { - if (Player* pMember = sObjectAccessor.FindPlayer(itr->first)) + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) { - pMember->GetSession()->SendLfgProposalUpdate(*proposal); + if (it->second == declinerGroup->second) + { + culprits.insert(it->first); + } } } - // Snapshot the membership before ProposalDeclined, which calls LeaveLFG and can - // mutate the maps we need to walk afterwards. - std::vector members; - members.reserve(proposal->answers.size()); - for (proposalAnswerMap::const_iterator itr = proposal->answers.begin(); - itr != proposal->answers.end(); ++itr) - { - members.push_back(itr->first); - } - - ProposalDeclined(plrGuid, proposal); - - // Clear EVERY member's dungeon finder state, not just the decliner's. - // - // ProposalDeclined calls LeaveLFG for the decliner alone, so without this the - // other members stayed at LFG_STATE_PROPOSAL for ever: their queue entry was - // already erased when the proposal fired, nothing else resets them, and JoinLFG - // now refuses a player in that state -- which would have left them unable to use - // the dungeon finder again until relog. - // - // Everyone leaving on a decline is deliberate. Retail puts the non-decliners - // back in the queue, but their queue data is gone by this point and rebuilding - // it is a separate piece of work; leaving LFG cleanly is correct-but-less, and - // is visible to the player rather than silent. - for (std::vector::const_iterator itr = members.begin(); - itr != members.end(); ++itr) - { - // Tell them they are out of the queue BEFORE the status entry goes, since - // the update is built from it. Closing the proposal window is not enough on - // its own: without an explicit LEAVE the client keeps showing itself as - // queued for a queue that no longer exists. - SetPlayerState(*itr, LFG_STATE_NONE); - SetPlayerUpdateType(*itr, LFG_UPDATE_LEAVE); - - bool const wasGrouped = m_playerData.find(*itr) != m_playerData.end() && - m_playerData[*itr].isGroup; - SendLfgUpdate(*itr, GetPlayerStatus(*itr), wasGrouped); - - m_queueSet.erase(*itr); - m_playerData.erase(*itr); - m_playerStatusMap.erase(*itr); - } - - // Unconditional. The premade path erased it and the solo path did not, which is - // what left a stale proposal able to complete short on a later accept. - m_proposalMap.erase(cancelledId); + CancelProposal(proposal->id, culprits); return; } @@ -656,6 +640,17 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) } pGroup->SetAsLfgGroup(); + + // A dungeon whose composition exceeds a party must be a RAID before anyone is + // added. Group::IsFull caps a normal party at MAX_GROUP_SIZE, and AddMember just + // returns false past that -- so a raid-finder proposal (2/6/17 = 25) silently + // completed as a five-man while the other twenty were told a group had been + // found, never added, and never teleported. + if (dungeon->Count_tank + dungeon->Count_healer + dungeon->Count_damage > MAX_GROUP_SIZE) + { + pGroup->ConvertToRaid(); + } + sObjectMgr.AddGroup(pGroup); } @@ -677,7 +672,13 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) Player::RemoveFromGroup(existing, it->first); } - pGroup->AddMember(it->first, pMember->GetName()); + if (!pGroup->AddMember(it->first, pMember->GetName())) + { + // Ignoring this return is how the raid case failed silently. Say so. + sLog.outError("LFG: could not add %s to dungeon group %u (full at %u members).", + it->first.GetString().c_str(), pGroup->GetId(), + pGroup->GetMembersCount()); + } } LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(proposal->dungeonID); @@ -898,6 +899,12 @@ LFGGroupStatus* LFGMgr::GetGroupStatus(ObjectGuid guid) } } +/// Legacy per-player decline teardown. +/// +/// No longer on the decline path: ProposalUpdate routes declines through CancelProposal, +/// which implements the three outcomes the client actually describes (decliner out, +/// their premade out, everyone else requeued). Kept because the boot/kick flow still +/// references this shape, but it must not be called for a proposal response. void LFGMgr::ProposalDeclined(ObjectGuid guid, LFGProposal* proposal) { Player* pPlayer = sObjectAccessor.FindPlayer(guid); From c4081b5ac2db067280bf43a7698ec8c476634556 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 02:16:41 +0100 Subject: [PATCH 13/81] LFG: add .debug dungeon so the finder can be tested without nine other people Mirrors `.debug bg`, which lets a battleground start 1v0. The dungeon finder has the same problem and worse: a normal five-man will not form until 1 tank, 1 healer and 3 damage are all present, so on a test realm with two accounts the proposal, group-creation and teleport paths are simply unreachable -- correct behaviour that cannot be exercised. .debug dungeon a game master's queue entry completes on its own .debug dungeon group as above, and it also absorbs whoever else is waiting, whatever roles they picked .debug dungeon off back to normal matchmaking Bare `.debug dungeon` toggles off when a mode is already active, matching how `.debug bg` behaves with no arguments. While any mode is active a game master leads the resulting dungeon group regardless of who holds the LEADER bit, so the operator keeps control of the group under test. Every relaxation is gated on the queue entry actually CONTAINING a game master: - TryFormGroup waives the needed-role test only for such an entry. - RoleMapsAreCompatible waives only the role composition in group mode, and only when a GM is on one side. The size cap and the duplicate-membership check both still apply, so a five-man still caps at five and nobody can end up in two entries. Scoping it this way matters. Relaxing the matchmaker globally would change how ordinary players match each other while the operator is testing, which makes a debug switch untrustworthy -- you can no longer tell whether what you observed was the system working or the switch lying. Game master is account security, not `.gm on`: the operator should not have to make themselves untargetable to test the dungeon finder. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/ChatCommands/DebugCommands.cpp | 75 +++++++++++++++++++++++ src/game/WorldHandlers/Chat.cpp | 1 + src/game/WorldHandlers/Chat.h | 1 + src/game/WorldHandlers/LFGMgr.cpp | 42 ++++++++++++- src/game/WorldHandlers/LFGMgr.h | 21 +++++++ src/game/WorldHandlers/LFGMgrProposal.cpp | 21 ++++++- 6 files changed, 159 insertions(+), 2 deletions(-) diff --git a/src/game/ChatCommands/DebugCommands.cpp b/src/game/ChatCommands/DebugCommands.cpp index bcb410f67..e2b3e014e 100644 --- a/src/game/ChatCommands/DebugCommands.cpp +++ b/src/game/ChatCommands/DebugCommands.cpp @@ -35,6 +35,7 @@ */ #include "Common.h" +#include "LFGMgr.h" #include "Database/DatabaseEnv.h" #include "WorldPacket.h" #include "Player.h" @@ -1057,6 +1058,80 @@ bool ChatHandler::HandleDebugGetItemStateCommand(char* args) * @param args Command arguments. * @returns True if the command executed successfully, false otherwise. */ +/** + * @brief Handler for the `.debug dungeon` command. + * + * Mirrors `.debug bg`, which lets a battleground start 1v0 so it can be tested without + * finding nineteen other people. The dungeon finder has the same problem and worse: a + * normal five-man will not form until 1 tank, 1 healer and 3 damage are all present, so + * the proposal, group-creation and teleport paths are unreachable on a test realm. + * + * .debug dungeon a game master's queue entry completes on its own + * .debug dungeon group as above, and it also absorbs whoever else is waiting, + * whatever roles they picked + * .debug dungeon off back to normal matchmaking + * + * While any mode is active a game master leads the resulting dungeon group regardless of + * who holds the leader bit. + * + * The relaxations are scoped to entries that actually contain a game master, so ordinary + * players continue to match each other by the normal rules while this is on. + * + * @param args "group", "off", or empty for solo mode. + * @returns True if the command executed successfully, false otherwise. + */ +bool ChatHandler::HandleDebugDungeonCommand(char* args) +{ + char* mode = ExtractLiteralArg(&args); + + LFGDebugMode newMode = LFG_DEBUG_SOLO; + if (mode) + { + if (!stricmp(mode, "group")) + { + newMode = LFG_DEBUG_GROUP; + } + else if (!stricmp(mode, "off")) + { + newMode = LFG_DEBUG_OFF; + } + else + { + SendSysMessage("Usage: .debug dungeon [group|off]"); + SetSentErrorMessage(true); + return false; + } + } + else if (sLFGMgr.GetDebugMode() != LFG_DEBUG_OFF) + { + // Bare `.debug dungeon` toggles off when something is already on, so the command + // behaves like `.debug bg` when used without arguments. + newMode = LFG_DEBUG_OFF; + } + + sLFGMgr.SetDebugMode(newMode); + + switch (newMode) + { + case LFG_DEBUG_SOLO: + SendSysMessage("Dungeon finder debug ON: a game master's queue entry now forms a group on its own."); + break; + case LFG_DEBUG_GROUP: + SendSysMessage("Dungeon finder debug ON (group): a game master's entry now also takes whoever else is queued, whatever roles they picked."); + break; + default: + SendSysMessage("Dungeon finder debug OFF: normal matchmaking."); + break; + } + + if (newMode != LFG_DEBUG_OFF) + { + SendSysMessage("Ordinary players still match by the normal rules; only entries containing a game master are affected."); + } + + return true; +} + bool ChatHandler::HandleDebugBattlegroundCommand(char* /*args*/) { sBattleGroundMgr.ToggleTesting(); diff --git a/src/game/WorldHandlers/Chat.cpp b/src/game/WorldHandlers/Chat.cpp index 257d95aa7..34cb62787 100644 --- a/src/game/WorldHandlers/Chat.cpp +++ b/src/game/WorldHandlers/Chat.cpp @@ -277,6 +277,7 @@ ChatCommand* ChatHandler::getCommandTable() { "anim", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugAnimCommand, "", NULL }, { "arena", SEC_ADMINISTRATOR, false, &ChatHandler::HandleDebugArenaCommand, "", NULL }, { "bg", SEC_ADMINISTRATOR, false, &ChatHandler::HandleDebugBattlegroundCommand, "", NULL }, + { "dungeon", SEC_ADMINISTRATOR, false, &ChatHandler::HandleDebugDungeonCommand, "", NULL }, { "getitemstate", SEC_ADMINISTRATOR, false, &ChatHandler::HandleDebugGetItemStateCommand, "", NULL }, { "lootrecipient", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugGetLootRecipientCommand, "", NULL }, { "losdebug", SEC_GAMEMASTER, false, &ChatHandler::HandleDebugLosCommand, "", NULL }, diff --git a/src/game/WorldHandlers/Chat.h b/src/game/WorldHandlers/Chat.h index eb5155189..943a48212 100644 --- a/src/game/WorldHandlers/Chat.h +++ b/src/game/WorldHandlers/Chat.h @@ -586,6 +586,7 @@ class ChatHandler bool HandleDebugAnimCommand(char* args); bool HandleDebugArenaCommand(char* args); bool HandleDebugBattlegroundCommand(char* args); + bool HandleDebugDungeonCommand(char* args); bool HandleDebugGetItemStateCommand(char* args); bool HandleDebugGetItemValueCommand(char* args); bool HandleDebugGetLootRecipientCommand(char* args); diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 5a31ec418..a7215aab6 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -785,6 +785,29 @@ void LFGMgr::AddToWaitMap(uint8 role, std::set dungeons) } } +bool LFGMgr::EntryHasGameMaster(LFGPlayers const* entry) const +{ + if (!entry) + { + return false; + } + + for (roleMap::const_iterator it = entry->currentRoles.begin(); it != entry->currentRoles.end(); ++it) + { + Player* pPlayer = sObjectAccessor.FindPlayer(it->first); + + // Account security, not `.gm on`. The operator should not have to make + // themselves untargetable just to test the dungeon finder. + if (pPlayer && pPlayer->GetSession() && + pPlayer->GetSession()->GetSecurity() >= SEC_GAMEMASTER) + { + return true; + } + } + + return false; +} + bool LFGMgr::TryFormGroup(ObjectGuid guid) { LFGPlayers* entry = GetPlayerOrPartyData(guid); @@ -793,7 +816,12 @@ bool LFGMgr::TryFormGroup(ObjectGuid guid) return false; } - if (entry->neededTanks || entry->neededHealers || entry->neededDps) + // `.debug dungeon` lets an entry containing a game master go without a full + // composition, so the operator can drive the whole proposal -> group -> teleport + // chain without finding four other people. Everyone else still needs a real group. + bool const debugComplete = m_debugMode != LFG_DEBUG_OFF && EntryHasGameMaster(entry); + + if (!debugComplete && (entry->neededTanks || entry->neededHealers || entry->neededDps)) { return false; } @@ -1044,6 +1072,13 @@ bool LFGMgr::RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo, return false; } + // `.debug dungeon group`: an entry containing a game master takes whoever else is + // waiting, whatever they picked. The size cap above still applies, and the duplicate + // check below still applies -- only the role composition is waived, and only when a + // GM is involved. + bool const debugMerge = m_debugMode == LFG_DEBUG_GROUP && + (EntryHasGameMaster(groupOne) || EntryHasGameMaster(groupTwo)); + roleMap combined = groupOne->currentRoles; for (roleMap::const_iterator it = groupTwo->currentRoles.begin(); it != groupTwo->currentRoles.end(); ++it) { @@ -1057,6 +1092,11 @@ bool LFGMgr::RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo, return false; } + if (debugMerge) + { + return true; + } + RoleQuota leftover; return RolesFitQuota(combined, quota, leftover); } diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 47a5d710e..489bcedd6 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -691,6 +691,19 @@ enum LFGRoles PLAYER_ROLE_DAMAGE = 0x08 }; +/// Dungeon finder debug modes, driven by `.debug dungeon`. +/// +/// Every relaxation these enable is gated on the queue entry actually containing a game +/// master. Relaxing the matchmaker globally would change how ordinary players match each +/// other while the operator is testing, which is exactly what makes a debug switch +/// untrustworthy. +enum LFGDebugMode +{ + LFG_DEBUG_OFF = 0, // normal matchmaking + LFG_DEBUG_SOLO = 1, // a GM's entry completes alone + LFG_DEBUG_GROUP = 2 // a GM's entry also absorbs whoever else is waiting +}; + /// Role amounts enum LFGRoleCount { @@ -992,6 +1005,10 @@ class LFGMgr */ void SetPlayerState(ObjectGuid guid, LFGState state); + /// Current `.debug dungeon` mode; LFG_DEBUG_OFF unless an administrator enabled it. + LFGDebugMode GetDebugMode() const { return m_debugMode; } + void SetDebugMode(LFGDebugMode mode) { m_debugMode = mode; } + /** * @brief Set the player's LFG update type * @@ -1097,6 +1114,9 @@ class LFGMgr /// Cancel proposals nobody answered within LFG_TIME_PROPOSAL. void RemoveOldProposals(); + /// Does this queue entry contain at least one game master? Scopes `.debug dungeon`. + bool EntryHasGameMaster(LFGPlayers const* entry) const; + /** * @brief Add the player or group to the Dungeon Finder queue * @@ -1257,6 +1277,7 @@ class LFGMgr /// Proposal information uint32 m_proposalId; + LFGDebugMode m_debugMode = LFG_DEBUG_OFF; proposalMap m_proposalMap; }; diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index aa93f3df5..d7e9ca00e 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -537,8 +537,27 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) // // Resolve the leader ONCE, up front, and require them to be online. ObjectGuid leaderGuid; + + // With `.debug dungeon` active a game master leads the dungeon regardless of who + // carries the LEADER bit, so the operator always has control of the group they are + // testing. Checked first, so it wins outright. + if (m_debugMode != LFG_DEBUG_OFF) + { + for (roleMap::const_iterator it = proposal->currentRoles.begin(); + it != proposal->currentRoles.end(); ++it) + { + Player* pPlayer = sObjectAccessor.FindPlayer(it->first); + if (pPlayer && pPlayer->GetSession() && + pPlayer->GetSession()->GetSecurity() >= SEC_GAMEMASTER) + { + leaderGuid = it->first; + break; + } + } + } + for (roleMap::const_iterator it = proposal->currentRoles.begin(); - it != proposal->currentRoles.end(); ++it) + !leaderGuid && it != proposal->currentRoles.end(); ++it) { if ((it->second & PLAYER_ROLE_LEADER) && sObjectAccessor.FindPlayer(it->first)) { From 2d291ec368a08e12d77071af8a7a5be2315867a4 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 02:21:09 +0100 Subject: [PATCH 14/81] LFG: address the CodeFactor complexity notices on the proposal path Both flagged functions were already large on master (ProposalUpdate 100 lines, SendDungeonProposal 95); my changes grew them to 143 and 106. Fair feedback, so fixed rather than waived. The decline branch is lifted out of ProposalUpdate into DeclineProposal. It was a self-contained forty lines that answered one question -- who is responsible for this cancellation -- and reads better named than inline. ProposalUpdate is back to 113 lines. SendDungeonProposal now takes the queue guid instead of recovering it by scanning m_playerData for an entry whose value has the same ADDRESS as the LFGPlayers* it was handed. The caller already knows the key. Beyond the complexity, identifying a map entry by the address of its value is the sort of thing that quietly stops working the first time anyone copies the struct, and LFGPlayers is copied in several places already. No behaviour change; 115/115 still pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 2 +- src/game/WorldHandlers/LFGMgr.h | 5 +- src/game/WorldHandlers/LFGMgrProposal.cpp | 84 ++++++++++++----------- 3 files changed, 48 insertions(+), 43 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index a7215aab6..28f0c1e52 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -867,7 +867,7 @@ bool LFGMgr::TryFormGroup(ObjectGuid guid) m_queueSet.erase(guid); entry->currentState = LFG_STATE_PROPOSAL; - SendDungeonProposal(entry); + SendDungeonProposal(guid, entry); return true; } diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 489bcedd6..78adcb85c 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1114,6 +1114,9 @@ class LFGMgr /// Cancel proposals nobody answered within LFG_TIME_PROPOSAL. void RemoveOldProposals(); + /// The decline half of ProposalUpdate: work out who is responsible and cancel. + void DeclineProposal(ObjectGuid plrGuid, LFGProposal* proposal); + /// Does this queue entry contain at least one game master? Scopes `.debug dungeon`. bool EntryHasGameMaster(LFGPlayers const* entry) const; @@ -1229,7 +1232,7 @@ class LFGMgr void MergeGroups(ObjectGuid guidOne, ObjectGuid guidTwo, std::set compatibleDungeons); /// Send a proposal to each member of a group - void SendDungeonProposal(LFGPlayers* lfgGroup); + void SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup); /// Tell a group member that someone else just confirmed their role void SendRoleChosen(ObjectGuid plrGuid, ObjectGuid confirmedGuid, uint8 roles); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index d7e9ca00e..5f68a4055 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -195,7 +195,7 @@ bool LFGMgr::ValidateGroupRoles(roleMap groupMap, std::set const& dungeo } //todo: remove from queue, update queue average settings -void LFGMgr::SendDungeonProposal(LFGPlayers* lfgGroup) +void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) { ++m_proposalId; // increment number to make a new proposal id @@ -212,15 +212,12 @@ void LFGMgr::SendDungeonProposal(LFGPlayers* lfgGroup) newProposal.joinedQueue = lfgGroup->joinedTime; newProposal.createdTime = time(NULL); - // Which queue entry this came from, so a failure can put the survivors back. - for (playerData::const_iterator it = m_playerData.begin(); it != m_playerData.end(); ++it) - { - if (&it->second == lfgGroup) - { - newProposal.queueGuid = it->first; - break; - } - } + // Which queue entry this came from, so a failure can put the survivors back. Passed + // in rather than recovered by scanning m_playerData for a matching address: the + // caller already knows the key, and identifying a map entry by the address of its + // value is the kind of thing that quietly stops working the first time anyone copies + // the struct. + newProposal.queueGuid = queueGuid; bool premadeGroup = IsProposalSameGroup(newProposal); @@ -355,6 +352,41 @@ bool LFGMgr::IsProposalSameGroup(LFGProposal const& proposal) } // From a CMSG_LFG_PROPOSAL_RESPONSE call +/// A decline cancels the proposal, but it does NOT eject everyone. +/// +/// The client states all three outcomes plainly: +/// ERR_LFG_PROPOSAL_FAILED "Someone has declined the invite. You have been +/// returned to the front of the queue." +/// ERR_LFG_PROPOSAL_DECLINED_SELF "You have been removed from the queue because you +/// did not accept the invitation." +/// ERR_LFG_PROPOSAL_DECLINED_PARTY "...because someone in your party did not accept." +/// +/// So the decliner leaves, their premade leaves with them, and everyone else is +/// requeued. An earlier version of this removed everyone, which is why the queue entry +/// is now kept alive for the lifetime of the proposal -- there has to be something left +/// to put people back into. +void LFGMgr::DeclineProposal(ObjectGuid plrGuid, LFGProposal* proposal) +{ + std::set culprits; + culprits.insert(plrGuid); + + // A premade is removed alongside the member who declined for it. + playerGroupMap::const_iterator declinerGroup = proposal->groups.find(plrGuid); + if (declinerGroup != proposal->groups.end() && declinerGroup->second) + { + for (playerGroupMap::const_iterator it = proposal->groups.begin(); + it != proposal->groups.end(); ++it) + { + if (it->second == declinerGroup->second) + { + culprits.insert(it->first); + } + } + } + + CancelProposal(proposal->id, culprits); +} + void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted) { //note: create a group here if it doesn't exist and everyone accepted proposal @@ -384,39 +416,9 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted LFGProposalAnswer plrAnswer = (LFGProposalAnswer)accepted; proposal->answers[plrGuid] = plrAnswer; - // A decline cancels the proposal, but it does NOT eject everyone. - // - // The client states all three outcomes plainly: - // ERR_LFG_PROPOSAL_FAILED "Someone has declined the invite. You have been - // returned to the front of the queue." - // ERR_LFG_PROPOSAL_DECLINED_SELF "You have been removed from the queue because you - // did not accept the invitation." - // ERR_LFG_PROPOSAL_DECLINED_PARTY "...because someone in your party did not accept." - // - // So the decliner leaves, their premade leaves with them, and everyone else is - // requeued. An earlier version of this removed everyone, which is why the queue - // entry is now kept alive for the lifetime of the proposal -- there has to be - // something left to put people back into. if (plrAnswer == LFG_ANSWER_DENY) { - std::set culprits; - culprits.insert(plrGuid); - - // A premade is removed alongside the member who declined for it. - playerGroupMap::const_iterator declinerGroup = proposal->groups.find(plrGuid); - if (declinerGroup != proposal->groups.end() && declinerGroup->second) - { - for (playerGroupMap::const_iterator it = proposal->groups.begin(); - it != proposal->groups.end(); ++it) - { - if (it->second == declinerGroup->second) - { - culprits.insert(it->first); - } - } - } - - CancelProposal(proposal->id, culprits); + DeclineProposal(plrGuid, proposal); return; } From 3e07eec6ea44b4658f9b08a59662acea72a68ba4 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 02:25:07 +0100 Subject: [PATCH 15/81] LFG: tell merged queuers about their OWN queue, not the absorber's Found by driving two clients: two solo players queued for the same dungeon, both damage. One saw a full panel -- 0/1 tank, 0/1 healer, 2/3 damage, average wait, a live time-in-queue. The other saw a stub: no role counts, no average wait, a placeholder "< 1 minute", and most of the minimap eye's tooltip missing. The 2/3 proves the merge itself was fine. Both packets that describe a queue were reporting it to the wrong identity. SMSG_LFG_QUEUE_STATUS stamped every recipient with the merged entry's KEY, which is whichever entry did the absorbing. The absorbed player joined under their own guid -- that is what SMSG_LFG_UPDATE_STATUS sent them as requesterGuid -- so a status arriving under a stranger's identity does not match the queue their client is tracking and is ignored. The absorbing player never saw this, because for them the merged key IS their own guid, which is exactly why this looked like "one client works and the other does not". SMSG_LFG_UPDATE_STATUS had the mirror image. GetStatusPacketData looked the player up by queue guid alone, and a merged solo queuer has no entry of their own -- MergeGroups folds them into the absorber and erases theirs. The lookup missed, the caller got a default-constructed struct, and the update went out with zero roles, zero needed counts and a zero join time. It now falls back to whichever entry actually lists the player. Neither of these is visible from the corpus: it proves what a retail server sent, not that ours addressed the right person. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 26 +++++++++++++++++++++++++- src/game/WorldHandlers/LFGMgrQueue.cpp | 20 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 28f0c1e52..71ab4faed 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1187,8 +1187,32 @@ void LFGMgr::SendQueueStatus() { uint32 dungeonId = *queueInfo->dungeonList.begin(); + // Each recipient must be told about THEIR OWN queue, not the + // merged entry's key. + // + // The key is whichever entry did the absorbing, so after two solo + // players merge it is one of their guids. The other player joined + // under their own guid -- that is what SMSG_LFG_UPDATE_STATUS sent + // them as requesterGuid -- and a queue status arriving under a + // stranger's identity does not match the queue their client is + // tracking, so it is ignored: no role counts, no average wait, a + // placeholder time in queue, and most of the minimap eye's tooltip + // missing. The absorbing player saw none of this, because for them + // the merged key IS their own guid. + // + // Mirrors SendLfgUpdate: a party member's queue is keyed by the + // group guid, everyone else by their own. + ObjectGuid memberQueueGuid = rItr->first; + if (Group* pGroup = pPlayer->GetGroup()) + { + if (pGroup->GetObjectGuid() == *itr) + { + memberQueueGuid = *itr; + } + } + LFGQueueStatus status; - status.queueGuid = itr->GetRawValue(); + status.queueGuid = memberQueueGuid.GetRawValue(); status.dungeonID = dungeonId; status.neededTanks = queueInfo->neededTanks; status.neededHeals = queueInfo->neededHealers; diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 0b5da5e75..c3f184f63 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -583,6 +583,26 @@ LFGPlayerStatus LFGMgr::GetPlayerStatus(ObjectGuid guid) bool LFGMgr::GetStatusPacketData(ObjectGuid queueGuid, ObjectGuid playerGuid, LFGStatusPacketData& data) const { playerData::const_iterator queue = m_playerData.find(queueGuid); + + // A merged solo queuer has no entry of their own: MergeGroups folds them into the + // absorbing entry and erases theirs. The direct lookup then missed, the caller was + // handed a default-constructed struct, and their SMSG_LFG_UPDATE_STATUS went out + // with zero roles, zero needed counts and a zero join time -- which is most of the + // dungeon finder UI blank while the absorbing player's looked perfectly normal. + // + // So fall back to whichever entry actually LISTS this player. + if (queue == m_playerData.end()) + { + for (playerData::const_iterator it = m_playerData.begin(); it != m_playerData.end(); ++it) + { + if (it->second.currentRoles.find(playerGuid) != it->second.currentRoles.end()) + { + queue = it; + break; + } + } + } + if (queue == m_playerData.end()) return false; From aaebec2e3bceeb538f2c7e5957b557303925a76c Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 02:34:18 +0100 Subject: [PATCH 16/81] LFG: let a merged queuer actually leave the queue Reported from a live client: on the absorbed player, Leave Queue did nothing. It was worse than nothing. LeaveLFG erased m_playerData and m_queueSet by the player's OWN guid, but a solo queuer who has already been merged has no data under that key -- MergeGroups folds them into the absorbing entry and erases theirs. So the erase was a no-op while the client was still sent LFG_UPDATE_LEAVE: the UI cleared and the server kept them queued inside the merged entry, where a later proposal would have pulled them into a dungeon they had left. This is the third bug from one root cause, after the queue-status and update-status identity bugs in the previous commit. Anything keyed on a player's own guid silently misses them once they have been merged. So the lookup is now a named helper, FindQueueEntryContaining, and both the leave path and GetStatusPacketData go through it rather than each open-coding the scan. Removing a player also recomputes the entry's needed roles -- the survivors need one more of whatever the leaver was covering -- and drops the entry entirely when the last member leaves. The group leave path had the same hazard per member and now uses the same helper. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 55 ++++++++++++++++++++++++++ src/game/WorldHandlers/LFGMgr.h | 17 ++++++++ src/game/WorldHandlers/LFGMgrQueue.cpp | 25 ++++++------ 3 files changed, 86 insertions(+), 11 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 71ab4faed..3a871c217 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -785,6 +785,61 @@ void LFGMgr::AddToWaitMap(uint8 role, std::set dungeons) } } +ObjectGuid LFGMgr::FindQueueEntryContaining(ObjectGuid plrGuid) const +{ + // Their own key first: that is the common case and it is O(1). + playerData::const_iterator own = m_playerData.find(plrGuid); + if (own != m_playerData.end()) + { + return plrGuid; + } + + // Otherwise they were merged into somebody else's entry, or queued as part of a + // party keyed by the group guid. + for (playerData::const_iterator it = m_playerData.begin(); it != m_playerData.end(); ++it) + { + if (it->second.currentRoles.find(plrGuid) != it->second.currentRoles.end()) + { + return it->first; + } + } + + return ObjectGuid(); +} + +void LFGMgr::RemovePlayerFromQueue(ObjectGuid plrGuid) +{ + ObjectGuid const entryGuid = FindQueueEntryContaining(plrGuid); + + m_playerStatusMap.erase(plrGuid); + + if (!entryGuid) + { + m_queueSet.erase(plrGuid); + m_playerData.erase(plrGuid); + return; + } + + LFGPlayers* entry = GetPlayerOrPartyData(entryGuid); + if (!entry) + { + return; + } + + entry->currentRoles.erase(plrGuid); + + // Last one out takes the entry with them. + if (entry->currentRoles.empty()) + { + m_queueSet.erase(entryGuid); + m_playerData.erase(entryGuid); + return; + } + + // The survivors need one fewer of whatever this player was covering. + UpdateNeededRoles(entryGuid, entry); +} + bool LFGMgr::EntryHasGameMaster(LFGPlayers const* entry) const { if (!entry) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 78adcb85c..1731cd67a 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1117,6 +1117,23 @@ class LFGMgr /// The decline half of ProposalUpdate: work out who is responsible and cancel. void DeclineProposal(ObjectGuid plrGuid, LFGProposal* proposal); + /** + * @brief The key of the queue entry that LISTS this player. + * + * After a merge an absorbed player has no entry under their own guid -- MergeGroups + * folds them into the absorbing entry and erases theirs -- so anything keyed on the + * player's own guid silently misses them. + * + * @return the entry key, or an empty guid if the player is not queued anywhere. + */ + ObjectGuid FindQueueEntryContaining(ObjectGuid plrGuid) const; + + /** + * @brief Take a single player out of whichever queue entry holds them, recomputing + * that entry's needed roles, and drop the entry if it is left empty. + */ + void RemovePlayerFromQueue(ObjectGuid plrGuid); + /// Does this queue entry contain at least one game master? Scopes `.debug dungeon`. bool EntryHasGameMaster(LFGPlayers const* entry) const; diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index c3f184f63..0d317c0e7 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -414,8 +414,9 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) //todo: other state cases after they get implemented } - m_playerData.erase(grpPlrGuid); - m_playerStatusMap.erase(grpPlrGuid); + // Same hazard as the solo path: a party member may be listed in an + // entry keyed by something other than their own guid. + RemovePlayerFromQueue(grpPlrGuid); } } @@ -438,9 +439,14 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) // do other states after being implemented, if applicable for a single plr } - m_queueSet.erase(plrGuid); - m_playerData.erase(plrGuid); - m_playerStatusMap.erase(plrGuid); + // NOT `m_playerData.erase(plrGuid)`. + // + // A solo queuer who has already been merged into somebody else's entry has no + // data under their own guid, so erasing by it did nothing at all: the client was + // told LFG_UPDATE_LEAVE and cleared its UI while the server kept them queued + // inside the merged entry -- and would have pulled them into a later proposal + // for a dungeon they had left. + RemovePlayerFromQueue(plrGuid); } } @@ -593,13 +599,10 @@ bool LFGMgr::GetStatusPacketData(ObjectGuid queueGuid, ObjectGuid playerGuid, LF // So fall back to whichever entry actually LISTS this player. if (queue == m_playerData.end()) { - for (playerData::const_iterator it = m_playerData.begin(); it != m_playerData.end(); ++it) + ObjectGuid const containing = FindQueueEntryContaining(playerGuid); + if (containing) { - if (it->second.currentRoles.find(playerGuid) != it->second.currentRoles.end()) - { - queue = it; - break; - } + queue = m_playerData.find(containing); } } From a4a90720fbd89eaa34357121699ae1f5e72d8ee8 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 03:04:32 +0100 Subject: [PATCH 17/81] LFG: release the queue entry when a proposal SUCCEEDS Found live: after one successful proposal the player could not queue again -- five CMSG_LFG_JOIN attempts in ninety seconds, all refused. My regression, from the commit that made TryFormGroup keep the queue entry alive so a declined or timed-out proposal could put the survivors back. That commit handled both failure paths and neither success path: on success nothing erased the entry, so it sat in m_playerData for ever with currentState LFG_STATE_PROPOSAL, every member's stored status stayed LFG_STATE_PROPOSAL, and the re-queue guard added in the same commit refuses exactly that state. Enter a dungeon once, never queue again until relog. The success path now tears the entry down and moves each member to LFG_STATE_IN_DUNGEON, including any whose teleport was denied -- they must not be left reading LFG_STATE_PROPOSAL either. The guard itself was also too trusting. LFG_STATE_PROPOSAL is written in several places and cleared in fewer, so any path that forgets to reset it locks the player out of the dungeon finder entirely. It now asks m_proposalMap whether a proposal is actually awaiting this player's answer, which cannot go stale: if no live proposal lists them, there is nothing to protect. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 13 +++++++++++++ src/game/WorldHandlers/LFGMgr.h | 4 ++++ src/game/WorldHandlers/LFGMgrProposal.cpp | 21 +++++++++++++++++++++ src/game/WorldHandlers/LFGMgrQueue.cpp | 12 +++++++++--- 4 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 3a871c217..b81064e22 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -785,6 +785,19 @@ void LFGMgr::AddToWaitMap(uint8 role, std::set dungeons) } } +bool LFGMgr::HasLiveProposalFor(ObjectGuid plrGuid) const +{ + for (proposalMap::const_iterator it = m_proposalMap.begin(); it != m_proposalMap.end(); ++it) + { + if (it->second.answers.find(plrGuid) != it->second.answers.end()) + { + return true; + } + } + + return false; +} + ObjectGuid LFGMgr::FindQueueEntryContaining(ObjectGuid plrGuid) const { // Their own key first: that is the common case and it is O(1). diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 1731cd67a..7763ad3a4 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1128,6 +1128,10 @@ class LFGMgr */ ObjectGuid FindQueueEntryContaining(ObjectGuid plrGuid) const; + /// Is there a proposal still awaiting this player's answer? Authoritative, unlike + /// the LFG_STATE_PROPOSAL status flag, which several paths can leave stale. + bool HasLiveProposalFor(ObjectGuid plrGuid) const; + /** * @brief Take a single player out of whichever queue entry holds them, recomputing * that entry's needed roles, and drop the entry if it is left empty. diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 5f68a4055..08d23e4f8 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -498,6 +498,27 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted } CreateDungeonGroup(proposal); + + // Tear the queue entry down. TryFormGroup deliberately KEEPS it alive for the + // lifetime of the proposal so a decline or timeout can put the survivors back -- + // but on success nobody put it back, so it sat in m_playerData forever with + // currentState LFG_STATE_PROPOSAL, and every member's stored status stayed at + // LFG_STATE_PROPOSAL too. JoinLFG refuses that state, so a player who successfully + // entered a dungeon could never queue again until relog. Observed live: five + // rejected CMSG_LFG_JOIN attempts after one successful proposal. + ObjectGuid const queueGuid = proposal->queueGuid; + for (roleMap::const_iterator it = proposal->currentRoles.begin(); + it != proposal->currentRoles.end(); ++it) + { + // They are in the dungeon now, not queued. TeleportToDungeon sets this too for + // the players it actually moves, but a member whose teleport was denied must not + // be left reading LFG_STATE_PROPOSAL either. + SetPlayerState(it->first, LFG_STATE_IN_DUNGEON); + } + + m_queueSet.erase(queueGuid); + m_playerData.erase(queueGuid); + m_proposalMap.erase(proposal->id); } diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 0d317c0e7..27c8495a2 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -59,11 +59,17 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen // proposal window has no queue data, skipped that cleanup entirely, and got a // SECOND live entry. If the first proposal then completed, CreateDungeonGroup put // them in a dungeon group while they were still queued for another. - LFGPlayerStatus const existingStatus = GetPlayerStatus(plr->GetObjectGuid()); - if (existingStatus.state == LFG_STATE_PROPOSAL) + // Gated on a proposal that ACTUALLY EXISTS, not on the status flag alone. + // + // The flag is written in several places and cleared in fewer, so trusting it meant + // any path that failed to reset it locked the player out of the dungeon finder until + // relog -- which is exactly what happened when the success path forgot to tear the + // queue entry down. Asking m_proposalMap directly cannot go stale: if there is no + // live proposal listing this player, there is nothing to protect. + if (HasLiveProposalFor(plr->GetObjectGuid())) { partyForbidden noneForbidden; - plr->GetSession()->SendLfgJoinResult(ERR_LFG_NO_LFG_OBJECT, existingStatus.state, noneForbidden); + plr->GetSession()->SendLfgJoinResult(ERR_LFG_NO_LFG_OBJECT, LFG_STATE_PROPOSAL, noneForbidden); return; } From 1e5079fe8f42038714c23621c4bb3e7a19d93dcf Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 03:09:14 +0100 Subject: [PATCH 18/81] LFG: fix a use-after-free on decline, and four proposal-lifecycle defects From the second round of PR review. BLOCKING -- CancelProposal was a use-after-free on the world thread. It caches `entry = GetPlayerOrPartyData(proposal.queueGuid)`, then erases m_playerData for each culprit, then reads entry->currentRoles.empty(). A culprit is very often the entry key itself: the solo player whose entry did the absorbing, or the single queuer in a `.debug dungeon` proposal. Erasing m_playerData[queueGuid] destroyed the node `entry` pointed into and the survivor check read it. So an ordinary decline -- on exactly the path currently being live-tested -- was undefined behaviour. The queue entry is now left alone inside that loop and handled after `entry` is finished with. The timeout reaper cancelled with an EMPTY culprit set, which requeued the entry unchanged, including the member who never answered. The role counts were still complete, so the next tick re-formed the same proposal and timed out again, trapping everyone who did accept in a permanent loop. Whoever failed to answer, or went offline, is now the culprit -- exactly as a decliner is. A merged solo queuer re-joining created a SECOND live entry. The duplicate cleanup keys on m_playerData under the player's own guid, which an absorbed player does not have, so the cleanup was skipped and the solo branch built a fresh entry while the merged one still listed them. It now resolves through FindQueueEntryContaining and removes them from whatever entry actually holds them. SendDungeonProposal sent each player's opening proposal from INSIDE the loop that fills `groups` and `answers`. The packet serialises those maps, so every recipient but the last saw a proposal missing the members added after them -- the ready popup showed an incomplete group until somebody answered. Built first, sent second. A member who accepted and then logged out before the final answer still passed allOkay, and skipping them built a short group and teleported it while groupStatus recorded a role for someone never added. That now cancels, with the absent member as the culprit. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 39 +++++++++++++++++++++-- src/game/WorldHandlers/LFGMgrProposal.cpp | 26 +++++++++++++-- src/game/WorldHandlers/LFGMgrQueue.cpp | 23 +++++++------ 3 files changed, 74 insertions(+), 14 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index b81064e22..49b68c3f2 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -965,6 +965,13 @@ void LFGMgr::CancelProposal(uint32 proposalId, std::set const& culpr // The players responsible leave the dungeon finder outright -- the client says so: // "You have been removed from the queue because you did not accept the invitation." + // + // The entry keyed by proposal.queueGuid is deliberately NOT erased in this loop. + // A culprit is very often the entry key itself -- the solo player whose entry did + // the absorbing, or the single queuer in a `.debug dungeon` proposal -- and erasing + // m_playerData[queueGuid] here destroyed the node `entry` points into, which the + // survivor check below then read. An ordinary decline was a use-after-free on the + // world thread. for (std::set::const_iterator bad = culprits.begin(); bad != culprits.end(); ++bad) { if (entry) @@ -976,9 +983,15 @@ void LFGMgr::CancelProposal(uint32 proposalId, std::set const& culpr SetPlayerUpdateType(*bad, LFG_UPDATE_LEAVE); SendLfgUpdate(*bad, GetPlayerStatus(*bad), false); + m_playerStatusMap.erase(*bad); + + if (*bad == proposal.queueGuid) + { + continue; // handled below, after `entry` is finished with + } + m_queueSet.erase(*bad); m_playerData.erase(*bad); - m_playerStatusMap.erase(*bad); } if (!entry || entry->currentRoles.empty()) @@ -1023,7 +1036,29 @@ void LFGMgr::RemoveOldProposals() // -- and JoinLFG refuses that state, so they could not re-queue until relog. for (std::vector::const_iterator it = expired.begin(); it != expired.end(); ++it) { - CancelProposal(*it, std::set()); + proposalMap::const_iterator prop = m_proposalMap.find(*it); + if (prop == m_proposalMap.end()) + { + continue; + } + + // Whoever did not answer is the culprit, exactly as a decliner would be. + // + // Cancelling with an empty culprit set requeued the entry UNCHANGED, including + // the member who never responded. The role counts were still complete, so the + // very next tick re-formed the same proposal and timed out again -- trapping the + // players who did accept in a permanent timeout loop. + std::set silent; + for (proposalAnswerMap::const_iterator ans = prop->second.answers.begin(); + ans != prop->second.answers.end(); ++ans) + { + if (ans->second != LFG_ANSWER_AGREE || !sObjectAccessor.FindPlayer(ans->first)) + { + silent.insert(ans->first); + } + } + + CancelProposal(*it, silent); } } diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 08d23e4f8..1d409fdf7 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -265,9 +265,21 @@ void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) } newProposal.answers[plrGuid] = LFG_ANSWER_PENDING; + } - // then send SMSG_LFG_PROPOSAL_UPDATE - pPlayer->GetSession()->SendLfgProposalUpdate(newProposal); + // Sent only once the proposal is COMPLETE. + // + // This used to sit inside the loop above, which is still filling `groups` and + // `answers`. Since the packet serialises those maps, every recipient except the last + // one received an opening proposal that omitted the members added after them -- so + // the ready popup showed an incomplete group until somebody answered. + for (roleMap::const_iterator it = lfgGroup->currentRoles.begin(); + it != lfgGroup->currentRoles.end(); ++it) + { + if (Player* pMember = sObjectAccessor.FindPlayer(it->first)) + { + pMember->GetSession()->SendLfgProposalUpdate(newProposal); + } } // then if group guid is set, call Group::SetAsLfgGroup() @@ -463,7 +475,15 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted Player* pProposalPlayer = sObjectAccessor.FindPlayer(proposalPlrGuid); if (!pProposalPlayer) { - continue; + // Accepted, then logged out before the last answer arrived. allOkay still + // passed because their answer was already AGREE, and skipping them here + // built a SHORT group and teleported it while groupStatus recorded a role + // for someone who was never added. Cancel instead: the absent member is the + // culprit and everyone else goes back to the queue. + std::set absent; + absent.insert(proposalPlrGuid); + CancelProposal(proposal->id, absent); + return; } if (sendProposalUpdate) diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 27c8495a2..5370a9e54 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -73,7 +73,14 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen return; } - LFGPlayers* currentInfo = GetPlayerOrPartyData(guid); + // Keyed on whichever entry LISTS this player, not on their own guid. + // + // A solo queuer already absorbed into somebody else's entry has no m_playerData + // under their own guid, so this lookup missed, the duplicate cleanup below was + // skipped, and the solo branch built a SECOND live entry while the merged one still + // listed them -- two queue entries for one player, and potentially two proposals. + ObjectGuid const existingEntryGuid = pGroup ? guid : FindQueueEntryContaining(guid); + LFGPlayers* currentInfo = existingEntryGuid ? GetPlayerOrPartyData(existingEntryGuid) : nullptr; // check if we actually have info on the player/group right now if (currentInfo) @@ -83,17 +90,15 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen // are they already queued? if (currentInfo->currentState == LFG_STATE_QUEUED) { - // remove from that queue so they can later join this one - queueSet::iterator qItr = m_queueSet.find(guid); - if (qItr != m_queueSet.end()) - { - m_queueSet.erase(qItr); - } - // note: do we need to send a packet telling them the current queue is over? + // Take them out of whatever they are in now so they can join this instead. + // RemovePlayerFromQueue rather than a bare m_queueSet.erase, because the + // entry may be shared with other players who must stay queued. + RemovePlayerFromQueue(guid); + currentInfo = nullptr; } // are they already in a dungeon? - if (groupCurrentlyInDungeon) + if (currentInfo && groupCurrentlyInDungeon) { std::set currentDungeon = currentInfo->dungeonList; From d23acbb25dc81c3024c18b4a0d4c0c253916075f Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 22:22:39 +0100 Subject: [PATCH 19/81] LFG: instrument the queue and proposal paths The diagnostics that located the LfgDungeons row-ordinal bug. They print what a join actually stored and what the proposal actually chose: LFG JoinLFG: solo entry for Humanwarrior stores dungeons={6} LFG SendDungeonProposal: entry dungeons={6} -> chose 6 (entry 0x0100000C) LFG TeleportToDungeon: Humanwarrior DENIED, dungeon 12 map 349, player error 6 Read together those three lines are what made the bug obvious: the queue entry was correct throughout -- the player asked for dungeon 6 and we chose dungeon 6 -- yet GetDungeonEntry(6) returned 0x0100000C, which is id 12. That is a lookup returning the Nth ROW rather than the row with that id. The fix itself is no longer here. It was a one-character change to LfgDungeonsEntryfmt ('i' -> 'n', the DBC index marker) and it landed with the instance-difficulty work in PR #81, so rebasing onto that left only the instrumentation behind. Kept rather than dropped: the finder has a lot of state between a join and a teleport, and these three lines are what make it legible. This commit was previously titled for the fix it carried before the rebase. --- src/game/WorldHandlers/LFGMgrProposal.cpp | 19 ++++++++++++++++ src/game/WorldHandlers/LFGMgrQueue.cpp | 27 +++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 1d409fdf7..c964a8538 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -23,6 +23,7 @@ * and lore are copyrighted by Blizzard Entertainment, Inc. */ +#include #include #include "DBCEnums.h" @@ -219,6 +220,17 @@ void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) // the struct. newProposal.queueGuid = queueGuid; + { + std::ostringstream avail; + for (std::set::const_iterator it = lfgGroup->dungeonList.begin(); + it != lfgGroup->dungeonList.end(); ++it) + { + avail << (it == lfgGroup->dungeonList.begin() ? "" : ",") << *it; + } + DEBUG_LOG("LFG SendDungeonProposal: entry dungeons={%s} -> chose %u (entry 0x%08X)", + avail.str().c_str(), newProposal.dungeonID, GetDungeonEntry(newProposal.dungeonID)); + } + bool premadeGroup = IsProposalSameGroup(newProposal); // iterate through role map just so get everyone's guid @@ -860,6 +872,9 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) } else { + sLog.outError("LFG TeleportToDungeon: no map entrance trigger for map %u " + "(dungeon %u) -- areatrigger_teleport has no row targeting it", + mapID, dungeonID); err = LFG_TELEPORTERROR_INVALID_LOCATION; } } @@ -906,10 +921,14 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) if (err != LFG_TELEPORTERROR_OK) { + sLog.outError("LFG TeleportToDungeon: %s DENIED, dungeon %u map %u, group error %u", + pGroupPlr->GetName(), dungeonID, mapID, uint32(err)); pGroupPlr->GetSession()->SendLfgTeleportError(err); } else if (plrErr != LFG_TELEPORTERROR_OK) { + sLog.outError("LFG TeleportToDungeon: %s DENIED, dungeon %u map %u, player error %u", + pGroupPlr->GetName(), dungeonID, mapID, uint32(plrErr)); pGroupPlr->GetSession()->SendLfgTeleportError(plrErr); } else diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 5370a9e54..8d7a797de 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -28,6 +28,8 @@ #include "DBCStructure.h" #include "GameEventMgr.h" #include "Group.h" +#include + #include "LFGMgr.h" #include "Object.h" #include "Player.h" @@ -301,6 +303,21 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen } } + // Diagnostic: what survived the eligibility filter, and what was removed. + { + std::ostringstream kept; + for (std::set::const_iterator it = dungeons.begin(); it != dungeons.end(); ++it) + { + kept << (it == dungeons.begin() ? "" : ",") << *it; + } + + partyForbidden::const_iterator lockedFor = partyLockedDungeons.find(guid); + + DEBUG_LOG("LFG JoinLFG: %s isRandom=%u randomId=%u kept={%s} lockedCount=%u", + plr->GetName(), uint32(isRandom), randomDungeonID, kept.str().c_str(), + uint32(lockedFor != partyLockedDungeons.end() ? lockedFor->second.size() : 0)); + } + if (!dungeons.empty()) { partyLockedDungeons.clear(); @@ -377,6 +394,16 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen roleMap playerRole; playerRole[guid] = (uint8)roles; + { + std::ostringstream stored; + for (std::set::const_iterator it = dungeons.begin(); it != dungeons.end(); ++it) + { + stored << (it == dungeons.begin() ? "" : ",") << *it; + } + DEBUG_LOG("LFG JoinLFG: solo entry for %s stores dungeons={%s}", + plr->GetName(), stored.str().c_str()); + } + LFGPlayers playerInfo(LFG_STATE_QUEUED, dungeons, playerRole, comments, false, time(NULL), 0, 0, 0); m_playerData[guid] = playerInfo; From 0a38dc989fff02e42340957fdef09e82f16f5f88 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 03:39:43 +0100 Subject: [PATCH 20/81] LFG: register the three unnamed SMSG, and size SMSG_LFG_TELEPORT_DENIED correctly The packet log printed "OPCODE: UNKNOWN (0x1E3B)" during a live test. SMSG_LFG_PROPOSAL_UPDATE and SMSG_LFG_ROLE_CHECK_UPDATE transmit correctly -- they are admitted by IsEnterWorldConverted, which is the real send gate -- but neither had a DefS row, so the logger could not name them. DefS is logging metadata only in this tree and does not affect delivery. SMSG_LFG_TELEPORT_DENIED wrote a uint32. Every 18414 capture of it in the corpus is exactly 1 byte: capture-000044 seq 70879 and 219256, capture-000465 seq 283035, capture-000628 seq 31349, capture-000873 seq 154730. Now a uint8. It stays UNADMITTED on purpose. The size is settled but the value space is not -- the captured body carries 0x10 (16) while our LFGTeleportError enum stops at 8, so our codes are provably not the client's. A correctly sized packet with a wrong code shows the player a confidently wrong reason, which is worse than the current silence. This is why an LFG teleport failure currently produces no on-screen message at all: the packet is built, logged, and dropped at the gate. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 3 +++ src/game/WorldHandlers/LFGHandler.cpp | 15 +++++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index a72a30539..fecdb917a 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -1130,6 +1130,9 @@ void InitializeOpcodes() // Direct 18414 leaf: periodic queue wait estimates and role vacancies. DefS(SMSG_LFG_QUEUE_STATUS, "SMSG_LFG_QUEUE_STATUS"); + DefS(SMSG_LFG_PROPOSAL_UPDATE, "SMSG_LFG_PROPOSAL_UPDATE"); + DefS(SMSG_LFG_ROLE_CHECK_UPDATE, "SMSG_LFG_ROLE_CHECK_UPDATE"); + DefS(SMSG_LFG_TELEPORT_DENIED, "SMSG_LFG_TELEPORT_DENIED"); // Wave 13 talent-respec confirmation request and prompt. DefC(CMSG_CONFIRM_RESPEC_WIPE, "CMSG_CONFIRM_RESPEC_WIPE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleTalentWipeConfirmOpcode); diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 3969769af..743eebec8 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -582,8 +582,19 @@ void WorldSession::SendLfgProposalUpdate(LFGProposal const& proposal) void WorldSession::SendLfgTeleportError(uint8 error) { DEBUG_LOG("SMSG_LFG_TELEPORT_DENIED"); - WorldPacket data(SMSG_LFG_TELEPORT_DENIED, 4); - data << uint32(error); + + // One byte, not four. Every 18414 capture of this opcode in the corpus is exactly + // 1 byte (capture-000044 seq 70879 and 219256, capture-000465 seq 283035, + // capture-000628 seq 31349, capture-000873 seq 154730). + // + // NOT admitted by IsEnterWorldConverted, deliberately. The size is settled but the + // VALUE space is not: the one captured body carries 0x10 (16), while our + // LFGTeleportError enum stops at 8, so our codes are provably not the client's. + // Sending a correctly sized packet with a wrong code would show the player a + // confidently wrong reason, which is worse than the current silence. Admit this + // once the enum is derived from the client. + WorldPacket data(SMSG_LFG_TELEPORT_DENIED, 1); + data << uint8(error); SendPacket(&data); } From 590cc3bd6e1d7f0b0586f971c928d7e233559dfb Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 14:25:20 +0100 Subject: [PATCH 21/81] LFG: populate the dungeon lock list, so the finder stops offering everything Reported from a live client: every dungeon in the game appeared in the finder, so a player could queue for content they cannot enter. The cause is ours. FrameXML's LFGList_DefaultFilterFunction shows a dungeon when `not LFGLockList[dungeonID]`, and LFGLockList is built from the lock array in SMSG_LFG_PLAYER_INFO -- which we sent EMPTY. An empty array does not mean "no locks are known", it means "nothing is locked", so the client correctly concluded every dungeon was available. The eligibility filter was not degraded; it was absent, because we never gave it anything to filter on. Layout derived from a real reply rather than a fork: capture-000006 seq 1953, 6068 bytes to a max-level character, decoding as lockCount 206, hasPlayerGuid 0, randomDungeonCount 35. bits WriteBits(lockCount, 20) WriteBit(hasPlayerGuid) WriteBits(randomDungeonCount, 17) FlushBits -> 38 bits, 5 bytes ...random dungeon reward records, variable length... tail lockCount x 16 bytes, flat and unpacked: uint32 dungeonEntry (TypeID << 24) | id uint32 lockStatus uint32 subReason1 uint32 subReason2 That the array sits at the TAIL is what makes this shippable now. With zero random records the header and the array are adjacent, and the client installs the list and raises LFG_LOCK_INFO_RECEIVED whether or not random rows follow -- so the reward plumbing this manager cannot express is not needed to make the filter work. The random count stays 0 and is a separate piece of work. No translation is needed in either direction, which is worth stating because it looks too convenient: FindRandomDungeonsNotForPlayer already returns a map keyed by LfgDungeonsEntry::Entry(), and that IS the wire's dungeonEntry field; its LFGForbiddenTypes values are the client's LFG_INSTANCE_INVALID_CODES verbatim. The reference packet's own distribution confirms the codes line up -- 167 of its 206 records carry 3, LEVEL_TOO_HIGH, which is exactly what a max-level character sees for low-level content. subReason1 and subReason2 stay zero. They carry the required and current item level for the gear-score reasons, where the client formats them as "Requires: %2$d. Currently %3$d."; all 206 records of the reference capture have them zero, and this manager does not compute a gear score for the lock list. Sent only in reply to CMSG_LFG_LOCK_INFO_REQUEST, never pushed at login. The two pair seven-for-seven in capture-000006, and the client asks at world-enter -- CMSG_LFG_GET_STATUS then CMSG_LFG_LOCK_INFO_REQUEST at adjacent sequence numbers. Our handler was already registered and already replied in the right place; only the content was missing. Fixture uses the reference packet's own first five lock records byte for byte, including a TypeID 2 raid entry so the array is not assumed to be dungeons-only, plus its exact 5-byte header to pin the 20/1/17 field widths and their order. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/tests/CMakeLists.txt | 16 ++ .../mop_lfg_player_info_packets_test.cpp | 154 ++++++++++++++++++ src/game/WorldHandlers/LFGHandler.cpp | 40 ++++- src/game/WorldHandlers/LFGMgr.h | 61 +++++++ 4 files changed, 267 insertions(+), 4 deletions(-) create mode 100644 src/game/Server/tests/mop_lfg_player_info_packets_test.cpp diff --git a/src/game/Server/tests/CMakeLists.txt b/src/game/Server/tests/CMakeLists.txt index b6f2baea5..22a68b08d 100644 --- a/src/game/Server/tests/CMakeLists.txt +++ b/src/game/Server/tests/CMakeLists.txt @@ -330,6 +330,22 @@ if(WIN32) "PATH=path_list_prepend:${MOP_LFG_LEAVE_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") endif() +add_executable(mop_lfg_player_info_packets_test + mop_lfg_player_info_packets_test.cpp) +target_include_directories(mop_lfg_player_info_packets_test PRIVATE + ${CMAKE_CURRENT_SOURCE_DIR}/.. + ${CMAKE_SOURCE_DIR}/src/shared) +set_target_properties(mop_lfg_player_info_packets_test PROPERTIES + CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +target_link_libraries(mop_lfg_player_info_packets_test PRIVATE game) +add_test(NAME mop_lfg_player_info_packets COMMAND mop_lfg_player_info_packets_test) +if(WIN32) + get_filename_component(MOP_LFG_PI_MYSQL_RUNTIME_DIR "${MySQL_LIBRARY}" DIRECTORY) + set_tests_properties(mop_lfg_player_info_packets PROPERTIES + ENVIRONMENT_MODIFICATION + "PATH=path_list_prepend:${MOP_LFG_PI_MYSQL_RUNTIME_DIR}${MOP_TEST_LUA_PATH}") +endif() + add_executable(mop_lfg_proposal_response_packets_test mop_lfg_proposal_response_packets_test.cpp) target_include_directories(mop_lfg_proposal_response_packets_test PRIVATE diff --git a/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp b/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp new file mode 100644 index 000000000..48232fd46 --- /dev/null +++ b/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp @@ -0,0 +1,154 @@ +/** + * Byte-exact coverage for the SMSG_LFG_PLAYER_INFO (0x1861) lock array. + * + * The expected bytes are REAL captured server bytes at build 18414, lifted from a live + * reply, not inverses of our own writer. + * + * Corpus catalogueGenerationId + * 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752 + * Reference packet: capture-000006 sequence 1953, 6068 bytes, sent to a max-level + * character. Decoded header: lockCount 206, hasPlayerGuid 0, randomDungeonCount 35. + * + * Layout: + * bits WriteBits(lockCount, 20) + * WriteBit(hasPlayerGuid) + * WriteBits(randomDungeonCount, 17) + * FlushBits -> 38 bits, 5 bytes + * ...random dungeon reward records, variable length... + * tail lockCount x 16 bytes, flat and unpacked: + * uint32 dungeonEntry (TypeID << 24) | id + * uint32 lockStatus -- the client's LFG_INSTANCE_INVALID_CODES + * uint32 subReason1 + * uint32 subReason2 + * + * We send a locks-only reply (randomDungeonCount 0), which the client accepts: it + * installs the lock list and raises LFG_LOCK_INFO_RECEIVED whether or not random records + * follow. So the header differs from the reference by design, but the LOCK ARRAY must be + * byte-identical -- that is what these cases assert. + */ + +#include "LFGMgr.h" +#include "WorldPacket.h" + +#include +#include +#include + +namespace +{ + void AssertBytes(uint8 const* actual, std::vector const& expected, + size_t offset, char const* label) + { + for (size_t i = 0; i < expected.size(); ++i) + { + if (actual[offset + i] != expected[i]) + { + std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, + unsigned(offset + i), actual[offset + i], expected[i]); + assert(false); + } + } + } + + MopLfgPackets::PlayerLockInfo Lock(uint32 entry, uint32 status) + { + MopLfgPackets::PlayerLockInfo l; + l.dungeonEntry = entry; + l.lockStatus = status; + return l; + } + + /// The first five lock records of capture-000006 seq 1953, byte for byte. + /// + /// All five are lockStatus 3 -- LEVEL_TOO_HIGH -- which is what a max-level character + /// sees for low-level content, and 167 of that packet's 206 records carry it. Note + /// the third is a TypeID 2 (raid) entry, so the array is not dungeons-only. + void test_lock_records_match_capture() + { + std::vector const expected = { + 0xBC, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xAA, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xA0, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x93, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xA3, 0x00, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 + }; + + std::vector locks; + locks.push_back(Lock(0x010000BCu, 3)); + locks.push_back(Lock(0x010000AAu, 3)); + locks.push_back(Lock(0x020000A0u, 3)); // raid entry, TypeID 2 + locks.push_back(Lock(0x01000093u, 3)); + locks.push_back(Lock(0x010000A3u, 3)); + + WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5 + locks.size() * 16); + MopLfgPackets::BuildPlayerInfo(packet, locks); + + assert(packet.size() == 5 + expected.size()); // 5-byte header, then the array + AssertBytes(packet.contents(), expected, 5, "lock_records"); + } + + /// The 20/1/17 bit header, checked against the reference packet's own first five bytes. + /// + /// Feeding the reference counts back in must reproduce them exactly; this is what pins + /// the field widths and their order. + void test_header_matches_capture() + { + // 206 locks, hasPlayerGuid 0, 35 random records -> 00 0C E0 00 8D + std::vector const expectedHeader = { 0x00, 0x0C, 0xE0, 0x00, 0x8D }; + + WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5); + packet.WriteBits(206, 20); + packet.WriteBit(false); + packet.WriteBits(35, 17); + packet.FlushBits(); + + assert(packet.size() == expectedHeader.size()); + AssertBytes(packet.contents(), expectedHeader, 0, "header"); + } + + /// Our own locks-only header: same widths, random count zero. + void test_locks_only_header() + { + std::vector locks; + locks.push_back(Lock(0x010000BCu, 3)); + + WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5 + 16); + MopLfgPackets::BuildPlayerInfo(packet, locks); + + assert(packet.size() == 5 + 16); + + // Decode the header back out and confirm the counts survive the round trip. + uint8 const* b = packet.contents(); + uint32 const bits = (uint32(b[0]) << 24) | (uint32(b[1]) << 16) | + (uint32(b[2]) << 8) | uint32(b[3]); + assert((bits >> 12) == 1); // 20-bit lock count + assert(((bits >> 11) & 1) == 0); // hasPlayerGuid + } + + /// An empty lock list must still emit a well-formed 5-byte header, because that is + /// what a character with nothing locked legitimately produces. + void test_empty_lock_list() + { + std::vector locks; + + WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5); + MopLfgPackets::BuildPlayerInfo(packet, locks); + + assert(packet.size() == 5); + for (size_t i = 0; i < packet.size(); ++i) + { + assert(packet.contents()[i] == 0x00); + } + } +} + +int main() +{ + test_lock_records_match_capture(); + test_header_matches_capture(); + test_locks_only_header(); + test_empty_lock_list(); + + std::printf("mop_lfg_player_info_packets_test: OK\n"); + return 0; +} diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 743eebec8..57fde972d 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -293,10 +293,42 @@ void WorldSession::HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data) void WorldSession::SendLfgPlayerLockInfo() { - // The legacy LFG manager cannot express the 18414 random-dungeon reward - // records. Send the binary-proven empty shape instead of guessed fields. - WorldPacket data(SMSG_LFG_PLAYER_INFO, 5); - MopLfgPackets::BuildEmptyPlayerInfo(data); + Player* plr = GetPlayer(); + if (!plr) + { + return; + } + + // The eligibility data the client needs to grey out content it cannot enter. + // + // FindRandomDungeonsNotForPlayer already computes exactly this: a map keyed by + // LfgDungeonsEntry::Entry() -- which IS the wire's dungeonEntry field -- with an + // LFGForbiddenTypes value, and those codes are the client's LFG_INSTANCE_INVALID_CODES + // verbatim (2 LEVEL_TOO_LOW, 3 LEVEL_TOO_HIGH, 1025 MISSING_ITEM, 1031 NOT_IN_SEASON + // and so on). So no translation is required in either direction. + dungeonForbidden const locked = sLFGMgr.FindRandomDungeonsNotForPlayer(plr); + + std::vector locks; + locks.reserve(locked.size()); + + for (dungeonForbidden::const_iterator it = locked.begin(); it != locked.end(); ++it) + { + MopLfgPackets::PlayerLockInfo entry; + entry.dungeonEntry = it->first; + entry.lockStatus = it->second; + // subReason1/2 stay zero. They carry the required and current item level for the + // gear-score reasons; all 206 records of the reference capture have them zero. + locks.push_back(entry); + } + + // 5-byte header plus 16 bytes per lock. The reference reply was 6068 bytes for 206 + // locks and 35 random records; ours is locks-only, so 5 + 16 * n. + WorldPacket data(SMSG_LFG_PLAYER_INFO, 5 + locks.size() * 16); + MopLfgPackets::BuildPlayerInfo(data, locks); + + DEBUG_LOG("SMSG_LFG_PLAYER_INFO: %s, %u locked dungeon(s), %u bytes.", + GetPlayerName(), uint32(locks.size()), uint32(data.size())); + SendPacket(&data); } diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 7763ad3a4..4d730e3b7 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -160,7 +160,22 @@ namespace MopLfgPackets bool ParseLfrSearchRequest(WorldPacket& in, LfrSearchRequest& request); void BuildEmptyLfrSearchResponse(WorldPacket& out, LfrSearchRequest const& request); bool ParseLockInfoRequest(WorldPacket& in, bool& forPlayer); + /// One entry of the lock array at the tail of SMSG_LFG_PLAYER_INFO. + struct PlayerLockInfo + { + /// (TypeID << 24) | dungeonId -- the same value LfgDungeonsEntry::Entry() produces. + uint32 dungeonEntry = 0; + /// LFGForbiddenTypes, which are the client's LFG_INSTANCE_INVALID_CODES verbatim. + uint32 lockStatus = 0; + /// Only meaningful for the gear-score reasons, where the client formats them as + /// "Requires: %2$d. Currently %3$d." Zero for every other reason, and zero in all + /// 206 records of the reference capture. + uint32 subReason1 = 0; + uint32 subReason2 = 0; + }; + void BuildEmptyPlayerInfo(WorldPacket& out); + void BuildPlayerInfo(WorldPacket& out, std::vector const& locks); void BuildEmptyPartyInfo(WorldPacket& out); } @@ -517,6 +532,52 @@ inline bool MopLfgPackets::ParseLockInfoRequest(WorldPacket& in, return in.rpos() == in.size(); } +inline void MopLfgPackets::BuildPlayerInfo(WorldPacket& out, + std::vector const& locks) +{ + // SMSG_LFG_PLAYER_INFO with a populated lock list. + // + // Sent ONLY in reply to CMSG_LFG_LOCK_INFO_REQUEST -- it is not pushed at login. In + // capture-000006 the two pair seven-for-seven, and the client asks at world-enter + // (CMSG_LFG_GET_STATUS then CMSG_LFG_LOCK_INFO_REQUEST at adjacent sequence numbers). + // + // Layout verified byte-exact against capture-000006 seq 1953, a 6068-byte reply to a + // max-level character: + // + // bits WriteBits(lockCount, 20) -> 206 + // WriteBit(hasPlayerGuid) -> 0 + // WriteBits(randomDungeonCount, 17) -> 35 + // FlushBits -> 38 bits, 5 bytes + // ...random dungeon reward records, variable length... + // tail lockCount x 16 bytes, flat and unpacked: + // uint32 dungeonEntry (TypeID << 24) | id + // uint32 lockStatus + // uint32 subReason1 + // uint32 subReason2 + // + // The locks sit at the TAIL, after the random records. With zero randoms the two are + // adjacent, which is what makes a locks-only reply coherent: the client installs the + // lock list and raises LFG_LOCK_INFO_RECEIVED whether or not any random rows follow, + // so none of the reward plumbing is needed to make the eligibility filter work. + // + // Why this matters: LFGList_DefaultFilterFunction shows a dungeon when + // `not LFGLockList[dungeonID]`, and LFGLockList is built from this array. Sending it + // empty told the client nothing is locked, so every dungeon in the game appeared in + // the finder and players could queue for content they cannot enter. + out.WriteBits(uint32(locks.size()), 20); + out.WriteBit(false); // has player GUID -- 0 in the reference capture + out.WriteBits(0, 17); // random dungeon count; see above + out.FlushBits(); + + for (std::vector::const_iterator it = locks.begin(); it != locks.end(); ++it) + { + out << uint32(it->dungeonEntry); + out << uint32(it->lockStatus); + out << uint32(it->subReason1); + out << uint32(it->subReason2); + } +} + inline void MopLfgPackets::BuildEmptyPlayerInfo(WorldPacket& out) { out.WriteBits(0, 20); // locked dungeon count From ed85366e3ccfc709868016646b058b9c394eb40d Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 22:03:44 +0100 Subject: [PATCH 22/81] LFG: drop two declarations the rebase onto #81 duplicated Rebasing this branch onto merged master brought master's difficulty translation into CreateDungeonGroup, and that block carries its own lookups of `dungeon` and `groupGuid`. This branch already has both, earlier and deliberately: * `dungeon` is looked up at the top of the function, BEFORE anything is created, because the original order returned on an unknown id having already new'd a Group, run Create (a group id plus an INSERT) and registered it with ObjectMgr -- leaking the object and stranding its rows. * `groupGuid` is taken once as `ObjectGuid const`. Keeping both copies was a redefinition and did not compile. The earlier ones are the right ones to keep, so the later pair is removed and the reason each exists is noted where it would otherwise look redundant. Build clean; ctest 110/110. --- src/game/WorldHandlers/LFGMgrProposal.cpp | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index c964a8538..56a6f3730 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -755,12 +755,9 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) } } - LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(proposal->dungeonID); - if (!dungeon) - { - return; - } - + // `dungeon` is the lookup made at the top of this function, before any group was + // created -- it is not re-fetched here. + // // LfgDungeons.dbc carries a RAW client DifficultyID. Casting it straight to // Difficulty made LFG normal (id 1) select internal mode 1 -- HEROIC -- and LFG // heroic (id 2) select mode 2, CHALLENGE. That value does not stay in the session: @@ -825,8 +822,8 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) pGroup->SetDungeonDifficulty(Difficulty(dungeonMode)); } - // Add group to our group set and group map, then teleport to the dungeon - ObjectGuid groupGuid = pGroup->GetObjectGuid(); + // Add group to our group set and group map, then teleport to the dungeon. + // groupGuid is the one taken above; do not shadow it. LFGGroupStatus groupStatus(LFG_STATE_IN_DUNGEON, dungeon->ID, proposal->currentRoles, pGroup->GetLeaderGuid()); m_groupSet.insert(groupGuid); From 1590b13859d1d1c8ce8d614ab83ccbb31e476c93 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 22:18:19 +0100 Subject: [PATCH 23/81] LFG: propose a real dungeon for a random queue, not the category row A random selection expanded to its members and was then collapsed back to the single category row before the queue entry was built. SendDungeonProposal takes the first entry of that list, so the proposal named the category -- and a category is not a place. All 12 TypeID 6 rows in LfgDungeons.dbc carry MapID 0 or 0xFFFFFFFF: the eight with 0xFFFFFFFF fail the teleport outright, and the four with 0 send the group to Eastern Kingdoms. This was previously ruled harmless because the matchmaker never ran. That is no longer true on this branch -- it is this branch that consumes the WUPDATE_LFGMGR timer -- so the defect became live the moment the tick was wired. The expansion is now kept alongside the queued list rather than discarded, and the proposal carries two ids: dungeonID what was QUEUED. For a random queue the category row, which is what the client is shown and what the reward lookup keys on, so it is unchanged and nothing on the wire moves. concreteDungeonID where the group actually goes. Chosen from the expansion. PickConcreteDungeon excludes the category from its own expansion, which is not theoretical: Group_ID 33, behind Random Hour of Twilight Heroic, has exactly ONE member and that member is the category row itself. It also skips rows whose DifficultyID has no internal mode, for the same reason JoinLFG refuses them at admission. When nothing behind the selection is runnable the proposal is refused outright rather than built. Forming a group first would tear every member out of their previous group and then strand them, which is worse than a refusal the client can report. CreateDungeonGroup falls back to dungeonID when concreteDungeonID is 0, so a proposal created before this change still completes rather than returning early. Build clean; ctest 110/110. Not yet driven live -- a random queue needs the matchmaker to actually pair, which is the next check. --- src/game/WorldHandlers/LFGMgr.h | 21 +++++- src/game/WorldHandlers/LFGMgrProposal.cpp | 78 ++++++++++++++++++++++- src/game/WorldHandlers/LFGMgrQueue.cpp | 13 +++- 3 files changed, 109 insertions(+), 3 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 4d730e3b7..f210aeb8a 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -851,6 +851,16 @@ struct LFGPlayers //TODO: rename to LFGQueueData std::string comments; bool isGroup; + /// The concrete dungeons a RANDOM selection expanded to, kept so a proposal can name + /// one. Empty for a normal queue. + /// + /// dungeonList holds what the player asked for, which for a random queue is the single + /// category row -- that is what the client is shown and what the reward lookup keys on, + /// so it must not be replaced. But a category row is not a place: all 12 TypeID 6 rows + /// in LfgDungeons.dbc carry MapID 0 or 0xFFFFFFFF, so proposing one teleports the group + /// nowhere. The expansion is therefore kept alongside rather than collapsed away. + std::set candidateDungeons; + // Zeroed: the default constructor left these indeterminate, and needed* decides both // whether an entry is complete and what the queue advertises to the client. time_t joinedTime = 0; @@ -950,7 +960,16 @@ struct LFGProposal // making a new one. Left indeterminate, an all-solo proposal picked its branch from // whatever was on the stack. uint32 id = 0; // proposal id - uint32 dungeonID = 0; // dungeon id + uint32 dungeonID = 0; // dungeon id as QUEUED -- for a random queue this is the + // category row, which is what the client is shown and what + // the reward lookup keys on + + /// The dungeon the group is actually put into. Equals dungeonID for a normal queue. + /// + /// For a random queue it is a concrete member of the expansion, because the category row + /// has no map to teleport to. Split from dungeonID rather than replacing it so the + /// proposal packet and the reward path keep naming the random entry the player chose. + uint32 concreteDungeonID = 0; // The m_playerData key this proposal was built from. The queue entry is kept alive // for the lifetime of the proposal so a failure can put the survivors back, which is diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 56a6f3730..7362fdc83 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -195,6 +195,60 @@ bool LFGMgr::ValidateGroupRoles(roleMap groupMap, std::set const& dungeo return RolesAreValidForDungeons(groupMap, dungeonList); } +/** + * @brief The dungeon a proposal should actually put the group into. + * + * A normal queue names a real dungeon and this returns it unchanged. A RANDOM queue names a + * category, and a category is not a place: all 12 TypeID 6 rows in LfgDungeons.dbc carry MapID + * 0 or 0xFFFFFFFF. Proposing one sent the group to a plain teleport failure, or -- for the four + * carrying 0 -- silently to Eastern Kingdoms. + * + * The category row is excluded from its own expansion. Group_ID 33, behind Random Hour of + * Twilight Heroic, has exactly ONE member and that member is the category row itself, so + * without the exclusion that random would still propose an unrunnable row. + * + * Untranslatable tiers are excluded for the same reason JoinLFG refuses them at admission: a + * row whose DifficultyID has no internal mode cannot be entered at the tier it claims. + * + * @return a concrete dungeon id, or 0 when nothing behind the selection is runnable. + */ +static uint32 PickConcreteDungeon(uint32 queuedDungeonId, std::set const& candidates) +{ + LfgDungeonsEntry const* queued = sLfgDungeonsStore.LookupEntry(queuedDungeonId); + if (!queued) + { + return 0; + } + + if (queued->TypeID != LFG_TYPE_RANDOM_DUNGEON) + { + return queuedDungeonId; // already a real dungeon + } + + for (std::set::const_iterator it = candidates.begin(); it != candidates.end(); ++it) + { + if (*it == queuedDungeonId) + { + continue; // the category cannot host itself + } + + LfgDungeonsEntry const* candidate = sLfgDungeonsStore.LookupEntry(*it); + if (!candidate || candidate->TypeID == LFG_TYPE_RANDOM_DUNGEON) + { + continue; + } + + if (ToInternalDifficulty(candidate->DifficultyID) < 0) + { + continue; + } + + return candidate->ID; + } + + return 0; +} + //todo: remove from queue, update queue average settings void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) { @@ -209,6 +263,24 @@ void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) newProposal.encounters = 0; // todo: check if group has already started a dungeon and are looking for another plr newProposal.currentRoles = lfgGroup->currentRoles; newProposal.dungeonID = *dItr; + + // The dungeon the group is actually put into. + // + // For a normal queue that is the queued row. For a RANDOM one it cannot be: every TypeID 6 + // row in LfgDungeons.dbc carries MapID 0 or 0xFFFFFFFF, so proposing the category itself + // teleports the group nowhere -- 4 of the 12 silently to Eastern Kingdoms and the other 8 to + // a plain failure. A concrete member of the expansion is chosen instead, while dungeonID + // keeps naming the random entry for the proposal packet and the reward lookup. + newProposal.concreteDungeonID = PickConcreteDungeon(*dItr, lfgGroup->candidateDungeons); + if (!newProposal.concreteDungeonID) + { + // Nothing runnable behind the category. Do not build a proposal that cannot complete: + // the group would be formed, torn out of its previous groups and then left standing. + sLog.outError("LFG SendDungeonProposal: random dungeon %u expanded to no runnable " + "member; refusing to propose.", *dItr); + return; + } + newProposal.isNew = true; newProposal.joinedQueue = lfgGroup->joinedTime; newProposal.createdTime = time(NULL); @@ -625,7 +697,11 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) // an unknown dungeon id returned having already new'd a Group, run Create (a group // id plus an INSERT INTO groups) and registered it with ObjectMgr -- leaking the // object and stranding its rows, with the proposal also left in m_proposalMap. - LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(proposal->dungeonID); + // The CONCRETE dungeon: proposal->dungeonID may be a random category, which has no map. + // Older proposals predating the split carry 0 here, so fall back rather than refuse. + uint32 const runDungeonId = proposal->concreteDungeonID ? proposal->concreteDungeonID + : proposal->dungeonID; + LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(runDungeonId); if (!dungeon) { return; diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 8d7a797de..35d44c2d0 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -347,8 +347,15 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen roleCheck.waitForRoleTime = time_t(time(NULL) + LFG_TIME_ROLECHECK); // place original dungeon ID back in the set + // + // The expansion is SAVED first. dungeonList must go back to the single category row -- + // that is what the client is shown and what the reward lookup keys on -- but a category + // row is not a place: all 12 TypeID 6 rows carry MapID 0 or 0xFFFFFFFF. Discarding the + // expansion here is what left a random queue proposing a row it could not teleport to. + std::set candidates; if (isRandom) { + candidates = dungeons; dungeons.clear(); dungeons.insert(randomDungeonID); } @@ -377,15 +384,18 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen // used later if they enter the queue LFGPlayers groupInfo(LFG_STATE_NONE, dungeons, roleCheck.currentRoles, comments, false, time(NULL), 0, 0, 0); + groupInfo.candidateDungeons = candidates; m_playerData[guid] = groupInfo; PerformRoleCheck(plr, pGroup, (uint8)roles); } else { - // place original dungeon ID back in the set + // place original dungeon ID back in the set -- expansion saved first, as above + std::set candidates; if (isRandom) { + candidates = dungeons; dungeons.clear(); dungeons.insert(randomDungeonID); } @@ -405,6 +415,7 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen } LFGPlayers playerInfo(LFG_STATE_QUEUED, dungeons, playerRole, comments, false, time(NULL), 0, 0, 0); + playerInfo.candidateDungeons = candidates; m_playerData[guid] = playerInfo; // set up a status struct for client requests/updates From 361b8a3e1d0b0b5f12c7e5a4960168f24a314d26 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 22:35:33 +0100 Subject: [PATCH 24/81] LFG: stop replaying the queue after entry, and make leaving a dungeon work Two defects found by driving the finder live. The run itself succeeded -- a solo queue for Deadmines formed a group and teleported, with SMSG_GROUP_LIST, SMSG_TRANSFER_PENDING and SMSG_NEW_WORLD all sent and no TRANSFER_ABORTED -- but the session afterwards was wrong in two ways. "You are now queued in the dungeon finder", with the sound, played seconds AFTER walking into the dungeon. The client asks for its LFG status on every zone-in, so CMSG_LFG_GET_STATUS arrives immediately after the finder's own teleport. HandleLfgGetStatusOpcode answered from the stored record, which still described a queue: the reply carried the dungeon list, with 0x01000006 -- Deadmines -- visible in the captured body. A player standing inside the dungeon is not queued, so that request now returns without answering for IN_DUNGEON and FINISHED_DUNGEON. Nothing is sent instead; the queue-status reply has no meaning for someone already inside, and the group and instance state they need arrives through its own packets. "Leave Dungeon" did nothing, and there was no way out short of relogging. LFGMgr::LeaveLFG switches on the player's state and handled only PROPOSAL, QUEUED and, on the group branch, ROLECHECK. The finder sets IN_DUNGEON once it has placed someone, so every leave from inside fell through the switch, sent the client nothing, and left the player believing they were still in an LFG session. Two CMSG_LFG_LEAVE arrived in the observed run and neither produced any effect. Both branches now handle IN_DUNGEON and FINISHED_DUNGEON alongside the queue states. Note this clears the LFG session; it does not teleport the player out of the instance. That is a separate behaviour and is not claimed here. Build clean; ctest 110/110. Needs a live re-run to confirm both. --- src/game/WorldHandlers/LFGHandler.cpp | 15 +++++++++++++++ src/game/WorldHandlers/LFGMgrQueue.cpp | 12 ++++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 57fde972d..e3a4060ee 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -267,6 +267,21 @@ void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) if (status.state == LFG_STATE_NONE) return; + // A player who is already IN the dungeon is not queued, and must not be answered as + // though they were. + // + // The client asks for its status on every zone-in, so this fires immediately after the + // finder teleports someone. Answering from the stored record replayed the queue -- the + // reply still carried the dungeon list (0x01000006, Deadmines, observed live) -- and the + // client announced "you are now queued in the dungeon finder", with the sound, seconds + // AFTER the player had walked into the place. + // + // Nothing is sent instead. The queue-status reply has no meaning for someone inside, and + // the group and instance state the client needs at that point arrive through their own + // packets. + if (status.state == LFG_STATE_IN_DUNGEON || status.state == LFG_STATE_FINISHED_DUNGEON) + return; + status.updateType = LFG_UPDATE_STATUS; bool const groupFirst = GetPlayer()->GetGroup() != nullptr; SendLfgUpdate(groupFirst, status); diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 35d44c2d0..3d2880bef 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -451,6 +451,14 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) LFGPlayerStatus grpPlrStatus = GetPlayerStatus(grpPlrGuid); switch (grpPlrStatus.state) { + // IN_DUNGEON and FINISHED_DUNGEON are handled with the queue states, + // and leaving them out is what made "Leave Dungeon" do nothing at all. + // Once the finder has placed a player their state is IN_DUNGEON, so a + // leave fell through this switch, sent the client nothing, and left the + // player believing they were still in an LFG session -- with no way out + // short of relogging. Observed live: two CMSG_LFG_LEAVE with no effect. + case LFG_STATE_IN_DUNGEON: + case LFG_STATE_FINISHED_DUNGEON: case LFG_STATE_PROPOSAL: case LFG_STATE_QUEUED: grpPlrStatus.updateType = LFG_UPDATE_LEAVE; @@ -479,6 +487,10 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) LFGPlayerStatus plrStatus = GetPlayerStatus(plrGuid); switch (plrStatus.state) { + // Same set as the group branch above -- see the note there for why + // IN_DUNGEON must be handled rather than falling through. + case LFG_STATE_IN_DUNGEON: + case LFG_STATE_FINISHED_DUNGEON: case LFG_STATE_PROPOSAL: case LFG_STATE_QUEUED: plrStatus.updateType = LFG_UPDATE_LEAVE; From a7316d6d86e26fc908a39f18f89b4049d05d4af2 Mon Sep 17 00:00:00 2001 From: MadMax Date: Wed, 5 Aug 2026 22:46:49 +0100 Subject: [PATCH 25/81] LFG: put back the status reply after entry -- suppressing it was wrong Withdrawn. The previous commit made HandleLfgGetStatusOpcode return without answering once the player is IN_DUNGEON, on the theory that a queue-status reply is meaningless to someone already inside. Testing showed the opposite: with the reply gone the client stopped believing it was in an LFG session at all, and the Leave Dungeon button disappeared from the minimap. Reading the builder rather than the symptom explains why. SMSG_LFG_UPDATE_STATUS carries no explicit state field; SendLfgUpdate derives two booleans from it: case LFG_UPDATE_STATUS: isQueued = (status.state == LFG_STATE_QUEUED); joined = status.state != LFG_STATE_NONE; For IN_DUNGEON that is queued=false, joined=true -- which is precisely "in an LFG session but not queued", and is very likely what the client keys the Leave Dungeon button on. Suppressing the reply removed the only thing saying it. So the reply is not the defect. Whatever made the client announce "you are now queued" after entry is elsewhere, and the honest position is that it is not yet understood. Guessing at client rendering is what produced this regression, and the retail sequence is being pulled from the capture corpus instead -- what the server actually sends at join, proposal, entry and leave -- before anything here changes again. The join-state fix in the previous commit is NOT reverted. That one stands on its own evidence: the update announced LFG_UPDATE_JOIN while reporting the state as LFG_STATE_NONE, and corrected it to QUEUED one line later purely for storage, so the server contradicted itself within two statements regardless of what the client does with it. Build clean; ctest 110/110. --- src/game/WorldHandlers/LFGHandler.cpp | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index e3a4060ee..57fde972d 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -267,21 +267,6 @@ void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) if (status.state == LFG_STATE_NONE) return; - // A player who is already IN the dungeon is not queued, and must not be answered as - // though they were. - // - // The client asks for its status on every zone-in, so this fires immediately after the - // finder teleports someone. Answering from the stored record replayed the queue -- the - // reply still carried the dungeon list (0x01000006, Deadmines, observed live) -- and the - // client announced "you are now queued in the dungeon finder", with the sound, seconds - // AFTER the player had walked into the place. - // - // Nothing is sent instead. The queue-status reply has no meaning for someone inside, and - // the group and instance state the client needs at that point arrive through their own - // packets. - if (status.state == LFG_STATE_IN_DUNGEON || status.state == LFG_STATE_FINISHED_DUNGEON) - return; - status.updateType = LFG_UPDATE_STATUS; bool const groupFirst = GetPlayer()->GetGroup() != nullptr; SendLfgUpdate(groupFirst, status); From 5bae262b54222e43f9c77f77e03d3b52a77ba479 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 00:19:13 +0100 Subject: [PATCH 26/81] Make the LFG status and join-result packets match retail 18414 SMSG_LFG_JOIN_RESULT was neither registered nor admitted, and its body was still the 3.3.5 shape (uint32 result, uint32 state, raw uint64 GUIDs). The 18414 packet shares no field width with that, so admitting it as-is would have put a malformed body on the wire rather than merely dropping it. Every refused join was silent in both directions. MopLfgPackets::BuildJoinResult is the inverse of the 18414 reader sub_760C65 (dispatcher case 687), pinned by fixture to all three observed forms: capture-000059 seq 490545 (18 B refusal), capture-000044 seq 1547 (23 B) and capture-000075 seq 891753 (24 B). The GUID mask is split either side of the 22-bit lock count, giving len == 18 + popcount(byte0) + popcount(byte3). LfgJoinResult was the 3.3.5 numbering throughout. The client selects its string by linear scan of a 19-entry table at .data:00F66A30 bounded by `cmp ecx, 13h`; a code absent from it skips the DisplayError call entirely and the player is shown nothing. Values resolved through the descriptor array at .data:00F5C278 (stride 0x14). The shift is not constant -- +0x1B for the old 0x01..0x05, +0x1A from MISMATCHED_SLOTS on -- because MoP dropped NO_SLOTS_PARTY, whose string survives at index 0x2EE with no code mapping to it. The party branch that sent 0x06 now sends NO_SLOTS_PLAYER, which is also the only code carrying the lock array, so an ineligible party is told which dungeons were locked instead of nothing at all. Byte 5 is the role-check state, not LFGState: the client special-cases 3 and 4 to the TIMEOUT and NOT_VIABLE strings, and our LFGRoleCheckState already uses those values for MISSING_ROLE and WRONG_ROLES. SMSG_LFG_UPDATE_STATUS layout was already correct (5291/5291 retail bodies decode with zero leftover); the values in it were not: - joined was `state != LFG_STATE_NONE`, so it stayed 1 inside a dungeon where retail sends 0, and GetLFGMode returned "suspended" rather than "lfgparty" - the zone-in probe was answered with two packets, the second with the dungeon list cleared -- a body that occurs 0 times in 5291 - isParty was `isGroup`, emitting 0x40, which retail never emits - notifyUi defaulted true and was never assigned; retail keeps it equal to joined in 5288 of 5291 - lfgJoined was `updateType != LFG_UPDATE_LEAVE`; it actually marks a group-owned queue entry and moves with requesterGuid - ticketId was hardcoded 0, which retail never sends, and the client echoes it back in CMSG_LFG_PROPOSAL_RESPONSE and CMSG_LFG_LEAVE, so its replies could not be matched to the entry that produced them The join burst now follows retail: reason 24, reason 13, the join result, then reason 13 again. Opening with reason 6 was wrong -- that is the re-queue-from-inside-a-dungeon reason, and 257 of 276 observed joins lead with 24. SMSG_LFG_QUEUE_STATUS is also sent when the queue entry is created, not only from the matchmaker tick. SendQueueStatus ran after FindQueueMatches, so a queue that matched on its first tick was dequeued before any status was built and the player got none at all -- no role counts and no average wait. Retail's first one arrives 1-5s after the join. Corpus catalogueGenerationId 2BE10C899585BAECD237705AC13BBF9262D81B6BDC085B462808C6869CE88752. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 1 + src/game/Server/WorldSession.cpp | 2 + src/game/Server/WorldSession.h | 2 +- .../Server/tests/mop_lfg_packets_test.cpp | 75 ++++++++ src/game/WorldHandlers/LFGHandler.cpp | 119 ++++++++---- src/game/WorldHandlers/LFGMgr.cpp | 167 ++++++++++------- src/game/WorldHandlers/LFGMgr.h | 169 ++++++++++++++++-- src/game/WorldHandlers/LFGMgrProposal.cpp | 6 +- src/game/WorldHandlers/LFGMgrQueue.cpp | 56 ++++-- 9 files changed, 460 insertions(+), 137 deletions(-) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index fecdb917a..e2c5d9833 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -1130,6 +1130,7 @@ void InitializeOpcodes() // Direct 18414 leaf: periodic queue wait estimates and role vacancies. DefS(SMSG_LFG_QUEUE_STATUS, "SMSG_LFG_QUEUE_STATUS"); + DefS(SMSG_LFG_JOIN_RESULT, "SMSG_LFG_JOIN_RESULT"); DefS(SMSG_LFG_PROPOSAL_UPDATE, "SMSG_LFG_PROPOSAL_UPDATE"); DefS(SMSG_LFG_ROLE_CHECK_UPDATE, "SMSG_LFG_ROLE_CHECK_UPDATE"); DefS(SMSG_LFG_TELEPORT_DENIED, "SMSG_LFG_TELEPORT_DENIED"); diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index 7e5b4b49c..27de0e53b 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -609,6 +609,8 @@ static bool IsEnterWorldConverted(uint16 opcode) case SMSG_LFG_BOOT_PLAYER: // MopLfgPackets::BuildBootPlayer case SMSG_LFG_UPDATE_STATUS: // MopLfgPackets::BuildUpdateStatus case SMSG_LFG_QUEUE_STATUS: // MopLfgPackets::BuildQueueStatus + case SMSG_LFG_JOIN_RESULT: // MopLfgPackets::BuildJoinResult, byte-exact vs capture-000059 seq 490545 (18B refusal), + // capture-000044 seq 1547 (23B) and capture-000075 seq 891753 (24B) case SMSG_LFG_PLAYER_INFO: // MopLfgPackets::BuildEmptyPlayerInfo case SMSG_LFG_PARTY_INFO: // MopLfgPackets::BuildEmptyPartyInfo case SMSG_LFG_UPDATE_SEARCH: // MopLfgPackets::BuildEmptyLfrSearchResponse diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index 83805a77c..4249fb0e6 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -1559,7 +1559,7 @@ class WorldSession SendNotification(format, args...); } void SendPetNameInvalid(uint32 error, const std::string& name, DeclinedName* declinedName); - void SendLfgJoinResult(LfgJoinResult result, LFGState state, partyForbidden const& lockedDungeons); + void SendLfgJoinResult(LfgJoinResult result, uint8 detail, partyForbidden const& lockedDungeons); void SendLfgUpdate(bool isGroup, LFGPlayerStatus status); void SendLfgQueueStatus(LFGQueueStatus const& status); void SendLfgPlayerLockInfo(); diff --git a/src/game/Server/tests/mop_lfg_packets_test.cpp b/src/game/Server/tests/mop_lfg_packets_test.cpp index a00155720..b1992d994 100644 --- a/src/game/Server/tests/mop_lfg_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_packets_test.cpp @@ -135,6 +135,78 @@ static void test_queue_status_exact_fixture() })); } +/// SMSG_LFG_JOIN_RESULT, pinned to the three real payload shapes in the corpus +/// (build 18414, catalogue 2BE10C89). These are captured bytes, not synthesised +/// ones: the previous body was the 3.3.5 layout and would fail every one of them +/// on the first byte, so a shape-only test would not have caught it. +/// +/// The GUID mask is SPLIT either side of the 22-bit lock count, which is what makes +/// the length identity len == 18 + popcount(byte0) + popcount(byte3). +static void test_join_result_refusal_fixture() +{ + // capture-000059 seq 490545: role check failed, detail 6 (LFG_ROLECHECK_NO_ROLE). + // A refusal zeroes the GUID and the entire ticket -- that is what makes it 18 bytes. + MopLfgPackets::JoinResult update; + update.result = 0x1C; + update.detail = 6; + + WorldPacket packet(SMSG_LFG_JOIN_RESULT, 24); + MopLfgPackets::BuildJoinResult(packet, update); + CHECK(Equal(packet, { + 0x00,0x00,0x00,0x00, + 0x1C, 0x06, + 0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00, + 0x00,0x00,0x00,0x00 + })); +} + +static void test_join_result_success_23_fixture() +{ + // capture-000044 seq 1547. The ticket here is the SAME one carried by + // SMSG_LFG_QUEUE_STATUS seq 1577 in this capture: joinTime 0x54146107, + // queueId 0x9BFF. Three of the eight GUID bytes are zero, hence 23 not 26. + MopLfgPackets::JoinResult update; + update.requesterGuid = 0x0400000006296291ULL; + update.joinTime = 0x54146107; + update.clientQueueId = 0x9BFF; + update.ticketType = 3; + + WorldPacket packet(SMSG_LFG_JOIN_RESULT, 32); + MopLfgPackets::BuildJoinResult(packet, update); + CHECK(Equal(packet, { + 0xB0, 0x00, 0x00, 0x14, + 0x00, 0x00, + 0x28, + 0x07, 0x61, 0x14, 0x54, + 0xFF, 0x9B, 0x00, 0x00, + 0x03, 0x00, 0x00, 0x00, + 0x63, 0x90, 0x05, 0x07 + })); +} + +static void test_join_result_success_24_fixture() +{ + // capture-000075 seq 891753: a different GUID with one more non-zero byte. + MopLfgPackets::JoinResult update; + update.requesterGuid = 0x1F5400001249B4F0ULL; + update.joinTime = 0x53D28F06; + update.clientQueueId = 0x4692; + update.ticketType = 3; + + WorldPacket packet(SMSG_LFG_JOIN_RESULT, 32); + MopLfgPackets::BuildJoinResult(packet, update); + CHECK(Equal(packet, { + 0xF0, 0x00, 0x00, 0x14, + 0x00, 0x00, + 0x48, + 0x06, 0x8F, 0xD2, 0x53, + 0x92, 0x46, 0x00, 0x00, + 0x03, 0x00, 0x00, 0x00, + 0x55, 0xB5, 0xF1, 0x1E, 0x13 + })); +} + static void test_lock_info_request() { WorldPacket player(CMSG_LFG_LOCK_INFO_REQUEST, 2); @@ -182,6 +254,9 @@ int main(int /*argc*/, char** /*argv*/) { test_update_status_exact_fixture(); test_queue_status_exact_fixture(); + test_join_result_refusal_fixture(); + test_join_result_success_23_fixture(); + test_join_result_success_24_fixture(); test_lock_info_request(); test_lfr_search_request(); diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 57fde972d..a596b47f6 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -146,11 +146,9 @@ void WorldSession::HandleLfgJoinOpcode(WorldPacket& recv_data) // DifficultyID == 0 and no TypeID==1 row in LfgDungeons.dbc carries that, // so every entry reports needing nobody and any two would be matched. // - // Known gap, deliberately not hidden: SendLfgJoinResult builds - // SMSG_LFG_JOIN_RESULT, which is NOT admitted, so a REFUSED join tells the - // player nothing. Success is unaffected -- SendLfgUpdate goes out over the - // already-admitted SMSG_LFG_UPDATE_STATUS. The reply is held rather than - // admitted because the only fixture for its non-empty form is synthetic. + // SMSG_LFG_JOIN_RESULT is now built to the 18414 layout and admitted, so a + // refused join reaches the player. See MopLfgPackets::BuildJoinResult for the + // three captures it is pinned to. std::set requested(dungeons.begin(), dungeons.end()); sLFGMgr.JoinLFG(roles, requested, comment, GetPlayer()); } @@ -267,12 +265,20 @@ void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) if (status.state == LFG_STATE_NONE) return; + // Exactly ONE packet, with the dungeon list PRESENT. + // + // This used to send a second copy with dungeonList.clear(). No such body exists in + // retail traffic: 0 of 5291 observed SMSG_LFG_UPDATE_STATUS carry an empty dungeon + // list. It was the old 3.3.5 UPDATE_PARTY/UPDATE_PLAYER pair, and 5.4.8 has a + // single opcode. Retail's reply to the zone-in probe is one reason-15 body that + // still lists the dungeons (capture-000720 seq 1286, reproduced at capture-000044 + // seq 6354, capture-000656 seq 113708 and capture-000872 seq 14299). + // + // The LFG_STATE_NONE early return above is also correct and must stay: 1598 of 2144 + // GET_STATUS probes draw no reply at all, and none of the 504 post-completion or + // post-leave probes do. status.updateType = LFG_UPDATE_STATUS; - bool const groupFirst = GetPlayer()->GetGroup() != nullptr; - SendLfgUpdate(groupFirst, status); - - status.dungeonList.clear(); - SendLfgUpdate(!groupFirst, status); + SendLfgUpdate(GetPlayer()->GetGroup() != nullptr, status); } void WorldSession::HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data) @@ -349,35 +355,47 @@ void WorldSession::HandleSetLfgCommentOpcode(WorldPacket& recv_data) DEBUG_LOG("LFG comment \"%s\"", comment.c_str()); } -void WorldSession::SendLfgJoinResult(LfgJoinResult result, LFGState state, partyForbidden const& lockedDungeons) +void WorldSession::SendLfgJoinResult(LfgJoinResult result, uint8 detail, partyForbidden const& lockedDungeons) { - uint32 packetSize = 0; - for (partyForbidden::const_iterator it = lockedDungeons.begin(); it != lockedDungeons.end(); ++it) + MopLfgPackets::JoinResult update; + update.result = uint8(result); + update.detail = detail; + + // Retail zeroes the GUID and the whole ticket on a refusal -- that is what makes the + // 18-byte form -- and carries both on a success. All 11 observed refusals are the + // zeroed shape, so a refusal must not invent a ticket. + if (result == ERR_LFG_OK) { - packetSize += 12 + uint32(it->second.size()) * 8; + if (Player* player = GetPlayer()) + { + update.requesterGuid = player->GetObjectGuid().GetRawValue(); + } + LFGStatusPacketData queueData; + sLFGMgr.GetStatusPacketData(GetPlayer()->GetObjectGuid(), GetPlayer()->GetObjectGuid(), queueData); + update.joinTime = queueData.joinedTime ? queueData.joinedTime : uint32(time(NULL)); + update.clientQueueId = queueData.ticketId; + update.ticketType = 3; } - WorldPacket data(SMSG_LFG_JOIN_RESULT, packetSize); - data << uint32(result); - data << uint32(state); - - if (!lockedDungeons.empty()) + for (partyForbidden::const_iterator it = lockedDungeons.begin(); it != lockedDungeons.end(); ++it) { - for (partyForbidden::const_iterator it = lockedDungeons.begin(); it != lockedDungeons.end(); ++it) - { - dungeonForbidden dungeonInfo = it->second; + MopLfgPackets::JoinResultPlayer player; + player.guid = it->first.GetRawValue(); - data << uint64(it->first); // object guid of player - data << uint32(dungeonInfo.size()); // amount of their locked dungeons - - for (dungeonForbidden::iterator itr = dungeonInfo.begin(); itr != dungeonInfo.end(); ++itr) - { - data << uint32(itr->first); // dungeon entry - data << uint32(itr->second); // reason for dungeon being forbidden/locked - } + for (dungeonForbidden::const_iterator itr = it->second.begin(); itr != it->second.end(); ++itr) + { + MopLfgPackets::PlayerLockInfo lock; + lock.dungeonEntry = itr->first; + lock.lockStatus = itr->second; + player.locks.push_back(lock); } + + update.players.push_back(player); } + WorldPacket data(SMSG_LFG_JOIN_RESULT, 24); + MopLfgPackets::BuildJoinResult(data, update); + SendPacket(&data); } @@ -396,9 +414,20 @@ void WorldSession::SendLfgUpdate(bool isGroup, LFGPlayerStatus status) case LFG_UPDATE_PROPOSAL_BEGIN: joined = true; break; + case LFG_UPDATE_JOIN_QUEUE_INITIAL: + joined = true; + break; case LFG_UPDATE_STATUS: isQueued = (status.state == LFG_STATE_QUEUED); - joined = status.state != LFG_STATE_NONE; + // `joined` must go FALSE once the player is inside. It used to be + // `state != LFG_STATE_NONE`, and LFG_STATE_IN_DUNGEON is non-zero, so we + // reported joined=1 from inside the dungeon where retail sends 0 + // (capture-000720 seq 1286, byte 1 = 0x80). UIParent.lua:3902 GetLFGMode then + // returns "suspended" instead of falling through to "lfgparty" -- the client + // believes the player is still queued rather than in the run. + joined = (status.state != LFG_STATE_NONE + && status.state != LFG_STATE_IN_DUNGEON + && status.state != LFG_STATE_FINISHED_DUNGEON); break; default: break; @@ -415,15 +444,27 @@ void WorldSession::SendLfgUpdate(bool isGroup, LFGPlayerStatus status) MopLfgPackets::StatusUpdate update; update.requesterGuid = queueGuid.GetRawValue(); update.comment = status.comment; - update.needs = {{ queueData.neededTanks, queueData.neededHealers, queueData.neededDps }}; - update.isParty = isGroup; + // Retail leaves these 0,0,0 in all 5291 observed bodies without exception; the + // role shortage is advertised in SMSG_LFG_QUEUE_STATUS instead. + update.needs = {{ 0, 0, 0 }}; + // Always 1. Across 5291 retail bodies byte 1 takes only 0x00, 0x80 and 0xC0 -- + // the 0x40 our solo queue used to emit (bit9 set, bit8 clear) occurs zero times, + // and bit8 is set even for a solo queue with no group at all. The name "isParty" + // does not explain that; the wire value is not in doubt. + update.isParty = true; update.joined = joined; - update.lfgJoined = status.updateType != LFG_UPDATE_LEAVE; + // notifyUi tracks joined -- equal in 5288 of 5291 bodies, and 0 for every terminal + // reason (8, 9, 11, 15, 25). It was defaulted true and never assigned. + update.notifyUi = joined; + // Not "did the player leave" and not "is the player inside": this bit says the + // queue entry is owned by a GROUP. All 1931 bodies with a group-typed requesterGuid + // carry it at every stage, including open-world queueing. It moves together with + // requesterGuid, which is exactly the condition that selected queueGuid above. + update.lfgJoined = (queueGuid != playerGuid); update.queued = isQueued; update.requestedRoles = queueData.roles; update.updateReason = uint8(status.updateType); - // This legacy single-queue manager does not track the client queue ID. - update.ticketId = 0; + update.ticketId = queueData.ticketId; update.ticketTime = queueData.joinedTime; if (!status.dungeonList.empty()) @@ -452,8 +493,10 @@ void WorldSession::SendLfgQueueStatus(LFGQueueStatus const& status) update.waitTimeDps = status.dpsAvgWaitTime; update.dps = status.neededDps; update.joinTime = status.joinTime; - // This legacy single-queue manager has no client queue-ID allocation. - update.clientQueueId = 0; + // Retail's clientQueueId IS the status packet's ticketId -- capture-000044 carries + // 0x9BFF in SMSG_LFG_JOIN_RESULT seq 1547, SMSG_LFG_QUEUE_STATUS seq 1577 and the + // status bodies alike. One identifier, three packets. + update.clientQueueId = status.ticketId; update.waitTime = status.playerAvgWaitTime; update.dungeonEntry = sLFGMgr.GetDungeonEntry(status.dungeonID); diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 49b68c3f2..15d2ff728 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -45,6 +45,8 @@ INSTANTIATE_SINGLETON_1(LFGMgr); LFGMgr::LFGMgr() { m_proposalId = 0; + // Starts at a non-zero base: retail never sends ticketId 0. + m_nextTicketId = 1000; } LFGMgr::~LFGMgr() @@ -697,6 +699,12 @@ void LFGMgr::AddToQueue(ObjectGuid guid) { m_queueSet.insert(guid); } + + // Tell the client its queue status straight away. Retail's first SMSG_LFG_QUEUE_STATUS + // lands 1-5s after the join, long before any tick would fire -- see SendQueueStatusFor. + // This must stay AFTER UpdateNeededRoles above, which fills the tank/healer/dps counts + // the packet carries; sending first would report a queue that needs nobody. + SendQueueStatusFor(guid, time(0)); } void LFGMgr::RemoveFromQueue(ObjectGuid guid) @@ -1280,81 +1288,108 @@ void LFGMgr::SendQueueStatus() // Check who is listed as being in the queue for (queueSet::iterator itr = m_queueSet.begin(); itr != m_queueSet.end(); ++itr) { - // make sure it's not a false entry - LFGPlayers* queueInfo = GetPlayerOrPartyData(*itr); - if (queueInfo && queueInfo->currentState == LFG_STATE_QUEUED) + SendQueueStatusFor(*itr, timeNow); + } +} + +// Split out of SendQueueStatus so a single queue can be told its status the moment it is +// created, rather than only on the next matchmaker tick. +// +// Retail sends the first SMSG_LFG_QUEUE_STATUS within seconds of the join: across 55 queued +// sessions at build 18414 the delay from CMSG_LFG_JOIN to the first status is 1-5s (median 4), +// and it then repeats roughly every 35s. capture-000044 seq 1577 confirms both halves of that +// -- its queuedTime field reads 3, matching the 3s the index measured since the join. +// +// Tick-only delivery could not reproduce that. SendQueueStatus runs at the END of Update(), +// AFTER FindQueueMatches, so a queue that matched on its first tick was dequeued before the +// status was ever built and the player got NONE at all -- no role counts, no average wait, an +// empty eye tooltip. Observed live on a solo debug queue that matched 29s after joining. +void LFGMgr::SendQueueStatusFor(ObjectGuid queueGuid, time_t timeNow) +{ + // make sure it's not a false entry + LFGPlayers* queueInfo = GetPlayerOrPartyData(queueGuid); + if (!queueInfo || queueInfo->currentState != LFG_STATE_QUEUED) + { + return; + } + + // Guarded because this now also runs at join time: dungeonList.begin() on an empty + // set is undefined, and an empty list is reachable if every candidate was filtered. + if (queueInfo->dungeonList.empty()) + { + return; + } + + for (roleMap::iterator rItr = queueInfo->currentRoles.begin(); rItr != queueInfo->currentRoles.end(); ++rItr) + { + if (Player* pPlayer = sObjectAccessor.FindPlayer(rItr->first)) { - for (roleMap::iterator rItr = queueInfo->currentRoles.begin(); rItr != queueInfo->currentRoles.end(); ++rItr) + uint32 dungeonId = *queueInfo->dungeonList.begin(); + + // Each recipient must be told about THEIR OWN queue, not the + // merged entry's key. + // + // The key is whichever entry did the absorbing, so after two solo + // players merge it is one of their guids. The other player joined + // under their own guid -- that is what SMSG_LFG_UPDATE_STATUS sent + // them as requesterGuid -- and a queue status arriving under a + // stranger's identity does not match the queue their client is + // tracking, so it is ignored: no role counts, no average wait, a + // placeholder time in queue, and most of the minimap eye's tooltip + // missing. The absorbing player saw none of this, because for them + // the merged key IS their own guid. + // + // Mirrors SendLfgUpdate: a party member's queue is keyed by the + // group guid, everyone else by their own. + ObjectGuid memberQueueGuid = rItr->first; + if (Group* pGroup = pPlayer->GetGroup()) { - if (Player* pPlayer = sObjectAccessor.FindPlayer(rItr->first)) + if (pGroup->GetObjectGuid() == queueGuid) { - uint32 dungeonId = *queueInfo->dungeonList.begin(); - - // Each recipient must be told about THEIR OWN queue, not the - // merged entry's key. - // - // The key is whichever entry did the absorbing, so after two solo - // players merge it is one of their guids. The other player joined - // under their own guid -- that is what SMSG_LFG_UPDATE_STATUS sent - // them as requesterGuid -- and a queue status arriving under a - // stranger's identity does not match the queue their client is - // tracking, so it is ignored: no role counts, no average wait, a - // placeholder time in queue, and most of the minimap eye's tooltip - // missing. The absorbing player saw none of this, because for them - // the merged key IS their own guid. - // - // Mirrors SendLfgUpdate: a party member's queue is keyed by the - // group guid, everyone else by their own. - ObjectGuid memberQueueGuid = rItr->first; - if (Group* pGroup = pPlayer->GetGroup()) - { - if (pGroup->GetObjectGuid() == *itr) - { - memberQueueGuid = *itr; - } - } + memberQueueGuid = queueGuid; + } + } - LFGQueueStatus status; - status.queueGuid = memberQueueGuid.GetRawValue(); - status.dungeonID = dungeonId; - status.neededTanks = queueInfo->neededTanks; - status.neededHeals = queueInfo->neededHealers; - status.neededDps = queueInfo->neededDps; - status.timeSpentInQueue = uint32(timeNow - queueInfo->joinedTime); - status.joinTime = uint32(queueInfo->joinedTime); + LFGQueueStatus status; + status.queueGuid = memberQueueGuid.GetRawValue(); + status.dungeonID = dungeonId; + status.neededTanks = queueInfo->neededTanks; + status.neededHeals = queueInfo->neededHealers; + status.neededDps = queueInfo->neededDps; + status.timeSpentInQueue = uint32(timeNow - queueInfo->joinedTime); + status.joinTime = uint32(queueInfo->joinedTime); + status.ticketId = queueInfo->ticketId; - int32 playerWaitTime; + int32 playerWaitTime; - // strip leader flag from role - uint8 withoutLeader = rItr->second; - withoutLeader &= ~PLAYER_ROLE_LEADER; + // strip leader flag from role + uint8 withoutLeader = rItr->second; + withoutLeader &= ~PLAYER_ROLE_LEADER; - switch (withoutLeader) - { - case PLAYER_ROLE_TANK: - playerWaitTime = m_tankWaitTime[dungeonId].time; - break; - case PLAYER_ROLE_HEALER: - playerWaitTime = m_healerWaitTime[dungeonId].time; - break; - case PLAYER_ROLE_DAMAGE: - playerWaitTime = m_dpsWaitTime[dungeonId].time; - break; - default: - playerWaitTime = m_avgWaitTime[dungeonId].time; - break; - } + switch (withoutLeader) + { + case PLAYER_ROLE_TANK: + playerWaitTime = m_tankWaitTime[dungeonId].time; + break; + case PLAYER_ROLE_HEALER: + playerWaitTime = m_healerWaitTime[dungeonId].time; + break; + case PLAYER_ROLE_DAMAGE: + playerWaitTime = m_dpsWaitTime[dungeonId].time; + break; + default: + playerWaitTime = m_avgWaitTime[dungeonId].time; + break; + } - status.playerAvgWaitTime = playerWaitTime; - status.dpsAvgWaitTime = m_dpsWaitTime[dungeonId].time; - status.healerAvgWaitTime = m_healerWaitTime[dungeonId].time; - status.tankAvgWaitTime = m_tankWaitTime[dungeonId].time; - status.avgWaitTime = m_avgWaitTime[dungeonId].time; + status.playerAvgWaitTime = playerWaitTime; + status.dpsAvgWaitTime = m_dpsWaitTime[dungeonId].time; + status.healerAvgWaitTime = m_healerWaitTime[dungeonId].time; + status.tankAvgWaitTime = m_tankWaitTime[dungeonId].time; + status.avgWaitTime = m_avgWaitTime[dungeonId].time; - // Send packet to client - pPlayer->GetSession()->SendLfgQueueStatus(status); - } - } + // Send packet to client + pPlayer->GetSession()->SendLfgQueueStatus(status); } } } diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index f210aeb8a..091e5f9cf 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -177,6 +177,28 @@ namespace MopLfgPackets void BuildEmptyPlayerInfo(WorldPacket& out); void BuildPlayerInfo(WorldPacket& out, std::vector const& locks); void BuildEmptyPartyInfo(WorldPacket& out); + + /// One party member's lock list inside SMSG_LFG_JOIN_RESULT. Reuses PlayerLockInfo + /// because the 16-byte lock record is the same one SMSG_LFG_PLAYER_INFO carries -- + /// only the field ORDER on the wire differs between the two packets. + struct JoinResultPlayer + { + uint64 guid = 0; + std::vector locks; + }; + + struct JoinResult + { + std::vector players; // empty for every refusal observed + uint64 requesterGuid = 0; // zero on a refusal + uint32 joinTime = 0; // queue ticket, shared with SMSG_LFG_QUEUE_STATUS + uint32 clientQueueId = 0; + uint32 ticketType = 0; // 3 on success, 0 on every observed refusal + uint8 result = 0; // LfgJoinResult + uint8 detail = 0; // LFGRoleCheckState; only read when result == 0x1C + }; + + void BuildJoinResult(WorldPacket& out, JoinResult const& update); } namespace MopLfgPacketDetail @@ -516,6 +538,65 @@ inline void MopLfgPackets::BuildQueueStatus(WorldPacket& out, MopLfgPacketDetail::WriteGuidBytes(out, update.queueGuid, { 5, 3, 6 }); } +inline void MopLfgPackets::BuildJoinResult(WorldPacket& out, + JoinResult const& update) +{ + // SMSG_LFG_JOIN_RESULT (0x18E3). + // + // Direct inverse of the 18414 reader sub_760C65, reached from dispatcher case 687. + // The body that stood here was the 3.3.5 shape -- uint32 result, uint32 state, then + // raw uint64 GUIDs -- which shares no field WIDTH with this client, let alone field + // order. That, plus the opcode never having been admitted, is why a refused join was + // silent in both directions. + // + // Verified byte-exact against all three observed sizes, decoding to zero leftover + // bytes and zero non-zero pad bits (catalogueGenerationId 2BE10C89...88752): + // + // capture-000059 seq 490545, 18 B: refusal, result 0x1C detail 6, guid 0, ticket 0 + // capture-000044 seq 1547, 23 B: success, guid 0x0400000006296291, type 3 + // capture-000075 seq 891753, 24 B: success, guid 0x1F5400001249B4F0, type 3 + // + // The governing identity when no locks are present is + // len == 18 + popcount(byte0) + popcount(byte3) + // because the GUID mask is SPLIT either side of the 22-bit lock count. + // + // capture-000044 cross-checks against SMSG_LFG_QUEUE_STATUS seq 1577 in the same + // capture: joinTime 0x54146107 and queueId 0x9BFF are identical in both, so the + // ticket really is one shared identifier rather than a per-packet value. + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 7, 6, 3, 0 }); + out.WriteBits(update.players.size(), 22); + for (JoinResultPlayer const& player : update.players) + { + MopLfgPacketDetail::WriteGuidMask(out, player.guid, { 3 }); + out.WriteBits(player.locks.size(), 20); + MopLfgPacketDetail::WriteGuidMask(out, player.guid, { 6, 1, 4, 7, 2, 0, 5 }); + } + MopLfgPacketDetail::WriteGuidMask(out, update.requesterGuid, { 5, 1, 4, 2 }); + out.FlushBits(); + + out << update.result; + for (JoinResultPlayer const& player : update.players) + { + MopLfgPacketDetail::WriteGuidBytes(out, player.guid, { 4 }); + for (PlayerLockInfo const& lock : player.locks) + { + // Reverse of the SMSG_LFG_PLAYER_INFO order: the dungeon entry is written + // LAST here, after both sub-reasons and the lock status. + out << lock.subReason2; + out << lock.subReason1; + out << lock.lockStatus; + out << lock.dungeonEntry; + } + MopLfgPacketDetail::WriteGuidBytes(out, player.guid, { 1, 0, 5, 7, 3, 6, 2 }); + } + out << update.detail; + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 2 }); + out << update.joinTime; + out << update.clientQueueId; + out << update.ticketType; + MopLfgPacketDetail::WriteGuidBytes(out, update.requesterGuid, { 6, 4, 1, 0, 5, 7, 3 }); +} + inline bool MopLfgPackets::ParseLockInfoRequest(WorldPacket& in, bool& forPlayer) { @@ -612,26 +693,56 @@ enum LFGFlags }; /// Possible statuses to send after a request to join the dungeon finder +/// Result codes for SMSG_LFG_JOIN_RESULT, build 18414. +/// +/// Re-valued from the 3.3.5 numbering this was inherited with. The client picks the +/// displayed string by LINEAR SCAN of a 19-entry {u32 code, u32 stringId} table at +/// .data:00F66A30, bounded by `cmp ecx, 13h` at .text:0098E80C. A code that is not in +/// that table takes the `jmp short loc_98E820` at .text:0098E811, which skips the +/// DisplayError call outright -- the player is shown NOTHING. Every value below was +/// resolved through the descriptor array at .data:00F5C278 (stride 0x14, name pointer +/// at +0x00), so these are table reads, not an ordering guess. +/// +/// The shift is NOT a constant: +0x1B for the old 0x01..0x05, then +0x1A from +/// MISMATCHED_SLOTS on, because MoP dropped NO_SLOTS_PARTY. A blanket offset would +/// silently mis-value two thirds of the enum. enum LfgJoinResult { - ERR_LFG_OK = 0x00, - ERR_LFG_ROLE_CHECK_FAILED = 0x01, - ERR_LFG_GROUP_FULL = 0x02, - ERR_LFG_NO_LFG_OBJECT = 0x04, - ERR_LFG_NO_SLOTS_PLAYER = 0x05, - ERR_LFG_NO_SLOTS_PARTY = 0x06, - ERR_LFG_MISMATCHED_SLOTS = 0x07, - ERR_LFG_PARTY_PLAYERS_FROM_DIFFERENT_REALMS = 0x08, - ERR_LFG_MEMBERS_NOT_PRESENT = 0x09, - ERR_LFG_GET_INFO_TIMEOUT = 0x0A, - ERR_LFG_INVALID_SLOT = 0x0B, - ERR_LFG_DESERTER_PLAYER = 0x0C, - ERR_LFG_DESERTER_PARTY = 0x0D, - ERR_LFG_RANDOM_COOLDOWN_PLAYER = 0x0E, - ERR_LFG_RANDOM_COOLDOWN_PARTY = 0x0F, - ERR_LFG_TOO_MANY_MEMBERS = 0x10, - ERR_LFG_CANT_USE_DUNGEONS = 0x11, - ERR_LFG_ROLE_CHECK_FAILED2 = 0x12, + ERR_LFG_OK = 0x00, // success; not in the table, client shows nothing + ERR_LFG_ROLE_CHECK_FAILED = 0x1C, // detail byte refines this one -- see LfgJoinResultDetail + ERR_LFG_GROUP_FULL = 0x1D, + ERR_LFG_NO_LFG_OBJECT = 0x1F, + ERR_LFG_NO_SLOTS_PLAYER = 0x20, // the only code that also carries the per-player lock array + ERR_LFG_MISMATCHED_SLOTS = 0x21, + ERR_LFG_PARTY_PLAYERS_FROM_DIFFERENT_REALMS = 0x22, + ERR_LFG_MEMBERS_NOT_PRESENT = 0x23, + ERR_LFG_GET_INFO_TIMEOUT = 0x24, + ERR_LFG_INVALID_SLOT = 0x25, + ERR_LFG_DESERTER_PLAYER = 0x26, + ERR_LFG_DESERTER_PARTY = 0x27, + ERR_LFG_RANDOM_COOLDOWN_PLAYER = 0x28, + ERR_LFG_RANDOM_COOLDOWN_PARTY = 0x29, + ERR_LFG_TOO_MANY_MEMBERS = 0x2A, + ERR_LFG_CANT_USE_DUNGEONS = 0x2B, + ERR_LFG_ROLE_CHECK_FAILED2 = 0x2C, // genuine second code; renders the same string as 0x1C + ERR_LFG_TOO_FEW_MEMBERS = 0x32, // MoP-new + ERR_LFG_REASON_TOO_MANY_LFG = 0x33, // MoP-new + ERR_LFG_MISMATCHED_SLOTS_LOCAL_XREALM = 0x35, // MoP-new + + // ERR_LFG_NO_SLOTS_PARTY is deliberately absent. Its string still exists in the + // client (index 0x2EE) but NO result code maps to it, so there is no way to send + // it. Callers must use ERR_LFG_NO_SLOTS_PLAYER for a party too -- it is the code + // that carries the lock array, so the player is told which dungeons were locked + // instead of being shown nothing. +}; + +/// Second body byte, only consulted when the result is ERR_LFG_ROLE_CHECK_FAILED +/// (.text:0098E7DE `cmp dl, 1Ch`). Any other value falls through to the plain string. +enum LfgJoinResultDetail +{ + LFG_JOIN_DETAIL_NONE = 0, + LFG_JOIN_DETAIL_TIMEOUT = 3, // -> ERR_LFG_ROLE_CHECK_FAILED_TIMEOUT (string 0x2E9) + LFG_JOIN_DETAIL_NOT_VIABLE = 4, // -> ERR_LFG_ROLE_CHECK_FAILED_NOT_VIABLE (string 0x2EA) }; enum LfgUpdateType @@ -650,6 +761,14 @@ enum LfgUpdateType LFG_UPDATE_STATUS = 15, LFG_UPDATE_GROUP_MEMBER_OFFLINE = 16, LFG_UPDATE_GROUP_DISBAND = 17, + + /// Retail's opening reason for a fresh queue: 257 of 276 observed joins lead with + /// 24 and NONE lead with 6. LFG_UPDATE_JOIN (6) is the re-queue-from-inside-a- + /// dungeon reason, which is why it was the wrong thing to open with. + LFG_UPDATE_JOIN_QUEUE_INITIAL = 24, + /// Sent after SMSG_LFG_PLAYER_REWARD when the run completes. All 283 observed + /// reason-25 bodies carry the same flag tuple. + LFG_UPDATE_DUNGEON_FINISHED = 25, }; enum LfgType @@ -864,6 +983,11 @@ struct LFGPlayers //TODO: rename to LFGQueueData // Zeroed: the default constructor left these indeterminate, and needed* decides both // whether an entry is complete and what the queue advertises to the client. time_t joinedTime = 0; + /// The queue ticket. Retail never sends 0 in any of the 5291 observed status + /// bodies; it is stable for the life of a queue entry and the client ECHOES IT + /// BACK verbatim in CMSG_LFG_PROPOSAL_RESPONSE and CMSG_LFG_LEAVE, so with 0 the + /// client's own replies cannot be matched to the entry that produced them. + uint32 ticketId = 0; uint8 neededTanks = 0; uint8 neededHealers = 0; uint8 neededDps = 0; @@ -912,6 +1036,7 @@ struct LFGQueueStatus uint8 neededDps; // amount of dps needed uint32 timeSpentInQueue; // time already spent in the queue uint32 joinTime; // server epoch time when the queue entry was created + uint32 ticketId; // retail's clientQueueId equals the status packet's ticketId }; /// For CMSG_LFG_GET_STATUS, SMSG_LFG_UPDATE_PARTY, and SMSG_LFG_UPDATE_PLAYER @@ -932,6 +1057,7 @@ struct LFGStatusPacketData { uint32 roles = 0; uint32 joinedTime = 0; + uint32 ticketId = 0; uint8 neededTanks = 0; uint8 neededHealers = 0; uint8 neededDps = 0; @@ -1247,6 +1373,10 @@ class LFGMgr /// Send a periodic status update for queued players void SendQueueStatus(); + void SendQueueStatusFor(ObjectGuid queueGuid, time_t timeNow); + + /// Non-zero, stable per queue entry, monotonic. See LFGPlayers::ticketId. + uint32 AllocateTicketId() { return ++m_nextTicketId; } /// Role-Related Functions @@ -1345,7 +1475,7 @@ class LFGMgr void SendLfgUpdate(ObjectGuid plrGuid, LFGPlayerStatus status, bool isGroup); /// Send SMSG_LFG_JOIN_RESULT - void SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, LFGState state, partyForbidden const& lockedDungeons); + void SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, uint8 detail, partyForbidden const& lockedDungeons); /// Get rid of expired role checks void RemoveOldRoleChecks(); @@ -1360,6 +1490,7 @@ class LFGMgr /// General info related to joining / leaving the dungeon finder playerData m_playerData; queueSet m_queueSet; + uint32 m_nextTicketId; /// Dungeon Finder Status for players playerStatusMap m_playerStatusMap; diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 7362fdc83..1ca36dd1a 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -131,7 +131,7 @@ void LFGMgr::PerformRoleCheck(Player* pPlayer, Group* pGroup, uint8 roles) default: if (roleCheck.leaderGuidRaw == guidBuff.GetRawValue()) { - SendLfgJoinResult(guidBuff, ERR_LFG_ROLE_CHECK_FAILED, LFG_STATE_ROLECHECK, nullForbidden); + SendLfgJoinResult(guidBuff, ERR_LFG_ROLE_CHECK_FAILED, uint8(roleCheck.state), nullForbidden); } SetPlayerUpdateType(guidBuff, LFG_UPDATE_ROLECHECK_FAILED); SendLfgUpdate(guidBuff, GetPlayerStatus(guidBuff), true); @@ -1434,13 +1434,13 @@ void LFGMgr::SendLfgUpdate(ObjectGuid plrGuid, LFGPlayerStatus status, bool isGr } } -void LFGMgr::SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, LFGState state, partyForbidden const& lockedDungeons) +void LFGMgr::SendLfgJoinResult(ObjectGuid plrGuid, LfgJoinResult result, uint8 detail, partyForbidden const& lockedDungeons) { Player* pPlayer = sObjectAccessor.FindPlayer(plrGuid); if (pPlayer) { - pPlayer->GetSession()->SendLfgJoinResult(result, state, lockedDungeons); + pPlayer->GetSession()->SendLfgJoinResult(result, detail, lockedDungeons); } } diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 3d2880bef..66e2b8a70 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -71,7 +71,7 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen if (HasLiveProposalFor(plr->GetObjectGuid())) { partyForbidden noneForbidden; - plr->GetSession()->SendLfgJoinResult(ERR_LFG_NO_LFG_OBJECT, LFG_STATE_PROPOSAL, noneForbidden); + plr->GetSession()->SendLfgJoinResult(ERR_LFG_NO_LFG_OBJECT, LFG_JOIN_DETAIL_NONE, noneForbidden); return; } @@ -324,14 +324,20 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen } else { - result = (pGroup) ? ERR_LFG_NO_SLOTS_PARTY : ERR_LFG_NO_SLOTS_PLAYER; + // NO_SLOTS_PLAYER for a party too. 18414 has no NO_SLOTS_PARTY code: the + // GlobalString survives at index 0x2EE but nothing in the client's result + // table maps to it, so the old party value (0x06) matched no entry and the + // client displayed nothing at all -- the most common way to queue and see + // the button do nothing. 0x20 is also the code that carries the per-player + // lock array, so the party is told WHICH dungeons were locked. + result = ERR_LFG_NO_SLOTS_PLAYER; } } // If our result is not ERR_LFG_OK, send join result now with err message if (result != ERR_LFG_OK) { - plr->GetSession()->SendLfgJoinResult(result, LFG_STATE_NONE, partyLockedDungeons); + plr->GetSession()->SendLfgJoinResult(result, LFG_JOIN_DETAIL_NONE, partyLockedDungeons); return; } @@ -364,10 +370,12 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen { if (Player* pGroupPlr = itr->getSource()) { - LFGPlayerStatus overallStatus(LFG_STATE_NONE, LFG_UPDATE_JOIN, dungeons, comments); + // ROLECHECK, not NONE -- same reason as the solo path below. The update + // announced the join while reporting the state as NONE, and only moved to + // ROLECHECK for the stored copy afterwards. + LFGPlayerStatus overallStatus(LFG_STATE_ROLECHECK, LFG_UPDATE_JOIN, dungeons, comments); pGroupPlr->GetSession()->SendLfgUpdate(true, overallStatus); - overallStatus.state = LFG_STATE_ROLECHECK; ObjectGuid plrGuid = pGroupPlr->GetObjectGuid(); roleCheck.currentRoles[plrGuid] = 0; @@ -385,6 +393,7 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen // used later if they enter the queue LFGPlayers groupInfo(LFG_STATE_NONE, dungeons, roleCheck.currentRoles, comments, false, time(NULL), 0, 0, 0); groupInfo.candidateDungeons = candidates; + groupInfo.ticketId = AllocateTicketId(); m_playerData[guid] = groupInfo; PerformRoleCheck(plr, pGroup, (uint8)roles); @@ -416,20 +425,46 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen LFGPlayers playerInfo(LFG_STATE_QUEUED, dungeons, playerRole, comments, false, time(NULL), 0, 0, 0); playerInfo.candidateDungeons = candidates; + playerInfo.ticketId = AllocateTicketId(); m_playerData[guid] = playerInfo; // set up a status struct for client requests/updates + // + // QUEUED, not NONE. This used to announce the join while reporting the player's LFG + // state as LFG_STATE_NONE, and only correct it to QUEUED afterwards for storage -- so + // the packet said "you have joined the dungeon finder" and "you are not in the dungeon + // finder" at the same time, and the client had no active queue to announce. Observed + // live: pressing Queue produced no notification at all, and the only one the player + // ever saw was a stale status replayed after they had already entered the dungeon. LFGPlayerStatus plrStatus; - plrStatus.updateType = LFG_UPDATE_JOIN; - plrStatus.state = LFG_STATE_NONE; + plrStatus.updateType = LFG_UPDATE_JOIN_QUEUE_INITIAL; + plrStatus.state = LFG_STATE_QUEUED; plrStatus.dungeonList = dungeons; plrStatus.comment = comments; - // Send information back to the client - plr->GetSession()->SendLfgJoinResult(result, LFG_STATE_NONE, partyLockedDungeons); + // Retail's join burst, in this order (capture-000720 seq 182-185, and the same + // shape at capture-000044 seq 3601-3605): + // + // 1. SMSG_LFG_UPDATE_STATUS reason 24, queued = 0 + // 2. SMSG_LFG_UPDATE_STATUS reason 13, queued = 1 + // 3. SMSG_LFG_JOIN_RESULT + // 4. SMSG_LFG_UPDATE_STATUS reason 13 again -- a byte-identical duplicate of 2 + // + // We used to lead with the join result and send a single status packet. The + // opening reason was 6, which retail uses for re-queueing from INSIDE a dungeon + // and never to open a fresh queue: 257 of 276 observed joins lead with 24. + // + // Step 4 is not a mistake in the capture. Retail repeats reason 13 either side + // of the join result in every session walked. + plr->GetSession()->SendLfgUpdate(false, plrStatus); + + plrStatus.updateType = LFG_UPDATE_ADDED_TO_QUEUE; + plr->GetSession()->SendLfgUpdate(false, plrStatus); + + plr->GetSession()->SendLfgJoinResult(result, LFG_JOIN_DETAIL_NONE, partyLockedDungeons); + plr->GetSession()->SendLfgUpdate(false, plrStatus); - plrStatus.state = LFG_STATE_QUEUED; m_playerStatusMap[guid] = plrStatus; AddToQueue(guid); } @@ -676,6 +711,7 @@ bool LFGMgr::GetStatusPacketData(ObjectGuid queueGuid, ObjectGuid playerGuid, LF data.roles = role->second; data.joinedTime = uint32(information.joinedTime); + data.ticketId = information.ticketId; data.neededTanks = information.neededTanks; data.neededHealers = information.neededHealers; data.neededDps = information.neededDps; From 247b48590f3399dc0760e1e13b780ba0dc8db9fc Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 01:14:57 +0100 Subject: [PATCH 27/81] Make the LFG dungeon exits work, and refuse them in combat Found by driving a live client rather than by reading the corpus. Six defects, each confirmed on the wire or in the server log before being touched. SMSG_GROUP_LIST carried its LFG block with a ZERO dungeon entry: Group knows only that it is an LFG group (a bit in m_groupType) and nothing ever populated the id. That one field is what GetPartyLFGID() returns, and the whole minimap UI hangs off it -- at 0 it is falsy, so partyCategory stays nil, GetLFGMode returns nil instead of "lfgparty", and IsLFGModeActive fails the identical test that gates the Leave Dungeon entries in QueueStatusFrame.lua:752. One zero explained both the missing button and the client not believing it was in a dungeon at all. Retail carries the resolved dungeon there -- capture-000044 seq 6287 has 03 01 00 06, matching the same session's status packet. CMSG_LFG_TELEPORT (0x1AA6) had no handler: the dropdown's teleport logged as UNKNOWN and did nothing. The body is one MSB-first bit, not a uint8 -- 0x80 out, 0x00 back in, classified by the destination map of the SMSG_TRANSFER_PENDING that follows. 0x00 is not a leave; capture-000059 seqs 1038789..1040642 are all 0x00 with prevMap 960 and destMap 960, i.e. re-summons into the same instance. TeleportPlayer only ever implemented `out`, so teleporting back in resolved the group and the status and then fell off the end of the function. A player who ported out could not return, which is worse than not offering the option. Leaving an LFG group from inside the dungeon did not relocate the player at all -- it removed them from the group and left them standing in the instance until the 60-second homebind timer collected them. Retail answers the disband with GROUP_LIST (no-group form), TRANSFER_PENDING (mapId 0) and NEW_WORLD. CMSG_GROUP_DISBAND also left its one-byte body unread, logging "unprocessed tail data" on every click. CMSG_LFG_LEAVE could draw no reply at all. It switched on the recorded state and fell through silently when it matched nothing, and a leave that answers nothing leaves the finder showing a queue the player cannot dismiss -- observed live, four requests in a row, zero packets sent, stuck until relog. The usual way in is an unanswered proposal: TryFormGroup erases the queue entry the moment a proposal goes out, so a player who ignores the popup holds a status no case covered. Leave now always answers, and CancelProposalsFor tears down any proposal still listing the player first, so the entry it was built from cannot re-propose on the next tick. A queue leave also sends retail's pair, reason 14 then reason 8, rather than the terminal alone. None of the teleport paths checked combat. The dropdown was an instant combat escape and Leave Instance Group yanked a player out mid-pull. The guard sits in TeleportPlayer so both callers are covered and a third cannot forget it, and it refuses `in` as well -- teleporting into an instance while fighting something outside strands the mob. The client ships the message for this (ERR_PARTY_LFG_TELEPORT_IN_COMBAT, GlobalString 712 at .data:00F5FA18), but the numeric code is NOT derived: 30 is the case that pushes it in the dispatcher at .text:007AA970, whose neighbours are ERR_INVITE_* and ERR_PARTY_LFG_BOOT_*, so that is the party error space and may not be the one SMSG_LFG_TELEPORT_DENIED uses -- the single captured body of that opcode carries 0x10, which is in neither reading. The enumerator is marked provisional and reaches no client, since SendLfgTeleportError remains unadmitted for exactly that reason. The refusal is the part that is not in doubt. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 2 + src/game/Server/WorldSession.h | 1 + src/game/WorldHandlers/Group.cpp | 12 +++++ src/game/WorldHandlers/GroupHandler.cpp | 31 +++++++++++- src/game/WorldHandlers/LFGHandler.cpp | 30 ++++++++++++ src/game/WorldHandlers/LFGMgr.cpp | 32 +++++++++++++ src/game/WorldHandlers/LFGMgr.h | 25 +++++++++- src/game/WorldHandlers/LFGMgrProposal.cpp | 47 ++++++++++++++++++ src/game/WorldHandlers/LFGMgrQueue.cpp | 58 +++++++++++++++++------ 9 files changed, 221 insertions(+), 17 deletions(-) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index e2c5d9833..9cbb99d45 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -1134,6 +1134,8 @@ void InitializeOpcodes() DefS(SMSG_LFG_PROPOSAL_UPDATE, "SMSG_LFG_PROPOSAL_UPDATE"); DefS(SMSG_LFG_ROLE_CHECK_UPDATE, "SMSG_LFG_ROLE_CHECK_UPDATE"); DefS(SMSG_LFG_TELEPORT_DENIED, "SMSG_LFG_TELEPORT_DENIED"); + // Body is a single MSB-first bit (0x80 out, 0x00 in) -- see HandleLfgTeleportOpcode. + DefC(CMSG_LFG_TELEPORT, "CMSG_LFG_TELEPORT", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgTeleportOpcode); // Wave 13 talent-respec confirmation request and prompt. DefC(CMSG_CONFIRM_RESPEC_WIPE, "CMSG_CONFIRM_RESPEC_WIPE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleTalentWipeConfirmOpcode); diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index 4249fb0e6..90d3674d3 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -2212,6 +2212,7 @@ class WorldSession void HandleLfgSetRolesOpcode(WorldPacket& recv_data); void HandleLfgProposalResponseOpcode(WorldPacket& recv_data); void HandleLfgGetStatusOpcode(WorldPacket& recv_data); + void HandleLfgTeleportOpcode(WorldPacket& recv_data); void HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data); void HandleSetLfgCommentOpcode(WorldPacket& recv_data); void HandleSetTitleOpcode(WorldPacket& recv_data); diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index a9ee6fcf8..96fde6e1d 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -54,6 +54,7 @@ #include "ObjectMgr.h" #include "ObjectGuid.h" #include "Group.h" +#include "LFGMgr.h" #include "Formulas.h" #include "ObjectAccessor.h" #include "BattleGround/BattleGround.h" @@ -2612,6 +2613,17 @@ void Group::SendUpdateToPlayer(ObjectGuid guid) update.lootMethod = uint8(m_lootMethod); update.lootThreshold = uint8(m_lootThreshold); update.isLfg = isLFGGroup(); + if (update.isLfg) + { + // Without this the LFG block went out with a ZERO dungeon entry. Retail carries + // the resolved dungeon there -- capture-000044 seq 6287 has 03 01 00 06, matching + // the entry in the same session's SMSG_LFG_UPDATE_STATUS -- and the 40-byte + // no-group form omits the block entirely rather than zero-filling it. + // + // Note this is the RESOLVED dungeon, not the request list: capture-000696 queued + // 15 dungeons and its GROUP_LIST still carries exactly one entry. + update.lfgDungeonEntry = sLFGMgr.GetGroupDungeonEntry(GetObjectGuid()); + } update.groupType = uint8(m_groupType); update.partyIndex = player->GetOriginalGroup() == this ? 0 : uint8(isBGGroup() || isLFGGroup()); diff --git a/src/game/WorldHandlers/GroupHandler.cpp b/src/game/WorldHandlers/GroupHandler.cpp index 5b753b4e3..3d4c26cae 100644 --- a/src/game/WorldHandlers/GroupHandler.cpp +++ b/src/game/WorldHandlers/GroupHandler.cpp @@ -53,6 +53,7 @@ #include "Player.h" #include "SpellAuras.h" #include "Group.h" +#include "LFGMgr.h" #include "SocialMgr.h" #include "Util.h" #include "DB2Structure.h" @@ -471,9 +472,18 @@ void WorldSession::HandleGroupSetLeaderOpcode(WorldPacket& recv_data) * * @param recv_data The received opcode packet. */ -void WorldSession::HandleGroupDisbandOpcode(WorldPacket& /*recv_data*/) +void WorldSession::HandleGroupDisbandOpcode(WorldPacket& recv_data) { - if (!GetPlayer()->GetGroup()) + // One byte, observed 0x7F. It carries no authority -- the server acts on the caller -- + // but it must be consumed or the dispatcher logs "unprocessed tail data" on every + // Leave Instance Group click. + if (recv_data.size() - recv_data.rpos() >= 1) + { + recv_data.read_skip(); + } + + Group* pGroup = GetPlayer()->GetGroup(); + if (!pGroup) { return; } @@ -487,6 +497,23 @@ void WorldSession::HandleGroupDisbandOpcode(WorldPacket& /*recv_data*/) /** error handling **/ /********************/ + // Leaving an LFG group from INSIDE its dungeon has to put the player back where + // they came from. Retail answers the disband with SMSG_GROUP_LIST (the 40-byte + // no-group form), SMSG_TRANSFER_PENDING (mapId 0) and SMSG_NEW_WORLD + // (capture-000720 seq 46746, capture-000656 seq 191821). + // + // Without this the player simply stood in the instance, group gone, and was only + // collected 60 seconds later by the homebind timer that fires when the instance + // stops being valid for them. Observed live: "leave dungeon did not relocate me". + // + // Must run BEFORE RemoveFromGroup -- TeleportPlayer resolves the dungeon through the + // group's LFG status, which is gone once the group is. It is a no-op unless the + // player is actually standing on the dungeon's map. + if (pGroup->isLFGGroup()) + { + sLFGMgr.TeleportPlayer(GetPlayer(), true); + } + // everything is fine, do it SendPartyResult(PARTY_OP_LEAVE, GetPlayer()->GetName(), ERR_PARTY_RESULT_OK); diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index a596b47f6..f7fad71c1 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -281,6 +281,36 @@ void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) SendLfgUpdate(GetPlayer()->GetGroup() != nullptr, status); } +void WorldSession::HandleLfgTeleportOpcode(WorldPacket& recv_data) +{ + DEBUG_LOG("CMSG_LFG_TELEPORT"); + + // The body is ONE BIT, MSB-first, not a uint8. All 47 corpus events are a single + // byte carrying only 0x80 or 0x00, and the destination map of the SMSG_TRANSFER_PENDING + // that follows classifies them: 0x80 precedes a move to an outdoor map (0, 530, 571, + // 870, 974) and 0x00 precedes a move to an instance (70, 547, 556, 558, 574, 575, + // 599, 600, 960, 1004, 1098, 1136). So 0x80 is OUT and 0x00 is back IN -- 0x00 is not + // a leave. capture-000059 seqs 1038789..1040642 are all 0x00 with prevMap 960 and + // destMap 960, i.e. re-summons into the same instance. + // + // A reader switching on 0 and 1 would match neither value. + if (recv_data.size() - recv_data.rpos() != 1) + { + sLog.outError("WORLD: malformed CMSG_LFG_TELEPORT from %s", GetPlayerName()); + return; + } + + bool const out = recv_data.ReadBit(); + + Player* plr = GetPlayer(); + if (!plr) + { + return; + } + + sLFGMgr.TeleportPlayer(plr, out); +} + void WorldSession::HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data) { bool forPlayer = false; diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 15d2ff728..1db30b3b0 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1024,6 +1024,29 @@ void LFGMgr::CancelProposal(uint32 proposalId, std::set const& culpr m_queueSet.insert(proposal.queueGuid); } +void LFGMgr::CancelProposalsFor(ObjectGuid plrGuid) +{ + // Same teardown the expiry reaper performs, but driven by an explicit leave instead + // of the clock. The player is the culprit -- they are the one walking away -- so the + // others are requeued without them, exactly as a decline would do. + std::vector owned; + for (proposalMap::const_iterator it = m_proposalMap.begin(); it != m_proposalMap.end(); ++it) + { + if (it->second.answers.find(plrGuid) != it->second.answers.end()) + { + owned.push_back(it->first); + } + } + + // Collected first: CancelProposal erases from the map being walked. + for (std::vector::const_iterator it = owned.begin(); it != owned.end(); ++it) + { + std::set culprit; + culprit.insert(plrGuid); + CancelProposal(*it, culprit); + } +} + void LFGMgr::RemoveOldProposals() { time_t const now = time(NULL); @@ -1394,6 +1417,15 @@ void LFGMgr::SendQueueStatusFor(ObjectGuid queueGuid, time_t timeNow) } } +uint32 LFGMgr::GetGroupDungeonEntry(ObjectGuid groupGuid) +{ + // The Group object itself does not know which dungeon it is for -- isLFGGroup() is + // only a bit in m_groupType -- so the resolved id has to come from the group status + // LFGMgr records when the dungeon group is created. + LFGGroupStatus const* status = GetGroupStatus(groupGuid); + return status ? GetDungeonEntry(status->dungeonID) : 0; +} + uint32 LFGMgr::GetDungeonEntry(uint32 ID) { LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(ID); diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 091e5f9cf..60b19049e 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -902,7 +902,21 @@ enum LFGTeleportError LFG_TELEPORTERROR_IN_VEHICLE = 3, LFG_TELEPORTERROR_FATIGUE = 4, LFG_TELEPORTERROR_INVALID_LOCATION = 6, - LFG_TELEPORTERROR_CHARMING = 8 + LFG_TELEPORTERROR_CHARMING = 8, + + /// Refusing a teleport because the player is fighting. + /// + /// PROVISIONAL VALUE, and it must not be cited as derived. The client certainly has + /// the message -- ERR_PARTY_LFG_TELEPORT_IN_COMBAT, "You cannot teleport out of the + /// dungeon while in combat.", GlobalString index 712 at .data:00F5FA18 -- and 30 is + /// the case that pushes it in the dispatcher at .text:007AA970. But that dispatcher's + /// neighbouring cases are ERR_INVITE_* and ERR_PARTY_LFG_BOOT_*, so it is the PARTY + /// error space, which may not be the space SMSG_LFG_TELEPORT_DENIED uses. The one + /// captured body of that opcode carries 0x10, which is in neither reading. + /// + /// Harmless today because SendLfgTeleportError is not admitted, so nothing reaches the + /// client. The REFUSAL is the part that matters and that is not in doubt. + LFG_TELEPORTERROR_IN_COMBAT = 30 }; enum DungeonTypes @@ -1282,6 +1296,11 @@ class LFGMgr /// Given the ID of a dungeon, spit out its entry uint32 GetDungeonEntry(uint32 ID); + /// The resolved dungeon entry for a group that is in (or heading into) an LFG + /// dungeon, or 0 if it is not an LFG group. SMSG_GROUP_LIST carries this in its + /// LFG block; retail never sends the block with a zero entry. + uint32 GetGroupDungeonEntry(ObjectGuid groupGuid); + /// Return the 5.4.8 LFG status category byte for a dungeon. uint8 GetDungeonCategory(uint32 ID); @@ -1338,6 +1357,10 @@ class LFGMgr /// the LFG_STATE_PROPOSAL status flag, which several paths can leave stale. bool HasLiveProposalFor(ObjectGuid plrGuid) const; + /// Cancel every live proposal listing this player, counting them as the culprit. + /// Used when they leave the finder while a proposal is still open. + void CancelProposalsFor(ObjectGuid plrGuid); + /** * @brief Take a single player out of whichever queue entry holds them, recomputing * that entry's needed roles, and drop the entry if it is left empty. diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 1ca36dd1a..7af092596 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -972,6 +972,13 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) { plrErr = LFG_TELEPORTERROR_IN_VEHICLE; } + // Same reasoning as the guard in TeleportPlayer: a member who is fighting + // is not moved. This list already refused dead, falling and in-vehicle and + // simply had no combat case. + if (pGroupPlr->IsInCombat()) + { + plrErr = LFG_TELEPORTERROR_IN_COMBAT; + } lockedDungeons = FindRandomDungeonsNotForPlayer(pGroupPlr); if (lockedDungeons.find(dungeon->Entry()) != lockedDungeons.end()) @@ -1029,6 +1036,30 @@ void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) return; } + // Never move a player who is fighting, in EITHER direction. + // + // Without this the dropdown was an instant combat escape -- pull a pack, teleport + // out, and the fight is simply over -- and Leave Instance Group yanked the player + // out mid-pull, leaving the rest of the group in a fight they did not choose to + // take alone. The client agrees this is refusable: it ships the message for it + // (ERR_PARTY_LFG_TELEPORT_IN_COMBAT, "You cannot teleport out of the dungeon while + // in combat."). + // + // Deliberately covers `in` as well. Teleporting INTO a dungeon while fighting + // something outside it strands the mob and drops the player into an instance still + // flagged in combat. + // + // This guard sits in TeleportPlayer rather than at the call sites so that the + // dropdown (CMSG_LFG_TELEPORT) and the leave path (CMSG_GROUP_DISBAND) are both + // covered by one check that cannot be forgotten by a third caller. + if (pPlayer->IsInCombat()) + { + DEBUG_LOG("LFG TeleportPlayer: %s refused (%s) -- in combat", + pPlayer->GetName(), out ? "out" : "in"); + pPlayer->GetSession()->SendLfgTeleportError((uint8)LFG_TELEPORTERROR_IN_COMBAT); + return; + } + // Get dungeon info and then teleport the player out if applicable if (out) { @@ -1037,7 +1068,23 @@ void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) { pPlayer->TeleportToBGEntryPoint(); } + return; } + + // Teleport back IN. + // + // This branch did not exist: TeleportPlayer only ever handled `out`, so the dropdown's + // "Teleport to dungeon" resolved the group and the status and then fell off the end of + // the function doing nothing. Observed live -- a player who ported out could not get + // back, which is worse than not offering the option at all. + // + // TeleportToDungeon is the same routine the proposal uses on group creation. It moves + // only members whose map is not already the dungeon's, so calling it for the whole + // group moves exactly the one player who left, and it carries the dead / falling / + // in-vehicle checks and the SMSG_LFG_TELEPORT_DENIED replies with it. It also prefers + // the group leader's position when the leader is already inside, which is what puts a + // returning player back with the group rather than at the entrance. + TeleportToDungeon(status->dungeonID, pGroup); } LFGGroupStatus* LFGMgr::GetGroupStatus(ObjectGuid guid) diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 66e2b8a70..2640f8439 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -496,6 +496,21 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) case LFG_STATE_FINISHED_DUNGEON: case LFG_STATE_PROPOSAL: case LFG_STATE_QUEUED: + // Retail answers a queue leave with TWO status packets, in + // this order, both still carrying the dungeon list: + // reason 14, then reason 8 as the terminal + // 26 of 28 observed leaves produce the pair (capture-000044 seq + // 47667 -> 47701/47702, capture-000086 seq 1339 -> 1340/1341, + // capture-000133, capture-000326, capture-000720). We sent only + // the terminal, and the client left the queue on screen. + // + // LFG_UPDATE_PROPOSAL_BEGIN is simply the enumerator for wire + // reason 14; the name is inherited and does not describe this + // use. Our flag logic already yields retail's tuples for both: + // 14 -> joined 1 / queued 0, 8 -> joined 0 / queued 0. + grpPlrStatus.updateType = LFG_UPDATE_PROPOSAL_BEGIN; + SendLfgUpdate(grpPlrGuid, grpPlrStatus, true); + grpPlrStatus.updateType = LFG_UPDATE_LEAVE; grpPlrStatus.state = LFG_STATE_NONE; SendLfgUpdate(grpPlrGuid, grpPlrStatus, true); @@ -519,21 +534,36 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) { ObjectGuid plrGuid = plr->GetObjectGuid(); + // Tear down a proposal the player never answered BEFORE replying, so the queue + // entry it was built from is released and cannot re-propose on the next tick. + CancelProposalsFor(plrGuid); + LFGPlayerStatus plrStatus = GetPlayerStatus(plrGuid); - switch (plrStatus.state) - { - // Same set as the group branch above -- see the note there for why - // IN_DUNGEON must be handled rather than falling through. - case LFG_STATE_IN_DUNGEON: - case LFG_STATE_FINISHED_DUNGEON: - case LFG_STATE_PROPOSAL: - case LFG_STATE_QUEUED: - plrStatus.updateType = LFG_UPDATE_LEAVE; - plrStatus.state = LFG_STATE_NONE; - SendLfgUpdate(plrGuid, plrStatus, false); - break; - // do other states after being implemented, if applicable for a single plr - } + + // ALWAYS answer, whatever state we have recorded. + // + // This used to switch on the recorded state and simply fall through when it + // matched nothing, sending the client no packet at all -- and a CMSG_LFG_LEAVE + // that draws no reply leaves the dungeon finder showing a queue the player + // cannot dismiss. Observed live: four leave requests in a row, zero packets sent, + // stuck until relog. + // + // The state need not be one this switch ever knew about. TryFormGroup erases the + // queue entry the moment a proposal goes out, so a player who ignores the popup + // holds a status that matched none of the old cases; GetPlayerStatus then hands + // back a default-constructed LFG_STATE_NONE for anyone with no record at all. + // Neither is a reason to say nothing -- the client asked to leave, so tell it that + // it has, and reconcile the server side underneath. + // + // Two packets, reason 14 then reason 8, as the group branch above. + plrStatus.updateType = LFG_UPDATE_PROPOSAL_BEGIN; + SendLfgUpdate(plrGuid, plrStatus, false); + + plrStatus.updateType = LFG_UPDATE_LEAVE; + plrStatus.state = LFG_STATE_NONE; + SendLfgUpdate(plrGuid, plrStatus, false); + + SetPlayerState(plrGuid, LFG_STATE_NONE); // NOT `m_playerData.erase(plrGuid)`. // From 909c08be6c900e18065fd47834783f0dbfead825 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 01:40:26 +0100 Subject: [PATCH 28/81] Fill in the SMSG_GROUP_LIST LFG block from the traced client reader The binding this block has to the minimap UI is now traced rather than inferred. sub_90323B is the LFG-block applier, called from sub_905B64+0x41; it writes party+228, and party+232 -- four bytes into that struct -- is exactly what sub_90255D (IsPartyLFG) tests and sub_902594 (GetPartyLFGID) returns. Nothing else in the image writes it: sub_6ECF46 has two call sites, both inside the applier, and sub_902073 one. When the isLfg bit is 0 the client actively ZEROES the field, so SMSG_LFG_UPDATE_STATUS cannot influence it at any value. The layout our builder implements decodes all 27,917 build-18414 GROUP_LIST packets with zero leftover bytes. The block has TWO dungeon slots and the previous commit's comment cited the wrong one. Slot A is the gating field, carries type 1 in all 8475 populated retail packets, and is the resolved dungeon. Slot B carries the random category, type 6. Worked example, capture-000044 seq 6287 at payload offset 0x6E: 00 00 80 3F | 01 | 00 | 88 00 00 01 | 00 00 04 | 03 01 00 06 float=1.0 | b0 | b1 | A=0x01000088 | b2 b3 b4 | B=0x06000103 The 03 01 00 06 quoted as evidence for slot A is slot B. The code was right -- LFGGroupStatus records the concrete dungeon CreateDungeonGroup ran, so slot A was already type 1 even for a group formed off a random queue -- but the citation was not, and a wrong citation in a comment outlives the person who wrote it. Four fields were shipping as constants: - slot B was always 0; it now carries the random category, which LFGGroupStatus did not previously remember - b0 is the LFG state and the client reads bit 0x02 of it as IsLFGComplete() (sub_90261A). Retail flips it 1 -> 2 at DUNGEON_FINISHED (capture-000720 seq 1074 -> 46476). We sent 0, so IsLFGComplete() was permanently false and UIParent.lua:4176's `IsPartyLFG() and not IsLFGComplete()` fired the deserter warning on every leave - b4 tracks the member count, n-1 in 5192 sampled rows - groupType: retail LFG groups send 0x0C, GROUPTYPE_LFD plus bit 0x04, and 0x04 is what HasLFGRestrictions() returns (sub_9025EA, party+216 & 4). Set on the wire value only; m_groupType is persisted and used in server logic. b1, b2, both mask bits and the float's meaning remain underived and are left at their observed-dominant values. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.cpp | 47 +++++++++++++++++++---- src/game/WorldHandlers/LFGMgr.cpp | 12 ++++++ src/game/WorldHandlers/LFGMgr.h | 12 +++++- src/game/WorldHandlers/LFGMgrProposal.cpp | 6 +++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index 96fde6e1d..e5b819343 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -2615,16 +2615,49 @@ void Group::SendUpdateToPlayer(ObjectGuid guid) update.isLfg = isLFGGroup(); if (update.isLfg) { - // Without this the LFG block went out with a ZERO dungeon entry. Retail carries - // the resolved dungeon there -- capture-000044 seq 6287 has 03 01 00 06, matching - // the entry in the same session's SMSG_LFG_UPDATE_STATUS -- and the 40-byte - // no-group form omits the block entirely rather than zero-filling it. + // The LFG block has TWO dungeon slots and they are not interchangeable. // - // Note this is the RESOLVED dungeon, not the request list: capture-000696 queued - // 15 dungeons and its GROUP_LIST still carries exactly one entry. + // Slot A (lfgDungeonEntry) is the gating one: it is what the client copies to + // party+232, which is precisely what IsPartyLFG() tests and GetPartyLFGID() + // returns, and every UI gate for the minimap eye and the Leave Dungeon entries + // runs through it. It carries type 1 in all 8475 retail packets whose block is + // populated -- never type 6 -- so it must be the RESOLVED dungeon. Ours is, + // because LFGGroupStatus records the concrete dungeon CreateDungeonGroup ran. + // + // Slot B (lfgTail) carries the random category, type 6, and is 0 for a direct + // queue. Worked example, capture-000044 seq 6287, block at payload offset 0x6E: + // 00 00 80 3F | 01 | 00 | 88 00 00 01 | 00 00 04 | 03 01 00 06 + // float=1.0 | b0 | b1 | A=0x01000088 | b2 b3 b4 | B=0x06000103 + // An earlier revision of this comment cited 03 01 00 06 as evidence for slot A. + // Those bytes are slot B. The code was right and the citation was not. + // + // Sending isLfg with a zero A is WORSE than sending no block: the client copies + // it, party+232 becomes 0, and IsPartyLFG() is then false -- indistinguishable + // from having no LFG party at all. update.lfgDungeonEntry = sLFGMgr.GetGroupDungeonEntry(GetObjectGuid()); - } + update.lfgTail = sLFGMgr.GetGroupRandomDungeonEntry(GetObjectGuid()); + + // b0 is the LFG state, and the client reads bit 0x02 of it as IsLFGComplete() + // (sub_90261A: *(party+228) & 2). Retail flips it 1 -> 2 at DUNGEON_FINISHED + // (capture-000720 seq 1074 -> 46476). We sent 0 always, so IsLFGComplete() was + // permanently false and UIParent.lua:4176's `IsPartyLFG() and not IsLFGComplete()` + // always fired the deserter warning on leaving. + LFGState const lfgState = sLFGMgr.GetGroupLfgState(GetObjectGuid()); + update.lfgUnknownByte0 = (lfgState == LFG_STATE_FINISHED_DUNGEON) ? 2 : 1; + + // b4 tracks the member count, observed as n-1 in 5192 of the sampled rows. + update.lfgUnknownByte4 = m_memberSlots.empty() ? 0 : uint8(m_memberSlots.size() - 1); + } + // Retail's LFG groups send groupType 0x0C, i.e. GROUPTYPE_LFD (0x08) plus 0x04. + // Bit 0x04 is what the client returns from HasLFGRestrictions() (sub_9025EA reads + // party+216 & 4). We stored and sent 0x08 alone, so every LFG group reported having + // no restrictions. Set on the WIRE value only -- m_groupType is persisted and used + // in server-side logic, and widening the stored enum would change both. update.groupType = uint8(m_groupType); + if (update.isLfg) + { + update.groupType |= 0x04; + } update.partyIndex = player->GetOriginalGroup() == this ? 0 : uint8(isBGGroup() || isLFGGroup()); update.sequence = m_groupUpdateCounter; diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 1db30b3b0..53161a15e 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1426,6 +1426,18 @@ uint32 LFGMgr::GetGroupDungeonEntry(ObjectGuid groupGuid) return status ? GetDungeonEntry(status->dungeonID) : 0; } +uint32 LFGMgr::GetGroupRandomDungeonEntry(ObjectGuid groupGuid) +{ + LFGGroupStatus const* status = GetGroupStatus(groupGuid); + return (status && status->randomDungeonID) ? GetDungeonEntry(status->randomDungeonID) : 0; +} + +LFGState LFGMgr::GetGroupLfgState(ObjectGuid groupGuid) +{ + LFGGroupStatus const* status = GetGroupStatus(groupGuid); + return status ? status->state : LFG_STATE_NONE; +} + uint32 LFGMgr::GetDungeonEntry(uint32 ID) { LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(ID); diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 60b19049e..db5698e47 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1081,7 +1081,11 @@ struct LFGStatusPacketData struct LFGGroupStatus //todo: check for this in joinlfg function, not lfgplayers struct { LFGState state; // State of the group - uint32 dungeonID; // ID of the dungeon the group should be in + uint32 dungeonID; // ID of the dungeon the group should be in (the RESOLVED one) + /// The random category the group queued under, or 0 for a direct queue. Kept because + /// SMSG_GROUP_LIST carries BOTH: slot A is the resolved dungeon and slot B the random + /// row. Retail never puts a type-6 entry in slot A. + uint32 randomDungeonID = 0; roleMap playerRoles; // Container holding each player's objectguid and their roles ObjectGuid leaderGuid; // The group leader's object guid @@ -1301,6 +1305,12 @@ class LFGMgr /// LFG block; retail never sends the block with a zero entry. uint32 GetGroupDungeonEntry(ObjectGuid groupGuid); + /// The random-category entry a group queued under, or 0. SMSG_GROUP_LIST slot B. + uint32 GetGroupRandomDungeonEntry(ObjectGuid groupGuid); + + /// LFG state of a group, for the SMSG_GROUP_LIST state byte. + LFGState GetGroupLfgState(ObjectGuid groupGuid); + /// Return the 5.4.8 LFG status category byte for a dungeon. uint8 GetDungeonCategory(uint32 ID); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 7af092596..bd6d65eb9 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -901,6 +901,12 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) // Add group to our group set and group map, then teleport to the dungeon. // groupGuid is the one taken above; do not shadow it. LFGGroupStatus groupStatus(LFG_STATE_IN_DUNGEON, dungeon->ID, proposal->currentRoles, pGroup->GetLeaderGuid()); + // Only when the two differ was this a random queue; proposal->dungeonID is the row the + // player actually picked, which for a random IS the category. + if (proposal->concreteDungeonID && proposal->dungeonID != proposal->concreteDungeonID) + { + groupStatus.randomDungeonID = proposal->dungeonID; + } m_groupSet.insert(groupGuid); m_groupStatusMap[groupGuid] = groupStatus; From d918dbe827e06469b48f94b5f76557d49efb8446 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 02:10:13 +0100 Subject: [PATCH 29/81] Fix three blocking defects found by cross-model review HandleBossKilled erased m_groupStatusMap and m_groupSet while the Group object still carried GROUPTYPE_LFD. From that point every Group::SendUpdate resolved the LFG block's dungeon slot through GetGroupDungeonEntry -> GetGroupStatus -> null -> 0, so the client received isLfg = 1 with a ZERO slot A. Group.cpp's own comment says why that is worse than sending no block: the client copies it, party+232 becomes 0 and IsPartyLFG() goes false, so the Leave Dungeon button and the minimap dungeon state disappeared from the first tracked boss kill onward, mid-run. Nothing released the status on disband, so the erase could not simply be deleted; ReleaseGroupLfgStatus now does it from Group::Disband, where the Group is actually going away. LeaveLFG read the player status AFTER CancelProposalsFor, and CancelProposal erases m_playerStatusMap for the players it blames. The leave reply was therefore built from a default-constructed record and went out with an empty dungeon list, a shape that occurs 0 times in 5291 retail bodies. It now snapshots first, and when a live proposal existed it lets CancelProposal's own populated LEAVE stand rather than following it with a duplicate empty one. This is the cause of the empty-list packets a previous commit worked around by gating the reason-14 precursor. The combat guard added to TeleportToDungeon split newly formed groups. TeleportToDungeon also runs from CreateDungeonGroup, where a proposal has just been accepted: refusing one member teleported everyone else in and stranded that player, still in the group, still set to LFG_STATE_IN_DUNGEON, with no feedback at all because SMSG_LFG_TELEPORT_DENIED is not admitted. A proposal accept is a mandatory group form, and the client's own ERR_PARTY_LFG_TELEPORT_IN_COMBAT is about teleporting OUT of a dungeon rather than about being placed into one. The voluntary paths -- CMSG_LFG_TELEPORT and the leave teleport -- both run through TeleportPlayer, which keeps its guard. Found by a bounded Devin SWE-1.7 review; each finding re-verified against the code before acting on it. The same review independently decoded capture-000044 seq 1547 and matched the SMSG_LFG_JOIN_RESULT fixture byte for byte, confirmed the join burst against seq 1545/1546/1547/1548, and confirmed groupType |= 0x04 is wire-only with no server-side desync. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.cpp | 8 ++++ src/game/WorldHandlers/LFGMgr.cpp | 6 +++ src/game/WorldHandlers/LFGMgr.h | 4 ++ src/game/WorldHandlers/LFGMgrProposal.cpp | 36 ++++++++++++----- src/game/WorldHandlers/LFGMgrQueue.cpp | 49 +++++++++++++++++++---- 5 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index e5b819343..4f28a6cb9 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -1723,6 +1723,14 @@ void Group::Disband(bool hideDestroy) { CompleteReadyCheck(); + // Release the LFG status here rather than when the dungeon finishes: while the Group + // still reports GROUPTYPE_LFD, SendUpdate needs the status to fill the LFG block, and + // a missing status makes it emit a zero dungeon slot. + if (isLFGGroup()) + { + sLFGMgr.ReleaseGroupLfgStatus(GetObjectGuid()); + } + Player* player; for (member_citerator citr = m_memberSlots.begin(); citr != m_memberSlots.end(); ++citr) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 53161a15e..f3fa8230c 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1426,6 +1426,12 @@ uint32 LFGMgr::GetGroupDungeonEntry(ObjectGuid groupGuid) return status ? GetDungeonEntry(status->dungeonID) : 0; } +void LFGMgr::ReleaseGroupLfgStatus(ObjectGuid groupGuid) +{ + m_groupStatusMap.erase(groupGuid); + m_groupSet.erase(groupGuid); +} + uint32 LFGMgr::GetGroupRandomDungeonEntry(ObjectGuid groupGuid) { LFGGroupStatus const* status = GetGroupStatus(groupGuid); diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index db5698e47..c21123890 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1311,6 +1311,10 @@ class LFGMgr /// LFG state of a group, for the SMSG_GROUP_LIST state byte. LFGState GetGroupLfgState(ObjectGuid groupGuid); + /// Drop a disbanded group's LFG status. Must run when the Group is torn down, not + /// when its dungeon finishes -- see the note in HandleBossKilled. + void ReleaseGroupLfgStatus(ObjectGuid groupGuid); + /// Return the 5.4.8 LFG status category byte for a dungeon. uint8 GetDungeonCategory(uint32 ID); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index bd6d65eb9..e0757a1e4 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -978,13 +978,19 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) { plrErr = LFG_TELEPORTERROR_IN_VEHICLE; } - // Same reasoning as the guard in TeleportPlayer: a member who is fighting - // is not moved. This list already refused dead, falling and in-vehicle and - // simply had no combat case. - if (pGroupPlr->IsInCombat()) - { - plrErr = LFG_TELEPORTERROR_IN_COMBAT; - } + // NO combat check here, deliberately. + // + // TeleportToDungeon also runs from CreateDungeonGroup, where a proposal has + // just been accepted and the group formed. Refusing one member there teleports + // everyone else in and strands that player -- still in the group, still set to + // LFG_STATE_IN_DUNGEON, and with no feedback at all, because + // SMSG_LFG_TELEPORT_DENIED is not admitted. A proposal accept is a mandatory + // group form, and the client's own message for this + // (ERR_PARTY_LFG_TELEPORT_IN_COMBAT) is about teleporting OUT of a dungeon, + // not about being placed into one. + // + // The voluntary paths are still covered: CMSG_LFG_TELEPORT and the leave + // teleport both go through TeleportPlayer, which keeps its own combat guard. lockedDungeons = FindRandomDungeonsNotForPlayer(pGroupPlr); if (lockedDungeons.find(dungeon->Entry()) != lockedDungeons.end()) @@ -1322,9 +1328,19 @@ void LFGMgr::HandleBossKilled(Player* pPlayer) } } - // now we can remove the group from our maps - m_groupStatusMap.erase(groupGuid); - m_groupSet.erase(groupGuid); + // The status deliberately SURVIVES the final boss. + // + // It used to be erased here, while the Group object kept GROUPTYPE_LFD. From that + // moment every Group::SendUpdate emitted an LFG block whose dungeon slot resolved + // through GetGroupDungeonEntry -> GetGroupStatus -> null -> 0, i.e. isLfg = 1 with a + // ZERO slot A. That is the case Group.cpp warns is worse than sending no block at + // all: the client copies it, party+232 becomes 0, and IsPartyLFG() goes false -- + // so the Leave Dungeon button and the minimap dungeon state vanished from the first + // tracked boss kill onward, mid-run. + // + // The state was already moved to LFG_STATE_FINISHED_DUNGEON above, which is what + // makes the block's state byte report 2 (IsLFGComplete). Release happens in + // ReleaseGroupLfgStatus, called when the group is actually disbanded. } void LFGMgr::AttemptToKickPlayer(Group* pGroup, ObjectGuid guid, ObjectGuid kicker, std::string reason) diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 2640f8439..b29a63623 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -373,7 +373,13 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen // ROLECHECK, not NONE -- same reason as the solo path below. The update // announced the join while reporting the state as NONE, and only moved to // ROLECHECK for the stored copy afterwards. - LFGPlayerStatus overallStatus(LFG_STATE_ROLECHECK, LFG_UPDATE_JOIN, dungeons, comments); + // Reason 24, not 6 -- same correction the solo path already carries. + // Reason 6 is retail's re-queue-from-inside-a-dungeon reason (257 of 276 + // observed joins open with 24 and none with 6), and BOTH 6 and 13 make the + // client display ERR_LFG_JOINED_QUEUE. Opening with 6 and then having + // PerformRoleCheck send 13 announced "You are now queued in the Dungeon + // Finder" TWICE -- once in chat and once centre-screen. Observed live. + LFGPlayerStatus overallStatus(LFG_STATE_ROLECHECK, LFG_UPDATE_JOIN_QUEUE_INITIAL, dungeons, comments); pGroupPlr->GetSession()->SendLfgUpdate(true, overallStatus); @@ -534,12 +540,25 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) { ObjectGuid plrGuid = plr->GetObjectGuid(); - // Tear down a proposal the player never answered BEFORE replying, so the queue - // entry it was built from is released and cannot re-propose on the next tick. - CancelProposalsFor(plrGuid); - + // Snapshot BEFORE the teardown. CancelProposal erases m_playerStatusMap for the + // players it blames, so reading the status afterwards handed back a default- + // constructed record and the reply went out with an empty dungeon list -- a shape + // that occurs 0 times in 5291 retail bodies. LFGPlayerStatus plrStatus = GetPlayerStatus(plrGuid); + // Tear down a proposal the player never answered, so the queue entry it was built + // from is released and cannot re-propose on the next tick. Note this may itself + // send the player an LFG_UPDATE_LEAVE. + bool const hadLiveProposal = HasLiveProposalFor(plrGuid); + CancelProposalsFor(plrGuid); + if (hadLiveProposal) + { + // CancelProposal already answered with a properly populated LEAVE. Sending a + // second one here only adds a duplicate, so stop. + RemovePlayerFromQueue(plrGuid); + return; + } + // ALWAYS answer, whatever state we have recorded. // // This used to switch on the recorded state and simply fall through when it @@ -555,9 +574,23 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) // Neither is a reason to say nothing -- the client asked to leave, so tell it that // it has, and reconcile the server side underneath. // - // Two packets, reason 14 then reason 8, as the group branch above. - plrStatus.updateType = LFG_UPDATE_PROPOSAL_BEGIN; - SendLfgUpdate(plrGuid, plrStatus, false); + // Retail's pair is reason 14 then reason 8 -- but ONLY when we can name the + // dungeons. Reason 14 carries joined = 1, and the client files a status body by + // category, which it derives from the dungeon list. With an empty list it cannot + // attribute either packet to a category, so the joined = 1 can stick where the + // clearing packet does not reach: GetLFGMode then answers "suspended", which is + // non-nil, and QueueStatusFrame_Update lights the minimap eye for any non-nil + // mode. Observed live -- every click played the leave sound and left the eye on. + // + // 0 of 5291 retail status bodies carry an empty dungeon list, so the empty form + // is outside anything the client is built to handle. When we have no record, + // send the terminal reason 8 alone: it still answers the request and still fires + // the notification, without first asserting a joined state we cannot then clear. + if (!plrStatus.dungeonList.empty()) + { + plrStatus.updateType = LFG_UPDATE_PROPOSAL_BEGIN; + SendLfgUpdate(plrGuid, plrStatus, false); + } plrStatus.updateType = LFG_UPDATE_LEAVE; plrStatus.state = LFG_STATE_NONE; From 84145e21a4348c66ecf1c67f1f0c70db2e826729 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 02:16:47 +0100 Subject: [PATCH 30/81] Correct the LFG tick, the boot timer, the random pick and the accept key The LFG timer ran at 30 seconds. SMSG_LFG_QUEUE_STATUS is the only periodic LFG packet retail sends and its period is a hard 5000 ms: across 1283 one-beat intervals in millisecond-resolution captures the mean is 4999.7 ms, and long-run drift settles it -- capture-000873 carries 1258 beats over 6,289,554 ms, i.e. 4999.98 ms per beat. The 10/15/20 s gaps that appear are exact multiples, dropped sniff frames, and the payload's own queuedTime still advances by exactly 5 across them. One timer gates matchmaking, role-check expiry and proposal expiry as well, so all four were six times coarser than the client expects. LFG_TIME_BOOT was 120 seconds; retail's boot countdown is 30 in all 14 observed sessions. PickConcreteDungeon returned the FIRST runnable candidate. candidates is a std::set, which is ordered ascending, so a random queue deterministically produced the lowest dungeon id in the category on every single queue -- the same instance every time. It now collects the runnable members and picks one. On proposal accept the LEAVE that follows GROUP_FOUND was sent TWICE, once in each form, on the theory that one of them would match. It cannot help: SendLfgUpdate derives requesterGuid from the isGroup flag, so the wrong-form copy names a queue the client is not tracking, and at accept time GetGroup() is still the player's OLD party rather than the LFG group being formed, so that copy could name a third guid again. Both packets now go under the one key that owns the queue entry -- the same test JoinLFG used when it created it. The tick change is the only one here with a performance dimension: FindQueueMatches and the three expiry sweeps now run six times as often. They are all O(queue) over a set that is empty on an idle server. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.h | 2 +- src/game/WorldHandlers/LFGMgrProposal.cpp | 43 +++++++++++++++-------- src/game/WorldHandlers/World.cpp | 11 +++++- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index c21123890..fdbcde51c 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -811,7 +811,7 @@ enum LFGTimes // SECONDS, not milliseconds: waitForRoleTime is built from time(NULL), // so 45*IN_MILLISECONDS made a role check expire after 12.5 HOURS. LFG_TIME_ROLECHECK = 45, - LFG_TIME_BOOT = 120, + LFG_TIME_BOOT = 30, // retail: 30 s in all 14 observed boot sessions LFG_TIME_PROPOSAL = 45, }; diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index e0757a1e4..6ce59156b 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -225,6 +225,12 @@ static uint32 PickConcreteDungeon(uint32 queuedDungeonId, std::set const return queuedDungeonId; // already a real dungeon } + // Collect every runnable member, then pick one at random. + // + // This used to return the first match. candidates is a std::set, which is + // ordered ascending, so "random dungeon" deterministically produced the LOWEST + // dungeon id in the category every single time -- the same instance on every queue. + std::vector runnable; for (std::set::const_iterator it = candidates.begin(); it != candidates.end(); ++it) { if (*it == queuedDungeonId) @@ -243,10 +249,15 @@ static uint32 PickConcreteDungeon(uint32 queuedDungeonId, std::set const continue; } - return candidate->ID; + runnable.push_back(candidate->ID); + } + + if (runnable.empty()) + { + return 0; } - return 0; + return runnable[urand(0, uint32(runnable.size()) - 1)]; } //todo: remove from queue, update queue average settings @@ -585,20 +596,24 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted LFGPlayerStatus proposalPlrStatus = GetPlayerStatus(proposalPlrGuid); proposalPlrStatus.updateType = LFG_UPDATE_GROUP_FOUND; - if (pProposalPlayer->GetGroup()) - { - SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, true); - RemoveFromQueue(pProposalPlayer->GetGroup()->GetObjectGuid()); // not the best way to handle this - } - else - { - SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, false); - RemoveFromQueue(proposalPlrGuid); - } + // ONE key, used for both packets. + // + // The queue entry is owned by the player's CURRENT group if they have one -- + // that is the same test JoinLFG used to key m_playerData -- so the GROUP_FOUND + // and the LEAVE that follows it must both be sent under that key. The LEAVE used + // to be sent TWICE, once in each form, on the theory that one of them would + // match. It cannot help: SendLfgUpdate picks requesterGuid from the isGroup flag, + // so the wrong-form copy names a queue the client is not tracking, and at accept + // time GetGroup() is still the player's OLD party rather than the LFG group being + // formed -- so the group-form copy could name a third guid again. + bool const queueIsGroupOwned = pProposalPlayer->GetGroup() != nullptr; + + SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, queueIsGroupOwned); + RemoveFromQueue(queueIsGroupOwned ? pProposalPlayer->GetGroup()->GetObjectGuid() + : proposalPlrGuid); proposalPlrStatus.updateType = LFG_UPDATE_LEAVE; - SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, false); - SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, true); + SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, queueIsGroupOwned); } CreateDungeonGroup(proposal); diff --git a/src/game/WorldHandlers/World.cpp b/src/game/WorldHandlers/World.cpp index d88c3a98f..911d814a8 100644 --- a/src/game/WorldHandlers/World.cpp +++ b/src/game/WorldHandlers/World.cpp @@ -743,7 +743,16 @@ void World::SetInitialWorldSettings() m_timers[WUPDATE_AHBOT].SetInterval(20 * IN_MILLISECONDS); // every 20 sec // for Dungeon Finder - m_timers[WUPDATE_LFGMGR].SetInterval(30 * IN_MILLISECONDS); // every 30 sec + // 5 seconds, not 30. SMSG_LFG_QUEUE_STATUS is the only periodic LFG packet retail + // sends and its period is a hard 5000 ms: across 1283 one-beat intervals in + // millisecond-resolution captures the mean is 4999.7 ms, and long-run drift settles + // it (capture-000873: 1258 beats over 6,289,554 ms = 4999.98 ms/beat). The 10/15/20 s + // gaps that appear are exact multiples -- dropped sniff frames -- and the payload's + // own queuedTime still advances by exactly 5 across them, so the server never missed + // a beat. At 30 s the queue UI updated six times slower than the client expects, and + // matchmaking, role-check expiry and proposal expiry were all equally coarse because + // this one timer gates them together. + m_timers[WUPDATE_LFGMGR].SetInterval(5 * IN_MILLISECONDS); // for AutoBroadcast sLog.outString("Starting AutoBroadcast System"); From afd6fe4bfcf35a3938d459ffcb4081555c753fe9 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 02:21:27 +0100 Subject: [PATCH 31/81] Answer a leave request from the group branch too LeaveLFG has two branches and only the solo one was taught to always answer. The group branch was still a switch over four states with no default, so any other state fell through and sent the client nothing at all. LFG_STATE_NONE is exactly what a player holds after declining a proposal, so the commonest case reached the switch and matched none of it. Isolated repro from a live client: press I, accept the backfill, decline the proposal, then click Leave Queue. Fourteen CMSG_LFG_LEAVE arrived between 02:18:34 and 02:18:39 and the server sent not one packet in reply -- the queue could not be dismissed at all. A player who is in an LFG party takes this branch rather than the solo one, which is why earlier solo tests heard the leave notification and this one did not. Which branch runs depends only on whether the player happens to be grouped, and that is not something the client can reason about when it asks to leave, so the two branches must not disagree about whether a leave is answered. The reason-14 precursor is gated on having a dungeon list here as well: it carries joined = 1, the client files a status body by the category it derives from the dungeon list, and 0 of 5291 retail bodies carry an empty one. With no list only the terminal reason 8 goes out. A role check in progress still routes to PerformRoleCheck rather than a leave reply; that path tells every member and tears the check down itself. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrQueue.cpp | 66 ++++++++++++++------------ 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index b29a63623..28a51ddb2 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -490,41 +490,45 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) ObjectGuid grpPlrGuid = pGroupPlr->GetObjectGuid(); LFGPlayerStatus grpPlrStatus = GetPlayerStatus(grpPlrGuid); - switch (grpPlrStatus.state) + + if (grpPlrStatus.state == LFG_STATE_ROLECHECK) + { + // A role check in progress is aborted rather than answered with a + // leave; PerformRoleCheck tells everyone and tears the check down. + PerformRoleCheck(NULL, pGroup, 0); + } + else { - // IN_DUNGEON and FINISHED_DUNGEON are handled with the queue states, - // and leaving them out is what made "Leave Dungeon" do nothing at all. - // Once the finder has placed a player their state is IN_DUNGEON, so a - // leave fell through this switch, sent the client nothing, and left the - // player believing they were still in an LFG session -- with no way out - // short of relogging. Observed live: two CMSG_LFG_LEAVE with no effect. - case LFG_STATE_IN_DUNGEON: - case LFG_STATE_FINISHED_DUNGEON: - case LFG_STATE_PROPOSAL: - case LFG_STATE_QUEUED: - // Retail answers a queue leave with TWO status packets, in - // this order, both still carrying the dungeon list: - // reason 14, then reason 8 as the terminal - // 26 of 28 observed leaves produce the pair (capture-000044 seq - // 47667 -> 47701/47702, capture-000086 seq 1339 -> 1340/1341, - // capture-000133, capture-000326, capture-000720). We sent only - // the terminal, and the client left the queue on screen. - // - // LFG_UPDATE_PROPOSAL_BEGIN is simply the enumerator for wire - // reason 14; the name is inherited and does not describe this - // use. Our flag logic already yields retail's tuples for both: - // 14 -> joined 1 / queued 0, 8 -> joined 0 / queued 0. + // ALWAYS answer, whatever state is recorded. + // + // This was a switch over four states with no default, so any other + // state -- above all LFG_STATE_NONE, which is what a player holds + // after declining a proposal -- fell through and sent the client + // NOTHING. Observed live in an isolated repro: press I, accept the + // backfill, decline the proposal, then click Leave Queue fourteen + // times and receive not one packet in reply, because a player who is + // in an LFG party takes this branch rather than the solo one. + // + // The solo path already always answers. The two must not disagree: + // which branch runs depends only on whether the player happens to be + // grouped, which is not something the client can reason about when it + // asks to leave. + // + // Retail's pair is reason 14 then reason 8, but 14 carries joined = 1 + // and the client files a status body by category, derived from the + // dungeon list -- so with an empty list it is only safe to send the + // terminal. 0 of 5291 retail bodies carry an empty dungeon list. + if (!grpPlrStatus.dungeonList.empty()) + { grpPlrStatus.updateType = LFG_UPDATE_PROPOSAL_BEGIN; SendLfgUpdate(grpPlrGuid, grpPlrStatus, true); + } - grpPlrStatus.updateType = LFG_UPDATE_LEAVE; - grpPlrStatus.state = LFG_STATE_NONE; - SendLfgUpdate(grpPlrGuid, grpPlrStatus, true); - break; - case LFG_STATE_ROLECHECK: - PerformRoleCheck(NULL, pGroup, 0); - break; - //todo: other state cases after they get implemented + grpPlrStatus.updateType = LFG_UPDATE_LEAVE; + grpPlrStatus.state = LFG_STATE_NONE; + SendLfgUpdate(grpPlrGuid, grpPlrStatus, true); + + SetPlayerState(grpPlrGuid, LFG_STATE_NONE); } // Same hazard as the solo path: a party member may be listed in an From 7808f8705c792821a79cebac90a7778409d445df Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 10:11:05 +0100 Subject: [PATCH 32/81] Wire up dungeon completion, the requeue cooldown and Dungeon Deserter GetJoinResult already refused a queue for Dungeon Deserter (71041) or the LFG cooldown (71328), solo and party, with the right result codes. Nothing ever APPLIED either aura, so both checks were unreachable from our own gameplay and abandoning a run cost nothing. Worse, HandleBossKilled had no caller anywhere in the tree. Dungeon completion was never detected at all: no LFG run had ever paid a reward or reached LFG_STATE_FINISHED_DUNGEON. DungeonPersistentState::UpdateEncounterState carried a "Place LFG reward here" comment at the last-encounter branch, which is exactly where the call belonged. That branch is now the dungeon finder's view of progress. A credited encounter marks every LFG group with players on the map as having made progress, and the last encounter runs the completion path. Both spells are confirmed on retail wire, not assumed. Scanning all 98,553,967 build-18414 payloads in the corpus for the little-endian spell ids: 71041 appears in 752 payloads (457 of them SMSG_AURA_UPDATE 0x0072) and 71328 in 6920 (2876 in AURA_UPDATE). The roughly nine-to-one ratio is what MoP's rules predict -- the 15-minute cooldown starts when a player ENTERS so nearly everyone takes one, while Deserter only lands on players who leave early. MoP's deserter rule protected the opening of a run rather than requiring completion: leaving or being vote-kicked before the group had engaged or killed a boss gave 30 minutes of Deserter, and after the first boss you could ordinarily leave clean. Blizzard kept the exact predicate hidden and it had edge cases -- boss combat, a wipe, or enough time inside could satisfy it, and Heroic Scarlet Monastery was reported in 2013 to keep awarding Deserter after its first boss when other dungeons did not. A credited encounter is the one signal that is unambiguous and that this core can actually observe, so that is what is used. Deserter is applied only to a player still standing on the dungeon's own map, and before the leave teleport moves them off it. Leaving from outside -- never zoned in, or already ported out -- is not desertion. Vote-kick does not yet apply Deserter; the boot flow is separate and is left for a follow-up. NOTE: this makes HandleBossKilled live for the first time. Its reward, daily registration and FINISHED_DUNGEON transitions have never executed on this core and are untested at runtime. The teleport combat gate stays PER-PLAYER. A group-wide gate was written and withdrawn: Wowpedia's Dungeon Finder page and WoWWiki both put the restriction on the caller alone ("you won't be teleported if you are in combat, jumping or falling"), matching the per-player dead/falling/vehicle checks already there. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/GroupHandler.cpp | 4 + src/game/WorldHandlers/LFGMgr.cpp | 114 ++++++++++++++++++ src/game/WorldHandlers/LFGMgr.h | 25 ++++ src/game/WorldHandlers/LFGMgrProposal.cpp | 25 ++++ .../WorldHandlers/MapPersistentStateMgr.cpp | 12 +- 5 files changed, 178 insertions(+), 2 deletions(-) diff --git a/src/game/WorldHandlers/GroupHandler.cpp b/src/game/WorldHandlers/GroupHandler.cpp index 3d4c26cae..1ae8870a4 100644 --- a/src/game/WorldHandlers/GroupHandler.cpp +++ b/src/game/WorldHandlers/GroupHandler.cpp @@ -511,6 +511,10 @@ void WorldSession::HandleGroupDisbandOpcode(WorldPacket& recv_data) // player is actually standing on the dungeon's map. if (pGroup->isLFGGroup()) { + // Deserter BEFORE the teleport: OnPlayerLeftDungeonGroup only counts a player who + // is still standing on the dungeon's map, and TeleportPlayer is about to move + // them off it. + sLFGMgr.OnPlayerLeftDungeonGroup(GetPlayer()); sLFGMgr.TeleportPlayer(GetPlayer(), true); } diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index f3fa8230c..1d6b55930 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1426,6 +1426,120 @@ uint32 LFGMgr::GetGroupDungeonEntry(ObjectGuid groupGuid) return status ? GetDungeonEntry(status->dungeonID) : 0; } +void LFGMgr::OnDungeonEncounterCredited(Map* map, bool lastEncounter) +{ + if (!map) + { + return; + } + + // Mark by GROUP, not by player: the run's progress belongs to the run. Collected + // first so a group with five members inside is only handled once. + std::set lfgGroups; + Map::PlayerList const& players = map->GetPlayers(); + for (Map::PlayerList::const_iterator it = players.begin(); it != players.end(); ++it) + { + Player* pPlayer = it->getSource(); + if (!pPlayer) + { + continue; + } + + Group* pGroup = pPlayer->GetGroup(); + if (pGroup && pGroup->isLFGGroup()) + { + lfgGroups.insert(pGroup->GetObjectGuid()); + } + } + + for (std::set::const_iterator it = lfgGroups.begin(); it != lfgGroups.end(); ++it) + { + LFGGroupStatus* status = GetGroupStatus(*it); + if (!status) + { + continue; + } + + if (!status->madeProgress) + { + status->madeProgress = true; + DEBUG_LOG("LFG: group %s has made progress; leaving no longer earns Deserter", + it->GetString().c_str()); + } + } + + if (!lastEncounter) + { + return; + } + + // The final encounter completes the run. This is the call site the + // "Place LFG reward here" comment in DungeonPersistentState::UpdateEncounterState + // was left for: HandleBossKilled has existed all along with NO caller anywhere in + // the tree, so no LFG run has ever paid its reward or reached + // LFG_STATE_FINISHED_DUNGEON. + for (Map::PlayerList::const_iterator it = players.begin(); it != players.end(); ++it) + { + Player* pPlayer = it->getSource(); + if (!pPlayer) + { + continue; + } + + Group* pGroup = pPlayer->GetGroup(); + if (pGroup && pGroup->isLFGGroup() && GetGroupStatus(pGroup->GetObjectGuid())) + { + HandleBossKilled(pPlayer); + break; // HandleBossKilled already walks the whole group + } + } +} + +void LFGMgr::ApplyDungeonCooldown(Player* pPlayer) +{ + // Retail starts a 15-minute requeue cooldown when the player ENTERS, independently + // of Deserter. The corpus bears that out: spell 71328 appears in 6920 build-18414 + // payloads against 752 for Deserter (71041) -- roughly nine times as many, which is + // what you expect when everyone who zones in gets one and only early leavers get the + // other. + if (pPlayer && !pPlayer->HasAura(LFG_COOLDOWN_SPELL)) + { + pPlayer->CastSpell(pPlayer, LFG_COOLDOWN_SPELL, true); + } +} + +void LFGMgr::OnPlayerLeftDungeonGroup(Player* pPlayer) +{ + if (!pPlayer) + { + return; + } + + Group* pGroup = pPlayer->GetGroup(); + if (!pGroup || !pGroup->isLFGGroup()) + { + return; + } + + LFGGroupStatus const* status = GetGroupStatus(pGroup->GetObjectGuid()); + if (!status || status->madeProgress) + { + return; // past the protected opening -- leaving is free + } + + // Only for someone actually standing in the dungeon. Leaving the group from outside + // -- never zoned in, or already ported out -- is not desertion. + LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(status->dungeonID); + if (!dungeon || pPlayer->GetMapId() != uint32(dungeon->MapID)) + { + return; + } + + DEBUG_LOG("LFG: %s left dungeon %u before any encounter was credited -- Deserter", + pPlayer->GetName(), status->dungeonID); + pPlayer->CastSpell(pPlayer, LFG_DESERTER_SPELL, true); +} + void LFGMgr::ReleaseGroupLfgStatus(ObjectGuid groupGuid) { m_groupStatusMap.erase(groupGuid); diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index fdbcde51c..7e32ebb75 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -675,6 +675,7 @@ inline void MopLfgPackets::BuildEmptyPartyInfo(WorldPacket& out) struct LFGBoot; +class Map; struct LFGGroupStatus; struct LFGPlayers; struct LFGPlayerStatus; @@ -1082,6 +1083,18 @@ struct LFGGroupStatus //todo: check for this in joinlfg function, not lfgplayers { LFGState state; // State of the group uint32 dungeonID; // ID of the dungeon the group should be in (the RESOLVED one) + /// Has this run made enough progress that leaving is no longer desertion? + /// + /// MoP's rule protected the OPENING of a run: leave or get vote-kicked before the + /// group had engaged/killed a boss and you took Dungeon Deserter for 30 minutes; + /// after the first boss you could ordinarily leave clean. Blizzard deliberately kept + /// the exact predicate hidden and it had edge cases (boss combat, a wipe, or simply + /// enough time inside could satisfy it, and Heroic Scarlet Monastery was reported in + /// 2013 to keep awarding Deserter after its first boss when others did not), so this + /// tracks the one signal that is unambiguous and that we can actually observe: a + /// credited dungeon encounter. + bool madeProgress = false; + /// The random category the group queued under, or 0 for a direct queue. Kept because /// SMSG_GROUP_LIST carries BOTH: slot A is the resolved dungeon and slot B the random /// row. Retail never puts a type-6 entry in slot A. @@ -1315,6 +1328,18 @@ class LFGMgr /// when its dungeon finishes -- see the note in HandleBossKilled. void ReleaseGroupLfgStatus(ObjectGuid groupGuid); + /// A dungeon encounter was credited on this map. Marks every LFG group with players + /// present as having made progress, so leaving no longer earns Dungeon Deserter, and + /// on the LAST encounter runs the completion/reward path. + void OnDungeonEncounterCredited(Map* map, bool lastEncounter); + + /// Called when a player leaves an LFG group. Applies Dungeon Deserter if the run had + /// not yet made progress. + void OnPlayerLeftDungeonGroup(Player* pPlayer); + + /// Applies the 15-minute requeue cooldown that retail starts when a player enters. + void ApplyDungeonCooldown(Player* pPlayer); + /// Return the 5.4.8 LFG status category byte for a dungeon. uint8 GetDungeonCategory(uint32 ID); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 6ce59156b..661788adc 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1041,6 +1041,11 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) else { SetPlayerState(pGroupPlr->GetObjectGuid(), LFG_STATE_IN_DUNGEON); + + // Retail starts the 15-minute requeue cooldown on ENTRY, separately from + // Deserter -- which is why spell 71328 outnumbers 71041 roughly nine to + // one in the corpus. + ApplyDungeonCooldown(pGroupPlr); } } } @@ -1079,6 +1084,26 @@ void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) // This guard sits in TeleportPlayer rather than at the call sites so that the // dropdown (CMSG_LFG_TELEPORT) and the leave path (CMSG_GROUP_DISBAND) are both // covered by one check that cannot be forgotten by a third caller. + // Per-player, deliberately -- NOT group-wide. + // + // Retail gates this on the caller alone: Wowpedia's Dungeon Finder page says players + // may teleport in and out at any time if THEY are not in combat, and WoWWiki gives the + // condition as "you won't be teleported if you are in combat, jumping or falling" -- + // the same three per-player conditions TeleportToDungeon already checks below. + // + // A group-wide gate was written and then withdrawn. It closes a real hole (combat is + // per-unit, so a ranged dps who never took threat is not flagged and can leave mid-pull) + // but retail apparently does not close it, and wire fidelity wins here. If that hole + // ever needs closing, gate the LEAVE path rather than this function, and only on members + // standing on the dungeon's own map -- a member who already ported out and is fighting + // something in the world must not lock the players still inside. + // + // The check covers `in` as well as `out`: teleporting INTO a dungeon while fighting + // something outside it strands the mob and drops the player in still flagged. + // + // It sits in TeleportPlayer rather than at the call sites so the dropdown + // (CMSG_LFG_TELEPORT) and the leave path (CMSG_GROUP_DISBAND) are both covered by one + // check a third caller cannot forget. if (pPlayer->IsInCombat()) { DEBUG_LOG("LFG TeleportPlayer: %s refused (%s) -- in combat", diff --git a/src/game/WorldHandlers/MapPersistentStateMgr.cpp b/src/game/WorldHandlers/MapPersistentStateMgr.cpp index 9ff6f88f7..db7373486 100644 --- a/src/game/WorldHandlers/MapPersistentStateMgr.cpp +++ b/src/game/WorldHandlers/MapPersistentStateMgr.cpp @@ -45,6 +45,7 @@ */ #include "MapPersistentStateMgr.h" +#include "LFGMgr.h" #include "SQLStorages.h" #include "Player.h" @@ -434,11 +435,18 @@ void DungeonPersistentState::UpdateEncounterState(EncounterCreditType type, uint CharacterDatabase.PExecute("UPDATE `instance` SET `encountersMask` = '%u' WHERE `id` = '%u'", m_completedEncountersMask, GetInstanceId()); DEBUG_LOG("DungeonPersistentState: Dungeon %s (Id %u) completed encounter %s", GetMap()->GetMapName(), GetInstanceId(), dbcEntry->Name_lang[sWorld.GetDefaultDbcLocale()]); - if (/*uint32 dungeonId =*/ iter->second->lastEncounterDungeon) + + bool const isLastEncounter = iter->second->lastEncounterDungeon != 0; + if (isLastEncounter) { DEBUG_LOG("DungeonPersistentState:: Dungeon %s (Instance-Id %u) completed last encounter %s", GetMap()->GetMapName(), GetInstanceId(), dbcEntry->Name_lang[sWorld.GetDefaultDbcLocale()]); - // Place LFG reward here } + + // The dungeon finder's only view of run progress. A credited encounter clears + // the group to leave without Deserter, and the LAST one completes the run -- + // which is what the "Place LFG reward here" note stood for. HandleBossKilled + // had no caller anywhere in the tree, so no LFG run had ever paid a reward. + sLFGMgr.OnDungeonEncounterCredited(GetMap(), isLastEncounter); return; } } From fb31de1b3d5b36a61990ef0e5a2849695e77f44c Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 10:19:49 +0100 Subject: [PATCH 33/81] Do not let the Dungeon Cooldown refuse a queue from inside the run Wiring up Dungeon Cooldown (71328) immediately broke the case players used to complain about. Two different things arrive as an ordinary CMSG_LFG_JOIN from a player already standing in the dungeon: 1. Backfill -- "A player has left your group. Would you like to find another player to finish X?" -- replacing a member in the current run. 2. The leader re-queueing whatever is left of a short-handed group for a DIFFERENT dungeon, which retail also allows. GetJoinResult refused both, because every member had taken the cooldown when they zoned in. The moment anyone left, the remaining players could get neither a replacement nor a fresh run, so the group was simply dead. Reproduced live immediately after the cooldown was added. The waiver therefore keys on "already standing in an LFG dungeon", not on "same dungeon": case 2 names a different dungeon entirely and must still pass. The cooldown's purpose is to stop a player re-queueing for a new dungeon straight after entering one from OUTSIDE, and neither case is that. Deserter is deliberately not waived. Its point is that the deserter cannot use the finder at all for its duration, wherever they are standing. Worth recording for whoever reads this next: 71328 is an INVISIBLE aura. Applying it by hand produces no icon, so a refusal caused by it arrives with nothing on the UI to explain why -- which is why this failure mode reads as the finder simply being broken. Spell.dbc names 71041 "Dungeon Deserter" and 71328 "Dungeon Cooldown"; the only SpellMisc attribute bits distinguishing them are SPELL_ATTR_EX2_UNK28 and SPELL_ATTR_EX9_UNK6, both unnamed in this core, so the invisibility is an observed fact rather than something derived from the DBC. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrQueue.cpp | 40 ++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 28a51ddb2..fdb3632ba 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -647,6 +647,39 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) LfgJoinResult result = ERR_LFG_OK; Group* pGroup = plr->GetGroup(); + // Is the caller ALREADY STANDING IN an LFG dungeon? + // + // Two distinct things arrive as an ordinary CMSG_LFG_JOIN from inside a run, and the + // Dungeon Cooldown must gate neither: + // + // 1. Backfill -- "A player has left your group. Would you like to find another + // player to finish X?" -- replacing a member in the run they are in. + // 2. The leader re-queueing whatever is LEFT of a short-handed group for a + // DIFFERENT dungeon, which retail also allows. + // + // The test is therefore "already inside", not "same dungeon": case 2 names a + // different dungeon entirely and must still be waived. + // + // Dungeon Cooldown exists to stop a player re-queuing for a new dungeon straight + // after ENTERING one from the outside. Applied to either case above it means the + // moment anyone leaves, the remaining players are refused both a replacement and a + // fresh run, and the group is simply dead -- exactly the complaint players had, and + // reproduced here as soon as the cooldown was wired up. The aura is invisible + // (confirmed by applying 71328 by hand: no icon at all), so the refusal arrives with + // nothing on the UI to explain it. + // + // Deserter is deliberately NOT waived. Its whole point is that the deserter cannot + // use the finder at all for its duration, wherever they happen to be standing. + bool alreadyInLfgDungeon = false; + if (pGroup && pGroup->isLFGGroup()) + { + if (LFGGroupStatus const* groupStatus = GetGroupStatus(pGroup->GetObjectGuid())) + { + LfgDungeonsEntry const* runDungeon = sLfgDungeonsStore.LookupEntry(groupStatus->dungeonID); + alreadyInLfgDungeon = runDungeon && plr->GetMapId() == uint32(runDungeon->MapID); + } + } + /* Reasons for not entering: * Deserter spell * Dungeon finder cooldown @@ -667,7 +700,7 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_CANT_USE_DUNGEONS; } - else if (plr->HasAura(LFG_COOLDOWN_SPELL)) + else if (!alreadyInLfgDungeon && plr->HasAura(LFG_COOLDOWN_SPELL)) { result = ERR_LFG_RANDOM_COOLDOWN_PLAYER; } @@ -713,8 +746,11 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_CANT_USE_DUNGEONS; } - else if (pGroupPlr->HasAura(LFG_COOLDOWN_SPELL)) + else if (!alreadyInLfgDungeon && pGroupPlr->HasAura(LFG_COOLDOWN_SPELL)) { + // Waived for every member, not just the caller: they all entered + // together, so they all hold the same cooldown, and gating on any + // one of them refuses the backfill just as surely. result = ERR_LFG_RANDOM_COOLDOWN_PARTY; } // No `else { result = ERR_LFG_OK; }` here. Assigning per member meant From ac2d61aa78e4359a96d6de72d2ba29e1e4bad7f9 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 10:24:24 +0100 Subject: [PATCH 34/81] Gate the cooldown waiver on the finder flag, not on the player's map The waiver added a moment ago tested whether the caller was standing on the dungeon's map. That is the wrong signal twice over: - a leader who has already teleported OUT is off the map but still leading the run, and was refused; - it only happened to admit the "re-queue the remnant for a DIFFERENT dungeon" case, which names another dungeon entirely. The server already flags what actually matters. SetAsLfgGroup marks a group when the finder forms it, GROUPTYPE_LFD is persisted in `groups`.`groupType`, and nothing anywhere clears it -- so "did this party come from the dungeon finder" is a durable fact, not something to infer from where a player is standing. Because nothing clears it, membership alone is not sufficient: a finder group that has finished its dungeon and stayed together is still flagged, and those players should serve the cooldown like anyone else. The waiver therefore requires the run to still be LIVE -- a group status exists and has not reached LFG_STATE_FINISHED_DUNGEON. No functional change to the two cases already fixed; this removes a hole in how they were detected. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrQueue.cpp | 35 +++++++++++++++----------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index fdb3632ba..2a3964ed0 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -657,27 +657,34 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) // 2. The leader re-queueing whatever is LEFT of a short-handed group for a // DIFFERENT dungeon, which retail also allows. // - // The test is therefore "already inside", not "same dungeon": case 2 names a - // different dungeon entirely and must still be waived. + // The test is therefore "this party came from the finder and its run is still live", + // not "same dungeon" and not "standing on the dungeon's map": + // - case 2 names a different dungeon entirely, so a same-dungeon test refuses it; + // - a leader who has already teleported OUT is off the map but still leading the + // run, so a map test refuses them too. + // + // GROUPTYPE_LFD is the right signal and the server already keeps it: SetAsLfgGroup + // marks the group when the finder forms it, it is persisted in `groups`.`groupType`, + // and nothing ever clears it. Because nothing clears it, membership alone is not + // enough -- a finder group that has FINISHED its dungeon and stayed together is still + // flagged, and those players should serve their cooldown like anyone else. So the run + // must also still be live: a status exists and has not reached FINISHED_DUNGEON. // // Dungeon Cooldown exists to stop a player re-queuing for a new dungeon straight - // after ENTERING one from the outside. Applied to either case above it means the - // moment anyone leaves, the remaining players are refused both a replacement and a - // fresh run, and the group is simply dead -- exactly the complaint players had, and - // reproduced here as soon as the cooldown was wired up. The aura is invisible + // after ENTERING one from the outside. Neither case above is that. Applied to them it + // means the moment anyone leaves, the remaining players are refused both a replacement + // and a fresh run, and the group is simply dead -- exactly the complaint players had, + // and reproduced here as soon as the cooldown was wired up. The aura is invisible // (confirmed by applying 71328 by hand: no icon at all), so the refusal arrives with // nothing on the UI to explain it. // // Deserter is deliberately NOT waived. Its whole point is that the deserter cannot // use the finder at all for its duration, wherever they happen to be standing. - bool alreadyInLfgDungeon = false; + bool inLiveLfgRun = false; if (pGroup && pGroup->isLFGGroup()) { - if (LFGGroupStatus const* groupStatus = GetGroupStatus(pGroup->GetObjectGuid())) - { - LfgDungeonsEntry const* runDungeon = sLfgDungeonsStore.LookupEntry(groupStatus->dungeonID); - alreadyInLfgDungeon = runDungeon && plr->GetMapId() == uint32(runDungeon->MapID); - } + LFGGroupStatus const* groupStatus = GetGroupStatus(pGroup->GetObjectGuid()); + inLiveLfgRun = groupStatus && groupStatus->state != LFG_STATE_FINISHED_DUNGEON; } /* Reasons for not entering: @@ -700,7 +707,7 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_CANT_USE_DUNGEONS; } - else if (!alreadyInLfgDungeon && plr->HasAura(LFG_COOLDOWN_SPELL)) + else if (!inLiveLfgRun && plr->HasAura(LFG_COOLDOWN_SPELL)) { result = ERR_LFG_RANDOM_COOLDOWN_PLAYER; } @@ -746,7 +753,7 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_CANT_USE_DUNGEONS; } - else if (!alreadyInLfgDungeon && pGroupPlr->HasAura(LFG_COOLDOWN_SPELL)) + else if (!inLiveLfgRun && pGroupPlr->HasAura(LFG_COOLDOWN_SPELL)) { // Waived for every member, not just the caller: they all entered // together, so they all hold the same cooldown, and gating on any From e559391114f3085294d2e957711731f130beec61 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 10:38:00 +0100 Subject: [PATCH 35/81] Align the Deserter and cooldown lifecycle with the MoP research Four corrections against the MoP LFG deserter reference, which separates the two penalty systems far more sharply than the first pass did. 71328 is the RANDOM cooldown and is now applied only to a run that came from a random category, not to every entry. A player who queued for a specific dungeon never took it on retail, and applying it is not a harmless over-approximation: 71328 is what blocks the next random queue, so a specific-dungeon run locked the player out of random for 15 minutes they never owed. randomDungeonID is non-zero exactly when the queue was a random category. Completion is now idempotent, which the reference requires in terms. Two guards, because rewards are paid here: UpdateEncounterState returns early when the encounter bit is already set, so a re-credit does nothing at all -- no repeated DB write, no second LFG completion -- and HandleBossKilled refuses to run for a group already in LFG_STATE_FINISHED_DUNGEON, covering any future caller that does not know about the first guard. Before this, a duplicate final-encounter credit paid the dungeon's rewards twice. Completing the run now clears the random cooldown. The restriction exists to pace entry into NEW random runs and the player has finished the one they took it for; leaving it on punished a clean completion exactly as much as walking out at the door. Deserter no longer requires the player to be standing on the dungeon's map. That handed out a free abandon: teleport out through the dropdown, which is allowed, then leave the group, and no Deserter. A player refused the entry teleport (dead, falling, in a vehicle) was exempt for the same reason while still holding a place in the run. The test is membership of a live finder run -- GROUPTYPE_LFD, set when the finder forms the group, persisted in `groups`.`groupType` and never cleared, plus a status that has not reached FINISHED_DUNGEON. Where the deserter is standing when they quit is not the question. This is the same defect already fixed in the cooldown waiver, in the opposite direction; both now use the same signal. Deserter remains independent of 71328, which is the reference's most emphatic rule and the specific defect it attributes to JadeCore: requiring the random-only aura exempts early leavers from specific-dungeon groups entirely. Still outstanding from the reference, deliberately not attempted here: removals carry no reason, so voluntary leave, successful vote kick, disconnect timeout and disband are indistinguishable at the point Deserter is decided. The vote-kick policy and the short-handed group exemption both depend on that, and it is refactoring work across Group::RemoveMember and its callers rather than a lifecycle fix. LFR partial-run and scenario-stage policies need per-player join state that does not exist yet. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 25 ++++++---- src/game/WorldHandlers/LFGMgrProposal.cpp | 47 +++++++++++++++++-- .../WorldHandlers/MapPersistentStateMgr.cpp | 11 ++++- 3 files changed, 69 insertions(+), 14 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 1d6b55930..92fa89f50 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1522,18 +1522,25 @@ void LFGMgr::OnPlayerLeftDungeonGroup(Player* pPlayer) } LFGGroupStatus const* status = GetGroupStatus(pGroup->GetObjectGuid()); - if (!status || status->madeProgress) + if (!status || status->state == LFG_STATE_FINISHED_DUNGEON || status->madeProgress) { - return; // past the protected opening -- leaving is free + return; // run finished, or past the protected opening } - // Only for someone actually standing in the dungeon. Leaving the group from outside - // -- never zoned in, or already ported out -- is not desertion. - LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(status->dungeonID); - if (!dungeon || pPlayer->GetMapId() != uint32(dungeon->MapID)) - { - return; - } + // NOT gated on the player's current map. + // + // It used to require them to be standing on the dungeon's map, which handed out a + // free abandon: teleport out through the dropdown -- which is allowed -- then leave + // the group, and no Deserter. A player refused the entry teleport (dead, falling, in + // a vehicle) was exempt for the same reason while still holding a place in the run. + // + // Membership of a live finder run is the right test and the server already keeps it: + // GROUPTYPE_LFD is set when the finder forms the group, persisted in + // `groups`.`groupType`, and never cleared. Where the deserter happens to be standing + // when they quit is not the question. + // + // Accepting a proposal teleports the whole group in immediately, so "in the group but + // never zoned in" is not a state a player can choose to sit in anyway. DEBUG_LOG("LFG: %s left dungeon %u before any encounter was credited -- Deserter", pPlayer->GetName(), status->dungeonID); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 661788adc..ada2af6dd 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -945,6 +945,10 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) float x, y, z, o; LFGTeleportError err = LFG_TELEPORTERROR_OK; + // Whether this run came from a random category decides who takes the 15-minute + // cooldown below. + LFGGroupStatus const* runStatus = GetGroupStatus(pGroup->GetObjectGuid()); + Player* pGroupLeader = sObjectAccessor.FindPlayer(pGroup->GetLeaderGuid()); if (pGroupLeader && pGroupLeader->GetMapId() == mapID) // Already in the dungeon @@ -1042,10 +1046,24 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) { SetPlayerState(pGroupPlr->GetObjectGuid(), LFG_STATE_IN_DUNGEON); - // Retail starts the 15-minute requeue cooldown on ENTRY, separately from - // Deserter -- which is why spell 71328 outnumbers 71041 roughly nine to - // one in the corpus. - ApplyDungeonCooldown(pGroupPlr); + // ONLY for a queue that was made through Random Dungeon. + // + // 71328 is the RANDOM cooldown. A player who queued for a specific + // dungeon did not take it on retail, and applying it to them is not a + // harmless over-approximation: it is the aura that blocks the next + // random queue, so a specific-dungeon run would lock the player out of + // random for 15 minutes they never owed. + // + // It also matters that the two systems stay independent. Deserter must + // never be conditional on this aura -- that was JadeCore's bug, and it + // exempts early leavers from specific-dungeon groups entirely. + // + // randomDungeonID is non-zero exactly when the queue was a random + // category, recorded at group creation from the proposal. + if (runStatus && runStatus->randomDungeonID) + { + ApplyDungeonCooldown(pGroupPlr); + } } } } @@ -1302,6 +1320,17 @@ void LFGMgr::HandleBossKilled(Player* pPlayer) return; } + // Second guard on top of the encounter-bit check in UpdateEncounterState. Rewards + // are paid from here, so a run must complete exactly once however it is reached -- + // a re-credited encounter, a script firing the same completion twice, or a future + // caller that does not know about the first guard. + if (status->state == LFG_STATE_FINISHED_DUNGEON) + { + DEBUG_LOG("LFG HandleBossKilled: group %s already finished; ignoring", + groupGuid.GetString().c_str()); + return; + } + // set each player's lfgstate to LFG_STATE_FINISHED_DUNGEON // fetch reward info, and if it's the first dungeon of the day (per player), // give them 2x the xp (or 1x if it's not the first), and the reward item @@ -1316,6 +1345,16 @@ void LFGMgr::HandleBossKilled(Player* pPlayer) { SetPlayerState(pGroupPlr->GetObjectGuid(), LFG_STATE_FINISHED_DUNGEON); + // Completing the run resolves the random cooldown. Retail treats a finished + // dungeon as clearing it -- the restriction exists to pace entry into NEW + // random runs, and the player has now actually finished the one they took it + // for. Leaving it on would make a clean completion punish the player exactly + // as much as walking out at the door. + // + // Removed rather than left to expire so the group can immediately queue again, + // which is the whole point of finishing. + pGroupPlr->RemoveAurasDueToSpell(LFG_COOLDOWN_SPELL); + // check if player did a random dungeon uint32 randomDungeonId = 0; LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(status->dungeonID); diff --git a/src/game/WorldHandlers/MapPersistentStateMgr.cpp b/src/game/WorldHandlers/MapPersistentStateMgr.cpp index db7373486..9ba065dd2 100644 --- a/src/game/WorldHandlers/MapPersistentStateMgr.cpp +++ b/src/game/WorldHandlers/MapPersistentStateMgr.cpp @@ -430,7 +430,16 @@ void DungeonPersistentState::UpdateEncounterState(EncounterCreditType type, uint // direct comparison against an internal mode -- see EncounterDifficultyMatches. if (iter->second->creditType == type && EncounterDifficultyMatches(dbcEntry->MapID, dbcEntry->DifficultyID, GetDifficulty()) && dbcEntry->MapID == GetMapId()) { - m_completedEncountersMask |= 1 << dbcEntry->Bit; + uint32 const encounterBit = 1 << dbcEntry->Bit; + if (m_completedEncountersMask & encounterBit) + { + // Already credited. Returning here rather than re-applying makes the + // whole completion path idempotent: no repeated DB write, and above all + // no second LFG completion, which would pay the dungeon's rewards twice. + return; + } + + m_completedEncountersMask |= encounterBit; CharacterDatabase.PExecute("UPDATE `instance` SET `encountersMask` = '%u' WHERE `id` = '%u'", m_completedEncountersMask, GetInstanceId()); From d6e6a5bd54edeffe17464e6485f50092ff4d4813 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 10:50:41 +0100 Subject: [PATCH 36/81] Let the Dungeon Cooldown refuse only random queues 71328 is the RANDOM cooldown in both directions: taken on entering through Random Dungeon, and spent preventing ANOTHER random queue while it runs. GetJoinResult refused every queue while it was held, so a player who had taken it on a random run could not queue for a single named dungeon either -- locked out of the finder entirely for 15 minutes, with no icon to explain it, because the aura is invisible. Observed live. The result code already said what the check should have been doing: ERR_LFG_RANDOM_COOLDOWN_PLAYER. GetJoinResult could not tell, because it never saw what was being queued for. It now takes the request type, computed in JoinLFG from the requested dungeon set -- before the validation loop that sets isRandom, which runs too late to inform the verdict. This is the counterpart to applying 71328 only on random ENTRY: the same rule read from the other end. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.h | 5 +++- src/game/WorldHandlers/LFGMgrQueue.cpp | 34 +++++++++++++++++++++++--- 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 7e32ebb75..3b279505d 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1214,7 +1214,10 @@ class LFGMgr * * @param plr The pointer to the player */ - LfgJoinResult GetJoinResult(Player* plr); + /// \param queueIsRandom the request contains a random category. The Dungeon + /// Cooldown (71328) gates only random queues, so a specific-dungeon request + /// must pass while it is active. + LfgJoinResult GetJoinResult(Player* plr, bool queueIsRandom); /** * @brief Fetch the playerstatus struct of a player on request, if existant diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 2a3964ed0..597479d9e 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -114,7 +114,21 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen bool isRaid = false; bool isDungeon = false; - LfgJoinResult result = GetJoinResult(plr); + // Whether the REQUEST is random decides whether the Dungeon Cooldown may refuse it. + // Determined here rather than inside GetJoinResult because the validation loop below + // that sets isRandom runs after the verdict is needed. + bool requestIsRandom = false; + for (std::set::const_iterator it = dungeons.begin(); it != dungeons.end(); ++it) + { + LfgDungeonsEntry const* requested = sLfgDungeonsStore.LookupEntry(*it); + if (requested && requested->TypeID == LFG_TYPE_RANDOM_DUNGEON) + { + requestIsRandom = true; + break; + } + } + + LfgJoinResult result = GetJoinResult(plr, requestIsRandom); if (result == ERR_LFG_OK) { // additional checks on dungeon selection @@ -640,7 +654,7 @@ LFGProposal* LFGMgr::GetProposalData(uint32 proposalID) } } -LfgJoinResult LFGMgr::GetJoinResult(Player* plr) +LfgJoinResult LFGMgr::GetJoinResult(Player* plr, bool queueIsRandom) { // Initialised. `LfgJoinResult result;` was read uninitialised when a group had // members but every getSource() returned null. @@ -707,8 +721,19 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_CANT_USE_DUNGEONS; } - else if (!inLiveLfgRun && plr->HasAura(LFG_COOLDOWN_SPELL)) + else if (queueIsRandom && !inLiveLfgRun && plr->HasAura(LFG_COOLDOWN_SPELL)) { + // 71328 gates RANDOM queues only. + // + // It is the Random Dungeon cooldown in both directions: taken on entering + // through Random Dungeon, and spent preventing ANOTHER random queue while it + // runs. A specific-dungeon request is not what it exists to pace, and refusing + // one leaves the player unable to queue for anything at all for 15 minutes -- + // with no icon to explain why, since the aura is invisible. Observed live: + // queueing for a single named dungeon was refused by a cooldown taken on an + // earlier random run. + // + // Note the result code says as much: ERR_LFG_RANDOM_COOLDOWN_PLAYER. result = ERR_LFG_RANDOM_COOLDOWN_PLAYER; } else if (plr->getLevel() < 15) @@ -753,8 +778,9 @@ LfgJoinResult LFGMgr::GetJoinResult(Player* plr) { result = ERR_LFG_CANT_USE_DUNGEONS; } - else if (!inLiveLfgRun && pGroupPlr->HasAura(LFG_COOLDOWN_SPELL)) + else if (queueIsRandom && !inLiveLfgRun && pGroupPlr->HasAura(LFG_COOLDOWN_SPELL)) { + // Random-only, as in the solo branch above. // Waived for every member, not just the caller: they all entered // together, so they all hold the same cooldown, and gating on any // one of them refuses the backfill just as surely. From c1578772dbf698cecc7c987c8440920697d90dd7 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 11:04:52 +0100 Subject: [PATCH 37/81] Send every status body for a queue under one ticket Root cause of the stuck minimap eye, traced rather than inferred. The client does not key its LFG status records by GUID. It keys them by the whole 20-byte RideTicket: sub_90FEDB compares five dwords (Wow.exe.c:1591704) and sub_98D07D is get-OR-CREATE on that key. So two bodies about one queue carrying different tickets produce TWO records, and a later clearing body can only ever address one of them. The other survives with queued = 1 -- an animating eye that nothing can switch off, which is why leaving answered correctly and changed nothing. Two ways we emitted exactly that. Ordering. JoinLFG's group branch announced the burst from inside the membership loop, but the entry carrying the ticket was not stored until after it, so GetStatusPacketData missed on both the group key and FindQueueEntryContaining and the opener went out with ticketId = 0. PerformRoleCheck then sent reason 13 with the real ticket. Observed on our own wire: reason 24 with ticket 0 at line 29747, reason 13 with ticket 1002 at line 29768 -- two records, two "You are now queued in the Dungeon Finder" messages, and a queue that could not be left. The roster is now collected, the entry stored, and only then is anything announced. The solo path was always immune because it stores before it sends. Identity. SendLfgUpdate re-derived the ticket from live state on every body and discarded GetStatusPacketData's failure, shipping ticketId = 0 where retail sends 0 in none of 5291 observed bodies. It now remembers the first ticket a player's bodies go out under and reuses it for every later body, releasing it on the terminal so the next join starts clean. First-wins matters and is not incidental. MergeGroups erases an absorbed solo queuer's entry, after which the fallback resolves to whichever entry now LISTS them -- the absorbing one -- and returns a stranger's ticket. Adopting it would strand that player's own join record permanently, which is the failure mode that hits merged queuers and not the player driving the test, and is why every packet decoded for the driving character looked correct. Found by an Opus review panel that traced the client's record store; the three earlier explanations for this defect were all wrong. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGHandler.cpp | 41 +++++++++++++++- src/game/WorldHandlers/LFGMgr.h | 53 +++++++++++++++++++++ src/game/WorldHandlers/LFGMgrQueue.cpp | 65 +++++++++++++++++--------- 3 files changed, 135 insertions(+), 24 deletions(-) diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index f7fad71c1..6efe46237 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -494,8 +494,45 @@ void WorldSession::SendLfgUpdate(bool isGroup, LFGPlayerStatus status) update.queued = isQueued; update.requestedRoles = queueData.roles; update.updateReason = uint8(status.updateType); - update.ticketId = queueData.ticketId; - update.ticketTime = queueData.joinedTime; + // One ticket for the life of a queue, whatever happens to the entry underneath. + // + // The client keys its status records on the whole 20-byte RideTicket, so a body that + // carries a different ticket does not update the record -- it creates a second one and + // leaves the first stranded, queued and unclearable. GetStatusPacketData can legitimately + // miss (the entry was erased by a merge, or the caller is announcing before it is + // stored) and used to hand back a default-constructed struct, shipping ticketId = 0. + // Retail sends 0 in none of 5291 observed bodies. + // + // So: take the live ticket when there is one and remember it; otherwise reuse whatever + // this player's bodies have already gone out under. + // Remember the first ticket this player's bodies go out under, then use THAT for + // every later body -- including ones whose live lookup would now resolve elsewhere. + sLFGMgr.RetainTicket(playerGuid, queueData.ticketId, queueData.joinedTime); + + LFGMgr::RetainedTicket retained; + if (sLFGMgr.GetRetainedTicket(playerGuid, retained)) + { + update.ticketId = retained.id; + update.ticketTime = retained.time; + } + else + { + update.ticketId = queueData.ticketId; + update.ticketTime = queueData.joinedTime; + if (!update.ticketId) + { + sLog.outError("WORLD: SMSG_LFG_UPDATE_STATUS for %s has no ticket (reason %u); " + "the client cannot file this body against its queue record.", + GetPlayerName(), uint32(status.updateType)); + } + } + + // The terminal ends the queue, so the ticket must not survive into the next join -- + // a stale one would make the new queue's bodies land on the old record. + if (status.updateType == LFG_UPDATE_LEAVE) + { + sLFGMgr.ForgetTicket(playerGuid); + } if (!status.dungeonList.empty()) update.dungeonCategory = sLFGMgr.GetDungeonCategory(*status.dungeonList.begin()); diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 3b279505d..3ffec2052 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1443,6 +1443,58 @@ class LFGMgr /// Non-zero, stable per queue entry, monotonic. See LFGPlayers::ticketId. uint32 AllocateTicketId() { return ++m_nextTicketId; } + /// The ticket a player's status bodies have been going out under. + /// + /// The client files each status body under a 20-byte RideTicket and looks records up + /// by comparing it whole, so every body about one queue MUST carry the same one. If a + /// later body carries a different ticket the client creates a second record instead of + /// updating the first, and the first can never be addressed again. Re-deriving the + /// ticket from live state cannot guarantee that: the entry may have been erased (a + /// merge folds an absorbed queuer's entry away) or not yet stored. + struct RetainedTicket + { + uint32 id = 0; + uint32 time = 0; + }; + typedef std::unordered_map retainedTicketMap; + + /// FIRST WINS. Once a player's bodies have gone out under a ticket, every later body + /// about that queue must carry the same one or the client files a second record. + /// + /// Overwriting would reintroduce the very failure this exists to stop: MergeGroups + /// erases an absorbed solo queuer's entry, after which GetStatusPacketData falls back + /// to whichever entry now LISTS them -- the absorbing one -- and hands back a stranger's + /// ticket. Adopting it would strand that player's own join record for good. + /// + /// Cleared by ForgetTicket when the queue genuinely ends, so the next join starts fresh. + void RetainTicket(ObjectGuid plrGuid, uint32 id, uint32 time) + { + if (!id) + { + return; + } + + RetainedTicket& t = m_retainedTickets[plrGuid]; + if (!t.id) + { + t.id = id; + t.time = time; + } + } + + bool GetRetainedTicket(ObjectGuid plrGuid, RetainedTicket& out) const + { + retainedTicketMap::const_iterator it = m_retainedTickets.find(plrGuid); + if (it == m_retainedTickets.end() || !it->second.id) + { + return false; + } + out = it->second; + return true; + } + + void ForgetTicket(ObjectGuid plrGuid) { m_retainedTickets.erase(plrGuid); } + /// Role-Related Functions /** @@ -1556,6 +1608,7 @@ class LFGMgr playerData m_playerData; queueSet m_queueSet; uint32 m_nextTicketId; + retainedTicketMap m_retainedTickets; /// Dungeon Finder Status for players playerStatusMap m_playerStatusMap; diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 597479d9e..6fb4cc260 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -380,42 +380,63 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen dungeons.insert(randomDungeonID); } + // THE QUEUE ENTRY IS BUILT AND STORED BEFORE ANYTHING IS ANNOUNCED. + // + // The client files each LFG status body under a 20-byte RideTicket, not under a + // GUID: sub_90FEDB compares five dwords (Wow.exe.c:1591704) and sub_98D07D is + // get-OR-CREATE on that key. Two bodies for one queue carrying different tickets + // therefore produce TWO records -- and a later clearing body can only ever address + // one of them. The other is orphaned with queued = 1, which is an animating minimap + // eye that nothing can switch off. + // + // That is exactly what this function used to emit. The burst was sent from inside + // the membership loop, but the entry carrying the ticket was not stored until after + // it, so GetStatusPacketData missed on both the group key and + // FindQueueEntryContaining and the opener went out with ticketId = 0 and + // ticketTime = 0. PerformRoleCheck then sent reason 13 with the real ticket. + // Observed live: reason 24 with ticket 0 followed by reason 13 with ticket 1002 -- + // two records, two "You are now queued in the Dungeon Finder" messages, and a + // queue the player could not leave. + // + // So: collect the roster, store the entry, and only then announce. The solo path + // was always immune because it stores before it sends. for (GroupReference* itr = pGroup->GetFirstMember(); itr != NULL; itr = itr->next()) { if (Player* pGroupPlr = itr->getSource()) { - // ROLECHECK, not NONE -- same reason as the solo path below. The update - // announced the join while reporting the state as NONE, and only moved to - // ROLECHECK for the stored copy afterwards. - // Reason 24, not 6 -- same correction the solo path already carries. - // Reason 6 is retail's re-queue-from-inside-a-dungeon reason (257 of 276 - // observed joins open with 24 and none with 6), and BOTH 6 and 13 make the - // client display ERR_LFG_JOINED_QUEUE. Opening with 6 and then having - // PerformRoleCheck send 13 announced "You are now queued in the Dungeon - // Finder" TWICE -- once in chat and once centre-screen. Observed live. - LFGPlayerStatus overallStatus(LFG_STATE_ROLECHECK, LFG_UPDATE_JOIN_QUEUE_INITIAL, dungeons, comments); - - pGroupPlr->GetSession()->SendLfgUpdate(true, overallStatus); - - ObjectGuid plrGuid = pGroupPlr->GetObjectGuid(); - roleCheck.currentRoles[plrGuid] = 0; - - m_playerStatusMap[plrGuid] = overallStatus; + roleCheck.currentRoles[pGroupPlr->GetObjectGuid()] = 0; } } - // Stored AFTER the loop above, not before it. The stored copy used to be taken - // while currentRoles was still empty, so the role check the rest of the system - // saw listed nobody: PerformRoleCheck then found "everyone" had answered as soon - // as the FIRST member replied, and a five-man queued on a one-entry role map. + // Stored with a complete currentRoles. Taking the copy while it was still empty + // made the role check the rest of the system saw list nobody: PerformRoleCheck + // then found "everyone" had answered as soon as the FIRST member replied, and a + // five-man queued on a one-entry role map. m_roleCheckMap[guid] = roleCheck; - // used later if they enter the queue LFGPlayers groupInfo(LFG_STATE_NONE, dungeons, roleCheck.currentRoles, comments, false, time(NULL), 0, 0, 0); groupInfo.candidateDungeons = candidates; groupInfo.ticketId = AllocateTicketId(); m_playerData[guid] = groupInfo; + for (GroupReference* itr = pGroup->GetFirstMember(); itr != NULL; itr = itr->next()) + { + if (Player* pGroupPlr = itr->getSource()) + { + // ROLECHECK, not NONE -- the update used to announce the join while + // reporting the state as NONE, correcting it only for the stored copy. + // + // Reason 24, not 6: 6 is retail's re-queue-from-inside-a-dungeon reason + // (257 of 276 observed joins open with 24 and none with 6), and BOTH 6 and + // 13 make the client display ERR_LFG_JOINED_QUEUE. + LFGPlayerStatus overallStatus(LFG_STATE_ROLECHECK, LFG_UPDATE_JOIN_QUEUE_INITIAL, dungeons, comments); + + pGroupPlr->GetSession()->SendLfgUpdate(true, overallStatus); + + m_playerStatusMap[pGroupPlr->GetObjectGuid()] = overallStatus; + } + } + PerformRoleCheck(plr, pGroup, (uint8)roles); } else From 152c70b2b229321373a95eb359127a67a2962ca2 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 11:27:48 +0100 Subject: [PATCH 38/81] Never advertise an LFG block we cannot fill, and keep the ticket into the dungeon GROUPTYPE_LFD is one-way. SetAsLfgGroup only ORs it in, it is set at PROPOSAL CREATION -- before anyone has answered -- and nothing in the tree clears it. A declined or expired proposal therefore leaves an ordinary party flagged as a finder group for the rest of its life, and every SendUpdate advertised an LFG block whose dungeon slot resolved through a group status that no longer existed: isLfg = 1 with slot A = 0. Group.cpp's own note already says why that is worse than sending no block. The client copies it, party+232 becomes 0 and IsPartyLFG() goes false, while the party goes on claiming to be a finder group in every other respect. isLfg is now gated on the dungeon entry actually resolving, rather than clearing the flag. The flag is also what Group::Disband keys its LFG status release on and what marks the party for the Dungeon Cooldown waiver, so clearing it would cost more than it fixes. Second: ForgetTicket was too eager. Accepting a proposal also sends LFG_UPDATE_LEAVE -- the player has left the QUEUE, not the session -- and the client then probes with CMSG_LFG_GET_STATUS once it has zoned in. Dropping the ticket at the accept left that reply with nothing to quote, and it went out with ticketId = 0: line 28568 of world-packets_2026-08-06_11-04-27, reason 15, ticket 0. Harmless there only because joined was 0, which makes the client create a phantom record and immediately free it. The same body with joined = 1 would create a record keyed all-zero that nothing could ever address. The ticket is now kept while the player is IN_DUNGEON or FINISHED_DUNGEON. Evidence for what this does NOT fix, so it is not re-investigated blindly: over world-packets_2026-08-06_11-04-27 every status record decodes and the client store ends empty -- tickets 1001 through 1004 each created and destroyed. All three role checks terminated with state FINISHED. The decline's SMSG_LFG_PROPOSAL_UPDATE carried state 1 (FAILED) against 2 (SUCCESS) on an accept. Those three mechanisms are clean on the wire and are not the remaining cause of a stuck eye. Found by an Opus review panel (finding I3). 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.cpp | 17 ++++++++++++++++- src/game/WorldHandlers/LFGHandler.cpp | 14 ++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index 4f28a6cb9..b5c0255a3 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -2620,7 +2620,22 @@ void Group::SendUpdateToPlayer(ObjectGuid guid) update.hasLootMode = true; update.lootMethod = uint8(m_lootMethod); update.lootThreshold = uint8(m_lootThreshold); - update.isLfg = isLFGGroup(); + // isLfg only when we can actually name the dungeon. + // + // GROUPTYPE_LFD is one-way: SetAsLfgGroup only ORs it in, it is set at PROPOSAL + // CREATION -- before anyone has answered -- and nothing in the tree ever clears it. + // So a declined or expired proposal leaves an ordinary party flagged for the rest of + // its life, and every SendUpdate then advertised an LFG block whose dungeon slot + // resolved through a group status that no longer exists, i.e. isLfg = 1 with A = 0. + // + // That is the state the note below calls worse than sending no block at all: the + // client copies it, party+232 becomes 0, and IsPartyLFG() goes false -- while the + // party keeps claiming to be a finder group in every other respect. + // + // Gating on the entry rather than clearing the flag: the flag is also what + // Group::Disband keys its LFG status release on, and what marks the party for the + // cooldown waiver, so clearing it would cost more than it fixes. + update.isLfg = isLFGGroup() && sLFGMgr.GetGroupDungeonEntry(GetObjectGuid()) != 0; if (update.isLfg) { // The LFG block has TWO dungeon slots and they are not interchangeable. diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 6efe46237..229d43f1e 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -527,9 +527,19 @@ void WorldSession::SendLfgUpdate(bool isGroup, LFGPlayerStatus status) } } - // The terminal ends the queue, so the ticket must not survive into the next join -- + // The terminal ends the QUEUE, so the ticket must not survive into the next join -- // a stale one would make the new queue's bodies land on the old record. - if (status.updateType == LFG_UPDATE_LEAVE) + // + // But not when the player is on their way INTO a dungeon: accepting a proposal also + // sends LFG_UPDATE_LEAVE (they have left the queue, not the session), and the client + // then probes with CMSG_LFG_GET_STATUS once it has zoned in. Dropping the ticket at + // the accept left that reply with nothing to quote and it went out with ticketId = 0. + // Observed at line 28568 of world-packets_2026-08-06_11-04-27: reason 15, ticket 0. + // Harmless there only because joined was 0; the same body with joined = 1 would create + // a record keyed all-zero that nothing could ever address. + if (status.updateType == LFG_UPDATE_LEAVE + && status.state != LFG_STATE_IN_DUNGEON + && status.state != LFG_STATE_FINISHED_DUNGEON) { sLFGMgr.ForgetTicket(playerGuid); } From 120407dcbf89862e0d239642aa33dd7614146c19 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 11:37:15 +0100 Subject: [PATCH 39/81] Tie the LFG ticket to the queue entry, not to a packet The retained ticket was dropped whenever a body with LFG_UPDATE_LEAVE went out. That looked right and is not: accepting a proposal ALSO sends LFG_UPDATE_LEAVE -- the player has left the QUEUE, not the session -- with state PROPOSAL, so the guard exempting IN_DUNGEON did not catch it. The ticket went away while the client's record was still live, and every later body had nothing to quote. The diagnostic added with the previous commit caught it verbatim, five times in one second at 11:31:09: SMSG_LFG_UPDATE_STATUS for Humanwarrior has no ticket (reason 8); the client cannot file this body against its queue record. Reason 8 is the leave itself. The client keys records on the whole 20-byte RideTicket, so a ticketless body matches nothing -- five Leave Queue clicks, five unaddressable packets, eye still lit. Reproduced deliberately: accept two or three backfill proposals from inside a dungeon, then decline one. The ticket now belongs to the queue entry. BeginTicket seeds it where the entry is built -- for every member on the group path, for the player on the solo path -- and the next join overwrites it. Nothing drops it on a send. Overwriting at entry creation is safe precisely because the old queue is over at that point. The first-wins rule in RetainTicket stays for bodies, so a lookup that falls back to a merged entry still cannot substitute a stranger's ticket. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/CharacterHandler.cpp | 16 ++++++++++++++++ src/game/WorldHandlers/LFGHandler.cpp | 19 +++---------------- src/game/WorldHandlers/LFGMgr.h | 20 ++++++++++++++++++++ src/game/WorldHandlers/LFGMgrQueue.cpp | 10 ++++++++++ 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/src/game/WorldHandlers/CharacterHandler.cpp b/src/game/WorldHandlers/CharacterHandler.cpp index cbd42aa88..68e291c50 100644 --- a/src/game/WorldHandlers/CharacterHandler.cpp +++ b/src/game/WorldHandlers/CharacterHandler.cpp @@ -1281,6 +1281,22 @@ void WorldSession::HandlePlayerLogin(LoginQueryHolder* holder) m_playerLoading = false; + // The player's OWN auras, now that sends are no longer suppressed. + // + // SendPacket drops every SMSG_AURA_UPDATE while m_playerLoading is true, on the + // stated grounds that "the full post-add snapshot replaces them once the target + // exists client-side". There is no such snapshot for yourself: SendAurasForTarget is + // reached only when some OTHER unit becomes visible (PlayerVisibility.cpp:260, + // GridNotifiers.cpp:145, Player.cpp:4234), never for the logging-in player. + // + // So every aura restored from character_aura was invisible until something happened + // to re-send it. Observed live: Dungeon Deserter kept blocking the finder -- the aura + // was in memory and in the database with 29 minutes left -- while the client showed + // no debuff and no timer at all, leaving the refusal unexplainable. + // + // This is not specific to LFG. It affects every timed aura a character logs in with. + pCurrChar->SendAurasForTarget(pCurrChar); + // Used by Eluna #ifdef ENABLE_ELUNA if (Eluna* e = sWorld.GetEluna()) diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 229d43f1e..738c47904 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -527,22 +527,9 @@ void WorldSession::SendLfgUpdate(bool isGroup, LFGPlayerStatus status) } } - // The terminal ends the QUEUE, so the ticket must not survive into the next join -- - // a stale one would make the new queue's bodies land on the old record. - // - // But not when the player is on their way INTO a dungeon: accepting a proposal also - // sends LFG_UPDATE_LEAVE (they have left the queue, not the session), and the client - // then probes with CMSG_LFG_GET_STATUS once it has zoned in. Dropping the ticket at - // the accept left that reply with nothing to quote and it went out with ticketId = 0. - // Observed at line 28568 of world-packets_2026-08-06_11-04-27: reason 15, ticket 0. - // Harmless there only because joined was 0; the same body with joined = 1 would create - // a record keyed all-zero that nothing could ever address. - if (status.updateType == LFG_UPDATE_LEAVE - && status.state != LFG_STATE_IN_DUNGEON - && status.state != LFG_STATE_FINISHED_DUNGEON) - { - sLFGMgr.ForgetTicket(playerGuid); - } + // NOTHING is forgotten here. The ticket belongs to the queue entry and is replaced by + // LFGMgr::BeginTicket when the next entry is built -- see the note there for why + // dropping it on a leave body left the client holding records nothing could address. if (!status.dungeonList.empty()) update.dungeonCategory = sLFGMgr.GetDungeonCategory(*status.dungeonList.begin()); diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 3ffec2052..89fbfe493 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1495,6 +1495,26 @@ class LFGMgr void ForgetTicket(ObjectGuid plrGuid) { m_retainedTickets.erase(plrGuid); } + /// Start a NEW queue for this player: overwrite whatever was retained. + /// + /// Ticket lifetime is tied to the QUEUE ENTRY, not to any packet. Dropping it when a + /// leave body goes out looked right and is not: the accept path also sends + /// LFG_UPDATE_LEAVE (the player left the queue, not the session), so the ticket went + /// away while the client's record was still live, and every later body -- including + /// the ones answering the player's own Leave Queue clicks -- had nothing to quote and + /// went out with ticketId = 0. The client cannot file those against any record, so the + /// eye stayed lit however many times it was clicked. Observed live at 11:31:09 with + /// five such refusals in a row. + /// + /// Overwriting here is safe precisely because it happens when a new entry is built: + /// the old queue is over, and the new one owns the player's records from now on. + void BeginTicket(ObjectGuid plrGuid, uint32 id, uint32 time) + { + RetainedTicket& t = m_retainedTickets[plrGuid]; + t.id = id; + t.time = time; + } + /// Role-Related Functions /** diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 6fb4cc260..6fe4d833d 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -419,6 +419,15 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen groupInfo.ticketId = AllocateTicketId(); m_playerData[guid] = groupInfo; + // Every member's bodies go out under this entry's ticket from now on. + for (GroupReference* itr = pGroup->GetFirstMember(); itr != NULL; itr = itr->next()) + { + if (Player* pGroupPlr = itr->getSource()) + { + BeginTicket(pGroupPlr->GetObjectGuid(), groupInfo.ticketId, uint32(groupInfo.joinedTime)); + } + } + for (GroupReference* itr = pGroup->GetFirstMember(); itr != NULL; itr = itr->next()) { if (Player* pGroupPlr = itr->getSource()) @@ -468,6 +477,7 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen playerInfo.candidateDungeons = candidates; playerInfo.ticketId = AllocateTicketId(); m_playerData[guid] = playerInfo; + BeginTicket(guid, playerInfo.ticketId, uint32(playerInfo.joinedTime)); // set up a status struct for client requests/updates // From 89fbb91ee09e258b605c8bd731cdff5c6205a167 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 11:53:06 +0100 Subject: [PATCH 40/81] Refuse Leave Instance Group while fighting inside the dungeon The teleport out is refused in combat but the removal ran anyway, so a player who clicked Leave Instance Group mid-fight was taken out of the group and left standing in the instance. The refusal is mute -- SMSG_LFG_TELEPORT_DENIED is not admitted -- so nothing on the client explained it. Observed live: removed but not teleported, while stuck in a combat stance. The consequence for everyone else is worse. Once the group is gone the remaining player has no LFG state at all, so the minimap eye disappears and with it the only way out of the instance. Observed in the same session: the player left behind had no dungeon finder eyeball or teleport option of any kind. Removing the group is the irreversible half, so it must not happen when the half that gets the player out cannot. Refusing keeps the two consistent -- the player stays in the group and can leave once combat ends. Found by an Opus review panel (finding I1). 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/GroupHandler.cpp | 28 +++++++++++++++++++++++-- src/game/WorldHandlers/LFGMgr.cpp | 23 ++++++++++++++++++++ src/game/WorldHandlers/LFGMgr.h | 3 +++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/game/WorldHandlers/GroupHandler.cpp b/src/game/WorldHandlers/GroupHandler.cpp index 1ae8870a4..947995869 100644 --- a/src/game/WorldHandlers/GroupHandler.cpp +++ b/src/game/WorldHandlers/GroupHandler.cpp @@ -511,9 +511,33 @@ void WorldSession::HandleGroupDisbandOpcode(WorldPacket& recv_data) // player is actually standing on the dungeon's map. if (pGroup->isLFGGroup()) { + // REFUSE the leave outright while the player is fighting inside the dungeon. + // + // The teleport out is refused in combat, but the removal used to run anyway, so a + // player who clicked Leave Instance Group mid-fight was taken out of the group and + // left standing in the instance -- and the refusal is mute, because + // SMSG_LFG_TELEPORT_DENIED is not admitted. Observed live: "i did leave instance + // group on the leader, i just got removed but not teleported out", while stuck in + // a combat stance. + // + // Worse for everyone else: once the group is gone the remaining player has no LFG + // state at all, so the minimap eye disappears and with it the only way out. + // Observed in the same session -- "he had no dungeon finder eyeball to teleport out + // or anything at all". + // + // Removing the group is the irreversible half, so it must not happen when the + // half that gets the player out cannot. Refusing keeps the two consistent: the + // player stays in the group, still able to leave once combat ends. + if (GetPlayer()->IsInCombat() && sLFGMgr.IsPlayerInLfgDungeon(GetPlayer())) + { + SendPartyResult(PARTY_OP_LEAVE, GetPlayer()->GetName(), ERR_PARTY_RESULT_OK); + DEBUG_LOG("HandleGroupDisbandOpcode: %s refused -- in combat inside an LFG dungeon", + GetPlayer()->GetName()); + return; + } + // Deserter BEFORE the teleport: OnPlayerLeftDungeonGroup only counts a player who - // is still standing on the dungeon's map, and TeleportPlayer is about to move - // them off it. + // is still in a live run, and the teleport is about to move them out of it. sLFGMgr.OnPlayerLeftDungeonGroup(GetPlayer()); sLFGMgr.TeleportPlayer(GetPlayer(), true); } diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 92fa89f50..bc0aad790 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1508,6 +1508,29 @@ void LFGMgr::ApplyDungeonCooldown(Player* pPlayer) } } +bool LFGMgr::IsPlayerInLfgDungeon(Player* pPlayer) +{ + if (!pPlayer) + { + return false; + } + + Group* pGroup = pPlayer->GetGroup(); + if (!pGroup || !pGroup->isLFGGroup()) + { + return false; + } + + LFGGroupStatus const* status = GetGroupStatus(pGroup->GetObjectGuid()); + if (!status) + { + return false; + } + + LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(status->dungeonID); + return dungeon && pPlayer->GetMapId() == uint32(dungeon->MapID); +} + void LFGMgr::OnPlayerLeftDungeonGroup(Player* pPlayer) { if (!pPlayer) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 89fbfe493..7a2ea8cc5 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1340,6 +1340,9 @@ class LFGMgr /// not yet made progress. void OnPlayerLeftDungeonGroup(Player* pPlayer); + /// Is this player standing inside the dungeon of a live LFG run? + bool IsPlayerInLfgDungeon(Player* pPlayer); + /// Applies the 15-minute requeue cooldown that retail starts when a player enters. void ApplyDungeonCooldown(Player* pPlayer); From 5bd0543bdc4e5a6769d5341c90fa280a0275fd5d Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 12:25:04 +0100 Subject: [PATCH 41/81] Send the backfill offer, and arrive at the entrance rather than on a member SMSG_LFG_OFFER_CONTINUE (0x1EAB) was declared and never sent, so the prompt a player saw when someone left was the client's own LFGBackfillCover driven by party state rather than by us. The body is one uint32: the packed dungeon entry, (TypeID << 24) | id, which is exactly LfgDungeonsEntry::Entry(). All 31 build-18414 bodies in the corpus are 4 bytes and decode that way -- capture-000044 seq 278015 = 0x0100008C (type 1, dungeon 140), capture-000059 seq 1946578 = 0x010001D4, capture-000133 seq 560202 = 0x0100014A, capture-000187 seq 109049 = 0x01000020. Every one is type 1. It is now registered, admitted, and sent to the remaining members when someone leaves a live run, alongside the roster-shrink SMSG_GROUP_LIST as retail does (capture-000326 seq 582508/582522, capture-000656 255287/255318, capture-000913 636664/636680; the order of the two varies, so adjacency is real but ordering is not an invariant). Accepting the offer needs nothing new: capture-000326 shows the player answering yes 43 seconds later and the ordinary join burst following. There is no separate backfill opcode. TeleportToDungeon no longer drops a joining player on the group leader. Every SMSG_NEW_WORLD arrival into an instanced map in the corpus lands on ONE fixed coordinate per map, repeated across different captures, sessions and players -- map 959 (3657.3, 2551.9, 767.0) in five captures, map 1004 (1124.6, 512.5, 1.0) in five, map 994 and map 547 likewise. A fixed per-map point that never varies is an entrance trigger. Teleporting onto a member would scatter arrivals by however deep the group had pushed, and no instanced map shows any scatter; only open-world maps (0, 1, 530, 571, 870) do, which is hearthstones and portals. Checked specifically in the ten captures containing a backfill offer, since that is the case where the leader IS inside and the old code would have diverged. A Devin review recommended the opposite -- extending the leader preference to any member already inside. The wire refutes it, so it is not adopted. CreateDungeonGroup's group-status assignment replaced every field, so re-forming a proposal around a group that is already running reset madeProgress to false. The group would be back inside its protected opening and anyone leaving would take Dungeon Deserter for a run whose first boss was long dead. Progress and the random category now carry forward. RemoveOldProposals had no logging at all, so whether the 45-second reaper ran was unobservable -- which is exactly what blocked diagnosing a client left showing a proposal that no longer exists. It now says what it cancelled and why. Build confirmed to load and reach "listening"; not left running. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 2 ++ src/game/Server/WorldSession.cpp | 1 + src/game/Server/WorldSession.h | 1 + src/game/WorldHandlers/Group.cpp | 27 ++++++++++++++ src/game/WorldHandlers/LFGHandler.cpp | 22 ++++++++++++ src/game/WorldHandlers/LFGMgr.cpp | 3 ++ src/game/WorldHandlers/LFGMgrProposal.cpp | 44 +++++++++++++++++------ 7 files changed, 89 insertions(+), 11 deletions(-) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index 9cbb99d45..e070a93e6 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -1131,6 +1131,8 @@ void InitializeOpcodes() // Direct 18414 leaf: periodic queue wait estimates and role vacancies. DefS(SMSG_LFG_QUEUE_STATUS, "SMSG_LFG_QUEUE_STATUS"); DefS(SMSG_LFG_JOIN_RESULT, "SMSG_LFG_JOIN_RESULT"); + // 4-byte body, a single packed dungeon entry -- see WorldSession::SendLfgOfferContinue. + DefS(SMSG_LFG_OFFER_CONTINUE, "SMSG_LFG_OFFER_CONTINUE"); DefS(SMSG_LFG_PROPOSAL_UPDATE, "SMSG_LFG_PROPOSAL_UPDATE"); DefS(SMSG_LFG_ROLE_CHECK_UPDATE, "SMSG_LFG_ROLE_CHECK_UPDATE"); DefS(SMSG_LFG_TELEPORT_DENIED, "SMSG_LFG_TELEPORT_DENIED"); diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index 27de0e53b..4a89f7851 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -609,6 +609,7 @@ static bool IsEnterWorldConverted(uint16 opcode) case SMSG_LFG_BOOT_PLAYER: // MopLfgPackets::BuildBootPlayer case SMSG_LFG_UPDATE_STATUS: // MopLfgPackets::BuildUpdateStatus case SMSG_LFG_QUEUE_STATUS: // MopLfgPackets::BuildQueueStatus + case SMSG_LFG_OFFER_CONTINUE: // 4-byte packed dungeon entry; 31/31 corpus bodies are this shape case SMSG_LFG_JOIN_RESULT: // MopLfgPackets::BuildJoinResult, byte-exact vs capture-000059 seq 490545 (18B refusal), // capture-000044 seq 1547 (23B) and capture-000075 seq 891753 (24B) case SMSG_LFG_PLAYER_INFO: // MopLfgPackets::BuildEmptyPlayerInfo diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index 90d3674d3..d51756cc3 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -1568,6 +1568,7 @@ class WorldSession void SendLfgRoleChosen(uint64 rawGuid, uint8 roles); void SendLfgProposalUpdate(LFGProposal const& proposal); void SendLfgTeleportError(uint8 error); + void SendLfgOfferContinue(uint32 dungeonEntry); void SendLfgRewards(LFGRewards const& rewards); void SendLfgBootUpdate(LFGBoot const& boot); void SendPartyResult(PartyOperation operation, const std::string& member, PartyResult res); diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index b5c0255a3..cf3952277 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -1662,6 +1662,33 @@ uint32 Group::RemoveMember(ObjectGuid guid, uint8 removeMethod) } SendUpdate(); + + // Offer to backfill the slot that just opened. + // + // Retail sends SMSG_LFG_OFFER_CONTINUE alongside the roster-shrink + // SMSG_GROUP_LIST, in the same second -- capture-000326 seq 582508 and 582522, + // capture-000656 seq 255287/255318, capture-000913 seq 636664/636680. The order + // of the two varies between captures, so adjacency is real but strict ordering is + // not an invariant. + // + // Only while the run is still live: a group that has finished its dungeon has no + // slot worth filling, and one with no status is not in a run at all. + if (isLFGGroup()) + { + if (uint32 const dungeonEntry = sLFGMgr.GetGroupDungeonEntry(GetObjectGuid())) + { + if (sLFGMgr.GetGroupLfgState(GetObjectGuid()) != LFG_STATE_FINISHED_DUNGEON) + { + for (member_citerator citr = m_memberSlots.begin(); citr != m_memberSlots.end(); ++citr) + { + if (Player* pMember = sObjectMgr.GetPlayer(citr->guid)) + { + pMember->GetSession()->SendLfgOfferContinue(dungeonEntry); + } + } + } + } + } } // if group before remove <= 2 disband it else diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 738c47904..4b34ddf9f 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -718,6 +718,28 @@ void WorldSession::SendLfgProposalUpdate(LFGProposal const& proposal) SendPacket(&data); } +void WorldSession::SendLfgOfferContinue(uint32 dungeonEntry) +{ + // "A player has left your group. Would you like to find another player to finish %s?" + // + // The whole body is ONE uint32: the packed dungeon entry, (TypeID << 24) | id, which is + // exactly what LfgDungeonsEntry::Entry() returns. All 31 build-18414 bodies in the + // corpus are 4 bytes and decode that way -- capture-000044 seq 278015 = 0x0100008C + // (type 1, dungeon 140), capture-000059 seq 1946578 = 0x010001D4, capture-000133 seq + // 560202 = 0x0100014A, capture-000187 seq 109049 = 0x01000020. Every one is type 1. + // + // The client raises LFG_OFFER_CONTINUE from this and names the dungeon in the popup. + // Answering yes sends an ordinary CMSG_LFG_JOIN for that dungeon -- there is no + // separate backfill opcode. capture-000326 shows the whole episode: offer, then the + // normal join burst 43s later when the player accepted. + // + // We never sent this at all, so the prompt players saw was the client's own + // LFGBackfillCover driven by party state rather than by us. + WorldPacket data(SMSG_LFG_OFFER_CONTINUE, 4); + data << uint32(dungeonEntry); + SendPacket(&data); +} + void WorldSession::SendLfgTeleportError(uint8 error) { DEBUG_LOG("SMSG_LFG_TELEPORT_DENIED"); diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index bc0aad790..7a4e587f3 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1089,6 +1089,9 @@ void LFGMgr::RemoveOldProposals() } } + DEBUG_LOG("LFG RemoveOldProposals: proposal %u expired after %u s with %u " + "unanswered member(s); cancelling", + *it, uint32(LFG_TIME_PROPOSAL), uint32(silent.size())); CancelProposal(*it, silent); } } diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index ada2af6dd..bbd1a0042 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -923,6 +923,21 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) groupStatus.randomDungeonID = proposal->dungeonID; } + // Carry forward what the run has already achieved. + // + // This assignment replaces every field, so re-forming a proposal around a group that + // is ALREADY running -- which is what a backfill does -- reset madeProgress to false. + // The group would then be back inside its protected opening, and anyone leaving after + // that would take Dungeon Deserter for a run whose first boss was long dead. + if (LFGGroupStatus const* existing = GetGroupStatus(groupGuid)) + { + groupStatus.madeProgress = groupStatus.madeProgress || existing->madeProgress; + if (!groupStatus.randomDungeonID) + { + groupStatus.randomDungeonID = existing->randomDungeonID; + } + } + m_groupSet.insert(groupGuid); m_groupStatusMap[groupGuid] = groupStatus; @@ -949,17 +964,24 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) // cooldown below. LFGGroupStatus const* runStatus = GetGroupStatus(pGroup->GetObjectGuid()); - Player* pGroupLeader = sObjectAccessor.FindPlayer(pGroup->GetLeaderGuid()); - - if (pGroupLeader && pGroupLeader->GetMapId() == mapID) // Already in the dungeon - { - // set teleport location to that of the group leader - x = pGroupLeader->GetPositionX(); - y = pGroupLeader->GetPositionY(); - z = pGroupLeader->GetPositionZ(); - o = pGroupLeader->GetOrientation(); - } - else + // ALWAYS the entrance trigger -- never a group member's position. + // + // This used to drop a joining player on the group leader if the leader was already + // inside. Retail does not do that. Every SMSG_NEW_WORLD arrival into an instanced map + // in the build-18414 corpus lands on ONE fixed coordinate per map, repeated across + // different captures, sessions and players: + // + // map 959 (3657.3, 2551.9, 767.0) captures 000059, 000326, 000656, 000913, 000476 + // map 994 (-3969.7, -2542.7, 26.8) captures 000059, 000326, 000656 + // map 1004 (1124.6, 512.5, 1.0) captures 000059, 000326, 000656, 000887, 000476 + // map 547 (122.4, -123.6, -0.3) captures 000044, 000187 + // + // A fixed per-map point that never varies is an entrance trigger. Teleporting to a + // member would scatter arrivals by however deep the group had pushed, and no instanced + // map in the corpus shows any scatter at all -- only open-world maps (0, 1, 530, 571, + // 870) do, which is hearthstones and portals. This was checked specifically in the ten + // captures that contain a backfill offer, since that is the case where the leader IS + // inside and the old code would have diverged. { if (AreaTrigger const* at = sObjectMgr.GetMapEntranceTrigger(mapID)) { From 783c40c907e412142cbf173c5dd6026b4c821911 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 12:55:09 +0100 Subject: [PATCH 42/81] Withdraw the entrance-always teleport; the evidence for it did not hold 70ad09325 replaced the member-position preference in TeleportToDungeon with an unconditional entrance trigger, on the argument that every instanced SMSG_NEW_WORLD arrival in the corpus lands on one fixed coordinate per map. A review panel refuted it and the refutation is correct. The rule under test is "entrance UNLESS a group member is already on the dungeon map". For every ordinary run nobody is inside yet, so that rule REDUCES to the entrance trigger. A fixed per-map coordinate is therefore exactly what BOTH readings predict, and the measurement cannot separate them. Worse, the specific check the commit message cited is structurally impossible. It claimed the ten captures containing SMSG_LFG_OFFER_CONTINUE were examined for this. A capture carrying that offer is a REMAINING member's session -- that client is already inside the dungeon and never receives an SMSG_NEW_WORLD for the replacement's arrival. Those captures cannot contain the observation attributed to them. The number of arrivals looked large and the conclusion looked measured; it was neither. Against it: Warcraft Wiki states replacements appear at the location of the players already inside, and two of three fork lineages implement that live -- PandariaCore LFGMgr.cpp:2117-2150 (leader first, then any member) and Legends-of-Azeroth the same. SkyFire's copy is dead code: its loop condition `itr != NULL && !mapid` never fires because mapid is pre-set to dungeon->map. Restored, and slightly better than before: leader first, then ANY member standing on the dungeon's map, entrance last. The old code only considered the leader, so a backfill into a group whose leader had already ported out fell back to the entrance unnecessarily. This does not affect which INSTANCE is used. The group bind decides that, which is the actual cause of a backfilled player landing in a fresh instance. What would settle it on the wire, for whoever picks this up: find a session that receives SMSG_LFG_PROPOSAL_UPDATE, sends CMSG_LFG_PROPOSAL_RESPONSE, then receives SMSG_NEW_WORLD into an instanced map, and compare that coordinate with the map's entrance. An arrival that differs proves the member-position rule. A large sample that never differs remains consistent with both. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 57 +++++++++++++++++------ 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index bbd1a0042..7afc080c0 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -964,24 +964,51 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) // cooldown below. LFGGroupStatus const* runStatus = GetGroupStatus(pGroup->GetObjectGuid()); - // ALWAYS the entrance trigger -- never a group member's position. + // Prefer a group member already INSIDE the dungeon; entrance trigger otherwise. // - // This used to drop a joining player on the group leader if the leader was already - // inside. Retail does not do that. Every SMSG_NEW_WORLD arrival into an instanced map - // in the build-18414 corpus lands on ONE fixed coordinate per map, repeated across - // different captures, sessions and players: + // A previous commit here replaced this with an unconditional entrance trigger, arguing + // that every instanced SMSG_NEW_WORLD arrival in the corpus lands on one fixed + // coordinate per map. That argument does not hold and the change is withdrawn: // - // map 959 (3657.3, 2551.9, 767.0) captures 000059, 000326, 000656, 000913, 000476 - // map 994 (-3969.7, -2542.7, 26.8) captures 000059, 000326, 000656 - // map 1004 (1124.6, 512.5, 1.0) captures 000059, 000326, 000656, 000887, 000476 - // map 547 (122.4, -123.6, -0.3) captures 000044, 000187 + // - The rule being tested is "entrance UNLESS a member is already on the map". For + // every ordinary run nobody is inside yet, so it REDUCES to the entrance trigger. + // A fixed per-map coordinate is therefore what BOTH readings predict, and the + // observation cannot separate them. + // - The check was said to be made in the ten captures carrying a backfill offer. A + // capture containing SMSG_LFG_OFFER_CONTINUE is a REMAINING member's session -- + // that client is already inside and receives no SMSG_NEW_WORLD for the newcomer's + // arrival at all. Those captures cannot hold the observation claimed from them. // - // A fixed per-map point that never varies is an entrance trigger. Teleporting to a - // member would scatter arrivals by however deep the group had pushed, and no instanced - // map in the corpus shows any scatter at all -- only open-world maps (0, 1, 530, 571, - // 870) do, which is hearthstones and portals. This was checked specifically in the ten - // captures that contain a backfill offer, since that is the case where the leader IS - // inside and the old code would have diverged. + // Against it: Warcraft Wiki states replacements appear at the location of the players + // already inside, and PandariaCore (LFGMgr.cpp:2117-2150, leader then any member) and + // Legends-of-Azeroth implement exactly that. SkyFire's copy is dead code -- its loop + // condition `itr != NULL && !mapid` never fires because mapid is pre-set. + // + // Leader first, then any member on the map, entrance last. This is a no-op for a + // normal run and does not affect WHICH instance is used -- the group bind decides that. + Player* teleportTo = sObjectAccessor.FindPlayer(pGroup->GetLeaderGuid()); + if (!teleportTo || teleportTo->GetMapId() != mapID) + { + teleportTo = NULL; + for (GroupReference* itr = pGroup->GetFirstMember(); itr != NULL; itr = itr->next()) + { + Player* pMember = itr->getSource(); + if (pMember && pMember->GetMapId() == mapID) + { + teleportTo = pMember; + break; + } + } + } + + if (teleportTo) + { + x = teleportTo->GetPositionX(); + y = teleportTo->GetPositionY(); + z = teleportTo->GetPositionZ(); + o = teleportTo->GetOrientation(); + } + else { if (AreaTrigger const* at = sObjectMgr.GetMapEntranceTrigger(mapID)) { From 936be0a74d03bfe5eab305ed70cd8d8e46e8b0dd Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 13:10:30 +0100 Subject: [PATCH 43/81] Continue an in-progress run instead of forming a second group A player matched into an existing in-progress LFG group was put in a BRAND NEW group, and therefore a brand new instance. Reproduced live: leader in Deadmines instance 2 under Group Id 1, the backfilled player in instance 3 under Group Id 2. The cause is the group bind, not the teleport. Player::TeleportTo resolves through GetBoundInstanceSaveForSelfOrGroup -> Group::GetBoundInstance, so no group membership means no group bind and MapManager mints a fresh instance. Adding the newcomer to the existing group before teleporting is the whole mechanism; the add loop already runs before TeleportToDungeon. IsProposalSameGroup asked the wrong question. It required EVERY member to share one group, so a backfill -- a live group plus one solo queuer -- always answered false. It was right about one thing, recorded in its own comment: a plain world premade must NOT be reused, and no fork does. ResolveContinuingGroup keeps that distinction by resolving only a LIVE FINDER RUN, never an ordinary party, and IsLiveLfgRun carries the pair GetJoinResult already computed inline (GROUPTYPE_LFD is never cleared, so membership alone would also match a finished run that stayed together). Backfill is not a distinct protocol. The client answers the offer with an ordinary CMSG_LFG_JOIN carrying the same dungeon slot -- capture-000059 offer at seq 1946578 is D4 01 00 01 and the join three seconds later at seq 1946936 carries the identical value -- so "this is a backfill" is server-side state only. Changes: - At most ONE live run per proposal. RoleMapsAreCompatible refuses a merge spanning two, before the debug early-out so a .debug merge cannot build what the normal path refuses. Every fork treats this as hard-incompatible. - A continuing proposal PINS the dungeon to the one the group is standing in. Without it a random-category backfill re-rolls PickConcreteDungeon and proposes a different dungeon from the run in progress. - groupRawGuid and groupLeaderGuid are set once from the resolved group, not from whichever member happens to carry the leader bit, which for a mixed proposal is arbitrary. - isNew is finally set (it was hardcoded true and never cleared anywhere), which is what makes SendLfgProposalUpdate's silent and inProposedGroup flags work at all. - The incumbents are AUTO-ACCEPTED. This is mandatory, not a courtesy: with isNew = false their proposal is marked silent, so they get no window to answer it, and leaving them pending would time every backfill out at 45 seconds. They are already in the dungeon; they are not being asked anything. - CreateDungeonGroup resolves the group by ID rather than through the stored leader or the proposal's snapshot, both of which are stale after 45 seconds. If the group has since disbanded or finished it DOWNGRADES to forming a new one rather than refusing -- by that point every member has already been told a group was found and dequeued, and CancelProposal with an empty culprit set requeues unchanged and re-proposes forever. - The group status is MERGED on the reuse path, not replaced. Replacing would reset state, dungeonID and madeProgress on a group mid-dungeon. - The SetAsLfgGroup block is gone. It existed to mark a premade about to become a finder group; a continuing run is already flagged and a new group is flagged by CreateDungeonGroup. Marking a plain premade there is what made ordinary parties start reporting themselves as finder groups. Matching priority needs no code. The working hypothesis of solo-first is refuted: there is no such preference in any fork and none in the client. With one vacancy a queuer of size 2+ simply cannot fit, which produces the appearance without the rule. Roles remain a hard constraint on the union, which RoleMapsAreCompatible already enforces. Specified by an Opus review panel and a Devin research pass, reconciled where they disagreed. Build confirmed to load; not left running. Untested at runtime -- no live client while the user is away. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 17 ++ src/game/WorldHandlers/LFGMgr.h | 18 +- src/game/WorldHandlers/LFGMgrProposal.cpp | 233 +++++++++++++--------- 3 files changed, 168 insertions(+), 100 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 7a4e587f3..dd83b633a 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1229,6 +1229,23 @@ bool LFGMgr::RoleMapsAreCompatible(LFGPlayers* groupOne, LFGPlayers* groupTwo, return false; } + // At most ONE live finder run per proposal. + // + // Merging two in-progress runs has no sane resolution: whichever group were continued, + // the other's players would be torn out of a dungeon they are standing in and their + // instance abandoned. Every fork treats this as hard-incompatible -- SkyFire + // LFGQueue.cpp:355-360, CPP :478-483, PandariaCore :382-386, all named + // LFG_INCOMPATIBLES_MULTIPLE_LFG_GROUPS. + // + // Checked BEFORE the debug early-out on purpose: a `.debug dungeon` merge must not be + // able to build a proposal the normal path refuses. + uint32 liveRuns = 0; + ResolveContinuingGroup(combined, liveRuns); + if (liveRuns > 1) + { + return false; + } + if (debugMerge) { return true; diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 7a2ea8cc5..2a13b24c4 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1579,7 +1579,23 @@ class LFGMgr bool MatchesAreOfSameTeam(LFGPlayers* groupOne, LFGPlayers* groupTwo); /// Are the players in a proposal already grouped up? - bool IsProposalSameGroup(LFGProposal const& proposal); + /// Is this a finder group whose run is still live? + /// + /// GROUPTYPE_LFD is never cleared anywhere, so membership alone also matches a + /// finished run whose party stayed together -- hence the state test as well. + bool IsLiveLfgRun(Group* pGroup); + + /// The group a proposal must be built INTO, or an empty guid to build a fresh one. + /// + /// Replaces IsProposalSameGroup, which asked the wrong question: it required EVERY + /// member to share one group, so a backfill (a live group plus one solo queuer) always + /// answered false and a brand new group was formed -- and with it a brand new instance. + /// It was also right to refuse a plain world premade, which this preserves: only a LIVE + /// FINDER RUN is continued, never an ordinary party. + /// + /// outLiveRuns > 1 means the matchmaker merged two live runs, which must not happen: + /// every fork treats that as hard-incompatible. Callers refuse rather than pick one. + ObjectGuid ResolveContinuingGroup(roleMap const& members, uint32& outLiveRuns); /// Update a proposal after a player refused to join void ProposalDeclined(ObjectGuid guid, LFGProposal* proposal); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 7afc080c0..4afdf14b6 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -314,7 +314,53 @@ void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) avail.str().c_str(), newProposal.dungeonID, GetDungeonEntry(newProposal.dungeonID)); } - bool premadeGroup = IsProposalSameGroup(newProposal); + // Is this proposal CONTINUING an existing run, or forming a new group? + // + // A backfill is not a distinct protocol -- the client answers the offer with an + // ordinary CMSG_LFG_JOIN carrying the same dungeon slot -- so "this is a backfill" is + // server-side state only: one of the members belongs to a finder group whose run is + // still live. + uint32 liveRuns = 0; + ObjectGuid const continueGuid = ResolveContinuingGroup(lfgGroup->currentRoles, liveRuns); + if (liveRuns > 1) + { + // Two live runs in one proposal has no sane resolution -- whichever group were + // reused, the other's players would be torn out of a dungeon they are standing in. + // Every fork treats this as hard-incompatible; RoleMapsAreCompatible now refuses + // the merge, so reaching here means something upstream slipped. + sLog.outError("LFG SendDungeonProposal: %u live LFG runs in one proposal; refusing. " + "The queue entry is left intact.", liveRuns); + return; + } + + bool const continuing = !continueGuid.IsEmpty(); + Group* continueGroup = continuing ? sObjectMgr.GetGroupById(continueGuid.GetCounter()) : NULL; + if (continuing && !continueGroup) + { + sLog.outError("LFG SendDungeonProposal: continuing group %s vanished; refusing.", + continueGuid.GetString().c_str()); + return; + } + + if (continuing) + { + // PIN the dungeon to the one the group is standing in. Without this a random + // category re-rolls PickConcreteDungeon and proposes a DIFFERENT dungeon from the + // one the run is in -- the fork-unanimous isContinue pin. + if (LFGGroupStatus const* runStatus = GetGroupStatus(continueGuid)) + { + newProposal.concreteDungeonID = runStatus->dungeonID; + } + + // Set ONCE, from the resolved group -- not from whichever member happens to carry + // the leader role bit. + newProposal.groupRawGuid = continueGuid.GetRawValue(); + newProposal.groupLeaderGuid = continueGroup->GetLeaderGuid().GetRawValue(); + } + + // isNew drives SendLfgProposalUpdate's `silent` and `inProposedGroup` flags + // (LFGHandler.cpp:684-685, :708-709), which were dead while this was hardcoded true. + newProposal.isNew = !continuing; // iterate through role map just so get everyone's guid for (roleMap::iterator it = lfgGroup->currentRoles.begin(); it != lfgGroup->currentRoles.end(); ++it) @@ -334,18 +380,23 @@ void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) SetPlayerUpdateType(plrGuid, LFG_UPDATE_PROPOSAL_BEGIN); - if (premadeGroup && pGroup->IsLeader(plrGuid)) - { - newProposal.groupLeaderGuid = plrGuid.GetRawValue(); - } + // groupRawGuid / groupLeaderGuid are set ONCE above, from the resolved + // continuing group. They used to be filled in here from whichever member + // happened to carry the leader bit, which for a mixed proposal is arbitrary. + newProposal.groups[plrGuid] = grpGuid; - if (premadeGroup && !newProposal.groupRawGuid) + // AUTO-ACCEPT the players already in the continuing run. + // + // This is mandatory, not a courtesy. With isNew = false, SendLfgProposalUpdate + // marks their proposal `silent` -- they get NO window to answer it. Leaving + // them PENDING would time the proposal out at 45 s every single time and turn + // a broken feature into a total one. They are already in the dungeon; they are + // not being asked anything. + if (continuing && grpGuid == continueGuid) { - newProposal.groupRawGuid = grpGuid.GetRawValue(); + newProposal.answers[plrGuid] = LFG_ANSWER_AGREE; } - newProposal.groups[plrGuid] = grpGuid; - SendLfgUpdate(plrGuid, GetPlayerStatus(plrGuid), true); } else @@ -377,87 +428,55 @@ void LFGMgr::SendDungeonProposal(ObjectGuid queueGuid, LFGPlayers* lfgGroup) } } - // then if group guid is set, call Group::SetAsLfgGroup() - if (premadeGroup) - { - Player* pGroupLeader = sObjectAccessor.FindPlayer(ObjectGuid(newProposal.groupLeaderGuid)); - - if (pGroupLeader) - { - Group* pGroup = pGroupLeader->GetGroup(); - if (pGroup) - { - pGroup->SetAsLfgGroup(); - } - else - { - // Log an error: group not found for group leader - // In the future, we should determine the right actions for this scenario. - } - } - else - { - // Log an error: group leader not found - // In the future, we should determine the right actions for this scenario. - } - } + // No SetAsLfgGroup here. + // + // It existed to mark a PREMADE that was about to become a finder group. A continuing + // run is already flagged -- GROUPTYPE_LFD is set when the finder first formed it and + // is never cleared -- and a brand new group is flagged by CreateDungeonGroup when it + // builds one. Marking a plain world premade here was the behaviour that made an + // ordinary party start reporting itself as a dungeon-finder group. // also save the proposal m_proposalMap[newProposal.id] = newProposal; } -bool LFGMgr::IsProposalSameGroup(LFGProposal const& proposal) +bool LFGMgr::IsLiveLfgRun(Group* pGroup) { - // True only when EVERY member is in the SAME existing group. - // - // This used to skip ungrouped players entirely, so a two-man party matched with - // three solo queuers returned true -- the proposal was then treated as a premade - // and CreateDungeonGroup reused the party's group without ever adding the solos. - // It also returned true when nobody was grouped at all, because isSameGroup started - // true and had no way to become false. - bool firstLoop = true; - bool isSameGroup = true; - bool anyGrouped = false; + if (!pGroup || !pGroup->isLFGGroup()) + { + return false; + } - ObjectGuid priorGroupGuid; + LFGGroupStatus const* status = GetGroupStatus(pGroup->GetObjectGuid()); + return status && status->state != LFG_STATE_FINISHED_DUNGEON; +} - // when this is called we don't have the groups part filled, so iterate via role map - for (roleMap::const_iterator it = proposal.currentRoles.begin(); it != proposal.currentRoles.end(); ++it) - { - ObjectGuid plrGuid = it->first; +ObjectGuid LFGMgr::ResolveContinuingGroup(roleMap const& members, uint32& outLiveRuns) +{ + std::set runs; - Player* pPlayer = sObjectAccessor.FindPlayer(plrGuid); - // A queued player who logged out, or who is mid-teleport, is not found. - // This runs BEFORE the offline-skip loop in SendDungeonProposal, so one - // absent member would crash the whole proposal. + for (roleMap::const_iterator it = members.begin(); it != members.end(); ++it) + { + Player* pPlayer = sObjectAccessor.FindPlayer(it->first); if (!pPlayer) { - continue; - } - - Group* pGroup = pPlayer->GetGroup(); - if (!pGroup) - { - return false; // an ungrouped member means this is not one existing group + continue; // logged out or mid-teleport; the caller skips them too } - anyGrouped = true; - ObjectGuid grpGuid = pGroup->GetObjectGuid(); - - if (firstLoop) - { - priorGroupGuid = grpGuid; - firstLoop = false; - } - else if (grpGuid != priorGroupGuid) + if (Group* pGroup = pPlayer->GetGroup()) { - isSameGroup = false; + if (IsLiveLfgRun(pGroup)) + { + runs.insert(pGroup->GetObjectGuid()); + } } } - return anyGrouped && isSameGroup; + outLiveRuns = uint32(runs.size()); + return runs.size() == 1 ? *runs.begin() : ObjectGuid(); } + // From a CMSG_LFG_PROPOSAL_RESPONSE call /// A decline cancels the proposal, but it does NOT eject everyone. /// @@ -726,34 +745,31 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) if (proposal->groupRawGuid) { - // Reuse the premade group the proposal was built around. - Player* pGroupLeader = sObjectAccessor.FindPlayer(ObjectGuid(proposal->groupLeaderGuid)); - if (pGroupLeader) - { - pGroup = pGroupLeader->GetGroup(); - } + // Resolve by GROUP ID, not through the stored leader. + // + // The leader may have logged out, been promoted away, or left in the 45 seconds a + // proposal can sit open, and proposal->groups is a snapshot taken when it was + // built -- both are stale by the time anyone accepts. The group id is not. + pGroup = sObjectMgr.GetGroupById(ObjectGuid(proposal->groupRawGuid).GetCounter()); - // The stored leader may have logged out between proposal and acceptance. Fall - // back to any online member still in that same group. - if (!pGroup) + // VALIDATE, then DOWNGRADE -- never refuse here. + // + // By the time this runs, ProposalUpdate has already told every member the group + // was found and dequeued them. Refusing now would leave them holding that message + // with nothing behind it, and CancelProposal with an empty culprit set requeues + // the entry unchanged and re-proposes forever. + // + // Downgrading is safe in every case it can fire: if the group disbanded there is + // nothing left to continue, and if it finished its dungeon then forming a fresh + // group for these players is exactly right. + if (!IsLiveLfgRun(pGroup)) { - for (playerGroupMap::const_iterator it = proposal->groups.begin(); - it != proposal->groups.end(); ++it) - { - if (it->second.GetRawValue() != proposal->groupRawGuid) - { - continue; - } - - if (Player* pMember = sObjectAccessor.FindPlayer(it->first)) - { - pGroup = pMember->GetGroup(); - if (pGroup) - { - break; - } - } - } + DEBUG_LOG("LFG CreateDungeonGroup: continuing group %s is gone or finished; " + "forming a new group instead", + ObjectGuid(proposal->groupRawGuid).GetString().c_str()); + pGroup = nullptr; + proposal->groupRawGuid = 0; + proposal->groupLeaderGuid = 0; } } @@ -938,8 +954,27 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) } } - m_groupSet.insert(groupGuid); - m_groupStatusMap[groupGuid] = groupStatus; + if (proposal->groupRawGuid && GetGroupStatus(groupGuid)) + { + // CONTINUING a live run: merge the roster in, leave the run itself alone. + // + // Replacing wholesale would reset state, dungeonID and madeProgress on a group + // that is mid-dungeon -- putting it back inside its protected opening, so anyone + // leaving would take Deserter for a run whose first boss was long dead, and + // repointing dungeonID at whatever the proposal happened to carry. + LFGGroupStatus* live = GetGroupStatus(groupGuid); + for (roleMap::const_iterator it = proposal->currentRoles.begin(); + it != proposal->currentRoles.end(); ++it) + { + live->playerRoles[it->first] = it->second; + } + live->leaderGuid = pGroup->GetLeaderGuid(); + } + else + { + m_groupSet.insert(groupGuid); + m_groupStatusMap[groupGuid] = groupStatus; + } TeleportToDungeon(dungeon->ID, pGroup); From 5a2e844d51fbdd00b35c11d764bd171c516bb8d3 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 13:14:44 +0100 Subject: [PATCH 44/81] Close LFG status records under the key they were announced with The mainline cause of the minimap eye surviving a decline. CancelProposal hardcoded isGroup = false on both its sends. SendLfgUpdate derives requesterGuid from that flag, and the client files every status body under the whole 20-byte RideTicket -- so for a party-owned queue, whose opening bodies went out group-keyed (reason 24 from JoinLFG, reason 14 from SendDungeonProposal), the closing body carried a DIFFERENT ticket. The client created a second record and left the first at joined = 1, queued = 0, which UIParent.lua:3932 reports as "suspended". That is the "paused" eye, and it is the ordinary decline path, not an edge case. proposal.groups records exactly how each member was announced, so it is the authority. Both the culprit close and the survivor requeue now read it. A third group had no closing body at all: anyone in proposal.answers who was neither blamed nor requeued. Reachable whenever the entry no longer lists them -- a merged queuer whose entry was folded away, or a member removed by the group branch of LeaveLFG. They are now closed out under their announced key too. I2: LeaveLFG's group branch never cancelled proposals. The solo branch always has. Consequences, both deterministic: every member of the departing party was refused re-queue with ERR_LFG_NO_LFG_OBJECT for the full 45 seconds of LFG_TIME_PROPOSAL, because JoinLFG checks HasLiveProposalFor first; and since the branch erases the queue entry, when the reaper finally fired CancelProposal found a null entry and told the survivors nothing. Proposals are now cancelled for every member before the entry is touched, and the per-member announce is skipped when that already sent a properly keyed LEAVE -- the queue removal still runs for each of them. Found by an Opus review panel. Build confirmed to load; not left running. Untested at runtime. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 43 ++++++++++++++++++++++++-- src/game/WorldHandlers/LFGMgrQueue.cpp | 31 ++++++++++++++++++- 2 files changed, 71 insertions(+), 3 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index dd83b633a..8a18b16b8 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -987,9 +987,24 @@ void LFGMgr::CancelProposal(uint32 proposalId, std::set const& culpr entry->currentRoles.erase(*bad); } + // isGroup derived from how this member was ANNOUNCED, not hardcoded false. + // + // The client files each status body under the whole 20-byte RideTicket, and + // SendLfgUpdate picks requesterGuid from this flag. A party-owned queue announced + // its opening bodies group-keyed (reason 24 at LFGMgrQueue.cpp, reason 14 at + // SendDungeonProposal), so closing with false carried a DIFFERENT ticket: the + // client created a second record and left the first at joined = 1, queued = 0, + // which UIParent.lua:3932 reports as "suspended" -- the eye stuck until relog. + // This is the mainline path for an ordinary decline. + // + // proposal.groups records exactly how each member was announced, so it is the + // authority here. + playerGroupMap::const_iterator badGroup = proposal.groups.find(*bad); + bool const badWasGroupKeyed = badGroup != proposal.groups.end() && badGroup->second; + SetPlayerState(*bad, LFG_STATE_NONE); SetPlayerUpdateType(*bad, LFG_UPDATE_LEAVE); - SendLfgUpdate(*bad, GetPlayerStatus(*bad), false); + SendLfgUpdate(*bad, GetPlayerStatus(*bad), badWasGroupKeyed); m_playerStatusMap.erase(*bad); @@ -1016,9 +1031,33 @@ void LFGMgr::CancelProposal(uint32 proposalId, std::set const& culpr for (roleMap::const_iterator role = entry->currentRoles.begin(); role != entry->currentRoles.end(); ++role) { + playerGroupMap::const_iterator roleGroup = proposal.groups.find(role->first); + bool const roleWasGroupKeyed = roleGroup != proposal.groups.end() && roleGroup->second; + SetPlayerState(role->first, LFG_STATE_QUEUED); SetPlayerUpdateType(role->first, LFG_UPDATE_ADDED_TO_QUEUE); - SendLfgUpdate(role->first, GetPlayerStatus(role->first), false); + SendLfgUpdate(role->first, GetPlayerStatus(role->first), roleWasGroupKeyed); + } + + // Anyone in the proposal who was neither blamed nor requeued still has an open + // reason-14 record. Without a closing body it sits at joined = 1, queued = 0 and the + // eye stays lit. Reachable whenever `entry` no longer lists them -- a merged queuer + // whose entry was folded away, or a member removed by the group branch of LeaveLFG. + for (proposalAnswerMap::const_iterator ans = proposal.answers.begin(); + ans != proposal.answers.end(); ++ans) + { + if (culprits.find(ans->first) != culprits.end() || + entry->currentRoles.find(ans->first) != entry->currentRoles.end()) + { + continue; // already closed out or requeued above + } + + playerGroupMap::const_iterator ansGroup = proposal.groups.find(ans->first); + bool const ansWasGroupKeyed = ansGroup != proposal.groups.end() && ansGroup->second; + + SetPlayerState(ans->first, LFG_STATE_NONE); + SetPlayerUpdateType(ans->first, LFG_UPDATE_LEAVE); + SendLfgUpdate(ans->first, GetPlayerStatus(ans->first), ansWasGroupKeyed); } m_queueSet.insert(proposal.queueGuid); diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 6fe4d833d..5313f85e0 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -528,6 +528,29 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) Group* pGroup = plr->GetGroup(); ObjectGuid grpGuid = pGroup->GetObjectGuid(); + // Tear down a live proposal for ANY member before touching the queue entry. + // + // The solo branch has always done this; the group branch never did, and the queue + // entry is erased at the end of it -- so when the 45-second reaper finally fired, + // CancelProposal found a null entry and told the survivors nothing at all. Until + // then every member of the departing party was refused re-queue with + // ERR_LFG_NO_LFG_OBJECT, because JoinLFG checks HasLiveProposalFor first. + // + // Cancelling here also closes each member's status record under the key it was + // announced with, which is what stops the minimap eye surviving the leave. + bool cancelledAProposal = false; + for (GroupReference* itr = pGroup->GetFirstMember(); itr != NULL; itr = itr->next()) + { + if (Player* pGroupPlr = itr->getSource()) + { + if (HasLiveProposalFor(pGroupPlr->GetObjectGuid())) + { + CancelProposalsFor(pGroupPlr->GetObjectGuid()); + cancelledAProposal = true; + } + } + } + for (GroupReference* itr = pGroup->GetFirstMember(); itr != NULL; itr = itr->next()) { if (Player* pGroupPlr = itr->getSource()) @@ -536,7 +559,13 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) LFGPlayerStatus grpPlrStatus = GetPlayerStatus(grpPlrGuid); - if (grpPlrStatus.state == LFG_STATE_ROLECHECK) + if (cancelledAProposal) + { + // CancelProposal already sent this member a properly keyed LEAVE. + // A second one here would be a duplicate, and the queue removal below + // still has to happen, so only the announce is skipped. + } + else if (grpPlrStatus.state == LFG_STATE_ROLECHECK) { // A role check in progress is aborted rather than answered with a // leave; PerformRoleCheck tells everyone and tears the check down. From 0fbaf72c690d59e57977c6704693ceab8f8ed9ce Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 13:25:27 +0100 Subject: [PATCH 45/81] Match on what an entry can run, not on what it asked for A random queue stores only the CATEGORY row in dungeonList -- the expansion lives in candidateDungeons -- and FindSpecificQueueMatches intersected dungeonList directly. That compares a category id against real dungeon ids, so it matched nothing. Random-vs-random happened to work because both entries carried the same category row, and specific-vs-specific worked because both carried real ids. RANDOM-VS- SPECIFIC could never match at all, which is most of what a random queuer exists to do -- and it is why a random queuer could not backfill a run, whose entry is pinned to the dungeon it is standing in. The intersection now uses each entry's runnable set: candidateDungeons where it has one, dungeonList otherwise. M6: MergeGroups left candidateDungeons holding the absorbing entry's full expansion, so PickConcreteDungeon could propose a dungeon the newly merged-in player never asked for and may be locked out of. It is now narrowed to the same overlap, keeping the pick inside what both sides agreed to. Not changed, and why: the "unknown is now leader of your group" seen on a live join is undiagnosed. Ruled out so far -- the SMSG_GROUP_SET_LEADER layout (derived from three captured bodies), the name passed to AddMember, promotion versus announcement (both read m_memberSlots.front(), so they agree), and partyIndex (initialised to 1). Ordering was also considered and rejected on evidence: across 240 build-18414 occurrences the nearest SMSG_GROUP_LIST comes before the leader broadcast 102 times and after it 137, so there is no invariant to violate. Needs a packet log from a repro. Also worth recording so it is not chased again: a backfill proposing instantly to itself with nobody new in it is `.debug dungeon` behaving as documented -- TryFormGroup treats an entry containing a game master as complete without a full composition. Not a defect. 116/116 tests pass. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 41 +++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index 8a18b16b8..c8646a783 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1200,9 +1200,26 @@ void LFGMgr::FindSpecificQueueMatches(ObjectGuid guid) bool fullyCompatible = false; std::set compatibleDungeons; - for (std::set::iterator dItr = matchInfo->dungeonList.begin(); dItr != matchInfo->dungeonList.end(); ++dItr) + // Compare what each entry can actually RUN, not what it asked for. + // + // A random queue stores only the CATEGORY row in dungeonList -- the + // expansion lives in candidateDungeons -- so intersecting dungeonList + // directly compared a category id against real dungeon ids and matched + // nothing. Random-vs-random happened to work because both carried the same + // category, and specific-vs-specific worked; RANDOM-VS-SPECIFIC could never + // match at all, which is most of what a random queuer exists to do, and it + // is also why a random queuer could never backfill a run pinned to its + // dungeon. + std::set const& queueRunnable = + queueInfo->candidateDungeons.empty() ? queueInfo->dungeonList + : queueInfo->candidateDungeons; + std::set const& matchRunnable = + matchInfo->candidateDungeons.empty() ? matchInfo->dungeonList + : matchInfo->candidateDungeons; + + for (std::set::const_iterator dItr = matchRunnable.begin(); dItr != matchRunnable.end(); ++dItr) { - if (queueInfo->dungeonList.find(*dItr) != queueInfo->dungeonList.end()) + if (queueRunnable.find(*dItr) != queueRunnable.end()) { compatibleDungeons.insert(*dItr); } @@ -1335,6 +1352,26 @@ void LFGMgr::MergeGroups(ObjectGuid guidOne, ObjectGuid guidTwo, std::setdungeonList.clear(); mainGroup->dungeonList = compatibleDungeons; + // Narrow the candidates to the same overlap. + // + // candidateDungeons is what PickConcreteDungeon draws from, and it was left holding + // the absorbing entry's full expansion -- so a merged entry could be proposed a + // dungeon the newly merged-in player had never asked for and may be locked out of. + // Intersecting keeps the pick inside what BOTH sides agreed to. + if (!mainGroup->candidateDungeons.empty()) + { + std::set narrowed; + for (std::set::const_iterator it = compatibleDungeons.begin(); + it != compatibleDungeons.end(); ++it) + { + if (mainGroup->candidateDungeons.find(*it) != mainGroup->candidateDungeons.end()) + { + narrowed.insert(*it); + } + } + mainGroup->candidateDungeons = narrowed; + } + // move players / roles into a single roleMap for (roleMap::iterator it = bufferGroup->currentRoles.begin(); it != bufferGroup->currentRoles.end(); ++it) { From 40bbe41f5c4c98130dbdb695dd91bb101f7bf0db Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 13:48:23 +0100 Subject: [PATCH 46/81] LFG: fix world crash when a player enters a random dungeon Confirmed from the crash dump of 2026-08-06 13:28:31: an access violation reading Map+0x8258 (m_TerrainData) off a null this, inside WorldObject::GetTerrain (WorldObjectSummon.cpp:96, MANGOS_ASSERT(m_currMap) failed and the handler returned instead of aborting). Recovered from the frames: the caller chain is Spell::CheckCast -> m_caster->GetZoneAndAreaId() -> GetTerrain(), with m_spellInfo->Id == 0x116A0 (71328, the LFG requeue cooldown) and the caster still holding its pre-teleport position on map 0 while m_currMap was already NULL. TeleportToDungeon cast the cooldown AFTER pGroupPlr->TeleportTo(). A far teleport removes the player from the old map immediately -- Map::Remove calls ResetMap() -- and m_currMap stays NULL until the client answers with MSG_MOVE_WORLDPORT_ACK, so the very next statement cast a spell on a player who was on no map at all. It only ever survived because ApplyDungeonCooldown no-ops when the aura is already present: a character that had just run a random still had it, so the cast was skipped. The first fresh character to enter took the world down. - Cast the cooldown before the teleport, inside the "is about to enter" block. That is also the better semantics -- the cooldown starts on entry, and a member already standing on the dungeon's map is not entering and no longer takes a fresh one. - Skip the teleport entirely when one is already in flight (IsBeingTeleported). TeleportTo only records m_teleport_dest and raises the far semaphore, so m_mapId keeps its old value and `GetMapId() != mapID` is still true for a player halfway through this same teleport -- clicking "Enter Dungeon" right after a proposal auto-entry re-issued it against a player with no map. - Guard both LFG cast helpers on IsInWorld() so the crash class cannot come back through another caller. ApplyDeserter logs loudly if it ever fires, because that would mean a deserter escaped the debuff. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.cpp | 23 +++++++- src/game/WorldHandlers/LFGMgrProposal.cpp | 71 ++++++++++++++++------- 2 files changed, 72 insertions(+), 22 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index c8646a783..b3de41f13 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1598,10 +1598,17 @@ void LFGMgr::ApplyDungeonCooldown(Player* pPlayer) // payloads against 752 for Deserter (71041) -- roughly nine times as many, which is // what you expect when everyone who zones in gets one and only early leavers get the // other. - if (pPlayer && !pPlayer->HasAura(LFG_COOLDOWN_SPELL)) + // + // Never cast on a player who is between maps. A far teleport removes them from the old + // map at once and m_currMap stays NULL until MSG_MOVE_WORLDPORT_ACK; Spell::CheckCast + // reads the caster's zone through Map::m_TerrainData and takes the world down on the + // null. Callers must apply this BEFORE starting the teleport, not after. + if (!pPlayer || !pPlayer->IsInWorld() || pPlayer->HasAura(LFG_COOLDOWN_SPELL)) { - pPlayer->CastSpell(pPlayer, LFG_COOLDOWN_SPELL, true); + return; } + + pPlayer->CastSpell(pPlayer, LFG_COOLDOWN_SPELL, true); } bool LFGMgr::IsPlayerInLfgDungeon(Player* pPlayer) @@ -1661,6 +1668,18 @@ void LFGMgr::OnPlayerLeftDungeonGroup(Player* pPlayer) // Accepting a proposal teleports the whole group in immediately, so "in the group but // never zoned in" is not a state a player can choose to sit in anyway. + // Same null-map hazard as ApplyDungeonCooldown: casting on a player whose far teleport + // has already begun dereferences a NULL Map inside Spell::CheckCast. Every current + // caller runs this BEFORE the leave teleport, so this is a backstop -- and a loud one, + // because if it ever fires a deserter walked away without the debuff. + if (!pPlayer->IsInWorld()) + { + sLog.outError("LFG: %s left dungeon %u mid-teleport -- Deserter NOT applied, " + "the caller must apply it before starting the teleport", + pPlayer->GetName(), status->dungeonID); + return; + } + DEBUG_LOG("LFG: %s left dungeon %u before any encounter was credited -- Deserter", pPlayer->GetName(), status->dungeonID); pPlayer->CastSpell(pPlayer, LFG_DESERTER_SPELL, true); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 4afdf14b6..df0f9544d 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1101,13 +1101,60 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) plrErr = LFG_TELEPORTERROR_INVALID_LOCATION; } - if (err == LFG_TELEPORTERROR_OK && plrErr == LFG_TELEPORTERROR_OK && pGroupPlr->GetMapId() != mapID) + // A far teleport already in flight must not be started again. TeleportTo only + // records m_teleport_dest and raises the far semaphore; m_mapId keeps the OLD + // value until MSG_MOVE_WORLDPORT_ACK arrives, so `GetMapId() != mapID` is still + // true for a player who is halfway through this very teleport. Clicking "Enter + // Dungeon" right after a proposal auto-entry reaches here a second time and + // would re-issue the teleport against a player who is no longer on any map. + if (err == LFG_TELEPORTERROR_OK && plrErr == LFG_TELEPORTERROR_OK && + pGroupPlr->GetMapId() != mapID && !pGroupPlr->IsBeingTeleported()) { if (pGroupPlr->GetMap() && !pGroupPlr->GetMap()->IsDungeon() && !pGroupPlr->GetMap()->IsRaid() && !pGroupPlr->InBattleGround()) { pGroupPlr->SetBattleGroundEntryPoint(); // store current position and such } + // The 15-minute requeue cooldown is cast HERE, before the teleport, and not + // in the success branch below. + // + // A far TeleportTo removes the player from the old map immediately -- + // Map::Remove calls ResetMap(), leaving m_currMap NULL until the client + // answers with MSG_MOVE_WORLDPORT_ACK. Spell::CheckCast then reads + // m_caster->GetZoneAndAreaId(), which goes through WorldObject::GetTerrain() + // and dereferences that NULL map. Confirmed from the crash dump of + // 2026-08-06 13:28:31: read of Map+0x8258 (m_TerrainData) off a null this, + // m_spellInfo->Id == 0x116A0 (71328), caster m_currMap == 0 while still + // holding its pre-teleport position on map 0. + // + // It only ever survived because ApplyDungeonCooldown no-ops when the aura is + // already present -- a character who had just run a random still had it, so + // the cast was skipped. The first fresh character to enter crashed the world. + // + // Casting first is also the correct semantics: the cooldown starts when the + // player ENTERS, and this block is exactly the "is about to enter" path. A + // member already standing on the dungeon's map is not entering and no longer + // takes a fresh cooldown. + // + // ONLY for a queue that was made through Random Dungeon. + // + // 71328 is the RANDOM cooldown. A player who queued for a specific + // dungeon did not take it on retail, and applying it to them is not a + // harmless over-approximation: it is the aura that blocks the next + // random queue, so a specific-dungeon run would lock the player out of + // random for 15 minutes they never owed. + // + // It also matters that the two systems stay independent. Deserter must + // never be conditional on this aura -- that was JadeCore's bug, and it + // exempts early leavers from specific-dungeon groups entirely. + // + // randomDungeonID is non-zero exactly when the queue was a random + // category, recorded at group creation from the proposal. + if (runStatus && runStatus->randomDungeonID) + { + ApplyDungeonCooldown(pGroupPlr); + } + if (!pGroupPlr->TeleportTo(mapID, x, y, z, o)) { plrErr = LFG_TELEPORTERROR_INVALID_LOCATION; @@ -1128,26 +1175,10 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) } else { + // The cooldown is NOT applied here -- see the teleport block above. Casting + // anything on a player whose far teleport has already started dereferences a + // null Map. SetPlayerState(pGroupPlr->GetObjectGuid(), LFG_STATE_IN_DUNGEON); - - // ONLY for a queue that was made through Random Dungeon. - // - // 71328 is the RANDOM cooldown. A player who queued for a specific - // dungeon did not take it on retail, and applying it to them is not a - // harmless over-approximation: it is the aura that blocks the next - // random queue, so a specific-dungeon run would lock the player out of - // random for 15 minutes they never owed. - // - // It also matters that the two systems stay independent. Deserter must - // never be conditional on this aura -- that was JadeCore's bug, and it - // exempts early leavers from specific-dungeon groups entirely. - // - // randomDungeonID is non-zero exactly when the queue was a random - // category, recorded at group creation from the proposal. - if (runStatus && runStatus->randomDungeonID) - { - ApplyDungeonCooldown(pGroupPlr); - } } } } From 21ae8384c60e5d2785734ea8a0548a8bcfb79f42 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 18:38:51 +0100 Subject: [PATCH 47/81] LFG: close the ready popup for players who queued random The dungeon-ready popup stayed on screen forever for anyone who reached a proposal through a random category. Observed live 2026-08-06 18:02:07. The client does not decide a proposal's outcome from SMSG_LFG_PROPOSAL_UPDATE. It decides it in the SMSG_LFG_UPDATE_STATUS handler, which keeps the active proposal's dungeon entry in a global and searches each status body's dungeon list for it before doing anything: mov esi, dword_1209400 ; active proposal's dungeon, 0 when none mov eax, [ebx+11Ch] ; dungeon count in THIS body jbe skip ; empty list -> ignore the body entirely cmp [ecx], esi ; the list must CONTAIN it ... mov al, [ebx+15Ah] ; reason: 1, 11 or 17 -> LFG_PROPOSAL_SUCCEEDED On a miss it takes no action at all -- no reset of its proposal data, no event -- so LFGDungeonReadyPopup is never hidden and survives until relog. SendLfgUpdate fills dungeonEntries from the player's queued dungeonList, which for a random queue is the CATEGORY. The proposal, however, is for a concrete dungeon, so the search never matched. The same proposal produced two bodies that show it exactly: the player who had queued for dungeon 6 carried 0x01000006 and was fine, while the player who had queued random carried 0x06000102 (type 6, id 258) and stuck. Advertise the proposal's chosen dungeon in the GROUP_FOUND body. Scoped to that one body -- the LEAVE that follows closes the queue the player actually joined, so it keeps advertising the slots they queued for. Not the role check: there are no READY_CHECK packets in the trace at all, and the role-check handler fires LFG_ROLE_CHECK_HIDE for every state except 2. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 30 +++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index df0f9544d..cee141bc8 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -615,6 +615,35 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted LFGPlayerStatus proposalPlrStatus = GetPlayerStatus(proposalPlrGuid); proposalPlrStatus.updateType = LFG_UPDATE_GROUP_FOUND; + // The GROUP_FOUND body must advertise the dungeon the PROPOSAL chose, not the slot + // the player queued for. This is what closes the client's ready popup. + // + // The client decides a proposal's outcome in the SMSG_LFG_UPDATE_STATUS handler, not + // in the proposal packet. It keeps the active proposal's dungeon entry in a global, + // and on each status body it walks that body's dungeon list looking for it: + // + // mov esi, dword_1209400 ; active proposal's dungeon, 0 when none + // mov eax, [ebx+11Ch] ; dungeon count in THIS body + // jbe skip ; empty list -> ignore the body entirely + // cmp [ecx], esi ; the list must CONTAIN it + // ... + // mov al, [ebx+15Ah] ; reason: 1, 11 or 17 -> LFG_PROPOSAL_SUCCEEDED + // + // Miss that search and it takes no action at all -- no reset, no event -- so + // LFGDungeonReadyPopup is never hidden and sits on screen until relog. + // + // Observed live 2026-08-06 18:02:07. Two GROUP_FOUND bodies for one proposal on + // dungeon 0x01000006: the player who queued for that dungeon carried 0x01000006 and + // was fine, while the player who queued RANDOM carried 0x06000102 -- the category + // (type 6, id 258) -- and his popup stuck. dungeonList holds what the player queued + // for, which for a random is the category, so every random queuer hit this. + // + // Scoped to this one body deliberately. The LEAVE that follows closes the QUEUE the + // player actually joined, so it keeps advertising the slots they queued for. + std::set const queuedDungeons = proposalPlrStatus.dungeonList; + proposalPlrStatus.dungeonList.clear(); + proposalPlrStatus.dungeonList.insert(proposal->dungeonID); + // ONE key, used for both packets. // // The queue entry is owned by the player's CURRENT group if they have one -- @@ -632,6 +661,7 @@ void LFGMgr::ProposalUpdate(uint32 proposalID, ObjectGuid plrGuid, bool accepted : proposalPlrGuid); proposalPlrStatus.updateType = LFG_UPDATE_LEAVE; + proposalPlrStatus.dungeonList = queuedDungeons; SendLfgUpdate(proposalPlrGuid, proposalPlrStatus, queueIsGroupOwned); } From 4c27cc575428656dc510412004f4683ef2d09b85 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 19:45:42 +0100 Subject: [PATCH 48/81] LFG: survive a world restart, and stop the return teleport killing people Four fixes on the same path, prompted by a live restart with a party still inside Deadmines. 1. TeleportToBGEntryPoint refused nothing. m_bgData.joinPos default-constructs to map 0 at (0,0,0) -- the origin of Eastern Kingdoms, inside the terrain. Any return teleport without a matching SetBattleGroundEntryPoint() sent the player under the world to die. Observed live 2026-08-06: a player who was already inside the dungeon when the world restarted used "Teleport out of dungeon" and got SMSG_TRANSFER_PENDING map 0 followed by SMSG_NEW_WORLD map=0 (0.00, 0.00, 0.00). Now validated, with homebind as the fallback. 2. The entry point was captured on every entry instead of once. SetBattleGroundEntryPoint() ran inside TeleportToDungeon, so walking out of the portal and using the eye to come back overwrote the saved return point with the dungeon's own doorstep -- permanently losing where the player had queued from. Reported live: "each time i get ported outside the dungeon entrance, never have i been put back to the queue location". It is now taken once, at queue time, by LFGMgr::RecordEntryPoint. The corpus agrees the point is fixed for a run: capture-000044's teleport-out at seq 150590 and a later exit at seq 151999 are byte-identical (map 974, -4046.44 6351.08) despite a teleport back IN at seq 151132 between them, while different queue episodes carry different values. 3. A restart took the whole feature away from the client. Nothing in LFGMgr persists, so the party came back with its group and bind intact and an LFGMgr that had never heard of it. Group.cpp's `update.isLfg = isLFGGroup() && GetGroupDungeonEntry(...) != 0` then resolved to 0 and SMSG_GROUP_LIST went out with no LFG block -- and that block is the ONLY thing the 18414 client reads for an in-progress run. The group-list apply path is its sole writer and ZEROES those fields when the flag is clear; IsPartyLFG, GetPartyLFGID, HasLFGRestrictions and IsInLFGDungeon all hang off them. So no eye, no teleport options, no Vote Kick gate. Observed live. LFGMgr::RestoreDungeonGroup rebuilds the status from state that already persists -- dungeon from the bind's (map, difficulty), madeProgress from encountersMask, leader from the group -- called per bind as groups load. No new table and no schema change; restoring the inputs is enough because every existing send path then behaves normally. An ambiguous (map, difficulty) logs and leaves the group untouched rather than ejecting anyone. randomDungeonID and per-player roles are not recoverable and are left at 0 / PLAYER_ROLE_NONE rather than guessed. 4. Verified the resulting GET_STATUS reply. For a restored player at LFG_STATE_IN_DUNGEON the reason-15 body carries joined=0, queued=0, notifyUi=0, lfgJoined=1 -- matching capture-000257 seq 386. Emitting joined=1 there would render the eye as "Suspended" + "Leave Queue" instead of "In Progress"; that trap was already closed and stays shut. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/ObjectMgrInstanceData.cpp | 8 ++ src/game/Object/PlayerBattleGround.cpp | 23 ++++ src/game/WorldHandlers/LFGMgr.cpp | 151 ++++++++++++++++++++++ src/game/WorldHandlers/LFGMgr.h | 8 ++ src/game/WorldHandlers/LFGMgrProposal.cpp | 10 +- src/game/WorldHandlers/LFGMgrQueue.cpp | 4 + 6 files changed, 200 insertions(+), 4 deletions(-) diff --git a/src/game/Object/ObjectMgrInstanceData.cpp b/src/game/Object/ObjectMgrInstanceData.cpp index fda0b43d0..1ec92be95 100644 --- a/src/game/Object/ObjectMgrInstanceData.cpp +++ b/src/game/Object/ObjectMgrInstanceData.cpp @@ -38,6 +38,7 @@ #include "SQLStorages.h" #include "DBCStores.h" #include "Group.h" +#include "LFGMgr.h" /** * @brief Gets instance template data by map id. @@ -216,6 +217,13 @@ void ObjectMgr::LoadGroups() DungeonPersistentState* state = (DungeonPersistentState*)sMapPersistentStateMgr.AddPersistentState(mapEntry, fields[2].GetUInt32(), Difficulty(diff), (time_t)fields[5].GetUInt64(), (fields[6].GetUInt32() == 0), true, true, fields[8].GetUInt32()); group->BindToInstance(state, fields[3].GetBool(), true); + + // Nothing in LFGMgr is persisted, so a dungeon-finder party that was inside its + // instance when the world went down comes back with the group and the bind but + // no LFG status -- which empties SMSG_GROUP_LIST's LFG block and takes the eye, + // both teleport options and the Vote Kick gate away from the client. Rebuild it + // here, where the bind's map, difficulty and encounter mask are all in hand. + sLFGMgr.RestoreDungeonGroup(group, mapId, uint32(diff), fields[8].GetUInt32()); } while (result->NextRow()); delete result; diff --git a/src/game/Object/PlayerBattleGround.cpp b/src/game/Object/PlayerBattleGround.cpp index 2c9b83d84..1efe0f3c5 100644 --- a/src/game/Object/PlayerBattleGround.cpp +++ b/src/game/Object/PlayerBattleGround.cpp @@ -88,6 +88,29 @@ */ bool Player::TeleportToBGEntryPoint() { + // Refuse to teleport to an entry point that was never recorded. + // + // m_bgData.joinPos default-constructs to map 0 at (0,0,0) -- the origin of Eastern + // Kingdoms, which is inside the terrain. Teleporting there drops the player under the + // world and kills them. This is reachable whenever the return teleport runs without a + // matching SetBattleGroundEntryPoint(): observed live on 2026-08-06, where a player who + // was already standing inside an LFG dungeon when the world restarted used "Teleport out + // of dungeon" and was sent to SMSG_NEW_WORLD map=0 (0.00, 0.00, 0.00), fell and died. + // + // Homebind is the correct fallback -- it is where every other "we do not know where this + // player belongs" path in the core sends them. + if (!MaNGOS::IsValidMapCoord(m_bgData.joinPos.coord_x, m_bgData.joinPos.coord_y, + m_bgData.joinPos.coord_z) || + (m_bgData.joinPos.coord_x == 0.0f && m_bgData.joinPos.coord_y == 0.0f && + m_bgData.joinPos.coord_z == 0.0f)) + { + sLog.outError("Player::TeleportToBGEntryPoint: %s has no valid entry point " + "(map %u, %.2f %.2f %.2f) -- sending to homebind instead.", + GetName(), m_bgData.joinPos.mapid, m_bgData.joinPos.coord_x, + m_bgData.joinPos.coord_y, m_bgData.joinPos.coord_z); + return TeleportToHomebind(); + } + ScheduleDelayedOperation(DELAYED_BG_MOUNT_RESTORE); ScheduleDelayedOperation(DELAYED_BG_TAXI_RESTORE); return TeleportTo(m_bgData.joinPos); diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index b3de41f13..ef21ff761 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1611,6 +1611,157 @@ void LFGMgr::ApplyDungeonCooldown(Player* pPlayer) pPlayer->CastSpell(pPlayer, LFG_COOLDOWN_SPELL, true); } +void LFGMgr::RecordEntryPoint(Player* pPlayer) +{ + // The return location is captured ONCE, when the player joins the queue -- not when + // they are teleported in. + // + // It used to be taken inside TeleportToDungeon, which runs on EVERY entry. Walk out + // through the instance portal and you stand at the dungeon's outdoor entrance; use the + // eye to teleport back in and TeleportToDungeon overwrote the saved point with that + // doorstep. From then on "Teleport out of dungeon" returned the player to the dungeon + // door forever, and the place they actually queued from was gone. Reported live: + // "each time i get ported outside the dungeon entrance, never have i been put back to + // the queue location". + // + // The corpus agrees the point is fixed for the life of a run: in capture-000044 the + // teleport-out at seq 150590 and a later exit at seq 151999 are byte-identical + // (map 974, -4046.44 6351.08) even though the player teleported back IN at seq 151132 + // between them. Across DIFFERENT queue episodes it moves, which is what you expect from + // a value captured at queue time. + // + // Skipped on a dungeon or raid map on purpose: SetBattleGroundEntryPoint's dungeon + // branch resolves the CLOSEST GRAVEYARD rather than the player's position, which is not + // a place anyone queued from. A player queueing from inside a dungeon (a backfill + // re-queue) therefore keeps the entry point they already had, which is the correct one. + if (!pPlayer || !pPlayer->IsInWorld()) + { + return; + } + + Map* map = pPlayer->GetMap(); + if (!map || map->IsDungeon() || map->IsRaid() || pPlayer->InBattleGround()) + { + return; + } + + pPlayer->SetBattleGroundEntryPoint(); +} + +void LFGMgr::RestoreDungeonGroup(Group* pGroup, uint32 mapId, uint32 difficulty, uint32 encountersMask) +{ + // Rebuild a live run's LFG status after a world restart, from state that already + // persists. No new table, no schema change. + // + // Nothing in LFGMgr is saved, so after a restart a party still standing in its dungeon + // came back with the group intact (groups.groupType keeps GROUPTYPE_LFD, and the bind + // reloads from group_instance) and an LFGMgr that had never heard of it. One line then + // took the whole feature out: Group.cpp's `update.isLfg = isLFGGroup() && + // GetGroupDungeonEntry(...) != 0` resolved to 0, so login's SendUpdate emitted + // SMSG_GROUP_LIST with no LFG block at all. + // + // That block is the ONLY thing the 18414 client reads for an in-progress run: the + // group-list apply path is the sole writer of the LFG fields on its group object, and it + // ZEROES them when the packet's flag is clear. IsPartyLFG(), GetPartyLFGID(), + // HasLFGRestrictions() and IsInLFGDungeon() all hang off those fields, so with an empty + // block the minimap eye, "Teleport out of dungeon", "Leave Instance Group" and the Vote + // Kick gate simply do not exist. Observed live 2026-08-06: a player logged back into + // Deadmines after a restart, still grouped, with no eye. + // + // Everything needed is recoverable, so restoring the INPUTS is enough -- every existing + // send path then behaves normally: + // dungeonID <- the LfgDungeons row matching the bind's (map, difficulty) + // madeProgress <- encountersMask != 0, which is stronger than any reference fork + // manages; none of them persist it at all + // leaderGuid <- the group + // roles <- not persisted anywhere, so LFG_ROLE_NONE (see below) + // randomDungeonID is genuinely lost. Its only consequence is the random-dungeon + // completion bonus for a run a restart interrupted, which is not worth a schema change. + if (!pGroup || !pGroup->isLFGGroup()) + { + return; + } + + ObjectGuid const groupGuid = pGroup->GetObjectGuid(); + + // A group can hold several binds; the first one that resolves wins. + if (GetGroupStatus(groupGuid)) + { + return; + } + + // Resolve the dungeon by (map, internal difficulty). LfgDungeons.dbc carries a RAW + // client DifficultyID, so it has to be translated before comparing against the bind's + // internal Difficulty -- comparing them directly is the key-space bug PR #81 fixed. + uint32 dungeonId = 0; + uint32 matches = 0; + for (uint32 id = 0; id < sLfgDungeonsStore.GetNumRows(); ++id) + { + LfgDungeonsEntry const* dungeon = sLfgDungeonsStore.LookupEntry(id); + if (!dungeon || uint32(dungeon->MapID) != mapId) + { + continue; + } + + int32 const mode = ToInternalDifficulty(dungeon->DifficultyID); + if (mode < 0 || uint32(mode) != difficulty) + { + continue; + } + + ++matches; + if (!dungeonId) + { + dungeonId = dungeon->ID; + } + } + + if (!dungeonId || matches != 1) + { + // Ambiguous or absent: log once and leave the group ALONE. Do not clear + // GROUPTYPE_LFD and do not eject anyone -- a party that is merely missing its eye is + // a great deal better off than one teleported out from under itself. + sLog.outError("LFGMgr::RestoreDungeonGroup: group %s bound to map %u difficulty %u " + "resolves to %u LfgDungeons rows; leaving its LFG status unrestored.", + groupGuid.GetString().c_str(), mapId, difficulty, matches); + return; + } + + LFGGroupStatus status; + status.state = LFG_STATE_IN_DUNGEON; + status.dungeonID = dungeonId; + status.madeProgress = (encountersMask != 0); + status.randomDungeonID = 0; + status.leaderGuid = pGroup->GetLeaderGuid(); + + // Member GUIDs come from the persisted slots, NOT from GroupReference: no player is in + // world yet at group-load time, so the live member list is empty. + // + // Roles are not persisted, so everyone comes back as PLAYER_ROLE_NONE. That is honest + // rather than invented: the roles are only read for backfill role matching and the UI + // role icons, and a wrong guess there would mis-fill a replacement slot. + for (Group::MemberSlotList::const_iterator itr = pGroup->GetMemberSlots().begin(); + itr != pGroup->GetMemberSlots().end(); ++itr) + { + status.playerRoles[itr->guid] = uint8(PLAYER_ROLE_NONE); + + LFGPlayerStatus plrStatus; + plrStatus.state = LFG_STATE_IN_DUNGEON; + plrStatus.updateType = LFG_UPDATE_DEFAULT; + plrStatus.dungeonList.insert(dungeonId); + m_playerStatusMap[itr->guid] = plrStatus; + } + + m_groupSet.insert(groupGuid); + m_groupStatusMap[groupGuid] = status; + + sLog.outString("LFGMgr: restored dungeon group %s -- dungeon %u (map %u, difficulty %u), " + "%u member(s), progress %s.", + groupGuid.GetString().c_str(), dungeonId, mapId, difficulty, + uint32(pGroup->GetMemberSlots().size()), + status.madeProgress ? "yes" : "no"); +} + bool LFGMgr::IsPlayerInLfgDungeon(Player* pPlayer) { if (!pPlayer) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 2a13b24c4..cb7e7ae28 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1346,6 +1346,14 @@ class LFGMgr /// Applies the 15-minute requeue cooldown that retail starts when a player enters. void ApplyDungeonCooldown(Player* pPlayer); + /// Record where this player must be returned to when they leave the dungeon. + /// Called ONCE, when the queue is joined -- never on entry. See the definition. + void RecordEntryPoint(Player* pPlayer); + + /// Rebuild a dungeon group's LFG status after a restart, from persisted state alone. + /// Called once per group_instance bind while groups load. See the definition. + void RestoreDungeonGroup(Group* pGroup, uint32 mapId, uint32 difficulty, uint32 encountersMask); + /// Return the 5.4.8 LFG status category byte for a dungeon. uint8 GetDungeonCategory(uint32 ID); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index cee141bc8..77655bbd0 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1140,10 +1140,12 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) if (err == LFG_TELEPORTERROR_OK && plrErr == LFG_TELEPORTERROR_OK && pGroupPlr->GetMapId() != mapID && !pGroupPlr->IsBeingTeleported()) { - if (pGroupPlr->GetMap() && !pGroupPlr->GetMap()->IsDungeon() && !pGroupPlr->GetMap()->IsRaid() && !pGroupPlr->InBattleGround()) - { - pGroupPlr->SetBattleGroundEntryPoint(); // store current position and such - } + // NO SetBattleGroundEntryPoint() here. + // + // It used to be taken on every entry, which meant walking out of the portal + // and teleporting back in overwrote the saved return point with the dungeon's + // own doorstep -- permanently losing the place the player queued from. The + // point is now captured once, at queue time, by LFGMgr::RecordEntryPoint. // The 15-minute requeue cooldown is cast HERE, before the teleport, and not // in the success branch below. diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 5313f85e0..5c4fb3490 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -425,6 +425,8 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen if (Player* pGroupPlr = itr->getSource()) { BeginTicket(pGroupPlr->GetObjectGuid(), groupInfo.ticketId, uint32(groupInfo.joinedTime)); + // Where each member gets returned to. Per-player, taken here and never again. + RecordEntryPoint(pGroupPlr); } } @@ -478,6 +480,8 @@ void LFGMgr::JoinLFG(uint32 roles, std::set dungeons, std::string commen playerInfo.ticketId = AllocateTicketId(); m_playerData[guid] = playerInfo; BeginTicket(guid, playerInfo.ticketId, uint32(playerInfo.joinedTime)); + // Where this player gets returned to. Taken here and never again -- see RecordEntryPoint. + RecordEntryPoint(plr); // set up a status struct for client requests/updates // From d3d4bc535db5fdbec7ebd3afe2b946cf17b1810c Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 20:20:05 +0100 Subject: [PATCH 49/81] LFG: walking out of the dungeon returns you to where you queued The rule is conditioned on GROUP MEMBERSHIP, not on how the player leaves: still in the LFD group and you exit -> back to where you started left or were kicked, then you exit -> dropped outside the entrance Source is developer testimony from the getMangos community (2026-08-06) rather than a capture. The corpus contains no build-18414 episode of an on-foot exit from an LFG instance, so this one cannot be settled from the wire, and that limit is recorded in the comment rather than papered over. It is consistent with what IS proven, which is the same rule reached from the other direction: in capture-000044 every CMSG_LFG_TELEPORT exit lands on the player's pre-queue position on a DIFFERENT continent from the dungeon (map 974 Timeless Isle, while The Slave Pens' own entrance is on map 530), two exits within one run are byte-identical, and different queue episodes carry different values. One recorded point, used by every exit path, is the coherent reading of both. It also retro-explains a live observation from 2026-08-06 20:12:07, which had looked like a bug: two players walked out of Shadowfang Keep onto the trigger's fixed target (-225.22, 1519.33) after a vote-kick had already disbanded their group. That is precisely the second branch, behaving correctly. IsPlayerInLfgDungeon carries the whole condition -- it requires an LFG group, a live group status, and the player standing on that run's map -- so anyone who has left the group, or whose run has ended, falls through to the ordinary areatrigger unchanged. Non-LFG dungeon exits are untouched. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/MiscHandler.cpp | 30 ++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/game/WorldHandlers/MiscHandler.cpp b/src/game/WorldHandlers/MiscHandler.cpp index 540f88caf..d9be3ae7f 100644 --- a/src/game/WorldHandlers/MiscHandler.cpp +++ b/src/game/WorldHandlers/MiscHandler.cpp @@ -63,6 +63,7 @@ #include "CinematicFlyover.h" #include "GuildMgr.h" #include "ObjectMgr.h" +#include "LFGMgr.h" #include "WorldSession.h" #include "Auth/BigNumber.h" #include "Auth/Sha1.h" @@ -953,6 +954,35 @@ void WorldSession::HandleAreaTriggerOpcode(WorldPacket& recv_data) player->SpawnCorpseBones(); } + // Leaving a dungeon finder run on foot returns the player to where they QUEUED, not to + // the dungeon's doorstep -- but only while they are still in the group. + // + // The rule is conditioned on group membership rather than on how the player leaves: + // still in the LFD group and you exit -> back to where you started + // left or were kicked, then you exit -> dropped outside the entrance + // + // Source is developer testimony from the getMangos community (2026-08-06) rather than a + // capture: the corpus contains no build-18414 episode of an on-foot exit from an LFG + // instance, so this cannot be settled from the wire. It is consistent with what IS + // proven, which is the same rule reached the other way -- in capture-000044 the + // CMSG_LFG_TELEPORT exits land on the player's pre-queue position on a DIFFERENT + // continent from the dungeon (map 974 Timeless Isle, while The Slave Pens' own entrance + // is map 530), and two exits in the same run are byte-identical while different queue + // episodes differ. Both paths returning to one recorded point is the coherent reading. + // + // It also matches what was observed live on 2026-08-06 20:12:07: two players walked out + // of Shadowfang Keep to the trigger's fixed target after their group had already + // disbanded, which is exactly the second branch. + // + // IsPlayerInLfgDungeon covers the whole condition -- it requires an LFG group, a live + // group status, and the player actually standing on that run's map -- so a player who + // has left the group, or whose run has ended, falls through to the ordinary trigger. + if (targetMapEntry->ID != player->GetMapId() && sLFGMgr.IsPlayerInLfgDungeon(player)) + { + player->TeleportToBGEntryPoint(); + return; + } + // teleport player (trigger requirement will be checked on TeleportTo) player->TeleportTo(at->target_mapId, at->target_X, at->target_Y, at->target_Z, at->target_Orientation, TELE_TO_NOT_LEAVE_TRANSPORT, at); } From 537e3a062e6145976718fb8b82e434c181a2a77f Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 20:30:57 +0100 Subject: [PATCH 50/81] Group: persist GROUPTYPE_LFD so a finder group survives a restart SetAsLfgGroup was a header inline that only ORed the bit into the in-memory m_groupType. Nothing ever wrote it out, so `groups`.`groupType` kept whatever value the group was created with -- 0 for a group the finder built. Confirmed against the live database on 2026-08-06: an active dungeon finder group's row read groupType = 0 while its run was in progress. Everything that recognises a finder run keys off isLFGGroup(), so with the bit unsaved a restart brought the group back as an ORDINARY PARTY -- no LFG block in SMSG_GROUP_LIST, so no eye, no teleport options and no Vote Kick gate, and LFGMgr::RestoreDungeonGroup refusing on its first line. This also invalidated a load-bearing assumption of the restart investigation, which held that groups.groupType reloads verbatim including GROUPTYPE_LFD. It does reload verbatim; the bit was simply never written. ConvertToRaid is the pattern and this now matches it, isBGGroup guard included -- battleground groups have no row to update. No schema change: groupType is an existing column that stock group loading already reads back. Note that groups formed before this change still carry groupType = 0 on disk and stay invisible to the restore. That is self-correcting on the next group the finder forms. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.cpp | 26 ++++++++++++++++++++++++++ src/game/WorldHandlers/Group.h | 4 +++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index cf3952277..3c4b08830 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -1337,6 +1337,32 @@ bool Group::LoadMemberFromDB(uint32 guidLow, uint8 subgroup, bool assistant) /** * @brief Converts the group to raid mode and refreshes related state. */ +void Group::SetAsLfgGroup() +{ + // GROUPTYPE_LFD has to reach the DATABASE, not just m_groupType. + // + // This used to be a one-line header inline that only ORed the bit into the in-memory + // group type. Nothing ever wrote it out, so `groups`.`groupType` stayed at whatever it + // was when the group was created -- 0 for a group the finder built. Confirmed against + // the live database on 2026-08-06: an active dungeon finder group's row read + // groupType = 0 while the run was in progress. + // + // Everything that tries to recognise a finder run after a restart keys off + // isLFGGroup(), so with the bit unsaved the group came back as an ORDINARY PARTY: no + // LFG block in SMSG_GROUP_LIST, no eye, no teleport options, no Vote Kick gate, and + // LFGMgr::RestoreDungeonGroup refusing the group on its very first line. + // + // ConvertToRaid two functions down is the pattern; this now matches it. The isBGGroup + // guard is the same one every other group persist path uses -- battleground groups have + // no row to update. + m_groupType = GroupType(m_groupType | GROUPTYPE_LFD); + + if (!isBGGroup()) + { + CharacterDatabase.PExecute("UPDATE `groups` SET `groupType` = %u WHERE `groupId`='%u'", uint8(m_groupType), m_Id); + } +} + void Group::ConvertToRaid() { m_groupType = GroupType(m_groupType | GROUPTYPE_RAID); diff --git a/src/game/WorldHandlers/Group.h b/src/game/WorldHandlers/Group.h index adc6e51f6..876fc2277 100644 --- a/src/game/WorldHandlers/Group.h +++ b/src/game/WorldHandlers/Group.h @@ -1489,7 +1489,9 @@ class Group } // member manipulation methods - void SetAsLfgGroup() { m_groupType = GroupType(m_groupType | GROUPTYPE_LFD); } + /// Flag this group as a dungeon finder group AND persist it. Defined out of line + /// because it writes to `groups`.`groupType` -- see the definition for why. + void SetAsLfgGroup(); bool IsMember(ObjectGuid guid) const { return _getMemberCSlot(guid) != m_memberSlots.end(); From b3f8d82e95ff7b8b40f8729df7b304bbab22dd8d Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 20:41:18 +0100 Subject: [PATCH 51/81] LFG: make sure every member gets the group list carrying the LFG block Only one of two players entering a dungeon together came out with a minimap eye. Observed live 2026-08-06 20:35:04 and traced in the packet log: of the group lists emitted while the run formed, the second player received an 81-byte body with isLfg=1, while the leader's last was 64 bytes with the flag clear. SMSG_GROUP_LIST's LFG block is the ONLY thing the 18414 client reads for an in-progress run -- IsPartyLFG, GetPartyLFGID, HasLFGRestrictions and IsInLFGDungeon all hang off fields its apply path is the sole writer of -- so the leader had no eye, no teleport options and no Vote Kick gate. CreateDungeonGroup stored the LFG status, then called TeleportToDungeon, and only then SendUpdate. The far teleport removes each member from their map, and a member already in flight is not reached by the member walk, so the refreshed update never got to them. It only reproduced when queueing as a PARTY, because queueing separately adds and teleports members in a different order -- which is why the same run worked earlier in the day. Send the update before the teleport as well. The status is already stored at that point, so the block is present, and every member is still in world to receive it. The post-teleport call is kept for state that only settles after. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 77655bbd0..077c83b5f 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1006,6 +1006,26 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) m_groupStatusMap[groupGuid] = groupStatus; } + // Announce the group BEFORE the teleport as well as after. + // + // SMSG_GROUP_LIST's LFG block is the ONLY thing the 18414 client reads for an + // in-progress run -- IsPartyLFG, GetPartyLFGID, HasLFGRestrictions and IsInLFGDungeon + // all hang off fields the group-list apply path is the sole writer of. Miss it and the + // player has no eye, no teleport options and no Vote Kick gate. + // + // The status is stored just above, so an update sent here already carries the block. + // The one AFTER the teleport does not reliably reach everyone: TeleportToDungeon far- + // teleports each member, which removes them from their map, and a member already in + // flight is not reached by the member walk. Observed live 2026-08-06 20:35:04 -- of two + // players entering Wailing Caverns together, only the second received an isLfg=1 body + // (81 bytes); the leader's last group list was 64 bytes with the flag clear, and he had + // no eye. It only showed up when queueing as a PARTY, because queueing separately adds + // and teleports members in a different order. + // + // Both calls are kept. This one guarantees every member holds the LFG block while they + // are all still in world; the one after covers state that only settles post-teleport. + pGroup->SendUpdate(); + TeleportToDungeon(dungeon->ID, pGroup); pGroup->SendUpdate(); From fb1297c681a3fb45ffcb6b0f97ce69e25b0f7bae Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 20:58:48 +0100 Subject: [PATCH 52/81] Group: let a dungeon finder group live on one member Leaving a two-man finder run stranded the other player. RemoveMember gates on `GetMembersCount() > (isBGGroup() ? 1 : 2)`, so removing one of two gives 2 > 2 -- false -- and the group is disbanded. With no group there is no LFG block in SMSG_GROUP_LIST, and that block is the only thing the 18414 client reads for an in-progress run, so the survivor loses the minimap eye, "Teleport out of dungeon" and "Leave Instance Group" while standing inside an instance. No way out short of walking or a hearthstone. Observed live twice on 2026-08-06. Allow an LFD group down to one member, exactly as a battleground group already is. This is also what the rest of the feature assumes: SMSG_LFG_OFFER_CONTINUE ("a player has left your group, would you like to find another?") is sent to the REMAINING members from this very function, which cannot mean anything if the group being offered a backfill was just disbanded -- so that hook has been dead in precisely this case. The group still dies when the LAST member leaves: at one member the test is 1 > 1, false, and Disband runs as before. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.cpp | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index 3c4b08830..fc912d4dd 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -1639,7 +1639,22 @@ uint32 Group::RemoveMember(ObjectGuid guid, uint8 removeMethod) CompleteReadyCheck(); // remove member and change leader (if need) only if strong more 2 members _before_ member remove - if (GetMembersCount() > uint32(isBGGroup() ? 1 : 2)) // in BG group case allow 1 members group + // + // A dungeon finder group is allowed down to ONE member, exactly like a battleground + // group. Disbanding it strands the last player: with no group there is no LFG block in + // SMSG_GROUP_LIST, so the client loses the minimap eye, "Teleport out of dungeon" and + // "Leave Instance Group" -- and they are standing inside an instance with no way out + // short of walking or a hearthstone. Observed live twice on 2026-08-06: one player left + // a two-man run and the other was left inside with no eyeball at all. + // + // Keeping the group alive is also what the rest of the feature already assumes. + // SMSG_LFG_OFFER_CONTINUE ("a player has left your group, would you like to find + // another?") is sent to the REMAINING members from Group::RemoveMember, which is + // meaningless if the group it is offering to backfill has just been disbanded. + // + // The group still dies when the LAST member leaves: at one member this test is 1 > 1, + // which is false, so Disband runs as before. + if (GetMembersCount() > uint32((isBGGroup() || isLFGGroup()) ? 1 : 2)) { bool leaderChanged = _removeMember(guid); From a82d7ff90d9abead2d0ad5be10ee956095409502 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 21:08:47 +0100 Subject: [PATCH 53/81] LFG: stop TeleportPlayer refusing silently A player left alone in Shadowfang Keep pressed "Teleport out of dungeon" twice and nothing happened -- no movement, no message, and nothing in the server log. Observed live 2026-08-06 21:04. Every refusal in TeleportPlayer only calls SendLfgTeleportError, and SMSG_LFG_TELEPORT_DENIED is deliberately not admitted because its value space has not been derived from the client. So a refused teleport emits no packet at all, and none of the branches logged either. There was no way to tell which one fired: the combat guard was ruled out only because its DEBUG_LOG was absent, and group and status were both known good from an SMSG_LFG_OFFER_CONTINUE sent ten seconds earlier, which is gated on GetGroupDungeonEntry() != 0. Log all of them. The map-mismatch branch in particular was not just silent but unmarked as a defect: a player asking to leave a dungeon they are demonstrably standing in should always be able to, so a dungeonID that does not resolve to their map is a bug rather than a legitimate refusal. It now says so, with both map ids. The likeliest cause of that mismatch is a run whose dungeonID is a random CATEGORY row instead of the concrete dungeon entered -- SendDungeonProposal logged "entry dungeons={258} -> chose 258 (entry 0x06000102)", TypeID 6, for a group that then zoned into Shadowfang Keep. This commit does not fix that; it makes the next occurrence diagnosable. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 077c83b5f..d9f1b3f0a 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1239,9 +1239,17 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) { // Fetch necessary data first + // Every refusal below is INVISIBLE to the player: SMSG_LFG_TELEPORT_DENIED is not + // admitted (its value space is not derived -- see SendLfgTeleportError), so a refused + // teleport produces no packet at all. Log every one of them, or a player reporting + // "teleport out did nothing" leaves nothing behind to diagnose. Observed live + // 2026-08-06 21:04: two CMSG_LFG_TELEPORT from a player alone in Shadowfang Keep, no + // reply, no log line, and no way to tell which branch refused. Group* pGroup = pPlayer->GetGroup(); if (!pGroup) { + sLog.outError("LFG TeleportPlayer: %s refused (%s) -- not in a group", + pPlayer->GetName(), out ? "out" : "in"); pPlayer->GetSession()->SendLfgTeleportError((uint8)LFG_TELEPORTERROR_INVALID_LOCATION); return; } @@ -1249,6 +1257,9 @@ void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) LFGGroupStatus* status = GetGroupStatus(pGroup->GetObjectGuid()); if (!status) { + sLog.outError("LFG TeleportPlayer: %s refused (%s) -- group %s has no LFG status", + pPlayer->GetName(), out ? "out" : "in", + pGroup->GetObjectGuid().GetString().c_str()); pPlayer->GetSession()->SendLfgTeleportError((uint8)LFG_TELEPORTERROR_INVALID_LOCATION); return; } @@ -1305,6 +1316,22 @@ void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) { pPlayer->TeleportToBGEntryPoint(); } + else + { + // The silent branch. It fires when the run's recorded dungeon does not resolve + // to the map the player is standing on -- which is a REAL defect whenever it + // happens, not a legitimate refusal, because a player asking to leave a dungeon + // they are demonstrably inside should always be able to. + // + // The likeliest cause is a run whose dungeonID is a random CATEGORY row rather + // than the concrete dungeon that was entered: SendDungeonProposal has been seen + // to log "entry dungeons={258} -> chose 258 (entry 0x06000102)", i.e. TypeID 6, + // for a group that then zoned into Shadowfang Keep. + sLog.outError("LFG TeleportPlayer: %s refused (out) -- dungeon %u resolves to " + "map %d but the player is on map %u; leaving them stranded", + pPlayer->GetName(), status->dungeonID, + dungeon ? int32(dungeon->MapID) : -1, pPlayer->GetMapId()); + } return; } From c41ce496e986a938417daccf7cc1b1aabfc084c2 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 21:26:21 +0100 Subject: [PATCH 54/81] Group: stop telling every client that party members are phased out SMSG_PARTY_MEMBER_STATS carried GROUP_UPDATE_FLAG_PHASE on every update, and the writer for it is a stub: a hardcoded uint32(8) followed by an EMPTY phase list, for every player, regardless of their actual phase. The client reads that as "this member shares no phases with you", so PartyMemberFrame_UpdateNotPresentIcon takes its `not UnitInPhase(partyID)` branch and paints the phasing icon on a party member standing next to you -- and a client that believes a unit is phased out does not render it, while the minimap dot survives because it comes from the party roster rather than object visibility. Observed live 2026-08-06 21:16 with five characters, all inside instance 2 of Wailing Caverns. SMSG_GROUP_LIST was byte-identical (121 bytes, same roster) on all five sessions, so the group data agreed and only the phase claim did not. The icon landed on different members on different clients, which is what you expect from a packet built per member per recipient. One player reported the flagged members were invisible beside her but present on her minimap. Nothing in the world data explains a real phase split there either: all five rows in `phase_definitions` are zoneId 5736, the Wandering Isle. Clear the flag from GROUP_UPDATE_FULL so the block is not sent and the client keeps its default, which is in-phase. The writer is left in place for the day the core actually models phases and can report a real mask. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.h | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/game/WorldHandlers/Group.h b/src/game/WorldHandlers/Group.h index 876fc2277..166a6e2fd 100644 --- a/src/game/WorldHandlers/Group.h +++ b/src/game/WorldHandlers/Group.h @@ -1325,8 +1325,25 @@ enum GroupUpdateFlags GROUP_UPDATE_FLAG_ZONE | GROUP_UPDATE_FLAG_POSITION | GROUP_UPDATE_FLAG_AURAS | - GROUP_UPDATE_FLAG_VEHICLE_SEAT | - GROUP_UPDATE_FLAG_PHASE, + GROUP_UPDATE_FLAG_VEHICLE_SEAT, + // GROUP_UPDATE_FLAG_PHASE is deliberately NOT advertised. + // + // The writer for it is a stub: it emits a hardcoded `uint32(8)` and an EMPTY phase + // list, every time, for every player, regardless of their actual phase. The client + // reads that as "this member shares no phases with you", so + // PartyMemberFrame_UpdateNotPresentIcon takes its `not UnitInPhase(partyID)` branch + // and paints the phasing icon over a party member standing right next to you. + // + // Because the stats packet is built per member per recipient, the icon appeared on + // different members on different clients. Observed live 2026-08-06 21:16 with a + // five-man group all inside instance 2 of Wailing Caverns, whose SMSG_GROUP_LIST + // was byte-identical (121 bytes) on all five sessions -- so the roster agreed and + // only the phase claim did not. + // + // Sending nothing is both truthful and safer: with the flag clear the client keeps + // its default, which is in-phase. Restore this the day the core actually models + // phases and the writer reports a real phase mask; the writer itself is left in + // place for exactly that. }; class Roll : public LootValidatorRef From 1b2fb34a37e649edde59480835df34e62fc8a146 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 22:22:57 +0100 Subject: [PATCH 55/81] Keep dungeon finder groups together across a normal logout A graceful logout removed the player from their party. Raids were already exempt; dungeon finder groups were not, and for them the group IS the run -- losing it also loses the group's instance bind, so the player returns to a brand new instance of the same dungeon, ungrouped, standing somewhere nobody else is. Observed live 2026-08-06: five characters idled out to the character screen between 21:58:56 and 22:01:21, each sending a normal CMSG_LOGOUT_REQUEST. Each logout stripped that member and passed leadership down the line until `group_member` held a single row -- guid 8, the last to leave. Coming back put them in instance 3 of Wailing Caverns where they had left instance 2. The `m_Socket` term is why this hid for so long: a hard disconnect leaves no socket and skips the branch, so an alt-F4 relog kept the group and looked correct. Only a graceful logout destroyed it, which is the one people actually do. It also made restart restoration pointless -- there is no value in rebuilding a run's LFG status at group load when one member quitting to character select dissolves the group it was rebuilt for. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/WorldSession.cpp | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index 4a89f7851..5d42f1539 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -1204,8 +1204,29 @@ void WorldSession::LogoutPlayer(bool Save) _player->UninviteFromGroup(); #ifndef ENABLE_PLAYERBOTS // remove player from the group if he is: - // a) in group; b) not in raid group; c) logging out normally (not being kicked or disconnected) - if (_player->GetGroup() && !_player->GetGroup()->isRaidGroup() && m_Socket) + // a) in group; b) not in raid group; c) not in a dungeon finder group; + // d) logging out normally (not being kicked or disconnected) + // + // Dungeon finder groups are exempt for the same reason raids are: the group is the + // run. Dropping a member on logout loses the group, and with it the group's instance + // bind -- so the player comes back to a BRAND NEW instance of the same dungeon, with + // no group, standing inside an instance nobody else is in. + // + // Observed live 2026-08-06: five characters idled to the character screen between + // 21:58:56 and 22:01:21, each sending a normal CMSG_LOGOUT_REQUEST. Each logout + // stripped that member, leadership walking down the line, until `group_member` held + // only guid 8 -- the last to leave. Returning put them in instance 3 of Wailing + // Caverns where they had left instance 2. + // + // The m_Socket term is why this went unnoticed: a hard disconnect (alt-F4) leaves no + // socket and skips the branch entirely, so relogging that way kept the group and + // looked correct. Only a GRACEFUL logout destroyed it. + // + // This also makes restart restoration meaningful. There is no point rebuilding a + // run's LFG status at group load if one member quitting to the character screen + // dissolves the group it was rebuilt for. + if (_player->GetGroup() && !_player->GetGroup()->isRaidGroup() && + !_player->GetGroup()->isLFGGroup() && m_Socket) { _player->RemoveFromGroup(); } From 026129ac9bdf00f5a0e294a994ca628a25227c11 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 23:37:28 +0100 Subject: [PATCH 56/81] Implement /who for 18414: rebuild both bodies and register the opcode CMSG_WHO was declared but never registered, so /who has always been silent. HandleWhoOpcode existed, but read the 3.3.5 body -- two uint32s, two inline null-terminated strings, then masks and counts -- which shares no field order with what this client sends. Registering it unchanged would have desynchronised the read stream on the first query, so both directions were rebuilt first. REQUEST. Writer is sub_66E005 (vtable body writer; header writer sub_6624C1 calls sub_40F075(pkt, 6307)). Bit widths came from the helper thunks: sub_665157 = 1 bit, sub_664D4F = 3, sub_664DCD = 4, sub_664EC9 = 6, sub_664F47 = 7, and sub_665185 writes a whole byte, so a byte-then-bit pair is a 9-bit length. Four leading uint32s, then a bit block carrying every string length and both counts, FlushBits, then the byte payloads. Verified byte-exact with zero leftover against five build-18414 captures under catalogueGenerationId 2BE10C89...88752: capture-000135 seq 177671 ("Zynakinka"), capture-000146 seq 1464949 (word "twi"), capture-000059 seq 1637595 (accented UTF-8), and capture-000146 seq 1375738 / capture-000161 seq 82808, which each carry a name plus realm "Magtheridon" and pin the 6-bit string as the player name and the 9-bit one as the realm. RESPONSE. Read by the client in sub_720854, which fills a 536-byte JamWhoEntry per result -- RTTI confirms the struct name. sub_691684 reads 6 bits, sub_6650D3 reads 7. The reply is TWO passes over the results: a bit block for every entry, FlushBits, then a byte block for every entry, with three GUIDs (player, account, guild) interleaved through both in a fixed order taken verbatim from the reader. An empty result is a single 0x00 byte -- a 6-bit count of zero, flushed -- confirmed at capture-000059 seq 1637608 and capture-000326 seq 921262. The match loop is unchanged; only the parse and the serialisation are new. The 6-bit count caps a reply at 63 and the client stores 50, so results are clamped. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/Player.cpp | 31 ++- src/game/Server/MopWhoPackets.h | 361 +++++++++++++++++++++++++ src/game/Server/Opcodes.cpp | 8 + src/game/WorldHandlers/MiscHandler.cpp | 108 ++++---- 4 files changed, 456 insertions(+), 52 deletions(-) create mode 100644 src/game/Server/MopWhoPackets.h diff --git a/src/game/Object/Player.cpp b/src/game/Object/Player.cpp index 34f5717ee..ccfcd2e74 100644 --- a/src/game/Object/Player.cpp +++ b/src/game/Object/Player.cpp @@ -4225,7 +4225,36 @@ void Player::HandleStealthedUnitsDetection() if (!hasAtClient) { ObjectGuid i_guid = (*i)->GetObjectGuid(); - (*i)->SendCreateUpdateToPlayer(this); + + // Only record the object as known once the create block has ACTUALLY gone + // out. SendCreateUpdateToPlayer returns false when no block could be built + // -- BuildCreateUpdateBlockForPlayer bails on !CanBuildMopCreateUpdate(), + // which rejects vehicles, boarded units, units on a transport and units + // carrying optional movement extras -- and this site used to discard that + // result and insert the guid regardless. + // + // The consequence is not a missing object, it is a POISONED one. With the + // guid in m_clientGUIDs, HaveAtClient() reports true, so + // WorldObjectChangeAccumulator happily builds VALUES update blocks for an + // object the client was never given. The 18414 client resolves the guid in + // that block (ObjectMgrClient.cpp, block type 0), finds nothing, replies + // CMSG_OBJECT_UPDATE_FAILED naming the guid -- and then ABANDONS THE REST OF + // THE PACKET, losing every create block queued behind it. Those objects are + // recorded as known too, so they never get another create, and the failure + // sustains itself until the player zones. + // + // Observed live 2026-08-06: four of five clients in one instance each sent + // CMSG_OBJECT_UPDATE_FAILED 17 times, naming player guids 1 and 6 and a pet + // (0xF140000400000001) -- exactly the characters reported invisible while + // still showing on the minimap. It surfaces as the party frame's "phasing" + // icon because UnitInPhase is not a phase test at all: it returns false when + // the client simply has no object for that member. + // + // The other two insert sites already guard this way; this one did not. + if (!(*i)->SendCreateUpdateToPlayer(this)) + { + continue; + } m_clientGUIDs.insert(i_guid); DEBUG_FILTER_LOG(LOG_FILTER_VISIBILITY_CHANGES, "%s is detected in stealth by player %u. Distance = %f", i_guid.GetString().c_str(), GetGUIDLow(), GetDistance(*i)); diff --git a/src/game/Server/MopWhoPackets.h b/src/game/Server/MopWhoPackets.h new file mode 100644 index 000000000..cbd1bed1a --- /dev/null +++ b/src/game/Server/MopWhoPackets.h @@ -0,0 +1,361 @@ +/** + * MaNGOS is a full featured server for World of Warcraft, supporting + * the following clients: 1.12.x, 2.4.3, 3.3.5a, 4.3.4a and 5.4.8 + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation; either version 2 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + * + * World of Warcraft, and all World of Warcraft or Warcraft art, images, + * and lore are copyrighted by Blizzard Entertainment, Inc. + */ + +#ifndef MANGOS_H_MOP_WHO_PACKETS +#define MANGOS_H_MOP_WHO_PACKETS + +#include "Common.h" +#include "ByteBuffer.h" +#include "WorldPacket.h" +#include "Object/ObjectGuid.h" + +#include +#include + +/** + * @brief CMSG_WHO (0x18A3) and SMSG_WHO (0x161B) for client build 18414. + * + * Both bodies were derived from the client binary and then confirmed against real + * capture payloads. Nothing here is carried over from 3.3.5 -- the two shapes share + * no field order at all, which is why the inherited handler had to be rewritten + * rather than simply registered. + * + * REQUEST -- writer sub_66E005 (vtable body writer; header writer sub_6624C1 calls + * sub_40F075(pkt, 6307)). Bit widths recovered from the helper thunks: sub_665157 = 1 + * bit, sub_664D4F = 3, sub_664DCD = 4, sub_664EC9 = 6, sub_664F47 = 7, and sub_665185 + * writes a whole byte -- so a "byte then one bit" pair is a 9-bit length. + * + * uint32 x4 race mask, class mask, level max, level min + * bit x3 showEnemies, exactName, serverInfo + * bits8(len>>1) + bit(len&1) guild name length (9 bits) + * bit unknown + * bits6 len player name length + * bits4 count zone count + * bits8(len>>1) + bit(len&1) realm name length (9 bits) + * bits7 len fourth string length + * bits3 count word count + * per word: bits7 len + * FlushBits + * word bytes, guild name, uint32 zone ids, player name, realm name, fourth string + * if (serverInfo) uint32 x3 + * + * Verified byte-exact, zero leftover, against five build-18414 captures under + * catalogueGenerationId 2BE10C89...88752: + * capture-000135 seq 177671 name "Zynakinka" + * capture-000146 seq 1464949 word "twi" + * capture-000059 seq 1637595 accented UTF-8 name + * capture-000146 seq 1375738 name "Pawclaws" + realm "Magtheridon" + * capture-000161 seq 82808 name "Discocandy" + realm "Magtheridon" + * + * RESPONSE -- read by the client in sub_720854, which fills a 536-byte JamWhoEntry + * (RTTI confirms the name) per result. sub_691684 reads 6 bits, sub_6650D3 reads 7. + * The client stores the parsed entries via sub_A6BD8F, and GetWhoInfo surfaces + * name, guild, level, race, class and zone to Lua. + * + * The response is TWO passes over the entries: a bit block for every entry first, + * then FlushBits, then a byte block for every entry. Writing them interleaved + * desynchronises the client's reader. + * + * An empty result is a single 0x00 byte -- a 6-bit count of zero, flushed. Confirmed + * at capture-000059 seq 1637608 and capture-000326 seq 921262. + */ +namespace MopWhoPackets +{ + /// A parsed CMSG_WHO query. + struct WhoRequest + { + uint32 raceMask = 0; + uint32 classMask = 0; + uint32 levelMax = 0; + uint32 levelMin = 0; + + std::string playerName; ///< 6-bit length. "Zynakinka" in capture-000135 seq 177671. + std::string guildName; ///< 9-bit length. + std::string realmName; ///< 9-bit length. "Magtheridon" in capture-000146 seq 1375738. + std::string extraName; ///< 7-bit length. Empty in every observed capture. + + std::vector zoneIds; ///< 4-bit count, so at most 15; the UI caps at 10. + std::vector words; ///< 3-bit count, so at most 7; the UI caps at 4. + + bool showEnemies = false; + bool exactName = false; + bool serverInfo = false; + }; + + /// One row of an SMSG_WHO reply, in the order the client stores it. + struct WhoEntry + { + ObjectGuid playerGuid; + ObjectGuid accountGuid; + ObjectGuid guildGuid; + + std::string name; + std::string guildName; + + uint32 nameVirtualRealm = 0; + uint32 guildVirtualRealm = 0; + uint32 zoneId = 0; + uint32 unknown4 = 0; ///< entry+4. Zero in everything we send. + + uint8 race = 0; + uint8 gender = 0; + uint8 classId = 0; + uint8 level = 0; + + bool isGameMaster = false; ///< entry+532, the only bool the client keeps. + }; + + inline uint8 GuidByte(ObjectGuid const& guid, size_t index) + { + return uint8(guid.GetRawValue() >> (index * 8)); + } + + /** + * @brief Read a CMSG_WHO body. Returns false on anything malformed. + * + * Refusing is deliberate: a body we cannot account for must not be half-applied, + * because the reader shares the packet's bit cursor and a wrong length silently + * consumes the rest of the stream. + */ + inline bool ParseWhoRequest(WorldPacket& in, WhoRequest& req) + { + if (in.size() - in.rpos() < 16) + { + return false; + } + + in >> req.raceMask; + in >> req.classMask; + in >> req.levelMax; + in >> req.levelMin; + + req.showEnemies = in.ReadBit(); + req.exactName = in.ReadBit(); + req.serverInfo = in.ReadBit(); + + uint32 guildLen = in.ReadBits(8) << 1; + guildLen |= in.ReadBit() ? 1 : 0; + + in.ReadBit(); // unknown, zero in every capture + + uint32 const nameLen = in.ReadBits(6); + uint32 const zoneCount = in.ReadBits(4); + + uint32 realmLen = in.ReadBits(8) << 1; + realmLen |= in.ReadBit() ? 1 : 0; + + uint32 const extraLen = in.ReadBits(7); + uint32 const wordCount = in.ReadBits(3); + + std::vector wordLens; + wordLens.reserve(wordCount); + for (uint32 i = 0; i < wordCount; ++i) + { + wordLens.push_back(in.ReadBits(7)); + } + + // The client caps zones at 10 and words at 4. The wire fields are wider than + // that, so a hostile or broken body can claim more; refuse rather than trust it. + if (zoneCount > 10 || wordCount > 4) + { + return false; + } + + size_t needed = guildLen + nameLen + realmLen + extraLen + zoneCount * 4; + for (std::vector::const_iterator it = wordLens.begin(); it != wordLens.end(); ++it) + { + needed += *it; + } + if (req.serverInfo) + { + needed += 12; + } + if (in.size() - in.rpos() < needed) + { + return false; + } + + req.words.reserve(wordCount); + for (std::vector::const_iterator it = wordLens.begin(); it != wordLens.end(); ++it) + { + std::string word; + if (*it) + { + word.assign((char const*)in.contents() + in.rpos(), *it); + in.read_skip(*it); + } + req.words.push_back(word); + } + + if (guildLen) + { + req.guildName.assign((char const*)in.contents() + in.rpos(), guildLen); + in.read_skip(guildLen); + } + + req.zoneIds.reserve(zoneCount); + for (uint32 i = 0; i < zoneCount; ++i) + { + uint32 zone; + in >> zone; + req.zoneIds.push_back(zone); + } + + if (nameLen) + { + req.playerName.assign((char const*)in.contents() + in.rpos(), nameLen); + in.read_skip(nameLen); + } + if (realmLen) + { + req.realmName.assign((char const*)in.contents() + in.rpos(), realmLen); + in.read_skip(realmLen); + } + if (extraLen) + { + req.extraName.assign((char const*)in.contents() + in.rpos(), extraLen); + in.read_skip(extraLen); + } + + if (req.serverInfo) + { + uint32 ignored; + in >> ignored; + in >> ignored; + in >> ignored; + } + + return true; + } + + /** + * @brief Build an SMSG_WHO reply. + * + * Field and GUID-byte order taken from the client's reader sub_720854. The three + * GUIDs live at entry offsets +8..+15 (player), +16..+23 (account) and + * +416..+423 (guild); the interleaving below is that reader's exact sequence, not + * a tidied-up version of it. + */ + inline void BuildWhoResponse(WorldPacket& out, std::vector const& entries) + { + // 6-bit count. Zero entries therefore flushes to the single 0x00 byte retail + // sends for "no results". + out.WriteBits(uint32(entries.size()), 6); + + for (std::vector::const_iterator it = entries.begin(); it != entries.end(); ++it) + { + ObjectGuid const& p = it->playerGuid; + ObjectGuid const& a = it->accountGuid; + ObjectGuid const& g = it->guildGuid; + + out.WriteBit(GuidByte(p, 2) != 0); + out.WriteBit(GuidByte(a, 2) != 0); + out.WriteBit(GuidByte(p, 7) != 0); + out.WriteBit(GuidByte(g, 5) != 0); + out.WriteBits(uint32(it->guildName.size()), 7); + out.WriteBit(GuidByte(p, 1) != 0); + out.WriteBit(GuidByte(p, 5) != 0); + out.WriteBit(GuidByte(g, 7) != 0); + out.WriteBit(GuidByte(a, 5) != 0); + out.WriteBit(false); // entry+0 + out.WriteBit(GuidByte(g, 1) != 0); + out.WriteBit(GuidByte(a, 6) != 0); + out.WriteBit(GuidByte(g, 2) != 0); + out.WriteBit(GuidByte(a, 4) != 0); + out.WriteBit(GuidByte(g, 0) != 0); + out.WriteBit(GuidByte(g, 3) != 0); + out.WriteBit(GuidByte(p, 6) != 0); + out.WriteBit(it->isGameMaster); // entry+532 + out.WriteBit(GuidByte(a, 1) != 0); + out.WriteBit(GuidByte(g, 4) != 0); + out.WriteBit(GuidByte(p, 0) != 0); + + // Five word slots, always present. We never echo search words back, so + // every one is zero length and contributes no bytes below. + for (int w = 0; w < 5; ++w) + { + out.WriteBits(0, 7); + } + + out.WriteBit(GuidByte(a, 3) != 0); + out.WriteBit(GuidByte(g, 6) != 0); + out.WriteBit(GuidByte(a, 0) != 0); + out.WriteBit(GuidByte(p, 4) != 0); + out.WriteBit(GuidByte(p, 3) != 0); + out.WriteBit(GuidByte(a, 7) != 0); + out.WriteBits(uint32(it->name.size()), 6); + } + + out.FlushBits(); + + for (std::vector::const_iterator it = entries.begin(); it != entries.end(); ++it) + { + ObjectGuid const& p = it->playerGuid; + ObjectGuid const& a = it->accountGuid; + ObjectGuid const& g = it->guildGuid; + + out.WriteByteSeq(GuidByte(a, 1)); + out << uint32(it->nameVirtualRealm); + out.WriteByteSeq(GuidByte(a, 7)); + out << uint32(it->guildVirtualRealm); + out.WriteByteSeq(GuidByte(a, 4)); + if (!it->name.empty()) + { + out.append(it->name.c_str(), it->name.size()); + } + out.WriteByteSeq(GuidByte(g, 1)); + out.WriteByteSeq(GuidByte(a, 0)); + out.WriteByteSeq(GuidByte(g, 2)); + out.WriteByteSeq(GuidByte(g, 0)); + out.WriteByteSeq(GuidByte(g, 4)); + out.WriteByteSeq(GuidByte(a, 3)); + out.WriteByteSeq(GuidByte(g, 6)); + out << uint32(it->unknown4); + if (!it->guildName.empty()) + { + out.append(it->guildName.c_str(), it->guildName.size()); + } + out.WriteByteSeq(GuidByte(g, 3)); + out.WriteByteSeq(GuidByte(p, 4)); + out << uint8(it->classId); + out.WriteByteSeq(GuidByte(p, 7)); + out.WriteByteSeq(GuidByte(a, 6)); + out.WriteByteSeq(GuidByte(a, 2)); + // five zero-length words: nothing to emit + out.WriteByteSeq(GuidByte(p, 2)); + out.WriteByteSeq(GuidByte(p, 3)); + out << uint8(it->race); + out.WriteByteSeq(GuidByte(g, 7)); + out.WriteByteSeq(GuidByte(p, 1)); + out.WriteByteSeq(GuidByte(p, 5)); + out.WriteByteSeq(GuidByte(p, 6)); + out.WriteByteSeq(GuidByte(a, 5)); + out.WriteByteSeq(GuidByte(p, 0)); + out << uint8(it->gender); + out.WriteByteSeq(GuidByte(g, 5)); + out << uint8(it->level); + out << uint32(it->zoneId); + } + } +} + +#endif diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index e070a93e6..50d9da14e 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -916,6 +916,14 @@ void InitializeOpcodes() DefC(CMSG_NAME_QUERY, "CMSG_NAME_QUERY", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleNameQueryOpcode); DefS(SMSG_NAME_QUERY_RESPONSE, "SMSG_NAME_QUERY_RESPONSE"); + // The /who list. Both bodies were rebuilt for 18414 -- see MopWhoPackets for the + // layouts and the captures they were verified against. HandleWhoOpcode has existed + // all along but was never registered, so /who has always been silent; registering + // it against the old 3.3.5 reader would have been worse than silence, which is why + // the reader was rewritten first. + DefC(CMSG_WHO, "CMSG_WHO", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleWhoOpcode); + DefS(SMSG_WHO, "SMSG_WHO"); + // Realm-name query. The 18414 client fires this from its name-cache path when a // queried character's realm is not yet in its RealmCache; until it is answered the // client parks the queried name and never commits it (the name shows "Unknown"). diff --git a/src/game/WorldHandlers/MiscHandler.cpp b/src/game/WorldHandlers/MiscHandler.cpp index d9be3ae7f..00c7002b7 100644 --- a/src/game/WorldHandlers/MiscHandler.cpp +++ b/src/game/WorldHandlers/MiscHandler.cpp @@ -64,6 +64,7 @@ #include "GuildMgr.h" #include "ObjectMgr.h" #include "LFGMgr.h" +#include "Server/MopWhoPackets.h" #include "WorldSession.h" #include "Auth/BigNumber.h" #include "Auth/Sha1.h" @@ -148,56 +149,43 @@ void WorldSession::HandleWhoOpcode(WorldPacket& recv_data) uint32 clientcount = 0; - uint32 level_min, level_max, racemask, classmask, zones_count, str_count; - uint32 zoneids[10]; // 10 is client limit - std::string player_name, guild_name; - - recv_data >> level_min; // maximal player level, default 0 - recv_data >> level_max; // minimal player level, default 100 (MAX_LEVEL) - recv_data >> player_name; // player name, case sensitive... - - recv_data >> guild_name; // guild name, case sensitive... - - recv_data >> racemask; // race mask - recv_data >> classmask; // class mask - recv_data >> zones_count; // zones count, client limit=10 (2.0.10) - - if (zones_count > 10) + // Rebuilt for 18414. See MopWhoPackets for the derived layout and the five + // captures it was verified against. The inherited reader took a flat 3.3.5 body -- + // two uint32s, two inline null-terminated strings, then masks and counts -- and + // shares no field order whatsoever with what this client sends, so registering it + // unchanged would have desynchronised the read stream on the first /who. + MopWhoPackets::WhoRequest request; + if (!MopWhoPackets::ParseWhoRequest(recv_data, request)) { - return; // can't be received from real client or broken packet - } - - for (uint32 i = 0; i < zones_count; ++i) - { - uint32 temp; - recv_data >> temp; // zone id, 0 if zone is unknown... - zoneids[i] = temp; - DEBUG_LOG("Zone %u: %u", i, zoneids[i]); + sLog.outError("WORLD: malformed CMSG_WHO from %s", GetPlayerName()); + return; } - recv_data >> str_count; // user entered strings count, client limit=4 (checked on 2.0.10) + uint32 const racemask = request.raceMask; + uint32 const classmask = request.classMask; + uint32 const level_min = request.levelMin; + uint32 level_max = request.levelMax; + uint32 const zones_count = uint32(request.zoneIds.size()); + uint32 const str_count = uint32(request.words.size()); + std::string const& player_name = request.playerName; + std::string const& guild_name = request.guildName; - if (str_count > 4) - { - return; // can't be received from real client or broken packet - } - - DEBUG_LOG("Minlvl %u, maxlvl %u, name %s, guild %s, racemask %u, classmask %u, zones %u, strings %u", level_min, level_max, player_name.c_str(), guild_name.c_str(), racemask, classmask, zones_count, str_count); + DEBUG_LOG("CMSG_WHO: minlvl %u, maxlvl %u, name '%s', guild '%s', realm '%s', " + "racemask 0x%X, classmask 0x%X, zones %u, words %u", + level_min, level_max, player_name.c_str(), guild_name.c_str(), + request.realmName.c_str(), racemask, classmask, zones_count, str_count); std::wstring str[4]; // 4 is client limit for (uint32 i = 0; i < str_count; ++i) { - std::string temp; - recv_data >> temp; // user entered string, it used as universal search pattern(guild+player name)? - - if (!Utf8toWStr(temp, str[i])) + if (!Utf8toWStr(request.words[i], str[i])) { continue; } wstrToLower(str[i]); - DEBUG_LOG("String %u: %s", i, temp.c_str()); + DEBUG_LOG("String %u: %s", i, request.words[i].c_str()); } std::wstring wplayer_name; @@ -225,9 +213,11 @@ void WorldSession::HandleWhoOpcode(WorldPacket& recv_data) uint32 matchcount = 0; uint32 displaycount = 0; - WorldPacket data(SMSG_WHO, 50); // guess size - data << uint32(clientcount); // clientcount place holder, listed count - data << uint32(clientcount); // clientcount place holder, online count + // Collected first, then serialised. The 18414 reply is TWO passes over the result + // set -- a bit block for every entry, FlushBits, then a byte block for every entry + // -- so entries cannot be appended to the packet as they are matched the way the + // 3.3.5 body allowed. + std::vector results; uint32 count = 0; sObjectAccessor.DoForAllPlayers([&](Player* pl)->void @@ -293,7 +283,7 @@ void WorldSession::HandleWhoOpcode(WorldPacket& recv_data) bool z_show = true; for (uint32 i = 0; i < zones_count; ++i) { - if (zoneids[i] == pzoneid) + if (request.zoneIds[i] == pzoneid) { z_show = true; break; @@ -362,23 +352,39 @@ void WorldSession::HandleWhoOpcode(WorldPacket& recv_data) return; } - data << pname; // player name - data << gname; // guild name - data << uint32(lvl); // player level - data << uint32(class_); // player class - data << uint32(race); // player race - data << uint8(gender); // player gender - data << uint32(pzoneid); // player zone id + MopWhoPackets::WhoEntry entry; + entry.playerGuid = pl->GetObjectGuid(); + entry.guildGuid = pl->GetGuildId() ? ObjectGuid(HIGHGUID_GUILD, pl->GetGuildId()) : ObjectGuid(); + entry.name = pname; + entry.guildName = gname; + entry.zoneId = pzoneid; + entry.race = uint8(race); + entry.gender = gender; + entry.classId = uint8(class_); + entry.level = uint8(lvl); + // accountGuid is left empty: we do not model the battle.net account GUID, and + // the client only uses it for cross-realm grouping affordances the /who list + // does not need. nameVirtualRealm and guildVirtualRealm stay 0, which reads as + // "this realm" -- correct for a single-realm server. + results.push_back(entry); ++clientcount; }); - data.put(0, clientcount); // insert right count, listed count - data.put(4, count > 50 ? count : clientcount); // insert right count, online count - + // The 6-bit count caps a reply at 63 entries, and the client itself only stores 50 + // (dword_12C6FB0 is clamped there in sub_A6BD8F). The match loop already stops at + // 50, so this is a belt-and-braces guard against ever overflowing the count field. + if (results.size() > 50) + { + results.resize(50); + } + WorldPacket data(SMSG_WHO, 1 + results.size() * 64); + MopWhoPackets::BuildWhoResponse(data, results); SendPacket(&data); - DEBUG_LOG("WORLD: Send SMSG_WHO Message"); + + DEBUG_LOG("WORLD: Sent SMSG_WHO with %u result(s) (%u online)", + uint32(results.size()), count); } /** From baf05883ffa432409fac0f7a86e9d1f59c36f077 Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 23:42:20 +0100 Subject: [PATCH 57/81] Admit SMSG_WHO through the enter-world send gate /who parsed correctly and matched results, and the server logged "Sent SMSG_WHO with 2 result(s)", but nothing reached the client and nothing appeared in the packet log: four CMSG_WHO in, zero SMSG_WHO out. m_suppressWorldSends stays active for the whole in-world session, and WorldSession::SendPacket drops any opcode IsEnterWorldConverted() does not admit -- before the packet is logged, which is why the send left no trace at all. SMSG_WHO was not on that list. This is the fourth gate in this campaign's own registration checklist: unambiguous value, binary-derived reader, byte-exact reply, AND send-gate admission. The first three were done in 5ba07628b; this is the fourth. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/WorldSession.cpp | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index 5d42f1539..418b5be08 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -279,6 +279,10 @@ static bool IsEnterWorldConverted(uint16 opcode) case SMSG_WEATHER: case SMSG_ALL_ACHIEVEMENT_DATA: // Wave 5 Task 2 -- converted 6908c5f9e (MopAchievementPackets) case SMSG_CRITERIA_UPDATE: // Isolated timed-expiry tombstone (MopAchievementPackets) + case SMSG_WHO: // 0x161B -- /who results (MopWhoPackets::BuildWhoResponse, converted). + // Body derived from the client's reader sub_720854 and its + // 536-byte JamWhoEntry; the empty form is the single 0x00 byte + // seen at capture-000059 seq 1637608. return true; // Control/transition packets that must ALWAYS reach the client while suppression is active: From 35a72ed7d7017ec15e0f87a08028aeab10e210cc Mon Sep 17 00:00:00 2001 From: MadMax Date: Thu, 6 Aug 2026 23:48:50 +0100 Subject: [PATCH 58/81] /who: send our realm id, and correct the update-failed comment Two fixes. 1. The /who list stayed empty even with the reply on the wire. Our bytes were correct -- decoding our own SMSG_WHO with a reader built from the client's sub_720854 gives name/guild/level/race/class/zone back exactly, zero leftover, and the same reader decodes retail's capture-000135 seq 177672 to "Zynakinka" / "Bratrstvo Oceli" / level 90 / zone 139, also zero leftover. So the layout and the writer were both right and the CONTENT was wrong: we sent virtual realm 0. The client resolves every entry's realm through its realm cache (sub_621E5D against dword_1087180) before it will display the list, and a zero address never resolves. Retail sends 0x03010018 and 0x0304000D in that capture. Send realmID, which is already what Guild.cpp:1039 puts on the wire for the same field. 2. The comment added in the visibility-bookkeeping fix claimed a CMSG_OBJECT_UPDATE_FAILED makes the client abandon the rest of the packet. That is wrong, and IDA settles it: after replying, the client calls sub_79BC10, which walks the update mask, discards the fields and returns 1 -- so the caller's `if (result == 0) break;` does not fire and the loop continues to the next block. The damage is confined to the one object, but it is permanent, because nothing removes the guid from m_clientGUIDs. The fix itself is unaffected; only the explanation was wrong. Dropping the cascade story also removes the need for collateral damage to explain the symptom: the invisible players are exactly the ones the client named. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/Player.cpp | 16 +++++++++------- src/game/WorldHandlers/MiscHandler.cpp | 21 +++++++++++++++++---- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/game/Object/Player.cpp b/src/game/Object/Player.cpp index ccfcd2e74..3f85a65f8 100644 --- a/src/game/Object/Player.cpp +++ b/src/game/Object/Player.cpp @@ -4235,13 +4235,15 @@ void Player::HandleStealthedUnitsDetection() // // The consequence is not a missing object, it is a POISONED one. With the // guid in m_clientGUIDs, HaveAtClient() reports true, so - // WorldObjectChangeAccumulator happily builds VALUES update blocks for an - // object the client was never given. The 18414 client resolves the guid in - // that block (ObjectMgrClient.cpp, block type 0), finds nothing, replies - // CMSG_OBJECT_UPDATE_FAILED naming the guid -- and then ABANDONS THE REST OF - // THE PACKET, losing every create block queued behind it. Those objects are - // recorded as known too, so they never get another create, and the failure - // sustains itself until the player zones. + // WorldObjectChangeAccumulator builds VALUES update blocks for an object the + // client was never given. The 18414 client resolves the guid in that block + // (ObjectMgrClient.cpp, block type 0 -> sub_79DE32), finds nothing, and + // replies CMSG_OBJECT_UPDATE_FAILED naming the guid. It then skips the block + // (sub_79BC10 walks the update mask, discards the fields and returns 1, so + // the caller does not break) and carries on with the packet -- the damage is + // confined to that one object, but it is permanent: nothing ever removes the + // guid from m_clientGUIDs, so no create is ever sent again and the player + // stays invisible to that client until they zone. // // Observed live 2026-08-06: four of five clients in one instance each sent // CMSG_OBJECT_UPDATE_FAILED 17 times, naming player guids 1 and 6 and a pet diff --git a/src/game/WorldHandlers/MiscHandler.cpp b/src/game/WorldHandlers/MiscHandler.cpp index 00c7002b7..75f871df7 100644 --- a/src/game/WorldHandlers/MiscHandler.cpp +++ b/src/game/WorldHandlers/MiscHandler.cpp @@ -362,10 +362,23 @@ void WorldSession::HandleWhoOpcode(WorldPacket& recv_data) entry.gender = gender; entry.classId = uint8(class_); entry.level = uint8(lvl); - // accountGuid is left empty: we do not model the battle.net account GUID, and - // the client only uses it for cross-realm grouping affordances the /who list - // does not need. nameVirtualRealm and guildVirtualRealm stay 0, which reads as - // "this realm" -- correct for a single-realm server. + // The virtual realm ids must be OUR realm, not zero. + // + // Before displaying, the client resolves every entry's realm through its realm + // cache (sub_621E5D against dword_1087180) and only reaches the display path once + // they all resolve; a zero address does not, so the whole list is silently held + // back. Retail's entries carry a real address -- capture-000135 seq 177672 sends + // 0x03010018 for the name and 0x0304000D for the guild. + // + // realmID is what the rest of this core already puts on the wire for the same + // field, e.g. Guild.cpp:1039 for the guild roster, so it is the consistent value + // rather than a new invention. + entry.nameVirtualRealm = realmID; + entry.guildVirtualRealm = realmID; + + // accountGuid stays empty: we do not model the battle.net account GUID, and the + // client only uses it for cross-realm grouping affordances the /who list does not + // need. results.push_back(entry); ++clientcount; From 68d0c233300b2015954ee3ff6c39f1eea0617fcb Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 00:03:32 +0100 Subject: [PATCH 59/81] Handle CMSG_OBJECT_UPDATE_FAILED with the layout the 18414 client writes The client has been telling us which object it cannot build, and we have been discarding the message. During tonight's party-phase incident the world log shows fifteen of these in seventeen seconds: 23:55:29-23:55:46 SESSION: received not handled opcode UNKNOWN (0x1061) A handler already existed but was never registered, and registering it as it stood would have been worse than dropping the packet, because its GUID layout is not this build's. The 18414 writer is the packet class at off_D65304: its header virtual sub_690E2A writes opcode 4193, and its body virtual sub_694863 emits mask order 3,5,6,0,1,2,7,4 followed by bytes 0,6,5,7,2,1,3,4, each XOR 1. The old reader used 2,3,5,0,4,7,6,1 / 1,2,5,0,3,4,6,7 -- a different build's order, carried in from a fork. Replaced with the derived one. The corpus has no CMSG 4193 at all for 18414, which is the point: a retail server does not provoke it. Ours does, so the binary is the only oracle here and this packet is the only signal that names a broken object. Recovery. m_clientGUIDs is what makes the breakage permanent: once we believe the client has an object, nothing ever re-sends a create. So the guid is erased unconditionally, before deciding whether we can do better -- our record is known false either way. If the object is still on the map we then re-send the create and re-insert on success. This is the same bookkeeping bug as the stealth-detection fix, reached from the other end: that one stopped us recording objects whose create we never sent, this one repairs records that were true once and are not any more. Together they should close the "out of phase" party icon, which is not a phase test at all -- UnitInPhase (sub_8A29C1) is an object-manager lookup, so it means exactly "I have no object for this member". Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 7 ++ src/game/WorldHandlers/MiscHandler.cpp | 129 ++++++++++++++++++------- 2 files changed, 100 insertions(+), 36 deletions(-) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index 50d9da14e..03d2ebf45 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -579,6 +579,13 @@ void InitializeOpcodes() DefC(CMSG_SETSHEATHED, "CMSG_SETSHEATHED", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleSetSheathedOpcode); DefC(CMSG_SET_SELECTION, "CMSG_SET_SELECTION", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleSetSelectionOpcode); DefC(CMSG_STANDSTATECHANGE, "CMSG_STANDSTATECHANGE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleStandStateChangeOpcode); + // The client reporting that it could not build an object we sent a VALUES + // update for. Body is a packed GUID; the 18414 writer is the packet class at + // off_D65304, whose header virtual sub_690E2A writes 4193 and whose body + // virtual sub_694863 emits mask 3,5,6,0,1,2,7,4 then bytes 0,6,5,7,2,1,3,4. + // Not present anywhere in the corpus, because a retail server does not + // provoke it. Ours does, so it is the only signal naming a broken object. + DefC(CMSG_OBJECT_UPDATE_FAILED, "CMSG_OBJECT_UPDATE_FAILED", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleObjectUpdateFailedOpcode); DefS(SMSG_STANDSTATE_UPDATE, "SMSG_STANDSTATE_UPDATE"); DefC(CMSG_ATTACKSWING, "CMSG_ATTACKSWING", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleAttackSwingOpcode); // Body recovered from decoded 18414 corpus payloads, not from size diff --git a/src/game/WorldHandlers/MiscHandler.cpp b/src/game/WorldHandlers/MiscHandler.cpp index 75f871df7..38dfb802d 100644 --- a/src/game/WorldHandlers/MiscHandler.cpp +++ b/src/game/WorldHandlers/MiscHandler.cpp @@ -629,6 +629,99 @@ void WorldSession::HandleSetSelectionOpcode(WorldPacket& recv_data) } } +/** + * @brief The client could not build an object we sent an update for. + * + * This is the client telling us our bookkeeping is wrong: it received a VALUES + * update for a GUID it has no object for, so it discarded the block. It keeps + * parsing the rest of the packet -- the client's sub_79BC10 walks the update + * mask, throws the fields away and returns 1, so the caller does not break out + * of the block loop -- but that one object is now permanently broken for that + * client, because nothing on our side ever notices and nothing re-sends a + * create. + * + * That is exactly the "out of phase" symptom: UnitInPhase (sub_8A29C1) is not a + * phase comparison at all, it is an object-manager lookup, so a party member the + * client has no object for is drawn with the phase icon and never rendered. + * + * Forgetting the GUID here makes the next visibility pass treat the object as + * unknown and send a fresh create, which repairs the client without a relog. + * + * Body from the 18414 writer (packet class off_D65304, header virtual + * sub_690E2A writes opcode 4193, body virtual sub_694863): a packed GUID with + * mask order 3,5,6,0,1,2,7,4 and byte order 0,6,5,7,2,1,3,4. + * + * @param recv_data The received opcode packet. + */ +void WorldSession::HandleObjectUpdateFailedOpcode(WorldPacket& recv_data) +{ + ObjectGuid guid; + + guid[3] = recv_data.ReadBit(); + guid[5] = recv_data.ReadBit(); + guid[6] = recv_data.ReadBit(); + guid[0] = recv_data.ReadBit(); + guid[1] = recv_data.ReadBit(); + guid[2] = recv_data.ReadBit(); + guid[7] = recv_data.ReadBit(); + guid[4] = recv_data.ReadBit(); + + recv_data.ReadByteSeq(guid[0]); + recv_data.ReadByteSeq(guid[6]); + recv_data.ReadByteSeq(guid[5]); + recv_data.ReadByteSeq(guid[7]); + recv_data.ReadByteSeq(guid[2]); + recv_data.ReadByteSeq(guid[1]); + recv_data.ReadByteSeq(guid[3]); + recv_data.ReadByteSeq(guid[4]); + + if (!_player) + { + return; + } + + if (guid == _player->GetObjectGuid()) + { + // The client has lost its own player object. Nothing we resend can + // rebuild that, so say so loudly rather than pretend a create will fix + // it. + sLog.outError("HandleObjectUpdateFailedOpcode: %s could not build its OWN object %s", + _player->GetGuidStr().c_str(), guid.GetString().c_str()); + return; + } + + // Drop the stale bookkeeping first, unconditionally. Even if we cannot + // repair the object right now, our record that the client has it is known + // to be false, and leaving it in place is what makes the breakage + // permanent. + _player->m_clientGUIDs.erase(guid); + + if (!_player->IsInWorld()) + { + sLog.outError("HandleObjectUpdateFailedOpcode: %s reported a missing object %s while not in world", + _player->GetGuidStr().c_str(), guid.GetString().c_str()); + return; + } + + WorldObject* obj = _player->GetMap()->GetWorldObject(guid); + if (!obj) + { + // The object is gone from our side too, so the client is right to have + // no object for it. Erasing the guid above is the whole repair. + sLog.outError("HandleObjectUpdateFailedOpcode: %s has no object for %s, and neither do we", + _player->GetGuidStr().c_str(), guid.GetString().c_str()); + return; + } + + sLog.outError("HandleObjectUpdateFailedOpcode: client of %s has no object for %s (%s); resending a create", + _player->GetGuidStr().c_str(), guid.GetString().c_str(), obj->GetName()); + + if (obj->SendCreateUpdateToPlayer(_player)) + { + _player->m_clientGUIDs.insert(guid); + } +} + /** * @brief Changes the player's stand state animation. * @@ -2193,39 +2286,3 @@ void WorldSession::HandleRequestHotfix(WorldPacket& recv_data) } } -void WorldSession::HandleObjectUpdateFailedOpcode(WorldPacket& recvPacket) -{ - ObjectGuid guid; - - guid[2] = recvPacket.ReadBit(); - guid[3] = recvPacket.ReadBit(); - guid[5] = recvPacket.ReadBit(); - guid[0] = recvPacket.ReadBit(); - guid[4] = recvPacket.ReadBit(); - guid[7] = recvPacket.ReadBit(); - guid[6] = recvPacket.ReadBit(); - guid[1] = recvPacket.ReadBit(); - - recvPacket.ReadByteSeq(guid[1]); - recvPacket.ReadByteSeq(guid[2]); - recvPacket.ReadByteSeq(guid[5]); - recvPacket.ReadByteSeq(guid[0]); - recvPacket.ReadByteSeq(guid[3]); - recvPacket.ReadByteSeq(guid[4]); - recvPacket.ReadByteSeq(guid[6]); - recvPacket.ReadByteSeq(guid[7]); - - - DEBUG_LOG("WORLD: Received CMSG_OBJECT_UPDATE_FAILED from %s (%u) guid: %s", GetPlayerName(), GetAccountId(), guid.GetString().c_str()); - if (_player->IsInWorld()) - { - if (WorldObject* obj = _player->GetMap()->GetWorldObject(guid)) - { - obj->SendCreateUpdateToPlayer(_player); - } - } - else - { - sLog.outError("WorldSession::HandleObjectUpdateFailedOpcode: received from player not in map"); - } -} From 70bbf44b45282ffe6dad4a1720335e5381055f1c Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 00:35:28 +0100 Subject: [PATCH 60/81] Forget the client's object set on a far teleport The client destroys its entire object manager when it processes SMSG_NEW_WORLD. We never noticed: m_clientGUIDs.clear() has never existed anywhere in this history. Upstream relies on VisibleNotifier's leftover sweep to remove stale entries instead, and that works whenever an observer's notifier gets to run on the old map between one player leaving and the next arriving. Teleporting a whole LFG party into a dungeon removes that gap. They all leave within the same tick, so for the FIRST player out no observer ever runs a pass that misses them, and their guid survives the transition in every other player's set. On arrival HaveAtClient() is still true, UpdateVisibilityOf takes the "already at client" branch, and no create is ever sent -- while those clients have no object at all. Permanently invisible, drawn with the "out of phase" party icon, because UnitInPhase (sub_8A29C1) is an object-manager lookup and not a phase comparison. Measured in world-server_2026-08-07_00-06-06.log, all five players entering Wailing Caverns at 00:10:31. Entry order was Humanwarrior, Huntdps, Thelma, Paltank, Gregory, and the erase events are strictly triangular: Huntdps out of range for players 7, 6, 2, 1 Paltank out of range for players 6, 2, 1 Thelma out of range for players 2, 1 Gregory out of range for player 1 Humanwarrior -- none Humanwarrior went first, so nothing ever erased him. He is the only player with no create in the instance, and at 00:15:32 all four other clients reported CMSG_OBJECT_UPDATE_FAILED for exactly his guid -- which is only visible at all because the previous commit registered that handler. He could see everyone, because his own set had been cleaned by the reciprocal path on the way out. Clearing on the worldport ack is the correct point: the ack answers the SMSG_NEW_WORLD the client has already acted on, and every failure path below re-teleports and so triggers another wipe anyway. m_pendingEmoteRefresh gets the same treatment for the same reason -- those are queued value re-sends aimed at objects the client has just discarded, and delivering one would only produce a spurious update-failed report. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/Player.h | 8 ++++++ src/game/WorldHandlers/MovementHandler.cpp | 32 ++++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/src/game/Object/Player.h b/src/game/Object/Player.h index ff5697458..221f1f3a5 100644 --- a/src/game/Object/Player.h +++ b/src/game/Object/Player.h @@ -5919,6 +5919,14 @@ class Player : public Unit m_pendingEmoteRefresh[guid] = EMOTE_REFRESH_DELAY_MS; } + /// Drop every queued refresh. Used when the client discards its object + /// manager wholesale (far teleport), which makes every pending target + /// unknown to it. + void ClearPendingEmoteRefresh() + { + m_pendingEmoteRefresh.clear(); + } + /// Client reported its loading screen appearing or disappearing. void SetAwaitingLoadScreen(bool loading) { diff --git a/src/game/WorldHandlers/MovementHandler.cpp b/src/game/WorldHandlers/MovementHandler.cpp index c38143088..c11fd692b 100644 --- a/src/game/WorldHandlers/MovementHandler.cpp +++ b/src/game/WorldHandlers/MovementHandler.cpp @@ -174,6 +174,38 @@ void WorldSession::HandleMoveWorldportAckOpcode() return; } + // The client destroyed its entire object manager when it processed the + // SMSG_NEW_WORLD that this ack answers, so everything we believed it had is + // now gone. Forget it here, before anything else, because every failure path + // below re-teleports and so triggers another client-side wipe anyway. + // + // Leaving this stale is not cosmetic. HaveAtClient() drives the create + // decision, so an object that exists on BOTH the old and the new map and is + // still recorded here takes the "already at client" branch on arrival and is + // never re-created. The client has no object for it and nothing on our side + // ever notices -- permanently invisible, and drawn with the "out of phase" + // party icon, because UnitInPhase (sub_8A29C1) is an object-manager lookup + // rather than a phase comparison. + // + // Normally the stale entry is removed for us: the departing player stops + // being iterated on the old map, lands in VisibleNotifier's leftover set and + // is erased there. That only happens if an observer's notifier actually runs + // on the old map in the gap. When a whole LFG party is teleported into a + // dungeon they all leave within the same tick, so for the FIRST player out + // that gap never exists and no observer ever erases them. + // + // Measured, world-server_2026-08-07_00-06-06.log, all five entering Wailing + // Caverns at 00:10:31 -- Humanwarrior went first and is the only one with no + // "out of range" event against any observer, the only one with no create in + // the instance, and at 00:15:32 all four other clients reported + // CMSG_OBJECT_UPDATE_FAILED for exactly his guid. + GetPlayer()->m_clientGUIDs.clear(); + + // Same reasoning: these are queued value re-sends aimed at objects the + // client has just discarded. Delivering one now would only produce a + // spurious CMSG_OBJECT_UPDATE_FAILED for an object it is correct not to have. + GetPlayer()->ClearPendingEmoteRefresh(); + // get start teleport coordinates (will used later in fail case) WorldLocation old_loc; GetPlayer()->GetPosition(old_loc); From 53eb39945b9dc25cb6bcbc4f05409ede11ff5adb Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 00:46:54 +0100 Subject: [PATCH 61/81] LFG: teleport only the player who asked to go back in CMSG_LFG_TELEPORT is a voluntary per-player action, but the `in` branch called TeleportToDungeon for the whole group. The comment justified that by arguing the map check inside would filter it down -- everyone else is already inside, so only the member who left still qualifies. That assumption fails as soon as the rest of the group is outside too. With the whole party in the world every member passes the map check, so one player's eyeball drags all five in. Reported live: ".tele group northshire", then any single member -- not just the leader -- clicking Teleport to dungeon moved the entire group. TeleportToDungeon now takes an optional onlyPlayer. The destination is still resolved from the group, which is what puts a returning player back with the party rather than at the entrance, and all the per-player refusals and denied replies are unchanged; only the set of members moved is restricted. CreateDungeonGroup passes no onlyPlayer and still moves everyone, which is correct -- a proposal accept IS a group entry. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.h | 5 ++- src/game/WorldHandlers/LFGMgrProposal.cpp | 38 ++++++++++++++++++----- 2 files changed, 34 insertions(+), 9 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index cb7e7ae28..0b4d78fb7 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1615,7 +1615,10 @@ class LFGMgr void CreateDungeonGroup(LFGProposal* proposal); /// Sends a group to the dungeon assigned to them - void TeleportToDungeon(uint32 dungeonID, Group* pGroup); + /// Teleport into the dungeon. Passing onlyPlayer restricts the move to that + /// member while still resolving the destination from the whole group; NULL + /// moves every eligible member, which is what a proposal accept needs. + void TeleportToDungeon(uint32 dungeonID, Group* pGroup, Player* onlyPlayer = NULL); /** * @brief Merges two players/groups/etc into one for dungeon assignment. diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index d9f1b3f0a..82890201c 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1031,7 +1031,7 @@ void LFGMgr::CreateDungeonGroup(LFGProposal* proposal) pGroup->SendUpdate(); } -void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) +void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup, Player* onlyPlayer /*= NULL*/) { // if the group's leader is already in the dungeon, teleport anyone not in dungeon to them // if nobody is in the dungeon, teleport all to beginning of dungeon (sObjectMgr.GetMapEntranceTrigger(mapid [not dungeonid])) @@ -1116,6 +1116,26 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup) { if (Player* pGroupPlr = itr->getSource()) { + // A voluntary "Teleport to dungeon" moves the player who asked for it, nobody + // else. The group is still what decides WHERE -- the leader-or-any-member + // position resolved above is what puts a returning player back with the party + // rather than at the entrance -- but only the caller is moved. + // + // This used to rely on the map check further down to do the filtering, on the + // reasoning that everyone else is already inside so only the one who left would + // qualify. That assumption fails the moment the rest of the group is outside + // too: with the whole party in the world, every member qualifies and one + // player's eyeball drags all five in. Reported live -- ".tele group northshire" + // then any single member clicking Teleport to dungeon moved the entire group, + // from any member, not just the leader. + // + // The mandatory path (CreateDungeonGroup, on proposal accept) passes no + // onlyPlayer and still moves everyone, which is correct: that IS a group entry. + if (onlyPlayer && pGroupPlr != onlyPlayer) + { + continue; + } + // further checks: player is dead, in vehicle, in battleground, on taxi, etc LFGTeleportError plrErr = LFG_TELEPORTERROR_OK; @@ -1342,13 +1362,15 @@ void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) // the function doing nothing. Observed live -- a player who ported out could not get // back, which is worse than not offering the option at all. // - // TeleportToDungeon is the same routine the proposal uses on group creation. It moves - // only members whose map is not already the dungeon's, so calling it for the whole - // group moves exactly the one player who left, and it carries the dead / falling / - // in-vehicle checks and the SMSG_LFG_TELEPORT_DENIED replies with it. It also prefers - // the group leader's position when the leader is already inside, which is what puts a - // returning player back with the group rather than at the entrance. - TeleportToDungeon(status->dungeonID, pGroup); + // TeleportToDungeon is the same routine the proposal uses on group creation, so it + // carries the dead / falling / in-vehicle checks and the SMSG_LFG_TELEPORT_DENIED + // replies with it, and it prefers a member already inside as the destination, which is + // what puts a returning player back with the group rather than at the entrance. + // + // Restricted to the caller. This is a voluntary, per-player action: the map check alone + // is NOT sufficient filtering, because when the rest of the group is also outside every + // member passes it and one player's eyeball teleports the whole party in. + TeleportToDungeon(status->dungeonID, pGroup, pPlayer); } LFGGroupStatus* LFGMgr::GetGroupStatus(ObjectGuid guid) From 7686eba58c9263e90929344a5a8b158d87181bb3 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 01:02:10 +0100 Subject: [PATCH 62/81] Never send a movement speed the client will reject the create over The 18414 create validator tests all nine speeds for approximate equality with zero and refuses the object if any of them is nearer than 2.3841858e-7 (2^-22). The refusal is the expensive kind: sub_768D2F fails, sub_769816 fails, sub_79DC30 returns 0, and the block loop in sub_79E087 BREAKS -- so the object is lost AND so is every later block in the same packet, with no reply of any kind. Combined with m_clientGUIDs being populated per block BUILT rather than per block delivered, one bad object silently poisons the client's view of every object behind it in that packet. Zero speeds are neither hypothetical nor always bad data. A totem SHOULD have SpeedWalk 0 -- it does not walk -- and Unit::UpdateSpeed multiplies straight through GetCreatureInfo()->SpeedWalk (UnitSpeed.cpp:246) with no validation anywhere; a grep for SpeedWalk across src/ returns only the struct field and that multiply. 64 rows in creature_template have a non-positive walk or run speed, and these are not obscure: 3968 Sentry Totem, 5923 Poison Cleansing Totem, 5924 Cleansing Totem, 5926 Frost Resistance Totem, 7467 Nature Resistance Totem, 15803 Tranquil Air Totem, 17539 Totem of Wrath, 30527 Training Dummy all carry SpeedWalk 0, plus 55151 Rumpus Brute (-3.72738e-21), 61928 Sik'thik Guardian (-2.97773e-20) and 57421 Mothran (SpeedRun 3.08858e-30). Any player gaining sight of a shaman totem lost the remainder of that update packet. SetSpeedRate clamps a negative rate to exactly 0.0f (UnitSpeed.cpp:284) and UpdateSpeed's min_speed floor is 0 without SPELL_AURA_MOD_MINIMUM_SPEED, so a -100% snare produces a true zero by the same route. Clamped at the create writer rather than in UpdateSpeed on purpose: the unit keeps its real speed rate and still does not move, the data stays as authored, and no future producer can bypass the correction, because this is the only place the create block's speeds are filled in. Found by adversarial review of the create path after the far-teleport fix (298f184da) had already explained the reported symptom. This is a separate, latent defect -- it did NOT cause the incident measured on 2026-08-07, where the server-side visibility log shows no create was ever built. It is committed on its own evidence, not as a second explanation of that. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/ObjectUpdate.cpp | 69 +++++++++++++++++++++++++++----- 1 file changed, 60 insertions(+), 9 deletions(-) diff --git a/src/game/Object/ObjectUpdate.cpp b/src/game/Object/ObjectUpdate.cpp index 23d1ea85e..e0131f2a5 100644 --- a/src/game/Object/ObjectUpdate.cpp +++ b/src/game/Object/ObjectUpdate.cpp @@ -68,6 +68,27 @@ namespace { + /// Smallest magnitude the 18414 client will accept for a movement speed. + /// + /// sub_45B733 compares with sub_409DD6(a, b, 0.00000023841858), i.e. an + /// approximately-equal test at 2^-22, and sub_768D2F rejects the create when + /// any of the nine speeds is approximately equal to zero. The bound is + /// exclusive (`eps > fabs(a - b)` fails the comparison), so exactly epsilon + /// is already accepted; the floor below sits an order of magnitude clear of + /// it so no later rounding can walk back across. + float const MIN_WIRE_SPEED = 0.000001f; + + /// Force a speed into the range the client's create validator accepts. + /// + /// Speeds are conceptually non-negative, so this floors rather than + /// preserving the sign: creature_template ships negative denormals + /// (-3.72738e-21 on 55151, -2.97773e-20 on 61928) which are meaningless as + /// speeds and would be rejected on magnitude anyway. + float SanitizeWireSpeed(float speed) + { + return speed < MIN_WIRE_SPEED ? MIN_WIRE_SPEED : speed; + } + static_assert(ITEM_END == MopUpdateObject::ItemFieldCount, "18414 Item direct-copy range must remain fields 0..68"); static_assert(CONTAINER_END == MopUpdateObject::ContainerFieldCount, @@ -527,15 +548,45 @@ void Object::BuildCreateUpdateBlockForPlayer(UpdateData* data, Player* target) c movement.z = unit->GetPositionZ(); movement.o = unit->GetOrientation(); movement.moveTime = GameTime::GetGameTimeMS(); - movement.speedWalk = unit->GetSpeed(MOVE_WALK); - movement.speedRun = unit->GetSpeed(MOVE_RUN); - movement.speedRunBack = unit->GetSpeed(MOVE_RUN_BACK); - movement.speedSwim = unit->GetSpeed(MOVE_SWIM); - movement.speedSwimBack = unit->GetSpeed(MOVE_SWIM_BACK); - movement.speedFlight = unit->GetSpeed(MOVE_FLIGHT); - movement.speedFlightBack = unit->GetSpeed(MOVE_FLIGHT_BACK); - movement.speedTurn = unit->GetSpeed(MOVE_TURN_RATE); - movement.speedPitch = unit->GetSpeed(MOVE_PITCH_RATE); + + // Every speed on the wire must be far enough from zero for the client to + // accept the block. Its create validator (sub_768D2F, reached from + // sub_769816 via sub_7691A4 when the LIVING bit is set) tests all nine + // speeds with an approximately-equal-to-zero comparison at epsilon + // 2.3841858e-7 = 2^-22, and rejects the object if any of them is nearer + // to zero than that. A rejected create returns 0 from sub_79DC30, which + // makes the block loop in sub_79E087 BREAK -- so the object is lost and + // so is every later block in the same packet, silently, with no reply. + // + // Zero is not hypothetical and is not always wrong data. A totem SHOULD + // have SpeedWalk 0, because a totem does not walk, and Unit::UpdateSpeed + // multiplies straight through it (UnitSpeed.cpp:246) with no validation: + // + // 3968 Sentry Totem, 5923/5924 Cleansing Totem, 5926 Frost + // Resistance Totem, 7467 Nature Resistance Totem, 15803 Tranquil Air + // Totem, 17539 Totem of Wrath, 30527 Training Dummy -- SpeedWalk 0 + // + // plus 55151 Rumpus Brute, 57421 Mothran and 61928 Sik'thik Guardian, + // which carry corrupt denormals (-3.7e-21, 3.1e-30). 64 rows in + // creature_template have a non-positive walk or run speed. A player + // gaining sight of any of them lost the rest of that update packet. + // + // SetSpeedRate also clamps a negative rate to exactly 0.0f + // (UnitSpeed.cpp:284), so a -100% snare reaches here as a true zero too. + // + // Clamping HERE rather than in UpdateSpeed is deliberate: the unit keeps + // its real speed rate and still does not move, and the correction cannot + // be bypassed by a future producer, because this is the only place the + // create block is filled in. + movement.speedWalk = SanitizeWireSpeed(unit->GetSpeed(MOVE_WALK)); + movement.speedRun = SanitizeWireSpeed(unit->GetSpeed(MOVE_RUN)); + movement.speedRunBack = SanitizeWireSpeed(unit->GetSpeed(MOVE_RUN_BACK)); + movement.speedSwim = SanitizeWireSpeed(unit->GetSpeed(MOVE_SWIM)); + movement.speedSwimBack = SanitizeWireSpeed(unit->GetSpeed(MOVE_SWIM_BACK)); + movement.speedFlight = SanitizeWireSpeed(unit->GetSpeed(MOVE_FLIGHT)); + movement.speedFlightBack = SanitizeWireSpeed(unit->GetSpeed(MOVE_FLIGHT_BACK)); + movement.speedTurn = SanitizeWireSpeed(unit->GetSpeed(MOVE_TURN_RATE)); + movement.speedPitch = SanitizeWireSpeed(unit->GetSpeed(MOVE_PITCH_RATE)); movement.self = false; MovementInfo const& movementInfo = unit->m_movementInfo; From 2ecf14f5fcb7ddfca2189f497d1cdc11cb8009d1 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 01:13:07 +0100 Subject: [PATCH 63/81] LFG: actually pay the dungeon completion reward HandleBossKilled computed a full reward -- doubled experience and money for the first run of the day, plus the satchel item -- built an LFGRewards from it, and then only ever announced it. Nothing granted anything. A grep for ModifyMoney, GiveXP or StoreNewItem across the LFG sources returned nothing at all, so finishing a dungeon paid exactly zero. The announcement did not arrive either, because SMSG_LFG_PLAYER_REWARD is not admitted through the enter-world send gate, so the whole feature was silent in both directions. Money and experience are now granted directly. GiveXP is a no-op at max level, which is the behaviour we want: a level-capped character keeps the money component and nothing else. The satchel goes through GiveDungeonRewardItem, which stores what fits and mails the remainder. Full bags are the expected case here, not an edge case -- the player has just looted a boss -- and a reward silently discarded because there was no free slot is worse than no reward at all. Mailing the overflow is what the achievement reward path in this server already does. RegisterPlayerDaily is now called as well. It had no callers whatsoever, so HasPlayerDoneDaily was permanently false and every single run took the first-of-the-day branch: doubled reward and a fresh satchel, indefinitely. It is recorded after the multiplier and item have been chosen, so the run that sets it still pays the first-run rate. The reward announcement itself still does not reach the client. Admitting SMSG_LFG_PLAYER_REWARD through the send gate is deliberately NOT done here -- its body has not been verified against the binary yet, and the gate exists because an unconverted body can crash the 18414 client. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgr.h | 3 + src/game/WorldHandlers/LFGMgrProposal.cpp | 83 +++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 0b4d78fb7..994bbd350 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1620,6 +1620,9 @@ class LFGMgr /// moves every eligible member, which is what a proposal accept needs. void TeleportToDungeon(uint32 dungeonID, Group* pGroup, Player* onlyPlayer = NULL); + /// Grant a completion reward item, mailing whatever does not fit in the bags. + void GiveDungeonRewardItem(Player* pPlayer, uint32 itemId, uint32 amount); + /** * @brief Merges two players/groups/etc into one for dungeon assignment. * diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 82890201c..dd94f6ff8 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -30,6 +30,8 @@ #include "DBCStores.h" #include "DBCStructure.h" #include "GameEventMgr.h" +#include "Item.h" +#include "Mail.h" #include "Group.h" #include "LFGMgr.h" #include "Object.h" @@ -1521,6 +1523,55 @@ void LFGMgr::UpdateWaitMap(LFGRoles role, uint32 dungeonID, time_t waitTime) } +/// Hand a completion reward item to a player, falling back to mail when the bags are full. +/// +/// A dungeon reward must not be silently dropped because the player finished the run with no +/// free slot -- that is precisely when it is most likely, since they have just looted a boss. +/// Mailing it is what retail does and what the rest of this server already does for +/// achievement rewards (AchievementMgr.cpp:2219). +void LFGMgr::GiveDungeonRewardItem(Player* pPlayer, uint32 itemId, uint32 amount) +{ + ItemPrototype const* proto = ObjectMgr::GetItemPrototype(itemId); + if (!proto) + { + sLog.outError("LFG GiveDungeonRewardItem: reward item %u does not exist; %s paid nothing", + itemId, pPlayer->GetGuidStr().c_str()); + return; + } + + ItemPosCountVec dest; + uint32 noSpaceCount = 0; + InventoryResult msg = pPlayer->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, amount, &noSpaceCount); + + uint32 stored = amount - noSpaceCount; + if (msg == EQUIP_ERR_OK && stored) + { + if (Item* item = pPlayer->StoreNewItem(dest, itemId, true)) + { + pPlayer->SendNewItem(item, stored, true, false); + } + } + + // Whatever did not fit goes in the post. + if (noSpaceCount) + { + if (Item* mailItem = Item::CreateItem(itemId, noSpaceCount, pPlayer)) + { + mailItem->SaveToDB(); // persist before send, or a failed send loses it + + std::string const subject = proto->Name1 ? proto->Name1 : ""; + MailDraft draft(subject, ""); + draft.AddItem(mailItem); + draft.SendMailTo(MailReceiver(pPlayer), MailSender(MAIL_CREATURE, uint32(0))); + } + else + { + sLog.outError("LFG GiveDungeonRewardItem: could not create %u x%u for mail to %s", + itemId, noSpaceCount, pPlayer->GetGuidStr().c_str()); + } + } +} + void LFGMgr::HandleBossKilled(Player* pPlayer) { Group* pGroup = pPlayer->GetGroup(); @@ -1616,6 +1667,38 @@ void LFGMgr::HandleBossKilled(Player* pPlayer) itemAmount = itemRewards.itemAmount; } + // Actually pay the player. + // + // Everything above computed a reward and then only ever announced it. Nothing + // in the LFG code granted money, experience or the satchel -- a grep for + // ModifyMoney, GiveXP or StoreNewItem across the LFG sources returned nothing + // -- so finishing a dungeon paid exactly zero, and the announcement was + // dropped by the enter-world send gate on top of that. + if (moneyReward) + { + pGroupPlr->ModifyMoney(int64(moneyReward)); + } + + if (xpReward) + { + // GiveXP is a no-op at max level, which is the correct behaviour here: + // the money component above is what a level-capped character keeps. + pGroupPlr->GiveXP(xpReward, NULL); + } + + if (itemReward && itemAmount) + { + GiveDungeonRewardItem(pGroupPlr, itemReward, itemAmount); + } + + // Record the run against the daily allowance AFTER the reward is decided. + // + // RegisterPlayerDaily had no callers at all, so HasPlayerDoneDaily was + // permanently false: every run took the first-of-the-day branch and paid the + // doubled reward plus the satchel, for ever. It has to be set here, once the + // multiplier and the item have already been chosen from the pre-run value. + RegisterPlayerDaily(pGroupPlr->GetGUIDLow(), type); + // and then fill a structure corresponding to SMSG_LFG_PLAYER_REWARD and // send one of these to each player LFGRewards reward(randomDungeonId, status->dungeonID, hasDoneDaily, moneyReward, xpReward, itemReward, itemAmount); From 3b3cbcca7f32ab03751528ac1ae273a06687f0a7 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 01:47:09 +0100 Subject: [PATCH 64/81] LFG: implement vote kick end to end AttemptToKickPlayer and CastVote existed but had no callers whatsoever, and CMSG_LFG_BOOT_PLAYER_VOTE was declared and never registered. The whole feature was dead code: the client's Remove entry did nothing at all in a dungeon group. The vote body, derived from the 18414 writer rather than a fork: the packet class at vtable 0xD63364, header virtual sub_661F56 writing opcode 6078 (0x17BE), body writer sub_688B4B, which is exactly WriteBit(agree) + FlushBits. One byte, 0x80 agree / 0x00 deny, MSB-first. No guid, no length, no second field -- so the client says only HOW someone voted. WHICH vote it belongs to must come from server state, hence the session identifies the voter and the voter's group identifies the boot. Initiation is CMSG_GROUP_UNINVITE_GUID. In an LFD group a removal request becomes a vote instead, which is why the client collects `reason` there -- it has no other consumer. The branch sits deliberately BEFORE CanUninviteFromGroup, which demands leader or assistant: an LFD group has no meaningful leadership here, any member may start a vote including against the leader, and routing through the normal path would both refuse ordinary members and, for a leader, perform a real removal nobody voted on. Defects fixed in the pre-existing code, each of which was reachable: - CastVote dereferenced pPlayer->GetGroup() unchecked. The vote body carries no group, so any client can send one while ungrouped. Straight crash. - SendLfgBootUpdate dereferenced answers.find(guid) unchecked. The target is skipped when results are broadcast and a late joiner never had an entry, so end() was reachable. Straight crash. - The tally used `yay == REQUIRED_VOTES_FOR_BOOT`. Exact equality on a counter that is only tested after it reaches the threshold happens to hold today, but it silently does nothing if it is ever crossed by more than one. - m_bootStatusMap was never erased, despite the comment saying it was. The stale entry meant one vote per group, ever. - The booted player was never teleported out. They stayed inside the instance and could simply walk back, which makes the removal pointless. - A survivor of a FAILED vote was left in LFG_STATE_BOOT for the rest of the run, because the target was skipped in the state-restoring loop. That blocks every later vote in the group and is invisible until someone tries. - Nothing expired a vote. RemoveOldBoots now reaps at LFG_TIME_BOOT (30 s, measured across all 14 observed retail boot sessions) and always FAILS the vote: a kick needs explicit agrees, so silence must never remove anyone. Added guards, all reported with SMSG_PARTY_COMMAND_RESULT, whose flat 18414 body is already verified and admitted: a boot already in progress, a finished dungeon, and a group too small for the threshold to be reachable at all. That last one matters -- everyone except the target may vote yes, so a group of REQUIRED_VOTES_FOR_BOOT or fewer can never pass, and starting the vote anyway would freeze the group until expiry. Voting is also refused from the target themselves and from anyone who was not in the answer map when the vote started, so a member joining mid-vote cannot tip a tally they were never counted in. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 6 ++ src/game/Server/WorldSession.h | 1 + src/game/WorldHandlers/GroupHandler.cpp | 32 +++++-- src/game/WorldHandlers/LFGHandler.cpp | 46 +++++++++- src/game/WorldHandlers/LFGMgr.cpp | 77 ++++++++++++++++- src/game/WorldHandlers/LFGMgr.h | 3 + src/game/WorldHandlers/LFGMgrProposal.cpp | 100 +++++++++++++++++++++- 7 files changed, 255 insertions(+), 10 deletions(-) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index 03d2ebf45..c67f51bdc 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -1153,6 +1153,12 @@ void InitializeOpcodes() DefS(SMSG_LFG_TELEPORT_DENIED, "SMSG_LFG_TELEPORT_DENIED"); // Body is a single MSB-first bit (0x80 out, 0x00 in) -- see HandleLfgTeleportOpcode. DefC(CMSG_LFG_TELEPORT, "CMSG_LFG_TELEPORT", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgTeleportOpcode); + // Vote on an in-progress kick. Body is a single MSB-first bit and nothing else: + // the 18414 writer sub_688B4B (packet class vtable 0xD63364, header virtual + // sub_661F56 writing 6078) is exactly WriteBit(agree) + FlushBits. The client + // does not say WHICH boot -- the session identifies the voter and the voter's + // group identifies the vote. + DefC(CMSG_LFG_BOOT_PLAYER_VOTE, "CMSG_LFG_BOOT_PLAYER_VOTE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgBootPlayerVoteOpcode); // Wave 13 talent-respec confirmation request and prompt. DefC(CMSG_CONFIRM_RESPEC_WIPE, "CMSG_CONFIRM_RESPEC_WIPE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleTalentWipeConfirmOpcode); diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index d51756cc3..a4cf6e75f 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -2214,6 +2214,7 @@ class WorldSession void HandleLfgProposalResponseOpcode(WorldPacket& recv_data); void HandleLfgGetStatusOpcode(WorldPacket& recv_data); void HandleLfgTeleportOpcode(WorldPacket& recv_data); + void HandleLfgBootPlayerVoteOpcode(WorldPacket& recv_data); void HandleLfgLockInfoRequestOpcode(WorldPacket& recv_data); void HandleSetLfgCommentOpcode(WorldPacket& recv_data); void HandleSetTitleOpcode(WorldPacket& recv_data); diff --git a/src/game/WorldHandlers/GroupHandler.cpp b/src/game/WorldHandlers/GroupHandler.cpp index 947995869..b477a5843 100644 --- a/src/game/WorldHandlers/GroupHandler.cpp +++ b/src/game/WorldHandlers/GroupHandler.cpp @@ -394,16 +394,38 @@ void WorldSession::HandleGroupUninviteGuidOpcode(WorldPacket& recv_data) return; } - PartyResult res = GetPlayer()->CanUninviteFromGroup(); - if (res != ERR_PARTY_RESULT_OK) + Group* grp = GetPlayer()->GetGroup(); + if (!grp) { - SendPartyResult(PARTY_OP_LEAVE, "", res); return; } - Group* grp = GetPlayer()->GetGroup(); - if (!grp) + // In a dungeon-finder group nobody may remove anybody unilaterally; the request + // becomes a vote kick instead. That is why the client bothers to collect + // `reason` here -- it is the free text shown in the boot dialog and it has no + // other consumer. + // + // Deliberately BEFORE CanUninviteFromGroup, which requires leader or assistant. + // An LFD group has no meaningful leadership for this purpose: any member may + // start a vote, including against the leader, and the vote is what decides it. + // Routing through the normal path would both refuse ordinary members and, for a + // leader, silently perform a real removal that no one voted on. + if (grp->isLFGGroup()) + { + if (!grp->IsMember(guid)) + { + SendPartyResult(PARTY_OP_LEAVE, "", ERR_TARGET_NOT_IN_GROUP_S); + return; + } + + sLFGMgr.AttemptToKickPlayer(grp, guid, GetPlayer()->GetObjectGuid(), request.reason); + return; + } + + PartyResult res = GetPlayer()->CanUninviteFromGroup(); + if (res != ERR_PARTY_RESULT_OK) { + SendPartyResult(PARTY_OP_LEAVE, "", res); return; } diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 4b34ddf9f..0e32941f2 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -281,6 +281,44 @@ void WorldSession::HandleLfgGetStatusOpcode(WorldPacket& /*recv_data*/) SendLfgUpdate(GetPlayer()->GetGroup() != nullptr, status); } +/** + * @brief A player's answer to an in-progress vote kick. + * + * The body is ONE BIT and nothing else, derived from the 18414 writer: the packet + * class at vtable 0xD63364, whose header virtual sub_661F56 writes opcode 6078 + * (0x17BE) and whose body writer sub_688B4B is exactly + * + * WriteBit(this->agree); FlushBits(); + * + * One byte on the wire, 0x80 for agree and 0x00 for deny, because ReadBit is + * MSB-first. There is no GUID, no length and no second field, so the client tells + * us only HOW the player voted. WHICH boot it belongs to has to come entirely from + * server state: the sending session identifies the voter, and the voter's group + * identifies the boot. + * + * @param recv_data The received opcode packet. + */ +void WorldSession::HandleLfgBootPlayerVoteOpcode(WorldPacket& recv_data) +{ + if (recv_data.size() - recv_data.rpos() != 1) + { + sLog.outError("WORLD: malformed CMSG_LFG_BOOT_PLAYER_VOTE from %s", GetPlayerName()); + return; + } + + bool const agree = recv_data.ReadBit(); + + if (!GetPlayer()) + { + return; + } + + DEBUG_LOG("CMSG_LFG_BOOT_PLAYER_VOTE: %s voted %s", + GetPlayer()->GetGuidStr().c_str(), agree ? "agree" : "deny"); + + sLFGMgr.CastVote(GetPlayer(), agree); +} + void WorldSession::HandleLfgTeleportOpcode(WorldPacket& recv_data) { DEBUG_LOG("CMSG_LFG_TELEPORT"); @@ -795,7 +833,13 @@ void WorldSession::SendLfgBootUpdate(LFGBoot const& boot) DEBUG_LOG("SMSG_LFG_BOOT_PLAYER (5.4.8)"); ObjectGuid plrGuid = GetPlayer()->GetObjectGuid(); - LFGProposalAnswer plrAnswer = boot.answers.find(plrGuid)->second; + + // The recipient is not guaranteed to have a vote recorded. The player being + // voted on is deliberately skipped when the result is broadcast, and a member + // who joined after the vote started never had an entry, so find() can and does + // return end(). Dereferencing it was an unchecked crash on the boot path. + proposalAnswerMap::const_iterator plrIt = boot.answers.find(plrGuid); + LFGProposalAnswer plrAnswer = plrIt != boot.answers.end() ? plrIt->second : LFG_ANSWER_PENDING; uint32 voteCount = 0, yayCount = 0; for (proposalAnswerMap::const_iterator it = boot.answers.begin(); it != boot.answers.end(); ++it) diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index ef21ff761..d3b4b4ef0 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -76,7 +76,7 @@ LFGMgr::~LFGMgr() void LFGMgr::Update() { - //todo: remove old queues, proposals & boot votes + //todo: remove old queues // remove old role checks RemoveOldRoleChecks(); @@ -84,6 +84,9 @@ void LFGMgr::Update() // and proposals nobody answered RemoveOldProposals(); + // and boot votes that ran out of time + RemoveOldBoots(); + // go through a waitTimeMap::iterator for each wait map and update times based on player count for (waitTimeMap::iterator tankItr = m_tankWaitTime.begin(); tankItr != m_tankWaitTime.end(); ++tankItr) { @@ -1086,6 +1089,78 @@ void LFGMgr::CancelProposalsFor(ObjectGuid plrGuid) } } +/// Expire boot votes nobody finished answering. +/// +/// LFG_TIME_BOOT is 30 seconds, measured across all 14 observed retail boot +/// sessions, and the client counts down against the timeLeft we send. Nothing +/// cleared the vote when that ran out, so a boot that never reached the threshold +/// pinned the whole group in LFG_STATE_BOOT permanently: CastVote refuses any +/// other state, AttemptToKickPlayer now refuses a boot already in progress, and +/// the state only ever moved again on a completed vote. One ignored popup +/// disabled vote kick for the rest of the run. +/// +/// An expired vote FAILS. A kick needs REQUIRED_VOTES_FOR_BOOT explicit agrees, +/// so silence must never remove anybody. +void LFGMgr::RemoveOldBoots() +{ + time_t const now = time(NULL); + + std::vector expired; + for (bootStatusMap::const_iterator it = m_bootStatusMap.begin(); it != m_bootStatusMap.end(); ++it) + { + if (it->second.inProgress && it->second.startTime && + (now - it->second.startTime) >= LFG_TIME_BOOT) + { + expired.push_back(it->first); + } + } + + // Collected first, because the loop below erases from the map being walked. + for (std::vector::const_iterator it = expired.begin(); it != expired.end(); ++it) + { + ObjectGuid const groupGuid = *it; + + bootStatusMap::iterator bootIt = m_bootStatusMap.find(groupGuid); + if (bootIt == m_bootStatusMap.end()) + { + continue; + } + + LFGBoot boot = bootIt->second; + boot.inProgress = false; + + if (LFGGroupStatus* status = GetGroupStatus(groupGuid)) + { + if (status->state == LFG_STATE_BOOT) + { + status->state = LFG_STATE_IN_DUNGEON; + m_groupStatusMap[groupGuid] = *status; + } + } + + // Tell everyone the vote lapsed and put their state back, including the + // player it was aimed at -- leaving the target in LFG_STATE_BOOT would block + // every later vote in the group just as surely as the stale entry did. + if (Group* pGroup = sObjectMgr.GetGroupById(groupGuid.GetCounter())) + { + for (GroupReference* ref = pGroup->GetFirstMember(); ref != NULL; ref = ref->next()) + { + if (Player* pGroupPlr = ref->getSource()) + { + SetPlayerState(pGroupPlr->GetObjectGuid(), LFG_STATE_IN_DUNGEON); + pGroupPlr->GetSession()->SendLfgBootUpdate(boot); + } + } + } + + m_bootStatusMap.erase(groupGuid); + + DEBUG_LOG("LFG RemoveOldBoots: vote against %s in group %s expired after %u s; nobody removed", + boot.playerVotedOn.GetString().c_str(), groupGuid.GetString().c_str(), + uint32(LFG_TIME_BOOT)); + } +} + void LFGMgr::RemoveOldProposals() { time_t const now = time(NULL); diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 994bbd350..9c3ee56f2 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1559,6 +1559,9 @@ class LFGMgr /// Group kick hook void AttemptToKickPlayer(Group* pGroup, ObjectGuid guid, ObjectGuid kicker, std::string reason); + /// Expire boot votes that ran past LFG_TIME_BOOT. An expired vote always fails. + void RemoveOldBoots(); + // Called when a player votes yes or no on a boot vote void CastVote(Player* pPlayer, bool vote); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index dd94f6ff8..c392733c5 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1723,15 +1723,56 @@ void LFGMgr::HandleBossKilled(Player* pPlayer) void LFGMgr::AttemptToKickPlayer(Group* pGroup, ObjectGuid guid, ObjectGuid kicker, std::string reason) { + if (!pGroup) + { + return; + } + ObjectGuid groupGuid = pGroup->GetObjectGuid(); LFGGroupStatus* status = GetGroupStatus(groupGuid); - - bootStatusMap::iterator bIt = m_bootStatusMap.find(groupGuid); if (!status) { return; } + Player* pKicker = sObjectAccessor.FindPlayer(kicker); + + // Refusals below are reported with SMSG_PARTY_COMMAND_RESULT, whose flat 18414 + // body is already verified and admitted. Without them the initiator gets no + // response at all and the client leaves the Remove entry looking broken. + if (status->state == LFG_STATE_BOOT) + { + if (pKicker) + { + pKicker->GetSession()->SendPartyResult(PARTY_OP_LEAVE, "", ERR_PARTY_LFG_BOOT_IN_PROGRESS); + } + return; + } + + if (status->state == LFG_STATE_FINISHED_DUNGEON) + { + // Nothing left to protect the group from once the run is done, and the + // client ships a dedicated message for exactly this. + if (pKicker) + { + pKicker->GetSession()->SendPartyResult(PARTY_OP_LEAVE, "", ERR_PARTY_LFG_BOOT_DUNGEON_COMPLETE); + } + return; + } + + // A vote that cannot possibly reach the threshold must not be started: it would + // freeze the group in LFG_STATE_BOOT until the timer expired, blocking any + // further attempt for the whole window. Everyone except the target may vote + // yes, so the group needs REQUIRED_VOTES_FOR_BOOT + 1 members to succeed at all. + if (int32(pGroup->GetMembersCount()) <= REQUIRED_VOTES_FOR_BOOT) + { + if (pKicker) + { + pKicker->GetSession()->SendPartyResult(PARTY_OP_LEAVE, "", ERR_PARTY_LFG_BOOT_TOO_FEW_PLAYERS); + } + return; + } + status->state = LFG_STATE_BOOT; m_groupStatusMap[groupGuid] = *status; @@ -1779,6 +1820,14 @@ void LFGMgr::CastVote(Player* pPlayer, bool vote) } Group* pGroup = pPlayer->GetGroup(); + if (!pGroup) + { + // The vote body carries no group and no target, so a client that votes with + // no group at all reaches here. Dereferencing was an unchecked crash on a + // packet any client can send. + return; + } + ObjectGuid groupGuid = pGroup->GetObjectGuid(); LFGGroupStatus* status = GetGroupStatus(groupGuid); @@ -1795,6 +1844,20 @@ void LFGMgr::CastVote(Player* pPlayer, bool vote) } LFGBoot boot = it->second; + + // The player being voted on does not get a say in their own removal. + if (pPlayer->GetObjectGuid() == boot.playerVotedOn) + { + return; + } + + // Nor does anyone who was not part of the vote when it started -- otherwise a + // member who joined mid-vote could tip a tally they were never counted in. + if (boot.answers.find(pPlayer->GetObjectGuid()) == boot.answers.end()) + { + return; + } + boot.answers[pPlayer->GetObjectGuid()] = LFGProposalAnswer(vote); int32 yay = 0, nay = 0; // keep a count of votes @@ -1823,6 +1886,9 @@ void LFGMgr::CastVote(Player* pPlayer, bool vote) boot.inProgress = false; status->state = LFG_STATE_IN_DUNGEON; + m_groupStatusMap[groupGuid] = *status; + + bool const passed = yay >= REQUIRED_VOTES_FOR_BOOT; for (GroupReference* itr = pGroup->GetFirstMember(); itr != NULL; itr = itr->next()) { @@ -1838,8 +1904,36 @@ void LFGMgr::CastVote(Player* pPlayer, bool vote) } } - if (yay == REQUIRED_VOTES_FOR_BOOT) + // The target is told the outcome too, and their state is restored either way. + // Skipping them entirely left a survivor of a failed vote stuck in + // LFG_STATE_BOOT for the rest of the run, which blocks the next vote against + // anyone and is invisible until someone tries. + if (Player* pVictim = sObjectAccessor.FindPlayer(boot.playerVotedOn)) + { + SetPlayerState(boot.playerVotedOn, LFG_STATE_IN_DUNGEON); + pVictim->GetSession()->SendLfgBootUpdate(boot); + } + + // The vote is over however it went; drop it before acting on the result so a + // rejected target can be voted on again later, and so RemoveMember below cannot + // re-enter this function against a boot that no longer exists. Nothing erased + // this map before, so one vote per group per session was the real behaviour. + m_bootStatusMap.erase(groupGuid); + + if (passed) { + // Put them back where they queued from BEFORE removing them from the group. + // Once the group is gone so is the LFG status this reads, and a booted + // player left standing inside the instance can simply walk back to the + // group -- the removal is meaningless without the teleport. + if (Player* pVictim = sObjectAccessor.FindPlayer(boot.playerVotedOn)) + { + if (pVictim->IsInWorld()) + { + pVictim->TeleportToBGEntryPoint(); + } + } + // kick player from group if (pGroup->RemoveMember(boot.playerVotedOn, 1) <= 1) { From 0441004198c27851d601592a6f2521d4f7eb7c46 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 02:07:16 +0100 Subject: [PATCH 65/81] Fix both blocking findings from review Devin SWE-1.7 Max reviewed 38bdd75f..67937535b and returned BLOCK with two blocking findings and one important one. All three were verified against the source before fixing; all three were real. BLOCKING 1 -- partial reward loss. GiveDungeonRewardItem gated the store on CanStoreNewItem returning EQUIP_ERR_OK. On a PARTIAL fit _CanStoreItem fills `dest` with the portion that does fit (PlayerItemValidation.cpp:711-719 returns the error only after _CanStoreItem_InInventorySlots has already populated it), reports the remainder through no_space_count, and returns an error. So the storable portion was discarded and only the overflow was mailed: the player lost part of the reward. Now driven off `dest` and noSpaceCount rather than the return code. This is the ordinary case for a two-item reward with one free bag slot, not an exotic one. BLOCKING 2 -- the speed clamp was bypassable, and my justification for its placement was wrong. The previous commit claimed ObjectUpdate.cpp was "the only place the create block is filled in". It is not: Map::SendInitSelf:1891-1899 builds the player's own create with raw GetSpeed values. Both paths funnel through MopUpdateObject::AppendSimpleLivingMovement, and block types 1 and 2 both reach the same client validator via sub_79DC30, so the self create is validated identically. A player whose speed was clamped to zero would have had their OWN create rejected -- strictly worse than an observer create, since the player would not exist on their own client at all. Moved the clamp into AppendSimpleLivingMovement, where every create block is actually serialised, and removed it from the call site. One source of truth, and no future writer can bypass it by construction rather than by comment. IMPORTANT 3 -- Item::CreateItem clamps count to the item's max stack size and creates exactly one stack, so mailing an overflow larger than a stack silently dropped the excess. Now loops until the remainder is exhausted. Latent today, since rewards are 1-2. The reviewer's remaining uncertainty -- whether the client applies the same near-zero test to UPDATEFLAG_LIVING values updates as to creates -- is not resolved here. The binary evidence covers the create validator only. Moving the clamp into the shared movement writer does not cover the values path, which has its own serialiser. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/ObjectUpdate.cpp | 75 +++++------------------ src/game/Server/MopUpdateObject.cpp | 56 ++++++++++++++--- src/game/WorldHandlers/LFGMgrProposal.cpp | 51 ++++++++++----- 3 files changed, 98 insertions(+), 84 deletions(-) diff --git a/src/game/Object/ObjectUpdate.cpp b/src/game/Object/ObjectUpdate.cpp index e0131f2a5..660b79a7f 100644 --- a/src/game/Object/ObjectUpdate.cpp +++ b/src/game/Object/ObjectUpdate.cpp @@ -68,27 +68,6 @@ namespace { - /// Smallest magnitude the 18414 client will accept for a movement speed. - /// - /// sub_45B733 compares with sub_409DD6(a, b, 0.00000023841858), i.e. an - /// approximately-equal test at 2^-22, and sub_768D2F rejects the create when - /// any of the nine speeds is approximately equal to zero. The bound is - /// exclusive (`eps > fabs(a - b)` fails the comparison), so exactly epsilon - /// is already accepted; the floor below sits an order of magnitude clear of - /// it so no later rounding can walk back across. - float const MIN_WIRE_SPEED = 0.000001f; - - /// Force a speed into the range the client's create validator accepts. - /// - /// Speeds are conceptually non-negative, so this floors rather than - /// preserving the sign: creature_template ships negative denormals - /// (-3.72738e-21 on 55151, -2.97773e-20 on 61928) which are meaningless as - /// speeds and would be rejected on magnitude anyway. - float SanitizeWireSpeed(float speed) - { - return speed < MIN_WIRE_SPEED ? MIN_WIRE_SPEED : speed; - } - static_assert(ITEM_END == MopUpdateObject::ItemFieldCount, "18414 Item direct-copy range must remain fields 0..68"); static_assert(CONTAINER_END == MopUpdateObject::ContainerFieldCount, @@ -549,44 +528,22 @@ void Object::BuildCreateUpdateBlockForPlayer(UpdateData* data, Player* target) c movement.o = unit->GetOrientation(); movement.moveTime = GameTime::GetGameTimeMS(); - // Every speed on the wire must be far enough from zero for the client to - // accept the block. Its create validator (sub_768D2F, reached from - // sub_769816 via sub_7691A4 when the LIVING bit is set) tests all nine - // speeds with an approximately-equal-to-zero comparison at epsilon - // 2.3841858e-7 = 2^-22, and rejects the object if any of them is nearer - // to zero than that. A rejected create returns 0 from sub_79DC30, which - // makes the block loop in sub_79E087 BREAK -- so the object is lost and - // so is every later block in the same packet, silently, with no reply. - // - // Zero is not hypothetical and is not always wrong data. A totem SHOULD - // have SpeedWalk 0, because a totem does not walk, and Unit::UpdateSpeed - // multiplies straight through it (UnitSpeed.cpp:246) with no validation: - // - // 3968 Sentry Totem, 5923/5924 Cleansing Totem, 5926 Frost - // Resistance Totem, 7467 Nature Resistance Totem, 15803 Tranquil Air - // Totem, 17539 Totem of Wrath, 30527 Training Dummy -- SpeedWalk 0 - // - // plus 55151 Rumpus Brute, 57421 Mothran and 61928 Sik'thik Guardian, - // which carry corrupt denormals (-3.7e-21, 3.1e-30). 64 rows in - // creature_template have a non-positive walk or run speed. A player - // gaining sight of any of them lost the rest of that update packet. - // - // SetSpeedRate also clamps a negative rate to exactly 0.0f - // (UnitSpeed.cpp:284), so a -100% snare reaches here as a true zero too. - // - // Clamping HERE rather than in UpdateSpeed is deliberate: the unit keeps - // its real speed rate and still does not move, and the correction cannot - // be bypassed by a future producer, because this is the only place the - // create block is filled in. - movement.speedWalk = SanitizeWireSpeed(unit->GetSpeed(MOVE_WALK)); - movement.speedRun = SanitizeWireSpeed(unit->GetSpeed(MOVE_RUN)); - movement.speedRunBack = SanitizeWireSpeed(unit->GetSpeed(MOVE_RUN_BACK)); - movement.speedSwim = SanitizeWireSpeed(unit->GetSpeed(MOVE_SWIM)); - movement.speedSwimBack = SanitizeWireSpeed(unit->GetSpeed(MOVE_SWIM_BACK)); - movement.speedFlight = SanitizeWireSpeed(unit->GetSpeed(MOVE_FLIGHT)); - movement.speedFlightBack = SanitizeWireSpeed(unit->GetSpeed(MOVE_FLIGHT_BACK)); - movement.speedTurn = SanitizeWireSpeed(unit->GetSpeed(MOVE_TURN_RATE)); - movement.speedPitch = SanitizeWireSpeed(unit->GetSpeed(MOVE_PITCH_RATE)); + // The nine speeds are sanitised inside AppendSimpleLivingMovement rather + // than here. The client's create validator rejects any object whose speed + // is approximately zero, and a rejected create discards the whole rest of + // the packet -- but this is NOT the only writer that fills a create block: + // Map::SendInitSelf builds the player's own create through the same + // emitter. Clamping at this call site left that path uncovered, and a + // rejected SELF create is worse than a rejected observer create. + movement.speedWalk = unit->GetSpeed(MOVE_WALK); + movement.speedRun = unit->GetSpeed(MOVE_RUN); + movement.speedRunBack = unit->GetSpeed(MOVE_RUN_BACK); + movement.speedSwim = unit->GetSpeed(MOVE_SWIM); + movement.speedSwimBack = unit->GetSpeed(MOVE_SWIM_BACK); + movement.speedFlight = unit->GetSpeed(MOVE_FLIGHT); + movement.speedFlightBack = unit->GetSpeed(MOVE_FLIGHT_BACK); + movement.speedTurn = unit->GetSpeed(MOVE_TURN_RATE); + movement.speedPitch = unit->GetSpeed(MOVE_PITCH_RATE); movement.self = false; MovementInfo const& movementInfo = unit->m_movementInfo; diff --git a/src/game/Server/MopUpdateObject.cpp b/src/game/Server/MopUpdateObject.cpp index bc36b26d7..2d7a66d0a 100644 --- a/src/game/Server/MopUpdateObject.cpp +++ b/src/game/Server/MopUpdateObject.cpp @@ -725,12 +725,50 @@ void MopUpdateObject::AppendStationaryGameObjectCreateBlock(ByteBuffer& out, uin AppendStaticValuesNoDynamic(out, fields, fieldCount); } +namespace +{ + /// Smallest speed magnitude the 18414 create validator accepts. + /// + /// sub_768D2F tests all nine speeds with an approximately-equal-to-zero + /// comparison (sub_45B733 -> sub_409DD6 at epsilon 0.00000023841858 = 2^-22) + /// and rejects the object when any of them is nearer to zero than that. A + /// rejected create returns 0 from sub_79DC30, which makes the block loop in + /// sub_79E087 BREAK: the object is lost and so is every later block in the + /// same packet, silently and with no reply. + /// + /// The floor sits an order of magnitude clear of the bound so no rounding can + /// walk back across it. Speeds are conceptually non-negative, so this floors + /// rather than preserving sign -- creature_template ships negative denormals + /// which are meaningless as speeds and would be rejected on magnitude anyway. + float SanitizeSpeed(float speed) + { + float const minWireSpeed = 0.000001f; + return speed < minWireSpeed ? minWireSpeed : speed; + } +} + void MopUpdateObject::AppendSimpleLivingMovement(ByteBuffer& out, SimpleLivingMovement const& movement) { const uint64 g = movement.guid; const uint64 transportGuid = movement.transportGuid; const bool hasTransport = transportGuid != 0; + // Sanitised HERE rather than at the callers, because there is more than one + // caller and a bypass is silent. ObjectUpdate.cpp builds the observer create + // and Map::SendInitSelf builds the player's own create; both funnel through + // this writer, and the self path was missed when the clamp lived at the + // observer call site. A rejected SELF create is worse than a rejected + // observer create -- the player does not exist on their own client at all. + float const speedWalk = SanitizeSpeed(movement.speedWalk); + float const speedRun = SanitizeSpeed(movement.speedRun); + float const speedRunBack = SanitizeSpeed(movement.speedRunBack); + float const speedSwim = SanitizeSpeed(movement.speedSwim); + float const speedSwimBack = SanitizeSpeed(movement.speedSwimBack); + float const speedFlight = SanitizeSpeed(movement.speedFlight); + float const speedFlightBack = SanitizeSpeed(movement.speedFlightBack); + float const speedTurn = SanitizeSpeed(movement.speedTurn); + float const speedPitch = SanitizeSpeed(movement.speedPitch); + out.WriteBit(0); // game-object data out.WriteBit(0); // animation kits out.WriteBit(1); // living @@ -818,26 +856,26 @@ void MopUpdateObject::AppendSimpleLivingMovement(ByteBuffer& out, SimpleLivingMo } out.WriteByteSeq(GuidByte(g, 4)); - out << movement.speedFlight; + out << speedFlight; out.WriteByteSeq(GuidByte(g, 2)); out.WriteByteSeq(GuidByte(g, 1)); - out << movement.speedTurn; + out << speedTurn; out << movement.moveTime; - out << movement.speedRunBack; + out << speedRunBack; out.WriteByteSeq(GuidByte(g, 7)); - out << movement.speedPitch; + out << speedPitch; out << movement.x; out << movement.o; - out << movement.speedWalk; + out << speedWalk; out << movement.y; - out << movement.speedFlightBack; + out << speedFlightBack; out.WriteByteSeq(GuidByte(g, 3)); out.WriteByteSeq(GuidByte(g, 5)); out.WriteByteSeq(GuidByte(g, 6)); out.WriteByteSeq(GuidByte(g, 0)); - out << movement.speedSwimBack; - out << movement.speedRun; - out << movement.speedSwim; + out << speedSwimBack; + out << speedRun; + out << speedSwim; out << movement.z; } diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index c392733c5..4ce2f2c5a 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1541,10 +1541,18 @@ void LFGMgr::GiveDungeonRewardItem(Player* pPlayer, uint32 itemId, uint32 amount ItemPosCountVec dest; uint32 noSpaceCount = 0; - InventoryResult msg = pPlayer->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, amount, &noSpaceCount); + pPlayer->CanStoreNewItem(NULL_BAG, NULL_SLOT, dest, itemId, amount, &noSpaceCount); - uint32 stored = amount - noSpaceCount; - if (msg == EQUIP_ERR_OK && stored) + // Driven off `dest` and `noSpaceCount`, NOT off the return code. + // + // On a PARTIAL fit _CanStoreItem fills `dest` with the portion that does fit, + // reports the remainder through noSpaceCount, and still returns an error. + // Gating the store on EQUIP_ERR_OK therefore threw the storable portion away + // and mailed only the overflow, so the player silently lost part of the + // reward. That is the ordinary case for a two-item reward with one free slot, + // not an exotic one. + uint32 const stored = amount > noSpaceCount ? amount - noSpaceCount : 0; + if (stored && !dest.empty()) { if (Item* item = pPlayer->StoreNewItem(dest, itemId, true)) { @@ -1552,23 +1560,34 @@ void LFGMgr::GiveDungeonRewardItem(Player* pPlayer, uint32 itemId, uint32 amount } } - // Whatever did not fit goes in the post. - if (noSpaceCount) + // Whatever did not fit goes in the post, in as many stacks as it takes. + // + // Item::CreateItem clamps count to the item's maximum stack size and creates + // exactly ONE stack, so a single call silently discarded anything above it. + // Today's rewards are 1-2 and fit any stack, but a larger one would have lost + // the excess without a trace. + uint32 remaining = noSpaceCount; + uint32 const maxStack = proto->GetMaxStackSize() ? proto->GetMaxStackSize() : 1; + while (remaining) { - if (Item* mailItem = Item::CreateItem(itemId, noSpaceCount, pPlayer)) - { - mailItem->SaveToDB(); // persist before send, or a failed send loses it + uint32 const thisStack = remaining < maxStack ? remaining : maxStack; - std::string const subject = proto->Name1 ? proto->Name1 : ""; - MailDraft draft(subject, ""); - draft.AddItem(mailItem); - draft.SendMailTo(MailReceiver(pPlayer), MailSender(MAIL_CREATURE, uint32(0))); - } - else + Item* mailItem = Item::CreateItem(itemId, thisStack, pPlayer); + if (!mailItem) { - sLog.outError("LFG GiveDungeonRewardItem: could not create %u x%u for mail to %s", - itemId, noSpaceCount, pPlayer->GetGuidStr().c_str()); + sLog.outError("LFG GiveDungeonRewardItem: could not create %u x%u for mail to %s; %u lost", + itemId, thisStack, pPlayer->GetGuidStr().c_str(), remaining); + return; } + + mailItem->SaveToDB(); // persist before send, or a failed send loses it + + std::string const subject = proto->Name1 ? proto->Name1 : ""; + MailDraft draft(subject, ""); + draft.AddItem(mailItem); + draft.SendMailTo(MailReceiver(pPlayer), MailSender(MAIL_CREATURE, uint32(0))); + + remaining -= thisStack; } } From 569cf8498acfa26808a3bb81f8cd40ef58bcb590 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 07:35:35 +0100 Subject: [PATCH 66/81] LFG: make role-chosen and teleport-denied actually reach the client Both had writers that were called on every relevant path and then dropped by the enter-world send gate, so neither had ever reached a client. Admitting them first required knowing the bodies were right; they were not. SMSG_ROLE_CHOSEN 0x1A1F. The old body was flat -- uint64, uint8, uint32 -- and this opcode is not flat. Layout derived from the 18414 reader sub_6E921A (its handler at 0x985605 logs "ROLE_CHOSEN - GUID: %016llX, Accepted: %s, Roles Desired: %x"): nine mask bits whose SIXTH is `accepted` rather than a guid bit, then guid bytes 0,3,6, then the roles dword, then 5,1,4,2,7. Eight corpus packets across seven captures reconstruct byte for byte under it, including two with accepted = 0 that also carry roles = 0 -- which confirms our existing `roles > 0` test for the flag matches retail. Registered with DefS and admitted. SMSG_LFG_TELEPORT_DENIED 0x063B. The body is FOUR BITS -- WriteBits(reason & 0xF, 4) then FlushBits -- not a byte, which is why every captured body is one byte long. That also dissolves the reason admission was withheld. The old comment argued the captured 0x10 lay outside our enum, so our codes must be wrong. The size was right and the reading was wrong: bits are MSB-first, so reason 1 sits in the high nibble and lands as exactly 0x10, and the corpus also carries 0x90, which is reason 9. Both are ordinary codes. The enum is corrected to the derived values -- FALLING 7, PLAYER_DEAD 9, FATIGUE 12, INVALID_LOCATION 15 -- and constrained to 0-15, because only the low nibble is transmitted and a larger value would silently truncate into a different reason. IN_VEHICLE, CHARMING and IN_COMBAT share 5, which routes to ERR_CLIENT_LOCKED_OUT: vague, but visible. 6 and 13 are silent in the client and are never sent, since either would reproduce the exact behaviour this commit exists to fix -- a click that does nothing and explains nothing. IN_COMBAT deliberately drops its provisional 30. That value came from the PARTY error dispatcher, and a four-bit field cannot carry it at all: it would truncate to 14. A vague visible message beats a confidently wrong one. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 5 +++ src/game/Server/WorldSession.cpp | 3 ++ src/game/WorldHandlers/LFGHandler.cpp | 52 +++++++++++++++++++-------- src/game/WorldHandlers/LFGMgr.h | 47 ++++++++++++++---------- 4 files changed, 74 insertions(+), 33 deletions(-) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index c67f51bdc..c834c7b4b 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -1159,6 +1159,11 @@ void InitializeOpcodes() // does not say WHICH boot -- the session identifies the voter and the voter's // group identifies the vote. DefC(CMSG_LFG_BOOT_PLAYER_VOTE, "CMSG_LFG_BOOT_PLAYER_VOTE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLfgBootPlayerVoteOpcode); + // The role-check confirmation each member receives as others answer. Layout from + // the 18414 reader sub_6E921A (handler 0x985605): nine mask bits whose SIXTH is + // `accepted` rather than a guid bit, then guid bytes 0,3,6, the roles dword, then + // 5,1,4,2,7. Confirmed against eight corpus packets across seven captures. + DefS(SMSG_ROLE_CHOSEN, "SMSG_ROLE_CHOSEN"); // Wave 13 talent-respec confirmation request and prompt. DefC(CMSG_CONFIRM_RESPEC_WIPE, "CMSG_CONFIRM_RESPEC_WIPE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleTalentWipeConfirmOpcode); diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index 418b5be08..d17473998 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -618,6 +618,9 @@ static bool IsEnterWorldConverted(uint16 opcode) // capture-000044 seq 1547 (23B) and capture-000075 seq 891753 (24B) case SMSG_LFG_PLAYER_INFO: // MopLfgPackets::BuildEmptyPlayerInfo case SMSG_LFG_PARTY_INFO: // MopLfgPackets::BuildEmptyPartyInfo + case SMSG_ROLE_CHOSEN: // nine mask bits (6th is `accepted`), guid 0/3/6, roles u32, guid 5/1/4/2/7; + // derived from sub_6E921A, byte-exact vs 8 corpus packets over 7 captures + case SMSG_LFG_TELEPORT_DENIED: // WriteBits(reason & 0xF, 4) + FlushBits; corpus 0x10 and 0x90 are reasons 1 and 9 case SMSG_LFG_UPDATE_SEARCH: // MopLfgPackets::BuildEmptyLfrSearchResponse case SMSG_RAID_INSTANCE_INFO: // MopRaidInstancePackets::BuildRaidInstanceInfo case SMSG_RESPEC_WIPE_CONFIRM: // MopRespecPackets::BuildRespecWipeConfirm diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index 0e32941f2..f3d20df95 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -678,10 +678,31 @@ void WorldSession::SendLfgRoleCheckUpdate(LFGRoleCheck const& roleCheck) void WorldSession::SendLfgRoleChosen(uint64 rawGuid, uint8 roles) { - WorldPacket data(SMSG_ROLE_CHOSEN, 13); - data << uint64(rawGuid); - data << uint8(roles > 0); + // Derived from the 18414 reader sub_6E921A (handler 0x985605, whose own log line + // is "ROLE_CHOSEN - GUID: %016llX, Accepted: %s, Roles Desired: %x") and confirmed + // by decoding eight corpus packets across seven captures, all of which reconstruct + // byte for byte. + // + // The previous body -- uint64, uint8, uint32 -- was flat, and this opcode is not. + // It never mattered because SMSG_ROLE_CHOSEN was not admitted through the send + // gate, so the wrong bytes were discarded before reaching anyone. + // + // Nine mask bits, and the SIXTH is not a guid bit: it is `accepted`. Then three + // guid bytes, the roles dword, then the remaining five guid bytes. + ObjectGuid const guid(rawGuid); + bool const accepted = roles > 0; + + WorldPacket data(SMSG_ROLE_CHOSEN, 2 + 8 + 4); + + data.WriteGuidMask<6, 2, 1, 7, 0>(guid); + data.WriteBit(accepted); + data.WriteGuidMask<3, 5, 4>(guid); + data.FlushBits(); + + data.WriteGuidBytes<0, 3, 6>(guid); data << uint32(roles); + data.WriteGuidBytes<5, 1, 4, 2, 7>(guid); + SendPacket(&data); } @@ -780,20 +801,23 @@ void WorldSession::SendLfgOfferContinue(uint32 dungeonEntry) void WorldSession::SendLfgTeleportError(uint8 error) { - DEBUG_LOG("SMSG_LFG_TELEPORT_DENIED"); + DEBUG_LOG("SMSG_LFG_TELEPORT_DENIED: reason %u", uint32(error)); - // One byte, not four. Every 18414 capture of this opcode in the corpus is exactly - // 1 byte (capture-000044 seq 70879 and 219256, capture-000465 seq 283035, - // capture-000628 seq 31349, capture-000873 seq 154730). + // FOUR BITS, not a byte. The 18414 body is WriteBits(reason & 0xF, 4) followed by + // FlushBits, which is why every captured body is exactly one byte. + // + // That also explains the value that previously blocked admission. The old comment + // here reasoned that the captured 0x10 lay outside our enum and so our codes must + // be wrong. The size was right and the reading was wrong: bits are MSB-first, so a + // reason of 1 sits in the HIGH nibble and lands as 0x10. The corpus also carries + // 0x90, which is reason 9. Both are ordinary codes. // - // NOT admitted by IsEnterWorldConverted, deliberately. The size is settled but the - // VALUE space is not: the one captured body carries 0x10 (16), while our - // LFGTeleportError enum stops at 8, so our codes are provably not the client's. - // Sending a correctly sized packet with a wrong code would show the player a - // confidently wrong reason, which is worse than the current silence. Admit this - // once the enum is derived from the client. + // Only the low nibble is transmitted, so a value above 15 would silently truncate + // into a different reason -- hence the enum is now constrained to 0-15 and this + // masks defensively rather than trusting callers. WorldPacket data(SMSG_LFG_TELEPORT_DENIED, 1); - data << uint8(error); + data.WriteBits(error & 0xF, 4); + data.FlushBits(); SendPacket(&data); } diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 9c3ee56f2..58c70c995 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -894,30 +894,39 @@ enum LFGRoleCount }; /// Teleport errors +/// Reason codes for SMSG_LFG_TELEPORT_DENIED. +/// +/// Derived from the 18414 client, not from a fork. The body is a FOUR BIT field +/// (WriteBits(reason & 0xF, 4) then FlushBits), which is why every captured body +/// is one byte and why the single observed value looked unmappable: MSB-first, a +/// reason of 1 occupies the high nibble and lands as 0x10, and the corpus also +/// carries 0x90 for reason 9. Both are ordinary codes, not an unknown space. +/// +/// Only the low nibble reaches the wire, so every value here must be 0-15. enum LFGTeleportError { - // 7 = "You can't do that right now" | 5 = No client reaction LFG_TELEPORTERROR_OK = 0, - LFG_TELEPORTERROR_PLAYER_DEAD = 1, - LFG_TELEPORTERROR_FALLING = 2, - LFG_TELEPORTERROR_IN_VEHICLE = 3, - LFG_TELEPORTERROR_FATIGUE = 4, - LFG_TELEPORTERROR_INVALID_LOCATION = 6, - LFG_TELEPORTERROR_CHARMING = 8, - - /// Refusing a teleport because the player is fighting. + LFG_TELEPORTERROR_FALLING = 7, + LFG_TELEPORTERROR_PLAYER_DEAD = 9, + LFG_TELEPORTERROR_FATIGUE = 12, + LFG_TELEPORTERROR_INVALID_LOCATION = 15, + + /// The three refusals with no dedicated client message. /// - /// PROVISIONAL VALUE, and it must not be cited as derived. The client certainly has - /// the message -- ERR_PARTY_LFG_TELEPORT_IN_COMBAT, "You cannot teleport out of the - /// dungeon while in combat.", GlobalString index 712 at .data:00F5FA18 -- and 30 is - /// the case that pushes it in the dispatcher at .text:007AA970. But that dispatcher's - /// neighbouring cases are ERR_INVITE_* and ERR_PARTY_LFG_BOOT_*, so it is the PARTY - /// error space, which may not be the space SMSG_LFG_TELEPORT_DENIED uses. The one - /// captured body of that opcode carries 0x10, which is in neither reading. + /// 5 and 10 both route to ERR_CLIENT_LOCKED_OUT ("You can't do that right + /// now"), which is vague but true and, crucially, VISIBLE. 6 and 13 are + /// silent in the client, so sending either would put the player back exactly + /// where they were before this opcode was admitted: a click that does + /// nothing and explains nothing. /// - /// Harmless today because SendLfgTeleportError is not admitted, so nothing reaches the - /// client. The REFUSAL is the part that matters and that is not in doubt. - LFG_TELEPORTERROR_IN_COMBAT = 30 + /// IN_COMBAT deliberately shares that generic code rather than using 30. The + /// client does own ERR_PARTY_LFG_TELEPORT_IN_COMBAT, but 30 was recovered + /// from the PARTY error dispatcher, and this opcode's four-bit field cannot + /// carry 30 at all -- it would truncate to 14. A vague visible message beats + /// a wrong one. + LFG_TELEPORTERROR_IN_VEHICLE = 5, + LFG_TELEPORTERROR_CHARMING = 5, + LFG_TELEPORTERROR_IN_COMBAT = 5 }; enum DungeonTypes From e3008effb8bfcbc89bddc63b8e4c206142a679cd Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 07:37:52 +0100 Subject: [PATCH 67/81] LFG: rebuild the completion reward packet to the 18414 layout and admit it The inherited SMSG_LFG_PLAYER_REWARD body shared no field order with 18414 and carried a uint8 flag the client never reads. Harmless only because the opcode was neither registered nor admitted, so the wrong bytes were discarded before reaching anyone. With the reward now actually being granted, the announcement should arrive too. Field MEANINGS are binary-derived, from the consumer at 0x989771 and the Lua accessor sub_986CDD behind GetLFGCompletionRewardItem. The client's own log lines name them: "LFG_PLAYER_REWARD - Queued Slot: %u, Actual Slot: %u, Base Money: %d, Base XP: %d" and "Receiving Item %u, Display %u, Quantity: %u". Field ORDER is corpus-derived and is labelled as such rather than claimed as binary-derived. 4634 appears nowhere as a literal -- the 5.4.8 client dispatches SMSG by an internal message index, not the opcode value, so the push-imm trick that works for CMSG does not apply -- and 0x989771 is reached through a runtime-computed pointer with no xrefs, so the deserialiser could not be walked. Thirteen corpus payloads decode with zero leftover under this order. Layout: money, queued slot, xp, actual slot, then a bit block carrying a 20-bit reward count followed by one is-currency bit per reward, then 16 bytes each of id / unknown / display id / quantity. Two details that decide correctness rather than cosmetics: - DisplayInfoID must be the THIRD reward dword. sub_986CDD takes the icon from entry+4 when the entry is an item, so putting the always-zero unknown there would leave the reward frame with no icon. - The is-currency bit is written 0. The currency branch divides quantity by 100 for high-precision currencies, so mislabelling an item as currency would misreport the amount rather than merely pick the wrong icon. QueuedSlot falls back to the concrete dungeon when the run was not random. The client masks ActualSlot (& 0xFFFFF) to look up the row it names and textures the alert frame from, so the two legitimately differ for a random run. Registered with DefS and admitted through the send gate. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 3 ++ src/game/Server/WorldSession.cpp | 1 + src/game/WorldHandlers/LFGHandler.cpp | 62 +++++++++++++++++++-------- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index c834c7b4b..456aa804a 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -1164,6 +1164,9 @@ void InitializeOpcodes() // `accepted` rather than a guid bit, then guid bytes 0,3,6, the roles dword, then // 5,1,4,2,7. Confirmed against eight corpus packets across seven captures. DefS(SMSG_ROLE_CHOSEN, "SMSG_ROLE_CHOSEN"); + // Completion reward. Meanings from the consumer at 0x989771 and Lua sub_986CDD; + // field order from 13 corpus payloads that decode with zero leftover. + DefS(SMSG_LFG_PLAYER_REWARD, "SMSG_LFG_PLAYER_REWARD"); // Wave 13 talent-respec confirmation request and prompt. DefC(CMSG_CONFIRM_RESPEC_WIPE, "CMSG_CONFIRM_RESPEC_WIPE", STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleTalentWipeConfirmOpcode); diff --git a/src/game/Server/WorldSession.cpp b/src/game/Server/WorldSession.cpp index d17473998..cdbf5c92b 100644 --- a/src/game/Server/WorldSession.cpp +++ b/src/game/Server/WorldSession.cpp @@ -618,6 +618,7 @@ static bool IsEnterWorldConverted(uint16 opcode) // capture-000044 seq 1547 (23B) and capture-000075 seq 891753 (24B) case SMSG_LFG_PLAYER_INFO: // MopLfgPackets::BuildEmptyPlayerInfo case SMSG_LFG_PARTY_INFO: // MopLfgPackets::BuildEmptyPartyInfo + case SMSG_LFG_PLAYER_REWARD: // money/queuedSlot/xp/actualSlot + 20-bit count + per-reward is-currency bit + 16B entries case SMSG_ROLE_CHOSEN: // nine mask bits (6th is `accepted`), guid 0/3/6, roles u32, guid 5/1/4/2/7; // derived from sub_6E921A, byte-exact vs 8 corpus packets over 7 captures case SMSG_LFG_TELEPORT_DENIED: // WriteBits(reason & 0xF, 4) + FlushBits; corpus 0x10 and 0x90 are reasons 1 and 9 diff --git a/src/game/WorldHandlers/LFGHandler.cpp b/src/game/WorldHandlers/LFGHandler.cpp index f3d20df95..83019c8a9 100644 --- a/src/game/WorldHandlers/LFGHandler.cpp +++ b/src/game/WorldHandlers/LFGHandler.cpp @@ -823,32 +823,56 @@ void WorldSession::SendLfgTeleportError(uint8 error) void WorldSession::SendLfgRewards(LFGRewards const& rewards) { - DEBUG_LOG("SMSG_LFG_PLAYER_REWARD"); + DEBUG_LOG("SMSG_LFG_PLAYER_REWARD: money %u xp %u item %u x%u", + rewards.moneyReward, rewards.expReward, rewards.itemID, rewards.itemAmount); - WorldPacket data(SMSG_LFG_PLAYER_REWARD, 42); - data << uint32(rewards.randomDungeonEntry); - data << uint32(rewards.groupDungeonEntry); - data << uint8(rewards.hasDoneDaily); - data << uint32(1); + // The inherited body shared no field order with 18414 and carried a uint8 flag the + // client never reads. It never mattered, because this opcode was not admitted + // through the send gate, so the wrong bytes were discarded before reaching anyone. + // + // Field MEANINGS are binary-derived, from the consumer at 0x989771 and the Lua + // accessor sub_986CDD behind GetLFGCompletionRewardItem. Its own log lines name + // them: "LFG_PLAYER_REWARD - Queued Slot: %u, Actual Slot: %u, Base Money: %d, + // Base XP: %d" and "Receiving Item %u, Display %u, Quantity: %u". + // + // Field ORDER is corpus-derived, not reader-derived: 0x989771 is reached through a + // runtime-computed pointer and has no xrefs to walk back from, so the deserialiser + // could not be read. Thirteen corpus payloads decode with zero leftover under the + // order below. + // + // Layout: money, queued slot, xp, actual slot, then a bit block of a 20-bit reward + // count followed by one is-currency bit per reward, then 16 bytes per reward. + // + // ActualSlot is the concrete dungeon; the client masks it (& 0xFFFFF) to look up the + // row it names and textures the alert frame from. QueuedSlot is what was queued for, + // which for a random run is the category row, so the two legitimately differ. + ItemPrototype const* proto = rewards.itemID ? ObjectMgr::GetItemPrototype(rewards.itemID) : NULL; + bool const hasItem = proto != NULL && rewards.itemAmount != 0; + uint32 const rewardCount = hasItem ? 1 : 0; + + WorldPacket data(SMSG_LFG_PLAYER_REWARD, 19 + 16 * rewardCount); data << uint32(rewards.moneyReward); + data << uint32(rewards.randomDungeonEntry ? rewards.randomDungeonEntry : rewards.groupDungeonEntry); data << uint32(rewards.expReward); - data << uint32(0); - data << uint32(0); - if (rewards.itemID != 0) + data << uint32(rewards.groupDungeonEntry); + + data.WriteBits(rewardCount, 20); + for (uint32 i = 0; i < rewardCount; ++i) { - ItemPrototype const* pProto = ObjectMgr::GetItemPrototype(rewards.itemID); - if (pProto) - { - data << uint8(1); - data << uint32(rewards.itemID); - data << uint32(pProto->DisplayInfoID); - data << uint32(rewards.itemAmount); - } + // Item, not currency. The currency branch divides quantity by 100 for + // high-precision currencies, so mislabelling one would misreport the amount. + data.WriteBit(0); } - else + data.FlushBits(); + + if (hasItem) { - data << uint8(0); + data << uint32(rewards.itemID); + data << uint32(0); // stored at struct+0xC and never read back + data << uint32(proto->DisplayInfoID); // drives the reward frame's icon + data << uint32(rewards.itemAmount); } + SendPacket(&data); } From 0bf621a05a14e02472dbecfb920ee24b9fe4f119 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 08:07:09 +0100 Subject: [PATCH 68/81] Make the proposal packet test able to fail, and fix the input it was fed AssertBytes reported mismatches through assert(false), which expands to nothing under NDEBUG. These tests build in Release, so every mismatch printed its diagnostic and then exited 0: the harness said OK while the bytes disagreed, which is the one thing a byte-exactness test exists to prevent. It had been failing. Running it printed raid_finder_proposal: byte 104 is 0x08, expected 0x09 raid_finder_proposal: byte 108 is 0x09, expected 0x08 mop_lfg_proposal_packets_test: OK The writer was not at fault. Roles occupy 25 uint32s starting at offset 44, so byte 104 is role slot 15 and byte 108 is slot 16. The captured expected[] bytes put 0x09 at slot 15; the hand-transcribed INPUT array put it at index 16. The two halves of the fixture disagreed with each other, and since expected[] is real captured traffic and the input array is a transcription of the same capture, the transcription was wrong. Moved 0x09 to index 15. The harness now records failures in a flag and returns 1 from main, so a mismatch fails the run in any configuration. Verified by injecting a deliberate one-byte corruption (proposalId 11132 -> 11133), rebuilding, and confirming the test reports raid_finder_proposal: FAIL byte 35 is 0x7D, expected 0x7C mop_lfg_proposal_packets_test: FAILED exit=1 then reverting and confirming a clean OK / exit=0. Asserting that a repaired guard works without demonstrating it would have repeated the original mistake. Found by the re-review of 67937535b..HEAD, which reported it as MINOR on the grounds that it predates this branch. The broken assert does predate it; a byte-exactness test that cannot fail does not stay minor. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/mop_lfg_proposal_packets_test.cpp | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp b/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp index d10b2f55a..033c67695 100644 --- a/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_proposal_packets_test.cpp @@ -20,29 +20,42 @@ #include "LFGMgr.h" #include "WorldPacket.h" -#include #include #include namespace { + /// Set by AssertBytes on any mismatch; main() returns non-zero if it is set. + /// + /// This used to call assert(false), which expands to nothing under NDEBUG. These + /// tests build in Release, so a mismatch printed its diagnostic and then exited 0 -- + /// the harness reported "OK" while the bytes disagreed, which is the one thing a + /// byte-exactness test exists to prevent. + /// + /// It had in fact been failing. raid_finder_proposal's INPUT role array carried 0x09 + /// one slot late, so the writer was fed the wrong roles for players 15 and 16. The + /// captured expected[] bytes were right all along and so was the writer -- only the + /// hand-transcribed input was wrong, and nothing could surface it. + bool g_failed = false; + void AssertBytes(WorldPacket const& packet, std::vector const& expected, char const* label) { if (packet.size() != expected.size()) { - std::printf("%s: size %u, expected %u\n", label, + std::printf("%s: FAIL size %u, expected %u\n", label, unsigned(packet.size()), unsigned(expected.size())); - assert(false); + g_failed = true; + return; } for (size_t i = 0; i < expected.size(); ++i) { if (packet.contents()[i] != expected[i]) { - std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, + std::printf("%s: FAIL byte %u is 0x%02X, expected 0x%02X\n", label, unsigned(i), packet.contents()[i], expected[i]); - assert(false); + g_failed = true; } } } @@ -124,7 +137,7 @@ namespace static uint32 const roles[25] = { 0x04, 0x04, 0x04, 0x04, 0x04, 0x04, 0x32, 0x08, 0x32, 0x08, - 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x09, 0x08, 0x08, 0x08, + 0x08, 0x08, 0x08, 0x08, 0x08, 0x09, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08, 0x08 }; @@ -157,6 +170,12 @@ int main() test_five_man_proposal(); test_raid_finder_proposal(); + if (g_failed) + { + std::printf("mop_lfg_proposal_packets_test: FAILED\n"); + return 1; + } + std::printf("mop_lfg_proposal_packets_test: OK\n"); return 0; } From c75e15564b394de6d659bf1321608bc5bf7e52f3 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 10:15:57 +0100 Subject: [PATCH 69/81] Make four more packet tests actually run their checks The proposal-test fix showed the pattern; a sweep found four more tests whose entire assertion mechanism was bare assert(). These targets build in Release, where NDEBUG makes assert a no-op, so all four passed unconditionally. They were worse than unchecked. assert does not EVALUATE its argument under NDEBUG, so assert(MopLfgSetRolesPackets::ParseRequest(packet, request)); never called the parser at all. These tests executed no code under test. Two independent proofs fell out of repairing them: - mop_lfg_set_roles_packets_test referenced PLAYER_ROLE_TANK, _HEALER and _DAMAGE with no declaration in scope. It could not compile, and nobody knew, because the only uses were inside assert(). They are declared locally now; including LFGMgr.h for them drags the whole game library into the target and the link then fails on unrelated globals. - All four then failed to LINK, needing the WorldDatabase / CharacterDatabase / LoginDatabase / realmID stubs that nineteen other tests in this directory already define. They had never needed them because they had never referenced the code under test. Converted to the CHECK/g_fail idiom the healthy tests here already use, rather than introducing a shared header, so a failure is recorded and returned from main in any configuration. One of the four was genuinely failing: header: byte 4 is 0x8C, expected 0x8D The writer is correct. SMSG_LFG_PLAYER_INFO's header is 20 + 1 + 17 = 38 bits, so byte 4 holds six header bits; FlushBits pads the last two with zeros, giving 0x8C. The capture's 0x8D has the next bit set because in the real 206-record packet those two bits are not padding -- they are the first bits of the lock array that follows. The test sliced five bytes off that capture and compared them against a standalone flushed header, asserting that our padding should equal someone else's data. Narrowed to the 38 bits that are actually the header. Full suite: 111/111 pass. Co-Authored-By: Claude Opus 5 (1M context) --- .../mop_lfg_player_info_packets_test.cpp | 54 ++++++++++++++----- ...mop_lfg_proposal_response_packets_test.cpp | 41 ++++++++------ .../tests/mop_lfg_role_check_packets_test.cpp | 23 +++++--- .../tests/mop_lfg_set_roles_packets_test.cpp | 54 +++++++++++++------ 4 files changed, 122 insertions(+), 50 deletions(-) diff --git a/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp b/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp index 48232fd46..5d5a97157 100644 --- a/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp @@ -30,10 +30,22 @@ #include "LFGMgr.h" #include "WorldPacket.h" -#include +#include "Database/DatabaseEnv.h" #include #include +// Linker stubs. The server defines these in mangosd; a test binary that reaches any +// game.lib translation unit needs them. This test did not need them before only +// because its checks lived inside assert(), which is not compiled under NDEBUG -- +// so it never actually referenced the code under test. +DatabaseType WorldDatabase; +DatabaseType CharacterDatabase; +DatabaseType LoginDatabase; +uint32 realmID = 0; + +static int g_fail = 0; +#define CHECK(c) do { if (!(c)) { std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); ++g_fail; } } while (0) + namespace { void AssertBytes(uint8 const* actual, std::vector const& expected, @@ -45,7 +57,7 @@ namespace { std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, unsigned(offset + i), actual[offset + i], expected[i]); - assert(false); + CHECK(false); } } } @@ -83,7 +95,7 @@ namespace WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5 + locks.size() * 16); MopLfgPackets::BuildPlayerInfo(packet, locks); - assert(packet.size() == 5 + expected.size()); // 5-byte header, then the array + CHECK(packet.size() == 5 + expected.size()); // 5-byte header, then the array AssertBytes(packet.contents(), expected, 5, "lock_records"); } @@ -102,8 +114,25 @@ namespace packet.WriteBits(35, 17); packet.FlushBits(); - assert(packet.size() == expectedHeader.size()); - AssertBytes(packet.contents(), expectedHeader, 0, "header"); + CHECK(packet.size() == expectedHeader.size()); + + // Compare 38 BITS, not 5 whole bytes. + // + // The header is 20 + 1 + 17 = 38 bits, so byte 4 carries only 6 header bits. + // In our standalone packet FlushBits pads the last two with zeros, giving + // 0x8C. The capture's 0x8D has the next bit set because in the real packet + // those two bits are not padding at all -- they are the start of the 206 lock + // records that follow the header. + // + // Comparing the whole byte therefore asserted that a flushed header equals a + // byte containing someone else's data, and it had been failing: + // header: byte 4 is 0x8C, expected 0x8D + // Nothing surfaced it because the check lived inside assert(), which is not + // compiled under NDEBUG. The writer is right; the comparison was too wide. + AssertBytes(packet.contents(), std::vector(expectedHeader.begin(), + expectedHeader.begin() + 4), + 0, "header"); + CHECK((packet.contents()[4] & 0xFC) == (expectedHeader[4] & 0xFC)); } /// Our own locks-only header: same widths, random count zero. @@ -115,14 +144,14 @@ namespace WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5 + 16); MopLfgPackets::BuildPlayerInfo(packet, locks); - assert(packet.size() == 5 + 16); + CHECK(packet.size() == 5 + 16); // Decode the header back out and confirm the counts survive the round trip. uint8 const* b = packet.contents(); uint32 const bits = (uint32(b[0]) << 24) | (uint32(b[1]) << 16) | (uint32(b[2]) << 8) | uint32(b[3]); - assert((bits >> 12) == 1); // 20-bit lock count - assert(((bits >> 11) & 1) == 0); // hasPlayerGuid + CHECK((bits >> 12) == 1); // 20-bit lock count + CHECK(((bits >> 11) & 1) == 0); // hasPlayerGuid } /// An empty lock list must still emit a well-formed 5-byte header, because that is @@ -134,10 +163,10 @@ namespace WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5); MopLfgPackets::BuildPlayerInfo(packet, locks); - assert(packet.size() == 5); + CHECK(packet.size() == 5); for (size_t i = 0; i < packet.size(); ++i) { - assert(packet.contents()[i] == 0x00); + CHECK(packet.contents()[i] == 0x00); } } } @@ -148,7 +177,6 @@ int main() test_header_matches_capture(); test_locks_only_header(); test_empty_lock_list(); - - std::printf("mop_lfg_player_info_packets_test: OK\n"); - return 0; + std::printf(g_fail ? "mop_lfg_player_info_packets_test: FAILED (%d)\n" : "mop_lfg_player_info_packets_test: OK\n", g_fail); + return g_fail ? 1 : 0; } diff --git a/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp b/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp index e08462dc4..2be62fca0 100644 --- a/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_proposal_response_packets_test.cpp @@ -27,10 +27,22 @@ #include "Group.h" #include "WorldPacket.h" -#include +#include "Database/DatabaseEnv.h" #include #include +// Linker stubs. The server defines these in mangosd; a test binary that reaches any +// game.lib translation unit needs them. This test did not need them before only +// because its checks lived inside assert(), which is not compiled under NDEBUG -- +// so it never actually referenced the code under test. +DatabaseType WorldDatabase; +DatabaseType CharacterDatabase; +DatabaseType LoginDatabase; +uint32 realmID = 0; + +static int g_fail = 0; +#define CHECK(c) do { if (!(c)) { std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); ++g_fail; } } while (0) + namespace { WorldPacket MakeBody(std::vector const& bytes) @@ -51,19 +63,19 @@ namespace WorldPacket packet = MakeBody(body); MopLfgProposalResponsePackets::Request request; - assert(MopLfgProposalResponsePackets::ParseRequest(packet, request)); + CHECK(MopLfgProposalResponsePackets::ParseRequest(packet, request)); - assert(request.accepted); - assert(request.proposalId == 11132); - assert(request.clientQueueId == 37743); - assert(request.flags == 3); - assert(request.joinTime == 1409232359u); + CHECK(request.accepted); + CHECK(request.proposalId == 11132); + CHECK(request.clientQueueId == 37743); + CHECK(request.flags == 3); + CHECK(request.joinTime == 1409232359u); // Both GUIDs match the SMSG_LFG_PROPOSAL_UPDATE this is answering. - assert(request.guidA.GetRawValue() == 0x0400000005FE4CD4ULL); - assert(request.guidB.GetRawValue() == 0x1F440000114CF200ULL); + CHECK(request.guidA.GetRawValue() == 0x0400000005FE4CD4ULL); + CHECK(request.guidB.GetRawValue() == 0x1F440000114CF200ULL); - assert(packet.rpos() == packet.size()); // no tail left unread + CHECK(packet.rpos() == packet.size()); // no tail left unread } /// A body truncated inside its GUID run must be refused, not read past its end. @@ -76,7 +88,7 @@ namespace WorldPacket packet = MakeBody(body); MopLfgProposalResponsePackets::Request request; - assert(!MopLfgProposalResponsePackets::ParseRequest(packet, request)); + CHECK(!MopLfgProposalResponsePackets::ParseRequest(packet, request)); } /// Shorter than the fixed header plus its mask bytes. @@ -86,7 +98,7 @@ namespace WorldPacket packet = MakeBody(body); MopLfgProposalResponsePackets::Request request; - assert(!MopLfgProposalResponsePackets::ParseRequest(packet, request)); + CHECK(!MopLfgProposalResponsePackets::ParseRequest(packet, request)); } } @@ -95,7 +107,6 @@ int main() test_accept_body(); test_truncated_body_is_refused(); test_short_body_is_refused(); - - std::printf("mop_lfg_proposal_response_packets_test: OK\n"); - return 0; + std::printf(g_fail ? "mop_lfg_proposal_response_packets_test: FAILED (%d)\n" : "mop_lfg_proposal_response_packets_test: OK\n", g_fail); + return g_fail ? 1 : 0; } diff --git a/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp b/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp index a99083af6..77930a943 100644 --- a/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp @@ -31,10 +31,22 @@ #include "LFGMgr.h" #include "WorldPacket.h" -#include +#include "Database/DatabaseEnv.h" #include #include +// Linker stubs. The server defines these in mangosd; a test binary that reaches any +// game.lib translation unit needs them. This test did not need them before only +// because its checks lived inside assert(), which is not compiled under NDEBUG -- +// so it never actually referenced the code under test. +DatabaseType WorldDatabase; +DatabaseType CharacterDatabase; +DatabaseType LoginDatabase; +uint32 realmID = 0; + +static int g_fail = 0; +#define CHECK(c) do { if (!(c)) { std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); ++g_fail; } } while (0) + namespace { void AssertBytes(WorldPacket const& packet, std::vector const& expected, @@ -44,7 +56,7 @@ namespace { std::printf("%s: size %u, expected %u\n", label, unsigned(packet.size()), unsigned(expected.size())); - assert(false); + CHECK(false); } for (size_t i = 0; i < expected.size(); ++i) @@ -53,7 +65,7 @@ namespace { std::printf("%s: byte %u is 0x%02X, expected 0x%02X\n", label, unsigned(i), packet.contents()[i], expected[i]); - assert(false); + CHECK(false); } } } @@ -150,7 +162,6 @@ int main() { test_two_member_role_check(); test_five_member_role_check(); - - std::printf("mop_lfg_role_check_packets_test: OK\n"); - return 0; + std::printf(g_fail ? "mop_lfg_role_check_packets_test: FAILED (%d)\n" : "mop_lfg_role_check_packets_test: OK\n", g_fail); + return g_fail ? 1 : 0; } diff --git a/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp b/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp index cb706a21d..140edc160 100644 --- a/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_set_roles_packets_test.cpp @@ -19,12 +19,35 @@ #include "Group.h" #include "WorldPacket.h" -#include +#include "Database/DatabaseEnv.h" #include #include +// Linker stubs. The server defines these in mangosd; a test binary that reaches any +// game.lib translation unit needs them. This test did not need them before only +// because its checks lived inside assert(), which is not compiled under NDEBUG -- +// so it never actually referenced the code under test. +DatabaseType WorldDatabase; +DatabaseType CharacterDatabase; +DatabaseType LoginDatabase; +uint32 realmID = 0; + +static int g_fail = 0; +#define CHECK(c) do { if (!(c)) { std::fprintf(stderr, "FAIL %s:%d: %s\n", __FILE__, __LINE__, #c); ++g_fail; } } while (0) + namespace { + // Mirrors enum LFGRoles in LFGMgr.h. Declared locally on purpose: including + // LFGMgr.h drags the whole game library into this target and the link fails on + // unrelated globals. These three bits are what the body under test encodes. + // + // The names were previously used with no declaration in scope at all. Nothing + // caught it because they only appeared inside assert(), whose argument is not + // compiled under NDEBUG -- so the test contained code that could not build. + uint32 const PLAYER_ROLE_TANK = 0x02; + uint32 const PLAYER_ROLE_HEALER = 0x04; + uint32 const PLAYER_ROLE_DAMAGE = 0x08; + WorldPacket MakeBody(std::vector const& bytes) { WorldPacket packet(CMSG_LFG_SET_ROLES, bytes.size()); @@ -40,11 +63,11 @@ namespace WorldPacket packet = MakeBody(body); MopLfgSetRolesPackets::Request request; - assert(MopLfgSetRolesPackets::ParseRequest(packet, request)); + CHECK(MopLfgSetRolesPackets::ParseRequest(packet, request)); - assert(request.roles == 0x08); // PLAYER_ROLE_DAMAGE - assert(request.roleCheckCounter == 0); - assert(packet.rpos() == packet.size()); // no tail left unread + CHECK(request.roles == 0x08); // PLAYER_ROLE_DAMAGE + CHECK(request.roleCheckCounter == 0); + CHECK(packet.rpos() == packet.size()); // no tail left unread } /// capture-000112 seq 90341, 5 bytes: 0A 00 00 00 00 @@ -58,14 +81,14 @@ namespace WorldPacket packet = MakeBody(body); MopLfgSetRolesPackets::Request request; - assert(MopLfgSetRolesPackets::ParseRequest(packet, request)); + CHECK(MopLfgSetRolesPackets::ParseRequest(packet, request)); - assert(request.roles == 0x0A); - assert((request.roles & PLAYER_ROLE_TANK) != 0); - assert((request.roles & PLAYER_ROLE_DAMAGE) != 0); - assert((request.roles & PLAYER_ROLE_HEALER) == 0); - assert(request.roleCheckCounter == 0); - assert(packet.rpos() == packet.size()); + CHECK(request.roles == 0x0A); + CHECK((request.roles & PLAYER_ROLE_TANK) != 0); + CHECK((request.roles & PLAYER_ROLE_DAMAGE) != 0); + CHECK((request.roles & PLAYER_ROLE_HEALER) == 0); + CHECK(request.roleCheckCounter == 0); + CHECK(packet.rpos() == packet.size()); } /// A body one byte short must be refused outright rather than read past its end. @@ -75,7 +98,7 @@ namespace WorldPacket packet = MakeBody(body); MopLfgSetRolesPackets::Request request; - assert(!MopLfgSetRolesPackets::ParseRequest(packet, request)); + CHECK(!MopLfgSetRolesPackets::ParseRequest(packet, request)); } } @@ -84,7 +107,6 @@ int main() test_single_role_body(); test_hybrid_role_body(); test_short_body_is_refused(); - - std::printf("mop_lfg_set_roles_packets_test: OK\n"); - return 0; + std::printf(g_fail ? "mop_lfg_set_roles_packets_test: FAILED (%d)\n" : "mop_lfg_set_roles_packets_test: OK\n", g_fail); + return g_fail ? 1 : 0; } From 47033172c7e1290cecbba5431280e5e418b69992 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 10:19:57 +0100 Subject: [PATCH 70/81] LFG: stop counting the victim's vote against their own kick AttemptToKickPlayer seeded the player being voted on with LFG_ANSWER_DENY, and CastVote's tally counted it toward `nay`. With REQUIRED_VOTES_FOR_BOOT = 3 that let a five-man kick fail on only TWO genuine denies, because the victim supplied the third for free. It also inflated the voteCount the client displays by a vote nobody cast. Retail does not poll the player being voted on. They are now left out of the answer map entirely, which is also what CastVote's membership check keys off -- so the victim cannot vote on their own removal by any route -- and SendLfgBootUpdate already tolerates a missing entry since the end() fix. Reported by the opcode derivation pass as gap G9. Its companion, the missing guard against starting a second vote while one is running, was fixed with the rest of the vote-kick work; this half was not. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 4ce2f2c5a..c1c504725 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1799,8 +1799,17 @@ void LFGMgr::AttemptToKickPlayer(Group* pGroup, ObjectGuid guid, ObjectGuid kick time_t now = time(NULL); proposalAnswerMap votes; - // safe to say the person attempting to kick them will vote yes, the kick-ee will vote no - votes[guid] = LFG_ANSWER_DENY; + // The initiator's own vote counts as agree. The VICTIM is deliberately not polled. + // + // They used to be seeded with LFG_ANSWER_DENY, which the tally counted toward `nay`. + // With REQUIRED_VOTES_FOR_BOOT = 3 that let a five-man kick fail on only TWO genuine + // denies, because the victim supplied the third for free, and it inflated the + // voteCount the client displays by a vote nobody cast. Retail does not poll the + // player being voted on. + // + // Leaving them out of the map entirely is also what CastVote's membership check + // keys off, so the victim cannot vote on their own removal by any route, and + // SendLfgBootUpdate tolerates the missing entry. votes[kicker] = LFG_ANSWER_AGREE; // set group state to boot vote, same for player states until it's over From 52356f616cfda009383f091552d138e2d589e855 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 10:46:50 +0100 Subject: [PATCH 71/81] Record why the boot reaper is load-bearing The victim does not vote, so a group of N has N-1 voters and the initiator is already AGREE. At N = 4 that caps denies at 2, below REQUIRED_VOTES_FOR_BOOT, so such a vote can pass on votes but can only fail by running out LFG_TIME_BOOT. That is a correct outcome and RemoveOldBoots delivers it. The point of the comment is that the reaper is therefore load-bearing, not belt-and-braces: removing it would wedge a four-man group in LFG_STATE_BOOT permanently. Retail avoids the corner by scaling votesNeeded with group size -- the corpus shows 13 for a 25-man LFR -- which we do not do. Found while verifying the previous commit rather than by testing it. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/LFGMgrProposal.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index c1c504725..627f4d7f6 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1783,6 +1783,15 @@ void LFGMgr::AttemptToKickPlayer(Group* pGroup, ObjectGuid guid, ObjectGuid kick // freeze the group in LFG_STATE_BOOT until the timer expired, blocking any // further attempt for the whole window. Everyone except the target may vote // yes, so the group needs REQUIRED_VOTES_FOR_BOOT + 1 members to succeed at all. + // Note the asymmetry this leaves, and why it is tolerable ONLY because expiry + // exists. The victim does not vote, so a group of N has N-1 voters and the + // initiator's is already AGREE. At N = 4 that means at most 2 denies, which can + // never reach the threshold: such a vote can PASS on votes but can only FAIL by + // running out the LFG_TIME_BOOT window. That is a correct outcome, and + // RemoveOldBoots delivers it -- but if the reaper is ever removed, a four-man + // group is wedged in LFG_STATE_BOOT permanently. Retail avoids the corner by + // scaling votesNeeded with group size (the corpus shows 13 for a 25-man LFR); + // we do not, so the reaper is load-bearing rather than belt-and-braces. if (int32(pGroup->GetMembersCount()) <= REQUIRED_VOTES_FOR_BOOT) { if (pKicker) From 169b19ac86c0c8563e745011a915464c46d9728f Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 11:03:05 +0100 Subject: [PATCH 72/81] Recognise CMSG_BATTLE_PAY_GET_PRODUCT_LIST instead of logging it as unknown The client sends this immediately after CMSG_BATTLE_PAY_GET_PURCHASE_LIST on login -- capture-000234 seqs 28138 and 28139, adjacent -- and we declared only the second of the pair, so every login logged SESSION: received not handled opcode UNKNOWN (0x0DE0) Value and shape are corpus-confirmed at build 18414: thirteen occurrences across as many captures, every body zero bytes, no direction conflict. Deliberately not answered. Retail replies with the whole store catalogue -- SMSG_BATTLE_PAY_GET_PRODUCT_LIST_RESPONSE, 3517 bytes in capture-000234 seq 28146 -- and we have no store to describe. Guessing an empty-catalogue layout would put underived bytes on the wire for a feature that does not exist here. Saying nothing leaves the store unavailable, which is true. Registered anyway, because the unknown-opcode line is how a genuinely unrecognised opcode gets noticed and a known one repeating in it hides the signal. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/Opcodes.cpp | 4 ++++ src/game/Server/Opcodes.h | 1 + src/game/Server/WorldSession.h | 1 + src/game/WorldHandlers/MiscHandler.cpp | 25 +++++++++++++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/src/game/Server/Opcodes.cpp b/src/game/Server/Opcodes.cpp index 456aa804a..c7a60b510 100644 --- a/src/game/Server/Opcodes.cpp +++ b/src/game/Server/Opcodes.cpp @@ -221,6 +221,10 @@ void InitializeOpcodes() // We have no Store backend, so we answer the same thing retail answers a player who has // bought nothing -- an empty list -- rather than dropping the request on the floor. DefC(CMSG_BATTLE_PAY_GET_PURCHASE_LIST, "CMSG_BATTLE_PAY_GET_PURCHASE_LIST", STATUS_AUTHED, PROCESS_INPLACE, &WorldSession::HandleBattlePayGetPurchaseListOpcode); + // Its partner: the client sends both on login, one after the other. We answered + // the purchase list and left this one to fall through as "UNKNOWN (0x0DE0)" in + // the log, which is noise that masks a genuinely unrecognised opcode. + DefC(CMSG_BATTLE_PAY_GET_PRODUCT_LIST, "CMSG_BATTLE_PAY_GET_PRODUCT_LIST", STATUS_AUTHED, PROCESS_INPLACE, &WorldSession::HandleBattlePayGetProductListOpcode); DefS(SMSG_BATTLE_PAY_GET_PURCHASE_LIST_RESPONSE, "SMSG_BATTLE_PAY_GET_PURCHASE_LIST_RESPONSE"); // The character-creation randomise button. CharacterCreate.lua's RequestRandomName() diff --git a/src/game/Server/Opcodes.h b/src/game/Server/Opcodes.h index b945dacb3..10d58c9e3 100644 --- a/src/game/Server/Opcodes.h +++ b/src/game/Server/Opcodes.h @@ -1247,6 +1247,7 @@ enum OpcodesList SMSG_REFORGE_RESULT = 0x141E, // 5.4.8 18414 (Wow.exe leaf; name reference-consensus) CMSG_LOAD_SCREEN = 0x1DBD, // 5.4.8 18414 (Wow.exe binary) CMSG_BATTLE_PAY_GET_PURCHASE_LIST = 0x18B2, // 5.4.8 18414 (C_PurchaseAPI.GetPurchaseList -> sub_92EADD -> empty writer sub_661E3F) + CMSG_BATTLE_PAY_GET_PRODUCT_LIST = 0x0DE0, // 5.4.8 18414 (corpus: 13 captures, always a 0-byte body) CMSG_QUERY_COUNTDOWN_TIMER = 0x044E, // 5.4.8 18414 (Wow.exe writer sub_690D37; retained usage literal) SMSG_START_TIMER = 0x0E3F, // 5.4.8 18414 (Wow.exe reader sub_6E7584; leaf sub_90BF87) CMSG_ENABLE_NAGLE = 0x12B3, // 5.4.8 18414 (Wow.exe binary) diff --git a/src/game/Server/WorldSession.h b/src/game/Server/WorldSession.h index a4cf6e75f..a03c9f1d0 100644 --- a/src/game/Server/WorldSession.h +++ b/src/game/Server/WorldSession.h @@ -2303,6 +2303,7 @@ class WorldSession void HandleUITimeRequestOpcode(WorldPacket& recv_data); void HandleReadyForAccountDataTimesOpcode(WorldPacket& recv_data); void HandleBattlePayGetPurchaseListOpcode(WorldPacket& recvPacket); + void HandleBattlePayGetProductListOpcode(WorldPacket& recvPacket); void HandleRandomizeCharNameOpcode(WorldPacket& recvPacket); void HandleQuestPOIQueryOpcode(WorldPacket& recv_data); void HandleQuestNpcQueryOpcode(WorldPacket& recv_data); diff --git a/src/game/WorldHandlers/MiscHandler.cpp b/src/game/WorldHandlers/MiscHandler.cpp index 38dfb802d..282e09b85 100644 --- a/src/game/WorldHandlers/MiscHandler.cpp +++ b/src/game/WorldHandlers/MiscHandler.cpp @@ -2230,6 +2230,31 @@ void WorldSession::HandleBattlePayGetPurchaseListOpcode(WorldPacket& /*recvPacke SendPacket(&data); } +/** + * @brief The client asking for the in-game store catalogue. + * + * Body is empty -- all thirteen corpus occurrences at build 18414 are zero bytes -- + * and the client sends it immediately after CMSG_BATTLE_PAY_GET_PURCHASE_LIST on + * login, so the two arrive as a pair. + * + * Deliberately NOT answered. Retail replies with SMSG_BATTLE_PAY_GET_PRODUCT_LIST_ + * RESPONSE carrying the whole catalogue -- 3517 bytes in capture-000234 seq 28146 -- + * and we have no store to describe. Sending a guessed empty catalogue would put an + * underived layout on the wire for a feature that does not exist here; saying nothing + * simply leaves the store unavailable, which is true. + * + * Registered rather than left to fall through so it stops appearing as + * "received not handled opcode UNKNOWN (0x0DE0)". That line is how a genuinely + * unrecognised opcode gets noticed, and a known one repeating in it is noise that + * hides the signal. + * + * @param recvPacket The received opcode packet. + */ +void WorldSession::HandleBattlePayGetProductListOpcode(WorldPacket& /*recvPacket*/) +{ + DEBUG_LOG("WORLD: Received opcode CMSG_BATTLE_PAY_GET_PRODUCT_LIST (no store; not answered)"); +} + void WorldSession::HandleHearthandResurrect(WorldPacket& /*recv_data*/) { DEBUG_LOG("WORLD: Received opcode CMSG_HEARTH_AND_RESURRECT"); From 8efb6bc6fd8124946c34050a03b2d3ed6df3e7d3 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 11:42:29 +0100 Subject: [PATCH 73/81] Demote finder groups that outlived their instance A dungeon finder group survives a restart; its instance does not. An ordinary dungeon's persistent state expires two hours after creation, so a party left assembled overnight comes back with groups.groupType still carrying GROUPTYPE_LFD and its group_instance bind already cleaned away. RestoreDungeonGroup cannot help, and correctly does not try: it rebuilds a run's LFG status FROM the bind, and the loop that calls it iterates binds, so a group with no bind is never visited. The group returns flagged as a finder run with no run behind it. That half-state is worse than either end of it. Group::SendUpdate sets `isLfg = isLFGGroup() && GetGroupDungeonEntry(...) != 0`, which is now false, so SMSG_GROUP_LIST carries no LFG block and the client zeroes its LFG fields -- no eye, no teleport options, no Vote Kick gate. Meanwhile every server-side isLFGGroup() test still answers yes, so the teleport path refuses with "group has no LFG status" rather than moving anybody. Observed live 2026-08-07 11:33: logged into Wailing Caverns still grouped, no eye, and ERROR:LFG TeleportPlayer: Humanwarrior refused (out) -- group Group (Guid: 1) has no LFG status Only the portrait's Leave Instance Group entry got the player out. The bind was correctly absent -- the previous night's instance was created at 00:10 with a 13:33-style two-hour resettime and had long expired -- and "Loaded 0 group-instance binds total" confirms nothing was lost. So the fix is not to restore more, it is to stop claiming the run exists. After the binds are loaded, any group still flagged LFD whose dungeon entry resolves to 0 is converted to an ordinary party and the change is persisted, using the same predicate SendUpdate uses so a group is demoted precisely when the client would otherwise have been left in the half-state. ClearLfgGroup is the counterpart to SetAsLfgGroup and writes groupType through to the database for the same reason that one does. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/ObjectMgrInstanceData.cpp | 45 +++++++++++++++++++++++ src/game/WorldHandlers/Group.cpp | 29 +++++++++++++++ src/game/WorldHandlers/Group.h | 1 + 3 files changed, 75 insertions(+) diff --git a/src/game/Object/ObjectMgrInstanceData.cpp b/src/game/Object/ObjectMgrInstanceData.cpp index 1ec92be95..5b8ef8e4b 100644 --- a/src/game/Object/ObjectMgrInstanceData.cpp +++ b/src/game/Object/ObjectMgrInstanceData.cpp @@ -232,6 +232,51 @@ void ObjectMgr::LoadGroups() sLog.outString(">> Loaded %u group-instance binds total", count); sLog.outString(); + // Any group still flagged as a finder run but WITHOUT a restored status has outlived + // its instance, and must be demoted rather than left in between. + // + // RestoreDungeonGroup above rebuilds a run's LFG status from its bind. A group whose + // bind is gone -- an ordinary dungeon instance expires two hours after it is created, + // so this is the normal outcome of leaving a party assembled overnight -- never + // reaches that call at all, because the loop iterates binds. It comes back with + // GROUPTYPE_LFD set and no status behind it. + // + // Left alone the client gets neither behaviour: SMSG_GROUP_LIST omits the LFG block, + // so the eye and the teleport options disappear, while every server-side + // isLFGGroup() test still says finder group -- which is why TeleportPlayer refuses + // with "has no LFG status" instead of moving anyone. Demote here, once, where the + // binds have all been processed and the answer is finally knowable. + { + uint32 demoted = 0; + for (GroupMap::const_iterator itr = mGroupMap.begin(); itr != mGroupMap.end(); ++itr) + { + Group* group = itr->second; + if (!group || !group->isLFGGroup()) + { + continue; + } + + // Exactly the predicate Group::SendUpdate uses to decide whether to emit an + // LFG block, so a group is demoted precisely when the client would otherwise + // have been sent nothing and left in the half-state. + if (sLFGMgr.GetGroupDungeonEntry(group->GetObjectGuid()) != 0) + { + continue; // a live run, restored above + } + + sLog.outString("Group %u was a dungeon finder group whose instance no longer " + "exists; converting it to an ordinary party.", group->GetId()); + group->ClearLfgGroup(); + ++demoted; + } + + if (demoted) + { + sLog.outString(">> Demoted %u finder group(s) with no surviving instance", demoted); + sLog.outString(); + } + } + sLog.outString(">> Loaded %u group members total", count); sLog.outString(); } diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index fc912d4dd..e0fffb5bb 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -1337,6 +1337,35 @@ bool Group::LoadMemberFromDB(uint32 guidLow, uint8 subgroup, bool assistant) /** * @brief Converts the group to raid mode and refreshes related state. */ +void Group::ClearLfgGroup() +{ + // The counterpart to SetAsLfgGroup, and it persists for the same reason. + // + // A dungeon finder group outlives its instance: the bind in group_instance is removed + // when the instance expires -- two hours after creation for a normal dungeon -- while + // the group row survives with GROUPTYPE_LFD still set. LFGMgr persists nothing, and + // RestoreDungeonGroup rebuilds the run's status FROM that bind, so once the bind is + // gone there is nothing to rebuild from and the group comes back flagged as a finder + // run with no run behind it. + // + // That half-state is worse than either end of it. Group.cpp's + // `update.isLfg = isLFGGroup() && GetGroupDungeonEntry(...) != 0` goes false, so + // SMSG_GROUP_LIST carries no LFG block, the client zeroes its LFG fields, and the eye, + // both teleport options and the Vote Kick gate vanish -- while the group still claims + // to be a finder group to every server-side isLFGGroup() test. Observed live + // 2026-08-07: a player logged into Wailing Caverns still grouped, with no eye, and + // "LFG TeleportPlayer: refused (out) -- group has no LFG status" in the log. Only the + // portrait's Leave Instance Group entry got them out. + // + // Demoting to an ordinary party is honest: the run really is over. + m_groupType = GroupType(m_groupType & ~GROUPTYPE_LFD); + + if (!isBGGroup()) + { + CharacterDatabase.PExecute("UPDATE `groups` SET `groupType` = %u WHERE `groupId`='%u'", uint8(m_groupType), m_Id); + } +} + void Group::SetAsLfgGroup() { // GROUPTYPE_LFD has to reach the DATABASE, not just m_groupType. diff --git a/src/game/WorldHandlers/Group.h b/src/game/WorldHandlers/Group.h index 166a6e2fd..7bb4fe45a 100644 --- a/src/game/WorldHandlers/Group.h +++ b/src/game/WorldHandlers/Group.h @@ -1509,6 +1509,7 @@ class Group /// Flag this group as a dungeon finder group AND persist it. Defined out of line /// because it writes to `groups`.`groupType` -- see the definition for why. void SetAsLfgGroup(); + void ClearLfgGroup(); bool IsMember(ObjectGuid guid) const { return _getMemberCSlot(guid) != m_memberSlots.end(); From 9a6c6e79d2a24cfd5c7225e67065b422aad8c50b Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 15:54:25 +0100 Subject: [PATCH 74/81] Fix two mislabelled doc briefs and widen the boot-expiry note Review findings from the third pass, which returned APPROVE with one MINOR. MINOR: ClearLfgGroup carried "Converts the group to raid mode and refreshes related state." That brief was already misplaced -- it sat above SetAsLfgGroup, which is not ConvertToRaid either -- and inserting ClearLfgGroup between the block and its function moved the wrong text onto the new function. Both now say what their function does. The reviewer also sharpened the vote-stall analysis, and the comment is the whole deliverable of 52356f616 so it has to be right. I had recorded only the N = 4 case. N = 5 stalls too: with the victim excluded and the initiator already AGREE, a 1-agree/2-deny split among the three remaining voters leaves both counts at 2, below the threshold, so it also resolves only by expiry. Same conclusion -- the reaper is load-bearing -- but it applies to the ordinary five-man case, not just a depleted party, which makes it considerably more than a corner note. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.cpp | 5 ++++- src/game/WorldHandlers/LFGMgrProposal.cpp | 10 +++++++--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index e0fffb5bb..7aa05c84b 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -1335,7 +1335,7 @@ bool Group::LoadMemberFromDB(uint32 guidLow, uint8 subgroup, bool assistant) } /** - * @brief Converts the group to raid mode and refreshes related state. + * @brief Clears the dungeon finder flag, turning the group back into an ordinary party. */ void Group::ClearLfgGroup() { @@ -1366,6 +1366,9 @@ void Group::ClearLfgGroup() } } +/** + * @brief Marks the group as a dungeon finder group and persists the flag. + */ void Group::SetAsLfgGroup() { // GROUPTYPE_LFD has to reach the DATABASE, not just m_groupType. diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 627f4d7f6..4a87c4c73 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1785,9 +1785,13 @@ void LFGMgr::AttemptToKickPlayer(Group* pGroup, ObjectGuid guid, ObjectGuid kick // yes, so the group needs REQUIRED_VOTES_FOR_BOOT + 1 members to succeed at all. // Note the asymmetry this leaves, and why it is tolerable ONLY because expiry // exists. The victim does not vote, so a group of N has N-1 voters and the - // initiator's is already AGREE. At N = 4 that means at most 2 denies, which can - // never reach the threshold: such a vote can PASS on votes but can only FAIL by - // running out the LFG_TIME_BOOT window. That is a correct outcome, and + // initiator's is already AGREE. + // + // At N = 4 at most 2 denies are possible, so a non-unanimous vote can never reach + // the deny threshold at all. At N = 5 a 1-agree/2-deny split among the three + // remaining voters leaves BOTH counts at 2 and stalls the same way. In each case + // the vote can only resolve by running out the LFG_TIME_BOOT window. That is a + // correct outcome, and // RemoveOldBoots delivers it -- but if the reaper is ever removed, a four-man // group is wedged in LFG_STATE_BOOT permanently. Retail avoids the corner by // scaling votesNeeded with group size (the corpus shows 13 for a 25-man LFR); From fcdee00a19410f69fad5b3e5e1d1070e09f6f37e Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 17:01:27 +0100 Subject: [PATCH 75/81] Send loot slot types the 18414 client actually understands Investigating a loot window that opened and closed after about a second. The symptom turned out not to be a bug -- retail does exactly the same thing, and the corpus proves it: capture-000004 seq 17370 and capture-000009 seq 28146 both show CMSG_LOOT_UNIT, SMSG_LOOT_RESPONSE, then CMSG_LOOT_RELEASE roughly a second later. "Response then release" is the retail signature, not a fault. The investigation did find a real defect underneath it. LootSlotType is inherited and predates this build, and we cast it straight onto the wire. A sweep of 9437 SMSG_LOOT_RESPONSE packets at 18414 finds the 3-bit slot field takes exactly {3, 4, 7} -- 4 on 1809 ordinary drops, 3 on 542, 7 on 34 -- and 0, 1, 2, 5 and 6 never appear. Our LOOT_SLOT_NORMAL is 0. The client branches on it, at loot-record offset +24: - the auto-loot pass (sub_9387D6, 0x938824) takes a slot ONLY when it is 3 or 4, so our 0 was silently skipped and never auto-looted; - only 4 suppresses the bind-on-pickup confirmation (0x937DCB), so our 0 raised a BoP prompt where retail shows none; - 2 opens the master looter list, 5 reports locked, 7 refuses the click. It only misfires in a GROUP. A solo kill resolves OWNER_PERMISSION to LOOT_SLOT_OWNER, which is already 4 and correct; shared loot resolves LOOT_SLOT_NORMAL, which is 0 and is not. The live repro was in a group -- header byte 0 was 0x20, i.e. hasLootMethod set. ToWireLootSlotType now maps the internal enum onto the client's space: OWNER 4, NORMAL 3, MASTER 2, VIEW and REQS 7, and never 0, 1, 5 or 6. 2 is reachable only from LOOT_SLOT_MASTER; it has no corpus support, but a master-loot row has to say so somehow and every other value would misrepresent it. Two byte-fidelity fixes alongside: - The per-item 2-bit field is 3 on all 67 corpus item records and we sent 0. The client parses it into msgItem+24 and never reads it back -- sub_9D5F3D, the only consumer of the item array, touches +0,+4,+8,+12,+16,+28,+32,+36 -- so this changes no behaviour. It was my first suspect for the close and it is provably not. - The trailing optional byte is now always emitted, value 17. Despite the field name it is not a failure reason: the client reads it into msg+40 and consults it only on the success == 0 branch. Retail ships it on all 32 decoded successes and omits it in only 3 of 9437 packets, all failures. Deliberately NOT changed: lootGuid. Retail's loot handle is HIGHGUID 0xF190 with a zero entry field, and we send the creature GUID, which is structurally impossible in retail traffic. That is a real lead, but the same value is the key our own CMSG_LOOT_RELEASE and autostore matching uses (LootHandler.cpp:84), so changing it is cross-cutting and needs its own investigation and a live retest. The 1-second close is still unexplained, and may well be normal auto-loot behaviour. The cheap discriminator is to repeat the pull SOLO, where slotType was already 4: if the window still closes, slot type was never involved. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/LootMgr.cpp | 20 +++++++++++++++++- src/game/Object/LootMgr.h | 36 ++++++++++++++++++++++++++++++++ src/game/WorldHandlers/Group.cpp | 5 ++++- 3 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/game/Object/LootMgr.cpp b/src/game/Object/LootMgr.cpp index 25c67052f..dac64553d 100644 --- a/src/game/Object/LootMgr.cpp +++ b/src/game/Object/LootMgr.cpp @@ -1137,6 +1137,17 @@ bool BuildMopLootResponse(WorldPacket& out, LootView const& view, response.lootType = uint8(lootType); response.success = true; + // The trailing optional byte. It is NOT a failure reason despite the field name: + // the client reads it into msg+40 and only consults it on the success == 0 branch + // (0x936FDE), where it selects an error string. On success it is dead. + // + // Retail nevertheless always ships it -- the gating bit is 0 on all 32 successful + // responses decoded from the corpus, and set in only 3 of 9437 packets, all of them + // failures. The value is 17 on success and 18 on failure. Emitting it costs one byte + // and removes the last shape difference between our response and retail's. + response.hasFailureReason = true; + response.failureReason = 17; + if (view.permission != NONE_PERMISSION) { response.money = loot.gold; @@ -1160,7 +1171,14 @@ bool BuildMopLootResponse(WorldPacket& out, LootView const& view, wireItem.situ.assign(4, 0); // Client-compatible empty item-modifier block. wireItem.randomPropertyId = item.randomPropertyId; wireItem.lootListId = lootListId; - wireItem.slotType = uint8(slotType); + wireItem.slotType = ToWireLootSlotType(slotType); + + // Retail sets this 2-bit field to 3 on every one of the 67 item records + // decoded from the corpus, and 0/1/2 never appear. The 18414 client parses + // it into msgItem+24 and never reads it back -- the only consumer of the + // item array, sub_9D5F3D, touches +0,+4,+8,+12,+16,+28,+32,+36 only -- so + // this is byte fidelity rather than behaviour. + wireItem.unknown = 3; if (ItemPrototype const* prototype = ObjectMgr::GetItemPrototype(item.itemid)) { diff --git a/src/game/Object/LootMgr.h b/src/game/Object/LootMgr.h index 760667212..851bd7912 100644 --- a/src/game/Object/LootMgr.h +++ b/src/game/Object/LootMgr.h @@ -91,6 +91,42 @@ enum LootSlotType MAX_LOOT_SLOT_TYPE // custom, use for mark skipped from show items }; +/// Translate the server's pre-MoP LootSlotType onto the value the 18414 client reads. +/// +/// The internal enum below is inherited and predates this build. Casting it straight onto +/// the wire ships values the client never receives from a retail server: a corpus sweep of +/// 9437 SMSG_LOOT_RESPONSE packets at build 18414 finds the 3-bit field takes exactly +/// {3, 4, 7} -- 4 on 1809 ordinary drops, 3 on 542, 7 on 34 -- and 0, 1, 2, 5 and 6 never +/// appear at all. Our LOOT_SLOT_NORMAL is 0. +/// +/// It matters because the client branches on this value (record offset +24, built by +/// sub_9D5F3D): +/// * the auto-loot pass (sub_9387D6, 0x938824) takes a slot ONLY when it is 3 or 4, so a +/// 0 is silently skipped and never auto-looted; +/// * only 4 suppresses the bind-on-pickup confirmation (sub_937CB8, 0x937DCB), so a 0 +/// raises a BoP prompt where retail shows none; +/// * 2 opens the master-loot list instead of looting, 5 reports the slot locked, and 7 +/// refuses the click outright. +/// +/// It only misfires in a GROUP. A solo kill already resolves OWNER_PERMISSION to +/// LOOT_SLOT_OWNER, which is 4 and happens to be correct; shared loot resolves +/// LOOT_SLOT_NORMAL, which is 0 and is not. +/// +/// 2 is deliberately reachable only from LOOT_SLOT_MASTER. It has no corpus support, but a +/// master-loot row has to say so somehow and every other value would misrepresent it. +inline uint8 ToWireLootSlotType(LootSlotType slotType) +{ + switch (slotType) + { + case LOOT_SLOT_OWNER: return 4; // takeable now, no bind confirmation + case LOOT_SLOT_NORMAL: return 3; // takeable, ordinary shared-loot rules + case LOOT_SLOT_MASTER: return 2; // opens the master looter list + case LOOT_SLOT_VIEW: // visible but not takeable by this player + case LOOT_SLOT_REQS: return 7; // refused: requirements not met + default: return 3; // never 0/1/5/6; 3 is the client's own default + } +} + namespace MopLootPackets { constexpr size_t MAX_TAKE_ENTRIES = 50; diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index 7aa05c84b..f76096533 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -1936,7 +1936,10 @@ static bool BuildMopGroupLootItem(Roll const& roll, item.randomSuffix = int32(roll.itemRandomSuffix); item.lootListId = roll.itemSlot; item.hasLootListId = true; - item.slotType = LOOT_SLOT_NORMAL; + // See ToWireLootSlotType: the client never receives 0 here from a retail server, + // and a 0 is skipped by the auto-loot pass and raises a bind confirmation. + item.slotType = ToWireLootSlotType(LOOT_SLOT_NORMAL); + item.unknown = 3; // retail's constant; the client discards it item.situ.assign(4, 0); // Client-compatible empty item-modifier block. return true; } From a5b1d39862c03cfdefec54afcc30628705409ea9 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 17:44:10 +0100 Subject: [PATCH 76/81] Translate the currency slot type too, and fail closed on the default Review of fcdee00a1 returned BLOCK with two findings. Both were right. BLOCKING: currency rows still put the raw internal LootSlotType on the wire. LootMgr.cpp:1289 assigned personalSlotType directly, so for group loot it sent LOOT_SLOT_NORMAL = 0 -- exactly the value the previous commit exists to stop sending. Currency was skipped by the auto-loot pass and raised a bind confirmation, for the same reason items did. Now goes through ToWireLootSlotType like the item and group-roll paths. This one was a plain miss rather than a judgement call: the grep that showed me the item assignments listed this line too, and I converted the ones above it and walked past this one. A sweep for raw assignments now returns nothing. IMPORTANT: the default arm returned 3, which is takeable and auto-loot eligible. The switch already covers every valid slot type 0-4, so the default is reachable only from MAX_LOOT_SLOT_TYPE -- the sentinel meaning "skip this row" -- or from a corrupt value. Offering such a row to the player and then rejecting the click server-side is the worse failure mode, so it returns 7 and fails closed. Full suite still 111/111. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/LootMgr.cpp | 6 +++++- src/game/Object/LootMgr.h | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/game/Object/LootMgr.cpp b/src/game/Object/LootMgr.cpp index dac64553d..04cf492c6 100644 --- a/src/game/Object/LootMgr.cpp +++ b/src/game/Object/LootMgr.cpp @@ -1286,7 +1286,11 @@ bool BuildMopLootResponse(WorldPacket& out, LootView const& view, currency.amount = item.count; currency.currencyId = item.itemid; currency.lootListId = itr->index; - currency.slotType = uint8(personalSlotType); + // Same translation as the item rows: a raw LOOT_SLOT_NORMAL + // puts 0 on the wire, which the auto-loot pass skips and which + // raises a bind confirmation. Missed when the item paths were + // converted; caught in review. + currency.slotType = ToWireLootSlotType(personalSlotType); response.currencies.push_back(currency); } } diff --git a/src/game/Object/LootMgr.h b/src/game/Object/LootMgr.h index 851bd7912..bd8596601 100644 --- a/src/game/Object/LootMgr.h +++ b/src/game/Object/LootMgr.h @@ -123,7 +123,11 @@ inline uint8 ToWireLootSlotType(LootSlotType slotType) case LOOT_SLOT_MASTER: return 2; // opens the master looter list case LOOT_SLOT_VIEW: // visible but not takeable by this player case LOOT_SLOT_REQS: return 7; // refused: requirements not met - default: return 3; // never 0/1/5/6; 3 is the client's own default + // Only reachable from MAX_LOOT_SLOT_TYPE -- the sentinel meaning "skip this + // row" -- or from a corrupt value. Refuse rather than offer: 3 would make a + // row the server already decided not to show look takeable and auto-lootable, + // leaving the server to reject the click it invited. 7 fails closed. + default: return 7; } } From bb9654b6b9e4fdd80aca9216f0f93b5f77838547 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 22:01:22 +0100 Subject: [PATCH 77/81] Clear player LFG state when a group carrying a boot vote dies Whole-branch review finding. The range reviews could not see it: the commit that made it reachable and the code that leaks are sixty commits apart. m_playerStatusMap is keyed by player guid and outlives the group. Nothing on the leave or disband path ever cleared it, which was harmless while no player state survived long enough to matter. Vote kick changed that. AttemptToKickPlayer sets every member to LFG_STATE_BOOT, and if the group then disbands -- members leaving one at a time, or the last one quitting -- ReleaseGroupLfgStatus erased the two group maps and left every player state behind. They persisted across relog, because the map is cleared on neither login nor logout, and HandleLfgGetStatusOpcode ships whatever it finds. The client was handed LFG_STATE_BOOT for a vote that no longer existed, in a group the player had left, and could raise a boot dialog nothing would ever resolve. It cleared only if the player happened to re-queue, or the world restarted. ReleaseGroupLfgStatus now takes the Group rather than its guid, resets every member slot to LFG_STATE_NONE, and erases any boot entry. Deferring the boot entry to the reaper is not enough: the reaper resolves the group to restore state, and by then there is no group. RemoveOldBoots gains the matching else-branch. When the group is gone it clears the polled players and the victim outright, since there is no dungeon state left to restore them to. Also corrected three comments that my own later commit made false. They said SMSG_LFG_TELEPORT_DENIED was not admitted and refusals were therefore silent; 569cf8498 derived its four-bit body and admitted it. The code was right and the comments were describing the world before it. Full suite 111/111. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/Group.cpp | 2 +- src/game/WorldHandlers/GroupHandler.cpp | 5 ++- src/game/WorldHandlers/LFGMgr.cpp | 49 ++++++++++++++++++++++- src/game/WorldHandlers/LFGMgr.h | 3 +- src/game/WorldHandlers/LFGMgrProposal.cpp | 15 ++++--- 5 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/game/WorldHandlers/Group.cpp b/src/game/WorldHandlers/Group.cpp index f76096533..881f20eac 100644 --- a/src/game/WorldHandlers/Group.cpp +++ b/src/game/WorldHandlers/Group.cpp @@ -1828,7 +1828,7 @@ void Group::Disband(bool hideDestroy) // a missing status makes it emit a zero dungeon slot. if (isLFGGroup()) { - sLFGMgr.ReleaseGroupLfgStatus(GetObjectGuid()); + sLFGMgr.ReleaseGroupLfgStatus(this); } Player* player; diff --git a/src/game/WorldHandlers/GroupHandler.cpp b/src/game/WorldHandlers/GroupHandler.cpp index b477a5843..dd2e26435 100644 --- a/src/game/WorldHandlers/GroupHandler.cpp +++ b/src/game/WorldHandlers/GroupHandler.cpp @@ -537,8 +537,9 @@ void WorldSession::HandleGroupDisbandOpcode(WorldPacket& recv_data) // // The teleport out is refused in combat, but the removal used to run anyway, so a // player who clicked Leave Instance Group mid-fight was taken out of the group and - // left standing in the instance -- and the refusal is mute, because - // SMSG_LFG_TELEPORT_DENIED is not admitted. Observed live: "i did leave instance + // left standing in the instance -- and at the time the refusal was mute, because + // SMSG_LFG_TELEPORT_DENIED was not admitted. It is admitted now, so the player is + // told; the removal-without-teleport is what this guard exists to stop. Observed live: "i did leave instance // group on the leader, i just got removed but not teleported out", while stuck in // a combat stance. // diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index d3b4b4ef0..fbf174f1c 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1152,6 +1152,19 @@ void LFGMgr::RemoveOldBoots() } } } + else + { + // The group went away while the vote was running. Everyone who was polled is + // still carrying LFG_STATE_BOOT, and with no group to walk there is nothing + // to restore them to a dungeon state -- so clear them outright. Skipping this + // is what left players holding a phantom boot state across relog. + for (proposalAnswerMap::const_iterator ans = boot.answers.begin(); + ans != boot.answers.end(); ++ans) + { + SetPlayerState(ans->first, LFG_STATE_NONE); + } + SetPlayerState(boot.playerVotedOn, LFG_STATE_NONE); + } m_bootStatusMap.erase(groupGuid); @@ -1911,8 +1924,42 @@ void LFGMgr::OnPlayerLeftDungeonGroup(Player* pPlayer) pPlayer->CastSpell(pPlayer, LFG_DESERTER_SPELL, true); } -void LFGMgr::ReleaseGroupLfgStatus(ObjectGuid groupGuid) +void LFGMgr::ReleaseGroupLfgStatus(Group* pGroup) { + if (!pGroup) + { + return; + } + + ObjectGuid const groupGuid = pGroup->GetObjectGuid(); + + // Reset the MEMBERS too, not just the group maps. + // + // m_playerStatusMap is keyed by player guid and outlives the group entirely: nothing + // on the leave or disband path ever cleared it, because until vote kick landed no + // player state survived long enough to matter. LFG_STATE_BOOT changed that. A boot + // vote sets every member to LFG_STATE_BOOT, and if the group then disbands -- members + // leaving one by one, or the last one quitting -- the group maps go and the player + // states stay. + // + // They stay across relog, because the map is never cleared on login or logout, and + // HandleLfgGetStatusOpcode ships whatever it finds: the client is handed + // LFG_STATE_BOOT for a vote that no longer exists, in a group the player has left, + // and can raise a boot dialog nothing can ever resolve. It only clears if the player + // happens to re-queue, or the world restarts. + // + // Found by a whole-branch review; the range reviews could not see it, because the + // commit that made the leak reachable and the code that leaks are far apart. + for (Group::MemberSlotList::const_iterator itr = pGroup->GetMemberSlots().begin(); + itr != pGroup->GetMemberSlots().end(); ++itr) + { + SetPlayerState(itr->guid, LFG_STATE_NONE); + } + + // A vote in flight dies with the group. Leaving it for the reaper is not enough: its + // recovery path resolves the group to restore state, and by then there is no group. + m_bootStatusMap.erase(groupGuid); + m_groupStatusMap.erase(groupGuid); m_groupSet.erase(groupGuid); } diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index 58c70c995..fd94a9cd1 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1338,7 +1338,8 @@ class LFGMgr /// Drop a disbanded group's LFG status. Must run when the Group is torn down, not /// when its dungeon finishes -- see the note in HandleBossKilled. - void ReleaseGroupLfgStatus(ObjectGuid groupGuid); + /// Drop a group's LFG state AND reset its members' player states. + void ReleaseGroupLfgStatus(Group* pGroup); /// A dungeon encounter was credited on this map. Marks every LFG group with players /// present as having made progress, so leaving no longer earns Dungeon Deserter, and diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 4a87c4c73..8158c2205 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1158,8 +1158,9 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup, Player* onlyPlay // TeleportToDungeon also runs from CreateDungeonGroup, where a proposal has // just been accepted and the group formed. Refusing one member there teleports // everyone else in and strands that player -- still in the group, still set to - // LFG_STATE_IN_DUNGEON, and with no feedback at all, because - // SMSG_LFG_TELEPORT_DENIED is not admitted. A proposal accept is a mandatory + // LFG_STATE_IN_DUNGEON. When this was written they also got no feedback at + // all, because SMSG_LFG_TELEPORT_DENIED was not admitted; it is now, but + // being stranded silently is not much improved by being told why. A proposal accept is a mandatory // group form, and the client's own message for this // (ERR_PARTY_LFG_TELEPORT_IN_COMBAT) is about teleporting OUT of a dungeon, // not about being placed into one. @@ -1261,10 +1262,12 @@ void LFGMgr::TeleportToDungeon(uint32 dungeonID, Group* pGroup, Player* onlyPlay void LFGMgr::TeleportPlayer(Player* pPlayer, bool out) { // Fetch necessary data first - // Every refusal below is INVISIBLE to the player: SMSG_LFG_TELEPORT_DENIED is not - // admitted (its value space is not derived -- see SendLfgTeleportError), so a refused - // teleport produces no packet at all. Log every one of them, or a player reporting - // "teleport out did nothing" leaves nothing behind to diagnose. Observed live + // Every refusal below now REACHES the player: SMSG_LFG_TELEPORT_DENIED carries a + // derived four-bit reason and is admitted through the send gate. It was not, when this + // logging was added -- a refused teleport then produced no packet at all, and the log + // line was the only trace. Keep logging anyway: the reason codes are coarse, and a + // player reporting "teleport out did nothing" is still far easier to diagnose with the + // branch recorded server-side. Observed live // 2026-08-06 21:04: two CMSG_LFG_TELEPORT from a player alone in Shadowfang Keep, no // reply, no log line, and no way to tell which branch refused. Group* pGroup = pPlayer->GetGroup(); From 99415d45d62361c639957e0a1a5e0fc3ad63c70a Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 22:16:47 +0100 Subject: [PATCH 78/81] Stop dropping a merged queuer, and clean up after a voluntary leaver Deep whole-branch audit findings. Three fixes; the first is the real one. IMPORTANT -- a merged solo queuer was silently destroyed. LeaveLFG's group branch removed every party member through RemovePlayerFromQueue and then erased m_queueSet[grpGuid] and m_playerData[grpGuid] unconditionally. RemovePlayerFromQueue only erases the entry once currentRoles empties, which is correct, so the extra erase was redundant in the ordinary case -- and destructive in the case that matters. MergeGroups can absorb a solo queuer into a party's entry, and that player is still in currentRoles after the whole party has left. The erase took their queue with the party's. Their m_playerStatusMap still read LFG_STATE_QUEUED while they were gone from m_queueSet and m_playerData, so no match and no queue-status update could reach them again: they sat believing they were queued until they manually left and re-queued. Reachable whenever a party of fewer than five absorbs a solo queuer and then cancels. Now only torn down when nothing is left in it. MINOR -- CastVote tested `RemoveMember(...) <= 1` for disband. RemoveMember returns the surviving count, and an LFG group is now allowed to live on with one member, so 1 no longer means disbanded and the branch would delete a group still in play. Unreachable today, because REQUIRED_VOTES_FOR_BOOT = 3 stops a vote starting below five members, but it is a use-after-free the moment that constant is lowered -- which the LFR work will want to do. Tests for zero now. MINOR -- a player who LEAVES an LFG group mid-vote kept their state. The previous commit cleared members when the group disbands, and the reaper clears the polled players when the group is gone; someone who simply walks out is in neither set. They kept LFG_STATE_BOOT across relog and HandleLfgGetStatusOpcode would hand their client a boot dialog for a vote that no longer existed. OnPlayerLeftLfgGroup now always runs on that path, clears their state and withdraws their vote -- an absent player should not count toward a threshold they can no longer be persuaded to change. It is deliberately separate from OnPlayerLeftDungeonGroup, whose early returns are right for deciding Deserter and wrong for cleanup. Full suite 111/111. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/WorldHandlers/GroupHandler.cpp | 5 +++ src/game/WorldHandlers/LFGMgr.cpp | 40 +++++++++++++++++++++++ src/game/WorldHandlers/LFGMgr.h | 4 +++ src/game/WorldHandlers/LFGMgrProposal.cpp | 9 ++++- src/game/WorldHandlers/LFGMgrQueue.cpp | 23 +++++++++++++ 5 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/game/WorldHandlers/GroupHandler.cpp b/src/game/WorldHandlers/GroupHandler.cpp index dd2e26435..a4a88e203 100644 --- a/src/game/WorldHandlers/GroupHandler.cpp +++ b/src/game/WorldHandlers/GroupHandler.cpp @@ -563,6 +563,11 @@ void WorldSession::HandleGroupDisbandOpcode(WorldPacket& recv_data) // is still in a live run, and the teleport is about to move them out of it. sLFGMgr.OnPlayerLeftDungeonGroup(GetPlayer()); sLFGMgr.TeleportPlayer(GetPlayer(), true); + + // Clear their own LFG state and withdraw any boot vote. Must run for every + // leaver, which is why it is not folded into OnPlayerLeftDungeonGroup -- that + // one returns early in several cases that are right for Deserter and wrong here. + sLFGMgr.OnPlayerLeftLfgGroup(GetPlayer(), pGroup); } // everything is fine, do it diff --git a/src/game/WorldHandlers/LFGMgr.cpp b/src/game/WorldHandlers/LFGMgr.cpp index fbf174f1c..60fd9c8a8 100644 --- a/src/game/WorldHandlers/LFGMgr.cpp +++ b/src/game/WorldHandlers/LFGMgr.cpp @@ -1873,6 +1873,46 @@ bool LFGMgr::IsPlayerInLfgDungeon(Player* pPlayer) return dungeon && pPlayer->GetMapId() == uint32(dungeon->MapID); } +/// Drop a departing player's own LFG state, and their vote if one is in flight. +/// +/// Distinct from OnPlayerLeftDungeonGroup, which decides Deserter and returns early in +/// several cases -- a finished run, a run past its protected opening, a group with no +/// status. Those early returns are correct for Deserter and wrong for cleanup, so the +/// cleanup lives here and always runs. +/// +/// Without it a voluntary leaver keeps whatever state they held. That matters most for +/// LFG_STATE_BOOT: ReleaseGroupLfgStatus clears the members of a group that DISBANDS, and +/// RemoveOldBoots clears the polled players when the group is GONE, but someone who simply +/// walks out mid-vote is in neither set. They kept LFG_STATE_BOOT across relog, and +/// HandleLfgGetStatusOpcode would hand their client a boot dialog for a vote that no +/// longer existed. +/// +/// Their vote is withdrawn too. Leaving it in `answers` counts an absent player toward a +/// threshold they can no longer be persuaded to change. +void LFGMgr::OnPlayerLeftLfgGroup(Player* pPlayer, Group* pGroup) +{ + if (!pPlayer || !pGroup) + { + return; + } + + ObjectGuid const plrGuid = pPlayer->GetObjectGuid(); + + bootStatusMap::iterator bootIt = m_bootStatusMap.find(pGroup->GetObjectGuid()); + if (bootIt != m_bootStatusMap.end()) + { + bootIt->second.answers.erase(plrGuid); + + // If the leaver WAS the target, the vote has nothing left to decide. + if (bootIt->second.playerVotedOn == plrGuid) + { + m_bootStatusMap.erase(bootIt); + } + } + + SetPlayerState(plrGuid, LFG_STATE_NONE); +} + void LFGMgr::OnPlayerLeftDungeonGroup(Player* pPlayer) { if (!pPlayer) diff --git a/src/game/WorldHandlers/LFGMgr.h b/src/game/WorldHandlers/LFGMgr.h index fd94a9cd1..f2a956584 100644 --- a/src/game/WorldHandlers/LFGMgr.h +++ b/src/game/WorldHandlers/LFGMgr.h @@ -1350,6 +1350,10 @@ class LFGMgr /// not yet made progress. void OnPlayerLeftDungeonGroup(Player* pPlayer); + /// Always-runs cleanup for a player leaving an LFG group: clears their LFG state + /// and withdraws any vote they had cast. + void OnPlayerLeftLfgGroup(Player* pPlayer, Group* pGroup); + /// Is this player standing inside the dungeon of a live LFG run? bool IsPlayerInLfgDungeon(Player* pPlayer); diff --git a/src/game/WorldHandlers/LFGMgrProposal.cpp b/src/game/WorldHandlers/LFGMgrProposal.cpp index 8158c2205..62b67665b 100644 --- a/src/game/WorldHandlers/LFGMgrProposal.cpp +++ b/src/game/WorldHandlers/LFGMgrProposal.cpp @@ -1979,7 +1979,14 @@ void LFGMgr::CastVote(Player* pPlayer, bool vote) } // kick player from group - if (pGroup->RemoveMember(boot.playerVotedOn, 1) <= 1) + // Test for ZERO, not <= 1. Group::RemoveMember returns the surviving member + // count, and an LFG group is now allowed to live on with a single member, so 1 + // no longer means "disbanded". Deleting there would free a group that is still + // in play. Unreachable today -- REQUIRED_VOTES_FOR_BOOT = 3 means a vote cannot + // start below five members, leaving at least three after a kick -- but it is a + // use-after-free the moment that constant is lowered, which the LFR work will + // want to do. + if (pGroup->RemoveMember(boot.playerVotedOn, 1) == 0) { // group->Disband(); already disbanded in RemoveMember sObjectMgr.RemoveGroup(pGroup); diff --git a/src/game/WorldHandlers/LFGMgrQueue.cpp b/src/game/WorldHandlers/LFGMgrQueue.cpp index 5c4fb3490..45e2649f6 100644 --- a/src/game/WorldHandlers/LFGMgrQueue.cpp +++ b/src/game/WorldHandlers/LFGMgrQueue.cpp @@ -615,6 +615,29 @@ void LFGMgr::LeaveLFG(Player* plr, bool isGroup) } } + // Tear the entry down ONLY if nobody is left in it. + // + // RemovePlayerFromQueue already erases the entry when currentRoles empties, so + // this was redundant in the ordinary case -- and destructive in the one that + // matters. MergeGroups can absorb a solo queuer into a party's entry, and that + // player is still in currentRoles after every party member has been removed + // above. Erasing unconditionally deleted their queue with the party's: their + // m_playerStatusMap still said LFG_STATE_QUEUED, but they were gone from + // m_queueSet and m_playerData, so no match and no queue-status update could ever + // reach them again. They sat believing they were queued until they manually left + // and re-queued. + // + // Reachable: a party of fewer than five queues, a solo player queues, the + // matchmaker merges the solo into the party's entry without completing a group, + // then the party cancels. + if (LFGPlayers const* remaining = GetPlayerOrPartyData(grpGuid)) + { + if (!remaining->currentRoles.empty()) + { + return; // absorbed queuers still hold it + } + } + m_queueSet.erase(grpGuid); m_playerData.erase(grpGuid); } From 216471b1f9a4ec6950f9b3eb1687c804ba3281f6 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 22:49:43 +0100 Subject: [PATCH 79/81] Fix a use-after-free and a startup disband, both from single-member LFG groups Sliced deep audit of the group-lifecycle files, which no previous reviewer had looked at. Two BLOCKING findings, same root cause seen from opposite ends: this branch let LFG groups survive with one member, and two callers that read RemoveMember's return value to decide whether it disbanded were never updated. BLOCKING -- use-after-free on the most ordinary leave path there is. Player::RemoveFromGroup tested `RemoveMember(...) <= 1` and freed the group, commented "already disbanded in RemoveMember". RemoveMember returns the SURVIVING count, and its threshold is now `GetMembersCount() > (isBGGroup() || isLFGGroup() ? 1 : 2)`, so removing one of two members takes the ordinary removal branch, returns 1, and never disbands. The old test then deleted a live group: the survivor kept a dangling m_group (only Disband nulls it), the groups and group_instance rows were left behind (only Disband deletes them), and the LFG status leaked (ReleaseGroupLfgStatus is called from Disband). The next GetGroup() reads freed memory. Reachable by a dungeon run bleeding to two players and one clicking Leave Instance Group. Also from proposal creation dissolving a finished two-member group, from character deletion, and from Eluna's player:RemoveFromGroup(). I fixed this exact defect in LFGMgr::CastVote earlier and did not check for other callers. This is the high-traffic one. BLOCKING -- the startup cleanup loop disbanded the groups the branch exists to preserve. ObjectMgrInstanceData.cpp dropped every group with fewer than two members, which was correct while logout dissolved one-member groups. It no longer does: an LFG group survives logout deliberately, so a run down to a single member persists and loads. The loop then disbanded it BEFORE the instance-bind loop, so RestoreDungeonGroup never ran for it, and before the demotion sweep, which could no longer see it. The player logged back in inside the instance with no group, no LFG block, no eye and no teleport out -- precisely the stranding this work exists to prevent, undone by a pre-existing loop nobody updated. The threshold now mirrors RemoveMember's. MINOR -- refusing to leave in combat reported ERR_PARTY_RESULT_OK, the same code as success, so the player got an "OK" and stayed in the group with nothing to explain it. Now ERR_PARTY_LFG_TELEPORT_IN_COMBAT, which is the client's own message for this case. The comment above it, which I made inaccurate in an earlier commit, is corrected too. Full suite 111/111. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Object/ObjectMgrInstanceData.cpp | 19 ++++++++++++++++++- src/game/Object/PlayerGroup.cpp | 22 +++++++++++++++++++++- src/game/WorldHandlers/GroupHandler.cpp | 16 ++++++++++++---- 3 files changed, 51 insertions(+), 6 deletions(-) diff --git a/src/game/Object/ObjectMgrInstanceData.cpp b/src/game/Object/ObjectMgrInstanceData.cpp index 5b8ef8e4b..d07c25250 100644 --- a/src/game/Object/ObjectMgrInstanceData.cpp +++ b/src/game/Object/ObjectMgrInstanceData.cpp @@ -146,7 +146,24 @@ void ObjectMgr::LoadGroups() // TODO: maybe delete from the DB before loading in this case for (GroupMap::iterator itr = mGroupMap.begin(); itr != mGroupMap.end();) { - if (itr->second->GetMembersCount() < 2) + // Mirror RemoveMember's own survival threshold rather than assuming two. + // + // This loop predates the branch and was correct while a one-member group could + // never reach startup: the logout path dissolved it. It no longer does -- an LFG + // group survives logout deliberately, so a run that bled down to a single member + // persists to the database and loads here. + // + // Disbanding it at this point defeats the whole restart-survival feature, and + // silently: it runs BEFORE the instance-bind loop, so RestoreDungeonGroup is + // never called for the group, and before the demotion sweep, which then cannot + // see it either. The player logs back in inside the instance with no group, no + // LFG block, no eye and no teleport out -- exactly the stranding this branch + // exists to prevent. + // + // 1 < 1 is false, so a single-member LFG or battleground group now survives to + // the bind loop and is either restored or demoted there. + uint32 const minMembers = (itr->second->isBGGroup() || itr->second->isLFGGroup()) ? 1u : 2u; + if (itr->second->GetMembersCount() < minMembers) { itr->second->Disband(); delete itr->second; diff --git a/src/game/Object/PlayerGroup.cpp b/src/game/Object/PlayerGroup.cpp index 40114932b..054117ff6 100644 --- a/src/game/Object/PlayerGroup.cpp +++ b/src/game/Object/PlayerGroup.cpp @@ -149,7 +149,27 @@ void Player::RemoveFromGroup(Group* group, ObjectGuid guid) { if (group) { - if (group->RemoveMember(guid, 0) <= 1) + // Test for ZERO. RemoveMember returns the SURVIVING member count, and this + // branch frees the group on the assumption that a return of 1 or less means + // RemoveMember already disbanded it. That assumption is no longer true. + // + // LFG groups are now allowed to live on with a single member -- RemoveMember's + // threshold is `GetMembersCount() > (isBGGroup() || isLFGGroup() ? 1 : 2)` -- so + // removing one of two members takes the ordinary removal branch, returns 1, and + // Disband is never called. The old `<= 1` then deleted a group that was still in + // play, leaving the survivor holding a dangling m_group (only Disband nulls it), + // the `groups` and `group_instance` rows undeleted (only Disband removes them), + // and the LFG status leaked (ReleaseGroupLfgStatus is called from Disband). + // The next touch of GetGroup() -- the very next world tick -- reads freed memory. + // + // Reachable on the most ordinary path there is: a dungeon run down to two + // players and one of them clicks Leave Instance Group. Also from proposal + // creation dissolving a finished two-member group, from character deletion, and + // from Eluna's player:RemoveFromGroup(). + // + // The same defect was found and fixed in LFGMgr::CastVote; this caller was + // missed, and it is the high-traffic one. + if (group->RemoveMember(guid, 0) == 0) { // group->Disband(); already disbanded in RemoveMember sObjectMgr.RemoveGroup(group); diff --git a/src/game/WorldHandlers/GroupHandler.cpp b/src/game/WorldHandlers/GroupHandler.cpp index a4a88e203..b2c350982 100644 --- a/src/game/WorldHandlers/GroupHandler.cpp +++ b/src/game/WorldHandlers/GroupHandler.cpp @@ -537,9 +537,9 @@ void WorldSession::HandleGroupDisbandOpcode(WorldPacket& recv_data) // // The teleport out is refused in combat, but the removal used to run anyway, so a // player who clicked Leave Instance Group mid-fight was taken out of the group and - // left standing in the instance -- and at the time the refusal was mute, because - // SMSG_LFG_TELEPORT_DENIED was not admitted. It is admitted now, so the player is - // told; the removal-without-teleport is what this guard exists to stop. Observed live: "i did leave instance + // left standing in the instance, and the refusal itself was mute: it reported + // ERR_PARTY_RESULT_OK, the same code as success. The removal-without-teleport is + // what this guard exists to stop; the party-result code below now says so. Observed live: "i did leave instance // group on the leader, i just got removed but not teleported out", while stuck in // a combat stance. // @@ -553,7 +553,15 @@ void WorldSession::HandleGroupDisbandOpcode(WorldPacket& recv_data) // player stays in the group, still able to leave once combat ends. if (GetPlayer()->IsInCombat() && sLFGMgr.IsPlayerInLfgDungeon(GetPlayer())) { - SendPartyResult(PARTY_OP_LEAVE, GetPlayer()->GetName(), ERR_PARTY_RESULT_OK); + // Tell them it was REFUSED, and why. This used to send ERR_PARTY_RESULT_OK -- + // the identical response the success path sends -- so a player who clicked + // Leave Instance Group in combat got an "OK" and stayed in the group, with + // nothing to explain the contradiction. + // + // The client owns the right message: ERR_PARTY_LFG_TELEPORT_IN_COMBAT, "You + // cannot teleport out of the dungeon while in combat." That is exactly this + // refusal, since the teleport is the half that cannot happen. + SendPartyResult(PARTY_OP_LEAVE, GetPlayer()->GetName(), ERR_PARTY_LFG_TELEPORT_IN_COMBAT); DEBUG_LOG("HandleGroupDisbandOpcode: %s refused -- in combat inside an LFG dungeon", GetPlayer()->GetName()); return; From 2c26bce0e0948352c12ff0c70b080671ef0d7285 Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 22:53:11 +0100 Subject: [PATCH 80/81] State the speed floor margin accurately The comment claimed the 1e-6 floor sat an order of magnitude clear of the client validator bound. It does not: 1e-6 over 2^-22 is about 4.2x. The floor is still ample, since the comparison is exclusive and nothing representable below it reaches the wire, but the overstatement is the dangerous kind -- a maintainer trusting ten times the headroom could lower the floor toward 3e-7 and land inside the rejection band. Found by a sliced audit of the object-update files, which no earlier reviewer had read. Co-Authored-By: Claude Opus 5 (1M context) --- src/game/Server/MopUpdateObject.cpp | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/game/Server/MopUpdateObject.cpp b/src/game/Server/MopUpdateObject.cpp index 2d7a66d0a..f92545e17 100644 --- a/src/game/Server/MopUpdateObject.cpp +++ b/src/game/Server/MopUpdateObject.cpp @@ -736,8 +736,12 @@ namespace /// sub_79E087 BREAK: the object is lost and so is every later block in the /// same packet, silently and with no reply. /// - /// The floor sits an order of magnitude clear of the bound so no rounding can - /// walk back across it. Speeds are conceptually non-negative, so this floors + /// The floor is 1e-6, about 4.2x the 2^-22 bound rather than the order of magnitude + /// an earlier version of this comment claimed. That is ample -- the comparison is + /// exclusive and no representable speed below the floor can reach the wire -- but the + /// real margin is stated here so nobody lowers the floor toward 3e-7 believing there + /// is ten times the headroom there actually is. + /// Speeds are conceptually non-negative, so this floors /// rather than preserving sign -- creature_template ships negative denormals /// which are meaningless as speeds and would be rejected on magnitude anyway. float SanitizeSpeed(float speed) From 6ccf5bf633197abf3a9712a79e98a6bd9723e3ae Mon Sep 17 00:00:00 2001 From: MadMax Date: Fri, 7 Aug 2026 23:09:56 +0100 Subject: [PATCH 81/81] Stop two test helpers reading past the end of a short packet Sliced audit of the test files. Both helpers detect a size mismatch, record it through CHECK -- which does not abort -- and then compare bytes anyway, indexing by the EXPECTED length rather than the actual one. A short packet is therefore read past its end. The vector's spare capacity usually absorbs that, which is worse than a crash: the run prints a wall of byte mismatches for bytes that do not exist, so a size bug presents as a content bug at precisely the moment someone is trying to diagnose it. That is the opposite of what these tests are for. Neither can fire while the writers are correct, which is why a passing suite never showed it. They fire on the first writer regression, which is the run whose output most needs to be trustworthy. mop_lfg_role_check_packets_test now returns after reporting the size mismatch. mop_lfg_player_info_packets_test gates its byte comparison on the size being right, since its AssertBytes takes a raw pointer and bounds-checks nothing. Full suite 111/111. Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/mop_lfg_player_info_packets_test.cpp | 12 ++++++++++-- .../Server/tests/mop_lfg_role_check_packets_test.cpp | 8 ++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp b/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp index 5d5a97157..1385992db 100644 --- a/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_player_info_packets_test.cpp @@ -95,8 +95,16 @@ namespace WorldPacket packet(SMSG_LFG_PLAYER_INFO, 5 + locks.size() * 16); MopLfgPackets::BuildPlayerInfo(packet, locks); - CHECK(packet.size() == 5 + expected.size()); // 5-byte header, then the array - AssertBytes(packet.contents(), expected, 5, "lock_records"); + // Gate the byte comparison on the size. AssertBytes bounds-checks nothing and + // reads actual[5 .. 4 + expected.size()], so a short packet is read past its + // end. CHECK records a failure without aborting, so without this gate the + // comparison ran anyway. + bool const sized = (packet.size() == 5 + expected.size()); + CHECK(sized); // 5-byte header, then the array + if (sized) + { + AssertBytes(packet.contents(), expected, 5, "lock_records"); + } } /// The 20/1/17 bit header, checked against the reference packet's own first five bytes. diff --git a/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp b/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp index 77930a943..83fb640d6 100644 --- a/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp +++ b/src/game/Server/tests/mop_lfg_role_check_packets_test.cpp @@ -57,6 +57,14 @@ namespace std::printf("%s: size %u, expected %u\n", label, unsigned(packet.size()), unsigned(expected.size())); CHECK(false); + + // Stop here. CHECK records the failure but does not abort, and the loop + // below indexes packet.contents() by expected.size() -- past the end of a + // packet that is short. The vector's spare capacity usually absorbs that, + // which is worse than a crash: it prints a wall of byte mismatches for + // bytes that do not exist, so a size bug reads as a content bug at exactly + // the moment someone is trying to diagnose it. + return; } for (size_t i = 0; i < expected.size(); ++i)