Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
56423ca
LFG: make the matchmaker able to form a group at all
MadMaxMangos Aug 4, 2026
00a7efb
LFG: wire CMSG_LFG_SET_ROLES 0x08A2, the role check reply
MadMaxMangos Aug 4, 2026
ef18dee
LFG: close the proposal-path crash and indeterminate-branch defects
MadMaxMangos Aug 4, 2026
46cba32
LFG: derive and admit SMSG_LFG_ROLE_CHECK_UPDATE 0x12BB
MadMaxMangos Aug 4, 2026
444290a
LFG: derive and admit SMSG_LFG_PROPOSAL_UPDATE 0x1E3B
MadMaxMangos Aug 4, 2026
05b384a
LFG: wire CMSG_LFG_PROPOSAL_RESPONSE 0x1D9D and tick the manager
MadMaxMangos Aug 5, 2026
98532e1
LFG: fix group formation, which the tick just made reachable
MadMaxMangos Aug 5, 2026
7481a00
LFG: make the join gate and the leave path actually gate
MadMaxMangos Aug 5, 2026
ed42eae
LFG: fix review findings -- resolver blowup, decline handling, leaks
MadMaxMangos Aug 5, 2026
10cafb4
LFG: clear every member's state on a decline, not just the decliner's
MadMaxMangos Aug 5, 2026
d65eb4e
LFG: send LFG_UPDATE_LEAVE when a decline tears the proposal down
MadMaxMangos Aug 5, 2026
5f23b3e
LFG: keep the queue entry alive across a proposal, and requeue on fai…
MadMaxMangos Aug 5, 2026
06e4ab2
LFG: add .debug dungeon so the finder can be tested without nine othe…
MadMaxMangos Aug 5, 2026
04cc915
LFG: address the CodeFactor complexity notices on the proposal path
MadMaxMangos Aug 5, 2026
da94c9a
LFG: tell merged queuers about their OWN queue, not the absorber's
MadMaxMangos Aug 5, 2026
b4c2ac9
LFG: let a merged queuer actually leave the queue
MadMaxMangos Aug 5, 2026
09c2537
LFG: release the queue entry when a proposal SUCCEEDS
MadMaxMangos Aug 5, 2026
0a873a7
LFG: fix a use-after-free on decline, and four proposal-lifecycle def…
MadMaxMangos Aug 5, 2026
726ffd8
LFG: instrument the queue and proposal paths
MadMaxMangos Aug 5, 2026
2e523b3
LFG: register the three unnamed SMSG, and size SMSG_LFG_TELEPORT_DENI…
MadMaxMangos Aug 5, 2026
f9e2f60
LFG: populate the dungeon lock list, so the finder stops offering eve…
MadMaxMangos Aug 5, 2026
052bd6e
LFG: drop two declarations the rebase onto #81 duplicated
MadMaxMangos Aug 5, 2026
42e74d5
LFG: propose a real dungeon for a random queue, not the category row
MadMaxMangos Aug 5, 2026
c1b8c03
LFG: stop replaying the queue after entry, and make leaving a dungeon…
MadMaxMangos Aug 5, 2026
0304138
LFG: put back the status reply after entry -- suppressing it was wrong
MadMaxMangos Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/game/ChatCommands/DebugCommands.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
*/

#include "Common.h"
#include "LFGMgr.h"
#include "Database/DatabaseEnv.h"
#include "WorldPacket.h"
#include "Player.h"
Expand Down Expand Up @@ -1071,6 +1072,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();
Expand Down
5 changes: 5 additions & 0 deletions src/game/Server/Opcodes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,8 @@ 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.
Expand Down Expand Up @@ -1128,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);
Expand Down
4 changes: 2 additions & 2 deletions src/game/Server/Opcodes_reference.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/game/Server/WorldSession.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -604,6 +604,8 @@ 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
case SMSG_LFG_QUEUE_STATUS: // MopLfgPackets::BuildQueueStatus
Expand Down
2 changes: 2 additions & 0 deletions src/game/Server/WorldSession.h
Original file line number Diff line number Diff line change
Expand Up @@ -2209,6 +2209,8 @@ class WorldSession
void HandleLfrLeaveOpcode(WorldPacket& recv_data);
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);
Expand Down
80 changes: 80 additions & 0 deletions src/game/Server/tests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,86 @@ 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
${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
${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
${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
${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
Expand Down
154 changes: 154 additions & 0 deletions src/game/Server/tests/mop_lfg_player_info_packets_test.cpp
Original file line number Diff line number Diff line change
@@ -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 <cassert>
#include <cstdio>
#include <vector>

namespace
{
void AssertBytes(uint8 const* actual, std::vector<uint8> 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<uint8> 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<MopLfgPackets::PlayerLockInfo> 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<uint8> 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<MopLfgPackets::PlayerLockInfo> 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<MopLfgPackets::PlayerLockInfo> 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;
}
Loading
Loading