Lfg list update to 12.1

This commit is contained in:
luis
2026-09-15 21:43:58 -03:00
parent a6fa569873
commit 167a0f0685
7 changed files with 3361 additions and 697 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+320 -11
View File
@@ -21,9 +21,14 @@
#include "Define.h"
#include "ObjectGuid.h"
#include "LFGListPackets.h"
#include "SharedDefines.h"
#include <array>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <vector>
class Group;
class Player;
// Premade Group Finder (the "Premade Groups" tab). Server-side registry of player-published group listings + the
@@ -46,7 +51,12 @@ namespace LFGList
{
uint32 Id = 0;
ObjectGuid ApplicantGuid; // the applying player (or group leader)
uint8 RoleMask = 0;
uint8 RoleMask = 0; // the role the applicant asked for
// The role the LEADER assigned when inviting. CMSG_LFG_LIST_INVITE_APPLICANT carries an Invitees[]
// list of { PackedGuid, u8 RoleMask } (client writer RVA 0x6A4A30) precisely so the leader can grant
// a role other than the one applied for - a healer applicant slotted as damage, say. Zero until an
// invite names it; SMSG_LFG_LIST_APPLICATION_STATUS_UPDATE.RoleGranted then carries this, not RoleMask.
uint8 GrantedRoleMask = 0;
uint32 SpecID = 0;
uint32 ItemLevel = 0;
std::string Comment;
@@ -54,18 +64,130 @@ namespace LFGList
uint32 AppliedTime = 0; // drives the retail 300s application timeout (sniff-verified)
};
// How the player has dealt with a flagged (censored) listing. Mirrors the tri-state the client keeps
// at LFG-list manager offset +0x13E0: 1 = flagged and undecided, 2 = the player confirmed it as is,
// 0 = not flagged / cleared. C_LFGList.IsCensoredActiveEntryUnresolved tests for exactly the 1.
enum class CensorState : uint8
{
None = 0,
Unresolved = 1,
Confirmed = 2,
};
// AdvancedFilterOptions as the client packs it into CMSG_LFG_LIST_SEARCH.AdvancedFilterMask (client RVA 0x24E1030,
// LSB first in the order Blizzard_APIDocumentationGenerated/LFGListInfoDocumentation.lua declares the fields).
enum AdvancedFilterFlag : uint32
{
ADVANCED_FILTER_NEEDS_TANK = 0x0001,
ADVANCED_FILTER_NEEDS_HEALER = 0x0002,
ADVANCED_FILTER_NEEDS_DAMAGE = 0x0004,
ADVANCED_FILTER_NEEDS_MY_CLASS = 0x0008,
ADVANCED_FILTER_HAS_TANK = 0x0010,
ADVANCED_FILTER_HAS_HEALER = 0x0020,
ADVANCED_FILTER_DIFFICULTY_NORMAL = 0x0040,
ADVANCED_FILTER_DIFFICULTY_HEROIC = 0x0080,
ADVANCED_FILTER_DIFFICULTY_MYTHIC = 0x0100,
ADVANCED_FILTER_DIFFICULTY_MYTHIC_PLUS = 0x0200,
ADVANCED_FILTER_GENERAL_PLAYSTYLE_1 = 0x0400, // Learning
ADVANCED_FILTER_GENERAL_PLAYSTYLE_2 = 0x0800, // FunRelaxed
ADVANCED_FILTER_GENERAL_PLAYSTYLE_3 = 0x1000, // FunSerious
ADVANCED_FILTER_GENERAL_PLAYSTYLE_4 = 0x2000, // Expert
ADVANCED_FILTER_DIFFICULTY_ANY = ADVANCED_FILTER_DIFFICULTY_NORMAL | ADVANCED_FILTER_DIFFICULTY_HEROIC
| ADVANCED_FILTER_DIFFICULTY_MYTHIC | ADVANCED_FILTER_DIFFICULTY_MYTHIC_PLUS,
ADVANCED_FILTER_GENERAL_PLAYSTYLE_ANY = ADVANCED_FILTER_GENERAL_PLAYSTYLE_1 | ADVANCED_FILTER_GENERAL_PLAYSTYLE_2
| ADVANCED_FILTER_GENERAL_PLAYSTYLE_3 | ADVANCED_FILTER_GENERAL_PLAYSTYLE_4,
};
// What a listing's party looks like to a browser: C_LFGList.GetSearchResultMemberCounts' TANK / HEALER / DAMAGER
// and per-class counts, taken from the very role bytes the search row carries so the two cannot disagree.
struct MemberComposition
{
uint8 Tanks = 0;
uint8 Healers = 0;
uint8 Damagers = 0;
std::array<uint8, MAX_CLASSES> Classes = { };
};
// The search terms of one CMSG_LFG_LIST_SEARCH, as the packet hands them over: one inner vector per
// term block, holding that block's non-empty values. Blocks are ANDed, values inside a block ORed -
// see LFGListSearch::GetKeywords, which owns that decision and marks it.
using SearchKeywords = std::vector<std::vector<std::string>>;
// Everything one CMSG_LFG_LIST_SEARCH asks for, in one object, so the search reply and the live push
// cannot drift apart: both run it through LFGListMgr::Matches and there is no second copy of the rule.
// Field meanings and their sources are documented on WorldPackets::LFGList::LFGListSearch; in short,
// ResolvedActivityIds is the set the CLIENT derived from category + filter + search box, while
// ActivityIds and ActivityGroupIds are the explicit narrowings from the advanced filter. Every member
// left empty / 0 is a wildcard.
struct SearchFilter
{
uint32 CategoryId = 0;
std::vector<uint32> ResolvedActivityIds; // GroupFinderActivity ids, client-resolved
std::vector<uint32> ActivityIds; // GroupFinderActivity ids, C_LFGList.Search arg 7
std::vector<uint32> ActivityGroupIds; // GroupFinderActivityGrp ids, advancedFilter.activities
SearchKeywords Keywords;
uint32 AdvancedFilterMask = 0; // AdvancedFilterFlag
uint32 MinimumRating = 0; // advancedFilter.minimumRating, against the leader's dungeon score
uint32 LanguageMask = 0; // one bit per LocaleConstant; 0 = no restriction
uint8 SearcherClass = 0; // needsMyClass is relative to the player who searches
};
// One published group listing.
struct Listing
{
uint32 Id = 0; // server-issued listing id (RideTicket.Id)
ObjectGuid LeaderGuid;
ObjectGuid GroupGuid; // the leader's group (empty = solo listing)
ObjectGuid GroupGuid; // the leader's group (empty = solo listing), LIVE - see TicketGuid
// The listing's identity on the wire, frozen at CreateListing and never written again. It is the
// group guid if the leader already had a group when publishing, otherwise the leader's own guid.
// It exists because GroupGuid is LIVE: a solo leader who publishes and then accepts an applicant
// gets a group created for him (LFGListHandler, HandleLFGListInviteResponse), and deriving the
// ticket from GroupGuid made the ticket of an already-published listing change underneath the
// client. The consumer of SMSG_LFG_LIST_UPDATE_STATUS (RVA 0x24DE410) compares the incoming ticket
// against its stored active entry field by field and drops anything that differs, so after that
// flip the leader's own delist, the expiry and every application status update were discarded in
// silence and the entry stayed on his screen. The row header of a search result is the same ticket
// in disguise, so an open browser saw a NEW row instead of an update of the one it had.
// GroupGuid stays live on purpose - it is what enumerates the current members.
ObjectGuid TicketGuid;
WorldPackets::LFGList::ListingDescriptor Descriptor;
// SMSG_LFG_LIST_SEARCH_RESULTS(+_UPDATE) row revision. The client applies an update record only when its revision
// is not older than the row it holds (0x7FF7CF2AF730), so it has to grow with every change. Every fresh 12.1
// retail row carries 3.
uint32 Revision = 3;
// Who last changed the listing, and its name / comment / voice-chat text (row guids +1840..+1888).
ObjectGuid LastEditorGuid;
ObjectGuid NameEditorGuid;
ObjectGuid CommentEditorGuid;
ObjectGuid VoiceChatEditorGuid;
uint32 CreatedTime = 0;
uint32 ExpireTime = 0;
std::vector<Application> Applications;
// Every client that has been handed this listing with Listed = true, and therefore holds an active
// entry for it. The delist has to reach ALL of them, not just the leader: the consumer of
// SMSG_LFG_LIST_UPDATE_STATUS (RVA 0x24DE410) creates the entry from any Listed payload whose ticket
// it does not know yet, and an applicant who accepts an invite is sent exactly that (status 0x19).
// Notifying only the leader left the joined member's group finder showing the entry until relog -
// the very damage DelistAndNotify exists to prevent, one flank short. Maintained by
// WorldSession::SendLFGListUpdateStatus, which is the only producer of a Listed payload.
std::unordered_set<ObjectGuid> StatusRecipients;
CensorState Censor = CensorState::None;
uint8 CensorCode = 0;
uint8 CensorFieldFlags = 0; // search row +2161: 1 = the name is flagged, 2 = the comment // non-zero is what makes the client treat the entry as flagged
uint32 GetCategoryID() const { return Descriptor.CategoryID; }
// Flagged at all - this is what the wire carries, and what decides whether
// SMSG_LFG_LIST_CENSORED_ACTIVE_ENTRY_UPDATE goes out with a code.
bool IsCensored() const { return Censor != CensorState::None && CensorCode != 0; }
// Flagged AND still undecided - this, and only this, is what withholds the listing's text.
// Once the player has confirmed the listing (CensorState::Confirmed, set by
// CMSG_LFG_LIST_CONFIRM_CENSORED_ACTIVE_ENTRY) the text goes out again. It has to: this wire can
// only ever push the client's censor state to 0 or 1, so a confirmed listing is never re-announced,
// and a client that lost its local 2 (any UI reload) would otherwise render an EMPTY title with no
// dialog and no explanation left to account for it. Withholding past the confirmation buys nothing
// and costs the owner their listing's name. See GetPublicDescriptor.
bool IsTextWithheld() const { return Censor == CensorState::Unresolved && CensorCode != 0; }
};
}
@@ -80,31 +202,183 @@ public:
uint32 CreateListing(Player* leader, WorldPackets::LFGList::ListingDescriptor const& descriptor);
bool UpdateListing(uint32 listingId, ObjectGuid leader, WorldPackets::LFGList::ListingDescriptor const& descriptor);
void RemoveListing(uint32 listingId, ObjectGuid leader);
void RemoveListingsBy(ObjectGuid leader); // logout cleanup
// Delist whatever this leader has listed - logout cleanup, and the replace step of a re-publish.
// Goes through DelistAndNotify like every other server-initiated delist; see its definition for why
// erasing the maps in silence was wrong on both paths.
void RemoveListingsBy(ObjectGuid leader);
// Delist AND tell everyone holding an active entry for the listing, in that order, from one place. The
// notification has to be built while the listing still exists: the client only accepts a delist whose
// ticket matches its stored active entry field for field (see FillListingTicket below), and a ticket
// cannot be reconstructed once the listing is gone. Every server-initiated delist goes through here; a
// hand-built "not listed" message is silently dropped by the client and leaves the entry standing in the
// recipient's group finder.
// "Everyone" is the leader plus Listing::StatusRecipients - an applicant who accepted an invite was sent
// the listing with Listed = true (status 0x19) and holds an entry of its own. Addressing the leader alone
// fixed the create/edit flank and left that one standing until relog.
// `status` selects the client's popup (consumer RVA 0x24DE410): 0x2C too many players, 0x3B timeout,
// 0x4B GameError 0x291, anything else no popup at all.
void DelistAndNotify(uint32 listingId, ObjectGuid leader, uint8 status);
LFGList::Listing* GetListing(uint32 listingId);
LFGList::Listing const* GetListing(uint32 listingId) const;
LFGList::Listing* GetListingByLeader(ObjectGuid leader);
// The listing published for THIS group, if any. There is no index for it because there cannot be more
// than a handful of listings at a time and because an index keyed on Listing::GroupGuid would have to be
// maintained across the one place that writes it after CreateListing (a solo leader whose first
// applicant forms the party, WorldSession::HandleLFGListInviteResponse). The three group hooks below are
// its only callers.
LFGList::Listing* GetListingByGroup(ObjectGuid groupGuid);
// Search the registry. Any argument left 0 acts as a wildcard. Results are capped by config.
std::vector<LFGList::Listing const*> Search(uint32 category, uint32 activityGroup, uint32 activityId, std::string const& keyword = std::string()) const;
// ---- the group's own lifecycle, as it reaches the listing (LFGGroupScript) ----
// Without these three the listing outlives the group it advertises. Logout of the leader and the expiry
// sweep were the only two events that could ever end a listing, so a disbanded group kept a phantom row
// in the browser for the rest of the 30-minute window, a promoted leader could not touch the listing of
// his own party, and a member who left kept an active entry until relog.
// The party has a new leader. The listing follows him: Listing::LeaderGuid is what UpdateListing,
// RemoveListing and DelistAndNotify all check the sender against, so leaving it on the previous leader
// locks the listing - the new leader can neither edit nor delist it while it goes on being advertised.
// He is also sent the active entry, because he needs one for C_LFGList.HasActiveEntryInfo to be true.
//
// Retail additionally WARNS him and delists on a timer, and that half does not live here: the warning
// is SMSG_PARTY_NOTIFY_LFG_LEADER_CHANGE (0x45030A, family 0x45, still STATUS_UNHANDLED and NOT part of
// this unit), which feeds LFG_GROUP_DELISTED_LEADERSHIP_CHANGE(listingName, automaticDelistTimeRemaining)
// (LFGListInfoDocumentation.lua:684-691) and through it the popup PREMADE_GROUP_LEADER_CHANGE_DELIST_WARNING
// (LFGList.lua:346-348). The delist itself is then the CLIENT's: GameDialogDefs.lua:3286-3317 shows the
// popup calling C_LFGList.RemoveListing() from its OnHide unless the new leader picks "List My Group" -
// i.e. the server must NOT delist here, it must keep the listing alive and owned so that the client's
// choice has something to act on. See dod_luecken/D2 in the status file for the handover.
void TransferListingLeadership(ObjectGuid groupGuid, ObjectGuid newLeader);
// The party is gone. So is anything it advertised.
void RemoveListingsByGroup(ObjectGuid groupGuid);
// One member joined a listed party. Nothing to decide, only something to say: the row a browser holds
// enumerates the LIVE group, so it is out of date until this pushes it.
void NotifyGroupMemberJoined(ObjectGuid groupGuid);
// One member left a listed party - the listing stays, that member's active entry must not. Also
// refreshes the row in every open browser, without the departing member.
void NotifyGroupMemberLeft(ObjectGuid groupGuid, ObjectGuid member);
// Search the registry. Every empty / zero member of the filter is a wildcard. Results are capped by
// config. The per-listing test is Matches(), which the live push uses too.
std::vector<LFGList::Listing const*> Search(LFGList::SearchFilter const& filter) const;
// Does this listing satisfy a search filter? THE one place that answers it - the search reply and the
// live SMSG_LFG_LIST_SEARCH_RESULTS_UPDATE push both call it, so a pushed row can never reach a browser
// whose filters would have excluded it from the reply. That agreement used to be a promise kept by two
// hand-maintained copies of the same conditions.
static bool Matches(LFGList::Listing const& listing, LFGList::SearchFilter const& filter);
// Does this listing satisfy the keyword part of a search? The ONE place that answers it, because the
// search reply and the live push have to agree - and because it has to agree with what
// GetPublicDescriptor actually delivers. A listing whose text is withheld is matched against an EMPTY
// name, i.e. it is never a keyword hit: matching the STORED name would have let a searcher confirm a
// word inside the withheld text from a result row that renders as CENSORED_LFG_GROUP_NAME, which is the
// one thing withholding the text is supposed to prevent. Such a listing still shows up in an unfiltered
// browse, exactly as in retail.
static bool MatchesKeywords(LFGList::Listing const& listing, LFGList::SearchKeywords const& keywords);
// The role a member advertises in a search row (0 tank, 1 healer, 2 damage - ChrSpecialization.Role): the role the
// party assigned (Group member slot, set when an applicant is invited with a granted role), else the member's
// specialization role. Used for the row's role byte and for the advanced filter, so both see the same party.
static uint8 GetMemberRole(Player const* player, Group const* group);
static LFGList::MemberComposition GetMemberComposition(LFGList::Listing const& listing, ObjectGuid excludeMember = ObjectGuid::Empty);
// The leader's overall Mythic+ rating (PlayerData.DungeonScore), what advancedFilter.minimumRating is compared to.
static float GetLeaderDungeonScore(LFGList::Listing const& listing);
// advancedFilter's difficulty bands for the listing's first activity (LFGList.lua uses activityIDs[1]). True when the
// activity is in no band the filter leaves out.
static bool MatchesDifficultyBand(LFGList::Listing const& listing, uint32 advancedFilterMask);
// Fills one search-result row for a listing. Shared by the search reply, the apply-result snapshot and the
// live update push so all three serialize a listing identically.
void FillSearchRow(WorldPackets::LFGList::SearchResultListing& row, LFGList::Listing const& listing) const;
// `excludeMember` leaves one guid out of the roster. It exists for exactly one caller and for a reason
// that is not negotiable: LFGGroupScript::OnRemoveMember fires from the FIRST statement of
// Group::RemoveMember (Group.cpp:551), long before the member slot is erased, so a row built from the
// live group at that moment still advertises the player who is leaving.
// `viewer` is the player the row is written for (the friend / guild-mate lists and hasSelf are relative to them);
// null for an update record, which carries none of those.
void FillSearchRow(WorldPackets::LFGList::SearchResultListing& row, LFGList::Listing const& listing,
Player const* viewer, ObjectGuid excludeMember = ObjectGuid::Empty) const;
// The two RideTickets of the listing system. These MUST be built in exactly one place: the client keys
// its stored active entry on the whole 32-byte ticket and compares it field by field before it accepts a
// delist (SMSG_LFG_LIST_UPDATE_STATUS consumer @ RVA 0x24DE410, six comparisons against LFG-list manager
// +0..+24). A ticket assembled differently on one path than on another is silently ignored by the client.
static void FillListingTicket(WorldPackets::LFG::RideTicket& ticket, LFGList::Listing const& listing);
static void FillApplicationTicket(WorldPackets::LFG::RideTicket& ticket, LFGList::Application const& app);
// The absolute deadline of an application, as BOTH messages of the application strand carry it:
// SMSG_LFG_LIST_APPLY_TO_GROUP_RESULT.ApplicationExpiration and
// SMSG_LFG_LIST_APPLICATION_STATUS_UPDATE.ApplicationExpiration. The two are not two fields that happen
// to look alike - they land in the SAME client slot. Both messages are decoded into the state setter
// @ RVA 0x24DD190 as (record, state, time, code, byte), and the consumer subtracts the client's time
// base qword_7FF7877C9640 before storing it, i.e. the wire value is an absolute server timestamp.
// Shared because it MUST be: the apply reply used to compute it inline while every status update sent a
// hard 0, so the reply set the applicant's deadline and the status update one line later overwrote it
// with 0 - timebase. Same defect class, same fix, as the drifted ticket builders above.
static uint64 GetApplicationExpiration(LFGList::Application const& app);
// The wire state bits of an application, and one whole applicant record of SMSG_LFG_LIST_APPLICANT_LIST_UPDATE.
// Shared for the same reason as the tickets: the message has two producers (the handler's push and the
// application-timeout sweep) and every field one of them forgets is a field the leader's applicant list loses.
static uint8 ApplicationStateToBits(LFGList::ApplicationState state);
static void FillApplicantInfo(WorldPackets::LFGList::ApplicantInfo& info, LFGList::Application const& app);
// Push the full applicant list of a listing to every connected member of the listed group (sniff: the
// packet goes to all members, not only the leader; solo listings notify just the leader). THE one
// producer of SMSG_LFG_LIST_APPLICANT_LIST_UPDATE - it lived in the handler's anonymous namespace while
// the timeout sweep in Update() had a hand-built copy that addressed the LEADER ALONE, so after every
// application timeout the non-leader members of a listed group kept the expired applicant in their list
// until relog. Same defect class as the four drifted copies of the delist message, and the same fix.
static void SendApplicantList(LFGList::Listing const& listing);
// SMSG_LFG_LIST_APPLICATION_STATUS_UPDATE with the wire state given DIRECTLY instead of derived from
// Application::State. It exists because an application can end in states the server-side enum has no
// member for - "declined because the party filled up", "declined because the listing is gone" - which
// the CLIENT does have (nibbles 6 and 7 of the state->string mapper @ RVA 0x24DADA0). Without it those
// endings were bare returns and the applicant was told nothing at all.
//
// failureReason has NO default on purpose. It is the message's reason field, and the client reads it on
// exactly one state: the setter @ RVA 0x24DD190 forwards it to the error presenter @ RVA 0x24E25E0 only
// when the state resolves to 3 = failed. There it must be one of the presenter table's values or the
// lookup falls off the end and the applicant sees nothing - the state changes, no text appears. On every
// other state pass ApplicationFailureReason::NotAFailure, which is the value the capture carries and
// says in its name that no reason is being claimed. A default would let a Failed caller forget it and
// reinstate exactly the silence this parameter exists to end.
static void SendApplicationStatusBits(LFGList::Listing const& listing, LFGList::Application const& app,
uint8 stateBits, uint8 failureReason);
// The descriptor as it must go out to a client. Identical to the stored one, except that a listing
// whose text was flagged and not yet resolved goes out WITHOUT its name and comment: retail withholds
// them (that is why the edit dialog has to ask the server via C_LFGList.DoesCensoredTextMatch instead
// of comparing locally), and the client renders CENSORED_LFG_GROUP_NAME in their place.
WorldPackets::LFGList::ListingDescriptor GetPublicDescriptor(LFGList::Listing const& listing) const;
// Runs the listing's text through the server-side check and updates its censor state. Returns true if
// the state changed, i.e. if SMSG_LFG_LIST_CENSORED_ACTIVE_ENTRY_UPDATE has to go out.
bool EvaluateCensorship(LFGList::Listing& listing);
// CMSG_LFG_LIST_CONFIRM_CENSORED_ACTIVE_ENTRY: the player keeps the flagged listing as it is.
// Returns true when the state actually moved to Confirmed, and a true return OBLIGES the caller to push
// the descriptor: this is the third mutation of the publicly visible descriptor (IsTextWithheld goes
// false, so GetPublicDescriptor stops clearing Name/Comment and MatchesKeywords stops rejecting), and
// unlike CreateListing and UpdateListing it does not notify by itself - the handler owns the two sends
// because only it has the owner's session. See WorldSession::HandleLFGListConfirmCensoredActiveEntry.
bool ConfirmCensoredListing(uint32 listingId, ObjectGuid leader);
// Live search updates. While a player has the Premade Groups browser open, retail keeps pushing
// SMSG_LFG_LIST_SEARCH_RESULTS_UPDATE as listings appear/change. A search registers the player's filters
// here; listing mutations then push the affected row to every subscriber whose filters still match.
void RegisterSearch(ObjectGuid player, uint32 category, uint32 activityGroup, std::string const& keyword);
void RegisterSearch(ObjectGuid player, LFGList::SearchFilter filter);
void UnregisterSearch(ObjectGuid player);
void NotifyListingChanged(uint32 listingId);
// `changes` (WorldPackets::LFGList::SearchResultChange) names the listing fields the update record carries.
void NotifyListingChanged(uint32 listingId, ObjectGuid excludeMember = ObjectGuid::Empty, uint32 changes = 0);
// Applications. An application gets a globally-unique id the client keys on via a RideTicket.
LFGList::Application* AddApplication(uint32 listingId, ObjectGuid applicant, uint8 roleMask, uint32 specId, uint32 itemLevel, std::string const& comment);
LFGList::Listing* GetListingByApplication(uint32 applicationId);
LFGList::Application* GetApplication(uint32 applicationId);
bool SetApplicationState(uint32 applicationId, LFGList::ApplicationState state);
// Records the role the leader granted in CMSG_LFG_LIST_INVITE_APPLICANT.Invitees[].
bool SetApplicationGrantedRole(uint32 applicationId, uint8 roleMask);
void RemoveApplication(uint32 applicationId);
// Drops every application this player has outstanding (logout cleanup).
void RemoveApplicationsBy(ObjectGuid applicant);
@@ -114,17 +388,52 @@ public:
private:
LFGListMgr() = default;
// Push ONE row of one listing to every open browser whose filters still match it, and reap the
// subscriptions that have lapsed while doing so. The one place that serializes
// SMSG_LFG_LIST_SEARCH_RESULTS_UPDATE, which is what keeps its two occasions in step: a listing that
// changed (delisted = false) and a listing that is about to cease to exist (delisted = true, the sticky
// isDelisted bit - see LFGListSearchResultsUpdate in LFGListPackets.h). The second occasion is why this
// takes the listing by reference rather than an id: it is called from inside DelistAndNotify, where the
// row has to be built while the listing is still there.
// Every push is a change of the listing, so it advances the listing's revision.
void PushSearchRow(LFGList::Listing& listing, bool delisted, ObjectGuid excludeMember, uint32 changes);
// The delist form of SMSG_LFG_LIST_UPDATE_STATUS - Listed = 0, no expiry, no descriptor - built from the
// LIVE listing. THE one builder, for the same reason the two ticket builders are: the client compares
// the incoming ticket against its stored active entry field by field and drops a mismatch in silence
// (consumer RVA 0x24DE410), and four hand-built copies of this message have already drifted apart once.
// Its two callers differ only in who they send it to - DelistAndNotify to everyone holding an entry,
// NotifyGroupMemberLeft to the single player who left.
static void BuildDelistPacket(WorldPackets::LFGList::LFGListUpdateStatus& packet,
LFGList::Listing const& listing, uint8 status);
// An open Premade Groups browser: the filters the player last searched with. 0 / empty = wildcard, matching
// Search(). Refreshed by every search; expires so a client that closed the browser (there is no "stopped
// searching" opcode) stops receiving pushes.
struct SearchSubscription
{
uint32 CategoryId = 0;
uint32 ActivityGroupId = 0;
std::string Keyword;
LFGList::SearchFilter Filter;
uint32 ExpireTime = 0;
};
// Parsed LFGList.CensorWords, lower-cased. Re-parsed whenever the config string changes, which is what
// makes `reload config` take effect without a hook of its own.
std::vector<std::string> _censorWords;
std::string _censorWordsConfig;
bool _censorWordsLoaded = false;
std::vector<std::string> const& GetCensorWords();
// Does this listing text hit the configured word list? The list is LFGList.CensorWords and nothing
// else. It used to be ObjectMgr::IsReservedName, i.e. the `reserved_name` table, and that was wrong in
// both directions: the table ships EMPTY (sql/base/characters_database.sql has not one INSERT for it),
// so on any normal realm the check could never fire and the whole censor pair was dead wire; and where
// an admin does fill it, he fills it with CHARACTER names he wants kept off the realm - class names,
// "gm", "admin" - which would then flag ordinary listing titles, withhold their name and comment from
// every searcher and drop them out of keyword search entirely (MatchesKeywords returns false for a
// withheld listing). Two unrelated policies on one table. Empty list = censorship off, which is the
// shipped default and is an honest off, not an accident.
bool ContainsCensoredWord(std::string const& text);
uint32 _nextListingId = 1;
uint32 _nextApplicationId = 1;
uint32 _expireTimer = 0;
+439 -254
View File
@@ -20,94 +20,153 @@
namespace WorldPackets::LFGList
{
// The published-listing parameters. RESOLVED from the 12.0.7.68275 premade-groups sniff + the client JOIN
// serializer (sub_7FF72914ABE0) + the generated Lua API doc (LfgListingCreateData). The descriptor is BIT-PACKED:
// a bit-packed header (5-bit trailing-vector count; three bit-packed string lengths of 10/11/8 bits; four boolean
// flags; and presence bits for the nilable numeric fields), then FlushBits, then the member-requirement block, the
// fixed activity fields, the trailing uint32 vector, the three strings, and the present optional fields. Only
// CategoryID + the activity vector + item-level drive server filtering; the rest are pass-through echo. Reads are guarded against
// over-run (the descriptor is variable-length and pass-through, so a malformed tail is tolerated, never fatal).
// Full layout + bit-widths: c:\dumps\LFG_LIST_WIRE_68275.md.
namespace
{
// Client buffer capacities of the three descriptor strings; they decide the bit width of each length
// prefix (N = ceil(log2(capacity))) and, because the client appends its own NUL at buf[len], the
// largest string that still fits.
constexpr std::size_t DESCRIPTOR_NAME_CAPACITY = 513; // bits<10>
constexpr std::size_t DESCRIPTOR_COMMENT_CAPACITY = 1025; // bits<11>
constexpr std::size_t DESCRIPTOR_VOICECHAT_CAPACITY = 129; // bits<8>
// The trailing uint32 vector's count is a 5-bit field, so it can never legitimately exceed 31.
constexpr uint32 DESCRIPTOR_MAX_ACTIVITY_IDS = 31;
}
// The embedded DungeonScoreSummary. MythicPlusPacketsCommon already provides operator<< for both this and
// its element type and both are byte-identical to the client's reader 0x6EC830 / writer 0x6EC980
// ({float, float, u32 count} then count x {i32, float, i32, i32, u8, one bit + flush}); only the read direction
// is missing there, so it lives here.
static ByteBuffer& operator>>(ByteBuffer& data, MythicPlus::DungeonScoreMapSummary& run)
{
data >> run.ChallengeModeID;
data >> run.MapScore;
data >> run.BestRunLevel;
data >> run.BestRunDurationMS;
data >> run.Unknown1110;
data >> Bits<1>(run.FinishedSuccess);
data.ResetBitPos();
return data;
}
static ByteBuffer& operator>>(ByteBuffer& data, MythicPlus::DungeonScoreSummary& summary)
{
data >> summary.OverallScoreCurrentSeason;
data >> summary.LadderScoreCurrentSeason;
uint32 runCount = 0;
data >> runCount;
// 18 wire bytes per run; refuse a count the packet cannot possibly contain rather than trusting it.
if (runCount <= (data.size() - data.rpos()) / 18)
{
summary.Runs.resize(runCount);
for (MythicPlus::DungeonScoreMapSummary& run : summary.Runs)
data >> run;
}
return data;
}
// ---------------------------------------------------------------------------------------------------
// ListingDescriptor. See the header for the full layout and for what is measured vs. inferred.
// Reads are size-guarded throughout: the descriptor is variable-length and largely pass-through, so a
// malformed tail must be tolerated, never fatal.
static ByteBuffer& operator>>(ByteBuffer& data, ListingDescriptor& d)
{
auto remaining = [&]() -> std::size_t { return data.size() - data.rpos(); };
// --- bit-packed header (client bit-writer, MSB-first) ---
uint32 vectorCount = data.ReadBits(5); // sub_7FF729064C20: count of the trailing uint32 vector
uint32 str0Len = data.ReadBits(10); // string @0x40 length
uint32 str1Len = data.ReadBits(11); // string @0x241 length
uint32 str2Len = data.ReadBits(8); // string @0x642 length ("crate" in the sniff)
d.IsAutoAccept = data.ReadBits(1) != 0; // presence/flag bits (client offsets 0x6c3..0x703)
d.IsCrossFactionListing = data.ReadBits(1) != 0;
d.IsPrivateGroup = data.ReadBits(1) != 0;
d.NewPlayerFriendly = data.ReadBits(1) != 0;
bool hasQuestId = data.ReadBits(1) != 0; // 0x6cc -> uint32 @0x6c8
bool hasOpt1 = data.ReadBits(1) != 0; // 0x6f4 -> uint32 @0x6f0
bool hasOpt2 = data.ReadBits(1) != 0; // 0x6fc -> uint32 @0x6f8
bool hasOpt3 = data.ReadBits(1) != 0; // 0x701 -> uint8 @0x700
data.ReadBits(1); // 0x703 standalone flag (unused server-side)
data.ResetBitPos(); // FlushBits (sub_7FF729064E60)
// --- bit header, MSB-first, 43 bits (client reader 0x7572C0 / writer 0x757660) ---
uint32 const activityCount = data.ReadBits(5);
uint32 const nameLength = data.ReadBits(10);
uint32 const commentLength = data.ReadBits(11);
uint32 const voiceChatLength = data.ReadBits(8);
d.IsAutoAccept = data.ReadBits(1) != 0; // +1699
d.IsPrivateGroup = data.ReadBits(1) != 0; // +1700
d.IsWarMode = data.ReadBits(1) != 0; // +1701
d.IsCrossFactionListing = data.ReadBits(1) != 0; // +1702
bool const hasQuestId = data.ReadBits(1) != 0;
bool const hasRequiredDungeonScore = data.ReadBits(1) != 0;
bool const hasRequiredPvpRating = data.ReadBits(1) != 0;
bool const hasPlaystyle = data.ReadBits(1) != 0;
d.NewPlayerFriendly = data.ReadBits(1) != 0; // +1763
data.ResetBitPos(); // 5 spare bits; the client never reads them
// --- member-requirement block (nested sub_7FF729167840) ---
data >> d.HeaderFloat0 >> d.HeaderFloat1;
uint32 memberCount = 0;
data >> memberCount;
if (memberCount <= remaining() / 0x11) // 0x11 = min bytes per entry; guard against a bad count
// --- byte-aligned body ---
data >> d.CategoryID;
data >> d.RequiredItemLevel;
// The client zeroes this block on every CreateListing / UpdateListing, so an inbound descriptor always
// carries an empty summary. Read it anyway - it is 12 bytes of wire either way.
data >> d.LeaderScore;
data >> d.GeneralPlaystyle;
// --- deferred: the uint32 vector, then the three strings ---
if (activityCount <= DESCRIPTOR_MAX_ACTIVITY_IDS && activityCount <= remaining() / 4)
{
d.MemberRequirements.resize(memberCount);
for (ListingMemberRequirement& m : d.MemberRequirements)
{
data >> m.Field0 >> m.Field1 >> m.Field2 >> m.Field3 >> m.Field4;
m.Flag = data.ReadBits(1) != 0;
data.ResetBitPos();
}
d.ActivityIDs.resize(activityCount);
for (uint32& activityId : d.ActivityIDs)
data >> activityId;
}
// --- fixed activity fields ---
data >> d.CategoryID; // uint32 @0x38: GroupFinderCategory id (68974: 1; the activities ride in the vector below)
data >> d.RequiredDungeonScore; // float @0x3c
data >> d.TrailingByte; // uint8 @0x702
if (nameLength <= remaining() && nameLength < DESCRIPTOR_NAME_CAPACITY)
d.Name.assign(data.ReadString(nameLength));
if (commentLength <= remaining() && commentLength < DESCRIPTOR_COMMENT_CAPACITY)
d.Comment.assign(data.ReadString(commentLength));
if (voiceChatLength <= remaining() && voiceChatLength < DESCRIPTOR_VOICECHAT_CAPACITY)
d.VoiceChat.assign(data.ReadString(voiceChatLength));
// --- trailing uint32 vector ---
if (vectorCount <= remaining() / 4)
{
d.ActivityIDs.resize(vectorCount);
for (uint32& v : d.ActivityIDs)
data >> v;
}
// --- string data (order matches the serializer: @0x40, @0x241, @0x642) ---
if (str0Len <= remaining()) d.Name.assign(data.ReadString(str0Len));
if (str1Len <= remaining()) d.VoiceChat.assign(data.ReadString(str1Len));
if (str2Len <= remaining()) d.Comment.assign(data.ReadString(str2Len));
// --- present optional (nilable) numeric fields ---
// --- deferred: the present optionals ---
if (hasQuestId && remaining() >= 4) { uint32 v; data >> v; d.QuestID = v; }
if (hasOpt1 && remaining() >= 4) { uint32 v; data >> v; d.OptionalValue1 = v; }
if (hasOpt2 && remaining() >= 4) { uint32 v; data >> v; d.OptionalValue2 = v; }
if (hasOpt3 && remaining() >= 1) { uint8 v; data >> v; d.OptionalValue3 = v; }
if (hasRequiredDungeonScore && remaining() >= 4) { uint32 v; data >> v; d.RequiredDungeonScore = v; }
if (hasRequiredPvpRating && remaining() >= 4) { uint32 v; data >> v; d.RequiredPvpRating = v; }
if (hasPlaystyle && remaining() >= 1) { uint8 v; data >> v; d.Playstyle = v; }
return data;
}
ByteBuffer& operator<<(ByteBuffer& data, ListingInfo const& listing)
static ByteBuffer& operator<<(ByteBuffer& data, ListingDescriptor const& d)
{
for (uint8 param : listing.Params)
data << param;
data << uint32(listing.ActivityID);
data << uint32(listing.Field1);
data << uint8(listing.Field2);
data << uint32(listing.RequiredItemLevel);
data << SizedString::BitsSize<10>(listing.Comment);
data << SizedString::BitsSize<7>(listing.LeaderName);
data << SizedString::BitsSize<7>(listing.VoiceChat);
// The client copies each string into a fixed-size buffer and writes its own NUL at buf[len], so
// capacity-1 is the largest length it can survive. ReadBytes on that side bounds-checks against the
// remaining packet only, never against the target buffer - an over-long string is a client-side
// memory overrun, so clamp here.
std::size_t const nameLength = std::min<std::size_t>(d.Name.length(), DESCRIPTOR_NAME_CAPACITY - 1);
std::size_t const commentLength = std::min<std::size_t>(d.Comment.length(), DESCRIPTOR_COMMENT_CAPACITY - 1);
std::size_t const voiceChatLength = std::min<std::size_t>(d.VoiceChat.length(), DESCRIPTOR_VOICECHAT_CAPACITY - 1);
std::size_t const activityCount = std::min<std::size_t>(d.ActivityIDs.size(), DESCRIPTOR_MAX_ACTIVITY_IDS);
data.WriteBits(activityCount, 5);
data.WriteBits(nameLength, 10);
data.WriteBits(commentLength, 11);
data.WriteBits(voiceChatLength, 8);
data << Bits<1>(d.IsAutoAccept); // +1699
data << Bits<1>(d.IsPrivateGroup); // +1700
data << Bits<1>(d.IsWarMode); // +1701
data << Bits<1>(d.IsCrossFactionListing); // +1702
data << OptionalInit(d.QuestID);
data << OptionalInit(d.RequiredDungeonScore);
data << OptionalInit(d.RequiredPvpRating);
data << OptionalInit(d.Playstyle);
data << Bits<1>(d.NewPlayerFriendly); // +1763
data.FlushBits();
data << SizedString::Data(listing.Comment);
data << SizedString::Data(listing.LeaderName);
data << SizedString::Data(listing.VoiceChat);
data << uint32(listing.Field3);
data << uint32(listing.Field4);
data << uint32(listing.Field5);
data << uint8(listing.Field6);
data << uint32(d.CategoryID);
data << uint32(d.RequiredItemLevel);
data << d.LeaderScore;
data << uint8(d.GeneralPlaystyle);
for (std::size_t i = 0; i < activityCount; ++i)
data << uint32(d.ActivityIDs[i]);
data.WriteString(d.Name.c_str(), nameLength);
data.WriteString(d.Comment.c_str(), commentLength);
data.WriteString(d.VoiceChat.c_str(), voiceChatLength);
if (d.QuestID)
data << uint32(*d.QuestID);
if (d.RequiredDungeonScore)
data << uint32(*d.RequiredDungeonScore);
if (d.RequiredPvpRating)
data << uint32(*d.RequiredPvpRating);
if (d.Playstyle)
data << uint8(*d.Playstyle);
return data;
}
@@ -115,17 +174,13 @@ namespace WorldPackets::LFGList
void LFGListJoin::Read()
{
std::size_t const start = _worldPacket.rpos();
_worldPacket >> Listing;
Listing.RawBytes.assign(_worldPacket.data() + start, _worldPacket.data() + _worldPacket.rpos());
}
void LFGListUpdateRequest::Read()
{
_worldPacket >> Ticket;
std::size_t const start = _worldPacket.rpos();
_worldPacket >> Listing;
Listing.RawBytes.assign(_worldPacket.data() + start, _worldPacket.data() + _worldPacket.rpos());
}
void LFGListLeave::Read()
@@ -133,35 +188,88 @@ namespace WorldPackets::LFGList
_worldPacket >> Ticket;
}
void LFGListSearch::Read()
std::vector<std::vector<std::string>> LFGListSearch::GetKeywords() const
{
// Sniff-exact (43B no keyword / 56B with one): see header comment. All reads size-guarded.
uint32 const termCount = _worldPacket.ReadBits(5);
_worldPacket.ReadBit(); // presence/flag bit (semantics approximate)
_worldPacket.ResetBitPos();
if (termCount)
std::vector<std::vector<std::string>> keywords;
keywords.reserve(Terms.size());
for (LFGListSearchTerm const& term : Terms)
{
std::array<uint32, 10> lengths = { };
for (uint32& len : lengths)
len = _worldPacket.ReadBits(5); // ten bits(5) lengths packed into the 8-byte block
_worldPacket.ReadBits(64 - 10 * 5); // padding to the full 8 bytes
_worldPacket.ResetBitPos();
std::vector<std::string> alternatives;
for (std::string const& value : term.Values)
if (!value.empty())
alternatives.push_back(value);
SearchTerms.resize(std::min<uint32>(termCount, 10));
for (std::size_t i = 0; i < SearchTerms.size(); ++i)
if (lengths[i] && _worldPacket.rpos() + lengths[i] <= _worldPacket.size())
SearchTerms[i] = _worldPacket.ReadString(lengths[i]);
// A block with nothing in it constrains nothing. Keeping it would turn every search that carries a
// trailing empty block into a search no listing can satisfy.
if (!alternatives.empty())
keywords.push_back(std::move(alternatives));
}
return keywords;
}
for (uint32& f : Filters)
_worldPacket >> f;
_worldPacket >> FilterByte1; // observed 0xFF
_worldPacket >> FilterByte2; // observed 0x05
void LFGListSearch::Read()
{
// Client writer 0x757EC0; term blocks 0x757D00. See the header for the three defects this replaces.
uint32 const termCount = _worldPacket.ReadBits(5);
_worldPacket >> Bits<1>(CrossFaction);
_worldPacket.ResetBitPos();
_worldPacket >> CategoryID;
_worldPacket >> Filter;
_worldPacket >> PreferredFilters;
_worldPacket >> LanguageMask;
uint32 resolvedActivityCount = 0;
_worldPacket >> resolvedActivityCount;
_worldPacket >> AdvancedFilterMask;
uint32 activityGroupCount = 0;
_worldPacket >> activityGroupCount;
uint32 activityCount = 0;
_worldPacket >> activityCount;
_worldPacket >> MinimumRating;
_worldPacket >> FilterByte1;
_worldPacket >> FilterByte2;
uint32 guidCount = 0;
_worldPacket >> guidCount;
if (guidCount <= 50)
// A term block is at least 8 bytes (the interleaved 60-bit header) - refuse counts the packet cannot
// hold before allocating.
auto remaining = [&]() -> std::size_t { return _worldPacket.size() - _worldPacket.rpos(); };
if (termCount && termCount <= remaining() / 8)
{
Terms.resize(termCount);
for (LFGListSearchTerm& term : Terms)
{
std::array<uint32, LFGListSearchTerm::MAX_VALUES> lengths = { };
for (std::size_t i = 0; i < LFGListSearchTerm::MAX_VALUES; ++i)
{
lengths[i] = _worldPacket.ReadBits(5); // client buffer 32 -> ceil(log2(32)) = 5
term.Flags[i] = _worldPacket.ReadBit(); // one presence bit per slot, interleaved
}
_worldPacket.ResetBitPos(); // 60 bits used, 4 padding -> 8 bytes
for (std::size_t i = 0; i < LFGListSearchTerm::MAX_VALUES; ++i)
if (lengths[i] && lengths[i] < LFGListSearchTerm::MAX_VALUE_LENGTH && lengths[i] <= remaining())
term.Values[i] = _worldPacket.ReadString(lengths[i]);
}
}
auto readValues = [&](std::vector<uint32>& out, uint32 count)
{
if (!count || count > remaining() / 4)
return;
out.resize(count);
for (uint32& value : out)
_worldPacket >> value;
};
// Order on the wire is fixed by the client writer and is NOT the order the counts appear in: the
// count of the first list sits at struct +48, i.e. between LanguageMask and AdvancedFilterMask,
// while the list itself follows the term blocks. See the header for what each one carries.
readValues(ResolvedActivityIDs, resolvedActivityCount);
readValues(ActivityGroupIDs, activityGroupCount);
readValues(ActivityIDs, activityCount);
if (guidCount && guidCount <= remaining() / 2) // a PackedGuid is at least its 2-byte mask
{
Guids.resize(guidCount);
for (ObjectGuid& guid : Guids)
@@ -174,7 +282,9 @@ namespace WorldPackets::LFGList
_worldPacket >> Ticket;
_worldPacket >> ActivityID;
_worldPacket >> RoleMask;
_worldPacket >> Field2;
_worldPacket >> SizedString::BitsSize<8>(Comment);
_worldPacket.ResetBitPos();
_worldPacket >> SizedString::Data(Comment);
}
void LFGListCancelApplication::Read()
@@ -191,10 +301,21 @@ namespace WorldPackets::LFGList
void LFGListInviteApplicant::Read()
{
_worldPacket >> Ticket;
_worldPacket >> ListingId;
_worldPacket >> ApplicantGuid;
_worldPacket >> RoleMask;
_worldPacket >> ApplicantTicket;
uint32 inviteeCount = 0;
_worldPacket >> inviteeCount;
// Each invitee is at least 3 bytes (2-byte guid mask + role); a group cannot exceed MAX_RAID_SIZE
// anyway, so a large count is malformed either way.
if (inviteeCount && inviteeCount <= (_worldPacket.size() - _worldPacket.rpos()) / 3)
{
Invitees.resize(inviteeCount);
for (LFGListInvitee& invitee : Invitees)
{
_worldPacket >> invitee.Guid;
_worldPacket >> invitee.RoleMask;
}
}
}
void LFGListInviteResponse::Read()
@@ -204,262 +325,326 @@ namespace WorldPackets::LFGList
_worldPacket.ResetBitPos();
}
void LFGListConfirmCensoredActiveEntry::Read()
{
_worldPacket >> Ticket;
}
// ---- SMSG Write ----
WorldPacket const* LFGListJoinResult::Write()
{
_worldPacket << Ticket;
_worldPacket << uint32(Status);
_worldPacket << uint8(Result);
_worldPacket << uint8(ResultDetail);
return &_worldPacket;
}
WorldPacket const* LFGListUpdateStatus::Write()
{
// Byte order re-derived from the 12.0.7.69587 create sniff (feed 0x5A000A, idx 44791/44792/44912).
// The previous writer (Ticket, ExpirationTime, Status, Descriptor, one Listed bit) is WRONG for this
// build: the descriptor comes straight after the ticket, and the tail is a per-status flag byte, the
// owner's PackedGuid and an optional trailing 0x00. Byte-exact for the statuses we emit:
// 0x06 listed (create) : ticket 27B | desc 39B | u64 expiry | 06 | C0 | owner guid (85B)
// 0x38 listed (steady) : ticket 27B | desc 39B | u64 expiry | 38 | E0 | owner guid | 00 (86B)
// 0x08 delisted : ticket 27B | desc 27x00 | u64 0 | 08 | 60 | owner guid | 00 (74B)
static constexpr std::size_t EMPTY_DESCRIPTOR_SIZE = 27;
_worldPacket << Ticket;
if (Listed && !RawDescriptor.empty())
_worldPacket.append(RawDescriptor.data(), RawDescriptor.size());
else
_worldPacket.append(std::vector<uint8>(EMPTY_DESCRIPTOR_SIZE, 0).data(), EMPTY_DESCRIPTOR_SIZE);
_worldPacket << Listing; // 12.1: directly behind the ticket, not at the end
_worldPacket << uint64(Listed ? ExpirationTime : 0);
_worldPacket << uint8(Status);
_worldPacket << Bits<1>(Listed);
_worldPacket << OptionalInit(LeaderGuid);
_worldPacket << OptionalInit(UnkByte);
_worldPacket.FlushBits();
if (LeaderGuid)
_worldPacket << *LeaderGuid;
if (UnkByte)
_worldPacket << uint8(*UnkByte);
// Tail flag byte + trailing 0x00 are status-dependent (sniff MSB-first bit-flush remnants, reproduced
// verbatim: 0x06 -> 0xC0 no trailing, 0x38 -> 0xE0 + 0x00, 0x08 -> 0x60 + 0x00).
uint8 tailFlags = 0xE0;
bool tail00 = true;
switch (Status)
{
case 0x06: tailFlags = 0xC0; tail00 = false; break;
case 0x08: tailFlags = 0x60; break;
default: tailFlags = 0xE0; break; // 0x38 and unknown statuses
}
_worldPacket << uint8(tailFlags);
_worldPacket << PlayerGuid; // PackedGuid, the listing owner
if (tail00)
_worldPacket << uint8(0);
return &_worldPacket;
}
WorldPacket const* LFGListUpdateExpiration::Write()
{
_worldPacket << Ticket;
_worldPacket << uint64(ExpirationTime);
_worldPacket << uint8(Reason);
return &_worldPacket;
}
WorldPacket const* LFGListCensoredActiveEntryUpdate::Write()
{
// 69587 create sniff (idx 44793): 41B = the 39-byte listing descriptor echoed verbatim + 0x80 + 0x00.
// The 0x80 byte mirrors the "listed" marker; semantics otherwise unknown, bytes cited verbatim.
if (!RawDescriptor.empty())
_worldPacket.append(RawDescriptor.data(), RawDescriptor.size());
_worldPacket << uint8(0x80);
_worldPacket << uint8(0x00);
return &_worldPacket;
}
WorldPacket const* LFGListSearchStatus::Write()
{
_worldPacket << Ticket;
_worldPacket << uint8(Status);
_worldPacket << Bits<1>(Complete);
_worldPacket.FlushBits();
return &_worldPacket;
}
// One MemberDetail record (head sub_7FF7291DBF80 + tail sub_7FF729162CC0), shared between the full
// search-result row and the compact SEARCH_RESULTS_UPDATE row (68974: byte-identical in both).
// Head decoded from the 68974 capture: guid, level, class, role (0 tank/1 healer/2 dps), spec; the head
// flag bit was set on both retail members (each was the listing's leader).
// One member record, reader 0x7FF7CD528140 (see SearchResultMember).
static void WriteSearchResultMember(ByteBuffer& data, SearchResultMember const& member)
{
data << member.Guid; // PackedGuid MemberGuid
data << member.Guid;
data << uint8(member.Level);
data << uint8(member.ClassID);
data << uint8(member.Role);
data << uint32(member.SpecID);
data << uint8(0); // Unk24
data << uint8(member.IsLeader ? 0x80 : 0x00); // head flag (bit-as-byte; 68974: 1 for the leader)
// tail (sub_7FF729162CC0)
data << member.Guid; // PackedGuid MemberGuid2
data << uint32(0); // T20
data << uint32(0); // T24 (68974 live values 17; semantics unknown, zero-filled)
data << uint32(0); // T28
data << uint32(0); // T32 (68974 live values 18)
data << uint32(0); // T36 (68974 live values 18)
data << uint64(0); // T40
data << uint64(0); // T48
data << uint32(0); // T56
data << uint8(0); // T_flag (bit-as-byte)
}
data << uint8(member.LfgRoles);
// Emit one SMSG_LFG_LIST_SEARCH_RESULTS row per c:\dumps\lfg_search_results_layout.md, re-verified byte-exact
// against the 12.0.7.68974 capture (both bodies consume to exact end with the same layout ? no structural
// drift 68275 -> 68974). Row-level "bit" fields are full wire bytes with the boolean in bit 7 (client reads
// x >> 7); PackedGuid uses the standard TrinityCore ObjectGuid operator<< (u16 mask + data bytes). Unknown
// scalars are zero-filled (the client parses them fine); observed retail constants are mirrored
// (Unk_b=4, Unk1816/Unk2160=3 at 68974 ? they were 5 at 68275).
// The row body from the age counter onward ? shared verbatim between SEARCH_RESULTS rows and the row
// snapshot embedded in SMSG_LFG_LIST_APPLY_TO_GROUP_RESULT (sniff: identical bytes in both containers).
static void WriteSearchResultRowBody(ByteBuffer& data, SearchResultListing const& row)
{
data << uint32(row.Age); // Unk40 (age counter; 68974 rows: 3)
data << uint8(3); // Unk1816 (68974: 3; was 5 at 68275)
data << row.LeaderGuid; // Guid_A
data << row.LeaderGuid; // Guid_B
data << row.LeaderGuid; // Guid_C
data << row.LeaderGuid; // Guid_D
data << row.LeaderGuid; // Guid_E
data << uint32(0); // Unk1904
data << uint32(0); // Unk1908
data << uint32(0); // Unk1912
data << uint32(0); // Count1 (GuidList1 length)
data << uint32(0); // Count2 (GuidList2 length)
data << uint32(0); // Count3 (GuidList3 length)
data << uint32(uint32(row.Members.size())); // MemberCount
data << uint32(0); // Unk2016
data << uint64(row.PostTime); // PostTime2 (== PostTime)
data << uint8(0); // Unk2032
data << row.GroupGuid; // LeaderGuidEcho (== GroupGuid)
for (uint32 i = 0; i < 9; ++i) // fixed 9-entry {u32,u8} table (sub_7FF729195220)
{
// 68974 capture: every entry is {u32 0, u8 index} ? the previous writer emitted {u32 index, u8 0},
// which put the index into the wrong client field (same byte count, wrong values).
// tail block (RVA 0x6E7EA0): leaver bookkeeping keyed by the Battle.net account
data << member.BnetAccountGuid;
data << uint32(0);
data << uint8(i);
}
data << uint8(3); // Unk2160 (68974: 3; was 5 at 68275)
data << uint8(0); // Unk2161
data << uint32(0);
data << uint32(0);
data << uint32(0);
data << uint32(0);
data << uint64(0);
data << uint64(0);
data << uint32(0);
data << Bits<1>(member.IsLeaver);
data.FlushBits();
// === three PackedGuid lists (all empty, Count1/2/3 == 0) -> emit nothing ===
// === embedded ListingDescriptor (echo verbatim) ===
if (!row.RawDescriptor.empty())
data.append(row.RawDescriptor.data(), row.RawDescriptor.size());
else
data.append(std::vector<uint8>(27, 0).data(), 27); // minimal all-zero descriptor fallback
// === trailing bit ===
data << uint8(0); // Unk1916 (bit-as-byte, observed 0)
// === block sub_7FF7291676F0 @2056 (empty) ===
data << uint32(0); // Blk_f0
data << uint32(0); // Blk_f1
data << uint32(0); // Blk_count == 0
// === member detail list x MemberCount (sub_7FF7291DBF80 + tail sub_7FF729162CC0) ===
for (SearchResultMember const& member : row.Members)
WriteSearchResultMember(data, member);
data << Bits<1>(member.IsLeader);
data.FlushBits();
}
// Emit one full row: header block (sub_7FF7291CCDB0) + body.
// The descriptor as a search row carries it: the leader's score rides in the row (+2056), the embedded copy is empty in
// every retail row.
static ListingDescriptor RowDescriptor(SearchResultListing const& row)
{
ListingDescriptor descriptor = row.Listing;
descriptor.LeaderScore = { };
return descriptor;
}
// One SMSG_LFG_LIST_SEARCH_RESULTS row, reader 0x7FF7CD528370 (see SearchResultListing).
static ByteBuffer& operator<<(ByteBuffer& data, SearchResultListing const& row)
{
data << row.GroupGuid; // PackedGuid GroupGuid
data << uint32(row.ListingId); // ListingId (APPLY_TO_GROUP key)
data << uint32(4); // Unk_b (observed constant 4)
data << uint64(row.PostTime); // PostTime
data << uint8(0); // Unk_hdrbit (bit-as-byte, observed 0)
WriteSearchResultRowBody(data, row);
data << row.GroupGuid;
data << uint32(row.ListingId);
data << uint32(4); // RideType::LfgListListing
data << uint64(row.PostTime);
data << Bits<1>(false); // IsCrossFaction
data.FlushBits();
data << uint32(row.Revision);
data << RowDescriptor(row);
// Not read by any 12.1 consumer of the row; 7 in all 105 retail 12.1 rows.
data << uint8(7);
data << row.LeaderGuid;
data << row.LastEditorGuid;
data << row.NameEditorGuid;
data << row.CommentEditorGuid;
data << row.VoiceChatEditorGuid;
data << uint32(row.LeaderVirtualRealmAddress);
data << uint32(row.LeaderAreaID);
data << uint32(0); // +1912: only ever set by an update record's flag bit
data << uint32(row.BNetFriendGuids.size());
data << uint32(row.CharacterFriendGuids.size());
data << uint32(row.GuildMateGuids.size());
data << uint32(row.Members.size());
data << uint32(0); // +2016: only ever set by an update record
data << uint64(row.PostTime);
data << uint8(0); // +2032: no 12.1 consumer; 0 in 93 of 105 retail rows
data << row.GroupGuid;
data << row.LeaderScore;
for (uint32 bracket = 0; bracket < row.LeaderPvpRatings.size(); ++bracket)
{
data << uint32(row.LeaderPvpRatings[bracket]);
data << uint8(bracket);
}
data << uint8(row.LeaderFactionMask);
data << uint8(row.CensorFlags);
for (ObjectGuid const& guid : row.BNetFriendGuids)
data << guid;
for (ObjectGuid const& guid : row.CharacterFriendGuids)
data << guid;
for (ObjectGuid const& guid : row.GuildMateGuids)
data << guid;
for (SearchResultMember const& member : row.Members)
WriteSearchResultMember(data, member);
data << Bits<1>(row.HasSelf);
data.FlushBits();
return data;
}
WorldPacket const* LFGListSearchResults::Write()
{
_worldPacket << uint16(Listings.size()); // Unk32 (duplicate row-count hint; == RowCount in sniffs)
_worldPacket << uint32(Listings.size()); // RowCount (array length)
_worldPacket << uint16(Listings.size()); // duplicate row-count hint (== RowCount in every sniff)
_worldPacket << uint32(Listings.size());
for (SearchResultListing const& row : Listings)
_worldPacket << row;
return &_worldPacket;
}
// One update record, reader 0x7FF7CD528690 (see LFGListSearchResultsUpdate).
WorldPacket const* LFGListSearchResultsUpdate::Write()
{
// 68974 capture (bodies idx 16193 len=69 / idx 18313 len=136): the UPDATE row is NOT the full
// search-result row (the previous writer emitted the full ~285B row ? wrong wire). Observed compact row:
// PackedGuid GroupGuid, u32 ListingId, u32 4, u64 PostTime, bit(0),
// u32 Age (3 / 4), u32 MemberCount (0 / 1),
// u8 0, u32 8 (constant in both bodies), u8[26] zero,
// MemberDetail x MemberCount (identical 66B record as SEARCH_RESULTS).
_worldPacket << uint32(Listings.size());
for (SearchResultListing const& row : Listings)
{
_worldPacket << row.GroupGuid;
_worldPacket << uint32(row.ListingId);
_worldPacket << uint32(4); // header constant (== full-row Unk_b)
_worldPacket << uint32(4); // RideType::LfgListListing
_worldPacket << uint64(row.PostTime);
_worldPacket << uint8(0); // header bit (bit-as-byte, observed 0)
_worldPacket << uint32(row.Age); // refresh/age counter (matches the row's Unk40)
_worldPacket << Bits<1>(false);
_worldPacket.FlushBits();
_worldPacket << uint32(row.Revision);
_worldPacket << uint32(row.Members.size());
_worldPacket << RowDescriptor(row);
_worldPacket << uint8(0);
_worldPacket << uint32(8); // observed constant 8 in both 68974 bodies
for (uint32 i = 0; i < 26; ++i)
_worldPacket << uint8(0); // zero block (semantics unknown, all-zero in both bodies)
for (SearchResultMember const& member : row.Members)
WriteSearchResultMember(_worldPacket, member);
uint32 const changes = row.Changes;
bool const leader = (changes & SEARCH_RESULT_CHANGE_LEADER) != 0;
bool const name = (changes & SEARCH_RESULT_CHANGE_NAME) != 0;
bool const comment = (changes & SEARCH_RESULT_CHANGE_COMMENT) != 0;
bool const voiceChat = (changes & SEARCH_RESULT_CHANGE_VOICE_CHAT) != 0;
_worldPacket << Bits<1>(leader);
_worldPacket << Bits<1>(leader);
_worldPacket << Bits<1>(false); // flag +1912 present
_worldPacket << Bits<1>(false); // u32 +2016 present
_worldPacket << Bits<1>(row.Delisted);
_worldPacket << Bits<1>(row.Delisted);
_worldPacket << Bits<1>(false); // guid the applier does not read
_worldPacket << Bits<1>(name);
_worldPacket << Bits<1>(comment);
_worldPacket << Bits<1>(voiceChat);
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_REQUIRED_ITEM_LEVEL) != 0);
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_AUTO_ACCEPT) != 0);
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_PRIVATE) != 0);
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_REQUIRED_DUNGEON_SCORE) != 0);
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_REQUIRED_PVP_RATING) != 0);
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_PLAYSTYLE) != 0);
_worldPacket << Bits<1>(false); // not read by the applier
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_CROSS_FACTION) != 0);
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_ACTIVITIES) != 0);
_worldPacket << Bits<1>((changes & SEARCH_RESULT_CHANGE_NEW_PLAYER_FRIENDLY) != 0);
_worldPacket << Bits<1>(false); // flag +1912 value
_worldPacket.FlushBits();
if (leader)
{
_worldPacket << row.LeaderGuid;
_worldPacket << uint32(row.LeaderVirtualRealmAddress);
}
if (name)
_worldPacket << row.NameEditorGuid;
if (comment)
_worldPacket << row.CommentEditorGuid;
if (voiceChat)
_worldPacket << row.VoiceChatEditorGuid;
}
return &_worldPacket;
}
WorldPacket const* LFGListCensoredActiveEntryUpdate::Write()
{
_worldPacket << Listing;
_worldPacket << OptionalInit(CensorCode);
_worldPacket.FlushBits();
if (CensorCode)
_worldPacket << uint8(*CensorCode);
return &_worldPacket;
}
WorldPacket const* LFGListApplicantListUpdate::Write()
{
// Sniff-exact: Ticket(listing) + u32 count + u32 unk + entries. Entry (status-only form, HasInfo=0):
// Ticket(application) + PackedGuid player + u32 HasInfo(0) + u8 StateBits + u8 pad.
_worldPacket << ListingTicket;
_worldPacket << uint32(Applicants.size());
_worldPacket << uint32(Unknown);
for (ApplicantInfo const& a : Applicants)
_worldPacket << Size<uint32>(Applicants);
_worldPacket << uint32(Unknown); // UNVERIFIED: see the field's note in LFGListPackets.h
for (ApplicantInfo const& applicant : Applicants)
{
_worldPacket << a.Ticket;
_worldPacket << a.PlayerGuid;
_worldPacket << uint32(0); // HasInfo: 0 = status-only entry (full snapshot form documented, unresolved scalars)
_worldPacket << uint8(a.StateBits);
_worldPacket << uint8(0); // pad
_worldPacket << applicant.Ticket;
_worldPacket << applicant.PlayerGuid;
_worldPacket << uint32(applicant.Members.size());
for (ApplicantMember const& member : applicant.Members)
{
_worldPacket << member.Guid;
_worldPacket << uint32(member.VirtualRealmAddress);
_worldPacket << uint32(member.Level);
_worldPacket << uint32(member.HonorLevel);
_worldPacket << uint8(member.RoleMask);
_worldPacket << uint8(member.AssignedRole);
_worldPacket << uint32(0); // {u32, u32} pairs: no consumer among the applicant getters
_worldPacket << member.DungeonScore;
for (uint32 bracket = 0; bracket < member.PvpRatings.size(); ++bracket)
{
_worldPacket << uint32(member.PvpRatings[bracket]);
_worldPacket << uint8(bracket);
}
_worldPacket << uint8(member.RaceID);
_worldPacket << uint8(member.FactionMask);
_worldPacket << member.BnetAccountGuid;
_worldPacket << uint32(0);
_worldPacket << uint32(0);
_worldPacket << uint32(0);
_worldPacket << uint32(0);
_worldPacket << uint32(0);
_worldPacket << uint64(0);
_worldPacket << uint64(0);
_worldPacket << uint32(0);
_worldPacket << Bits<1>(member.IsLeaver);
_worldPacket.FlushBits();
_worldPacket << float(member.ItemLevel);
_worldPacket << float(member.PvpItemLevel);
_worldPacket << uint32(member.SpecID);
}
// 12.1: the 13-bit block sits behind the member array. Written out bit by bit rather than as two
// hand-packed bytes so it stays correct if the member list is ever filled.
_worldPacket.WriteBits(applicant.StateBits >> 4, 4);
_worldPacket << Bits<1>(applicant.CommentUpdated);
_worldPacket << SizedString::BitsSize<8>(applicant.Comment);
_worldPacket.FlushBits();
_worldPacket << SizedString::Data(applicant.Comment);
}
return &_worldPacket;
}
WorldPacket const* LFGListApplicationStatusUpdate::Write()
{
_worldPacket << Ticket;
_worldPacket << uint64(0);
_worldPacket << ListingTicket; // 12.1: pulled forward, adjacent to the first ticket
_worldPacket << uint64(ApplicationExpiration);
_worldPacket << uint32(UnkResult);
_worldPacket << uint8(RoleGranted);
_worldPacket << ListingTicket;
_worldPacket << uint8(StateBits);
_worldPacket << uint8(StateBits); // client keeps bits 7..4 only
return &_worldPacket;
}
WorldPacket const* LFGListApplyToGroupResult::Write()
{
_worldPacket << Ticket;
_worldPacket << ListingTicket;
_worldPacket << Row; // 12.1: the row moved in front of the scalar tail
_worldPacket << uint64(ApplicationExpiration);
_worldPacket << uint8(Status);
_worldPacket << uint8(0);
_worldPacket << ListingTicket;
_worldPacket << uint8(0x10); // observed constant
_worldPacket << ListingTicket;
WriteSearchResultRowBody(_worldPacket, Row);
_worldPacket << uint8(RoleGranted);
_worldPacket << uint8(StateBits); // client keeps bits 7..4 only
return &_worldPacket;
}
WorldPacket const* LFGListUpdateBlacklist::Write()
{
_worldPacket << uint32(Entries.size());
_worldPacket << Size<uint32>(Entries);
for (LFGListBlacklistEntry const& entry : Entries)
{
_worldPacket << uint32(entry.ActivityID);
_worldPacket << uint32(entry.Reason);
}
return &_worldPacket;
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -649,7 +649,7 @@ void OpcodeTable::InitializeClientOpcodes()
DEFINE_HANDLER(CMSG_LEAVE_PET_BATTLE_QUEUE, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLeavePetBattleQueue);
DEFINE_HANDLER(CMSG_LFG_LIST_APPLY_TO_GROUP, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLFGListApplyToGroup);
DEFINE_HANDLER(CMSG_LFG_LIST_CANCEL_APPLICATION, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLFGListCancelApplication);
DEFINE_HANDLER(CMSG_LFG_LIST_CONFIRM_CENSORED_ACTIVE_ENTRY, STATUS_UNHANDLED, PROCESS_THREADUNSAFE, &WorldSession::Handle_NULL);
DEFINE_HANDLER(CMSG_LFG_LIST_CONFIRM_CENSORED_ACTIVE_ENTRY, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLFGListConfirmCensoredActiveEntry);
DEFINE_HANDLER(CMSG_LFG_LIST_DECLINE_APPLICANT, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLFGListDeclineApplicant);
DEFINE_HANDLER(CMSG_LFG_LIST_GET_STATUS, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLFGListGetStatus);
DEFINE_HANDLER(CMSG_LFG_LIST_INVITE_APPLICANT, STATUS_LOGGEDIN, PROCESS_THREADUNSAFE, &WorldSession::HandleLFGListInviteApplicant);
+3
View File
@@ -774,6 +774,7 @@ namespace WorldPackets
class LFGListDeclineApplicant;
class LFGListInviteApplicant;
class LFGListInviteResponse;
class LFGListConfirmCensoredActiveEntry;
class RequestLFGListBlacklist;
}
@@ -2322,8 +2323,10 @@ public:
void HandleLFGListDeclineApplicant(WorldPackets::LFGList::LFGListDeclineApplicant& packet);
void HandleLFGListInviteApplicant(WorldPackets::LFGList::LFGListInviteApplicant& packet);
void HandleLFGListInviteResponse(WorldPackets::LFGList::LFGListInviteResponse& packet);
void HandleLFGListConfirmCensoredActiveEntry(WorldPackets::LFGList::LFGListConfirmCensoredActiveEntry& packet);
void HandleRequestLFGListBlacklist(WorldPackets::LFGList::RequestLFGListBlacklist& packet);
void SendLFGListUpdateStatus(uint32 listingId, uint8 status = 0x38);
void SendLFGListCensoredActiveEntryUpdate(uint32 listingId);
void SendLfgUpdateStatus(lfg::LfgUpdateData const& updateData, bool party);
void SendLfgRoleChosen(ObjectGuid guid, uint8 roles);