feat(net): MATCH_REQUEST and MATCH_TRANSFER chat packets and the chat server's MatchUpdate

MATCH_TRANSFER is appended after GUILD_DISBAND (71); the chat ids from
CREATE_TEAM on are pinned.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 04:11:59 -05:00
parent 9a4282e6b3
commit e11493008d
8 changed files with 277 additions and 1 deletions

View File

@@ -76,5 +76,6 @@ namespace MessageType {
CREATE_TEAM,
GUILD_SET_RANK, // world -> chat: /gpromote, /gdemote, /gleader
GUILD_DISBAND, // world -> chat: /gdisband
MATCH_TRANSFER, // chat -> world: a lobby's match has an instance; send these players there
};
}

View File

@@ -648,3 +648,63 @@ bool ChatPackets::GuildStatus::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(guildName));
return true;
}
void ChatPackets::MatchRequest::Serialize(RakNet::BitStream& bitStream) const {
bitStream.Write(playerID);
bitStream.Write(type);
bitStream.Write(value);
bitStream.Write(activityID);
BitStreamUtils::WriteLengthPrefixed<uint32_t>(bitStream, playerName);
BitStreamUtils::WriteLengthPrefixed<uint32_t>(bitStream, playerChoices);
bitStream.Write(instanceMapID);
bitStream.Write(minTeams);
bitStream.Write(maxTeams);
bitStream.Write(minTeamSize);
bitStream.Write(maxTeamSize);
bitStream.Write(waitTime);
bitStream.Write(startDelay);
}
bool ChatPackets::MatchRequest::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(playerID));
VALIDATE_READ(bitStream.Read(type));
if (type != eMatchRequestType::JOIN && type != eMatchRequestType::READY && type != eMatchRequestType::LEAVE) return false;
VALIDATE_READ(bitStream.Read(value));
VALIDATE_READ(bitStream.Read(activityID));
VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed<uint32_t>(bitStream, playerName, LoginSessionNotify::MAX_NAME_LENGTH));
VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed<uint32_t>(bitStream, playerChoices, 4096));
VALIDATE_READ(bitStream.Read(instanceMapID));
VALIDATE_READ(bitStream.Read(minTeams));
VALIDATE_READ(bitStream.Read(maxTeams));
VALIDATE_READ(bitStream.Read(minTeamSize));
VALIDATE_READ(bitStream.Read(maxTeamSize));
VALIDATE_READ(bitStream.Read(waitTime));
VALIDATE_READ(bitStream.Read(startDelay));
return true;
}
void ChatPackets::MatchTransfer::Serialize(RakNet::BitStream& bitStream) const {
bitStream.Write(activityID);
WriteZone(bitStream, zoneID);
BitStreamUtils::WriteLengthPrefixed<uint32_t>(bitStream, serverIP);
bitStream.Write(serverPort);
bitStream.Write<uint8_t>(mythranShift);
bitStream.Write<uint32_t>(players.size());
for (const auto player : players) bitStream.Write(player);
}
bool ChatPackets::MatchTransfer::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(activityID));
VALIDATE_READ(ReadZone(bitStream, zoneID));
VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed<uint32_t>(bitStream, serverIP, 255));
VALIDATE_READ(bitStream.Read(serverPort));
uint8_t shift{};
VALIDATE_READ(bitStream.Read(shift));
mythranShift = shift != 0;
uint32_t count{};
VALIDATE_READ(bitStream.Read(count));
if (count > MAX_PLAYERS) return false;
players.resize(count);
for (auto& player : players) VALIDATE_READ(bitStream.Read(player));
return true;
}

View File

@@ -473,6 +473,55 @@ namespace ChatPackets {
bool Deserialize(RakNet::BitStream& bitStream) override;
};
/**
* Activity matchmaking (docs/Matchmaking.md). The chat server keeps the lobbies of every world, so players on
* different instances of a zone are matched into the same activity instance.
*/
enum class eMatchRequestType : int32_t {
JOIN = 0, // the client's MatchRequest type 0
READY = 1, // the client's MatchRequest type 1 (value: ready or not)
LEAVE = 2, // DLU: the player left the lobby (LobbyExit) or can't stay in it
};
// World -> chat: a player joins, readies in or leaves an activity lobby. On a join the world sends what it read
// from the activity (CDClient Activities, with its own overrides such as solo racing), so chat needs no CDClient.
struct MatchRequest : public LUBitStream {
LWOOBJID playerID{};
eMatchRequestType type{};
int32_t value{}; // READY: 1 ready, 0 not ready
int32_t activityID{};
std::string playerName; // u32 length, then 1 byte per character
std::string playerChoices; // the client's name-value text (like "droppedItem=13:<id>"), u32 length then bytes
uint32_t instanceMapID{};
int32_t minTeams{};
int32_t maxTeams{};
int32_t minTeamSize{};
int32_t maxTeamSize{};
int32_t waitTime{}; // milliseconds
int32_t startDelay{}; // milliseconds
MatchRequest() : LUBitStream(ServiceType::CHAT, MessageType::Chat::MATCH_REQUEST) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;
};
// Chat -> world (DLU): a lobby's match has its instance; send these players (the ones in this world) there
struct MatchTransfer : public LUBitStream {
// More players than this in one packet is a bad packet
static constexpr uint32_t MAX_PLAYERS = 64;
int32_t activityID{};
LWOZONEID zoneID{}; // map, instance and clone of the activity instance
std::string serverIP; // u32 length, then 1 byte per character
uint16_t serverPort{};
bool mythranShift{};
std::vector<LWOOBJID> players; // u32 count
MatchTransfer() : LUBitStream(ServiceType::CHAT, MessageType::Chat::MATCH_TRANSFER) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;
};
/**
* Chat-service packets the client receives, from the chat server (routed through the player's world) or from its world.
*/

View File

@@ -912,6 +912,21 @@ namespace ClientPackets {
VALIDATE_READ(ReadZone(bitStream, zoneID));
return true;
}
void MatchUpdate::Serialize(RakNet::BitStream& bitStream) const {
TeamGameMsg::Serialize(bitStream);
BitStreamUtils::WriteNameValueText(bitStream, std::u16string(data.begin(), data.end()));
bitStream.Write(type);
}
bool MatchUpdate::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(TeamGameMsg::Deserialize(bitStream));
std::u16string wide;
VALIDATE_READ(BitStreamUtils::ReadNameValueText(bitStream, wide));
data.assign(wide.begin(), wide.end());
VALIDATE_READ(bitStream.Read(type));
return true;
}
}
namespace ClientPackets {

View File

@@ -22,6 +22,7 @@
#include "eGuildCreateResponse.h"
#include "eGuildInviteResponse.h"
#include "eGuildRank.h"
#include "eMatchUpdate.h"
#include "eUgcResourceType.h"
#include "MessageType/Client.h"
#include "MessageType/Game.h"
@@ -553,6 +554,17 @@ namespace ClientPackets {
bool Deserialize(RakNet::BitStream& bitStream) override;
};
// The activity lobby's MatchUpdate, written by the chat server's matchmaking (docs/Matchmaking.md). Byte for byte
// GameMessages::MatchUpdate: name-value text (widened to UTF-16), then the eMatchUpdate type.
struct MatchUpdate : public TeamGameMsg {
std::string data; // name-value text, one byte per character here
eMatchUpdate type{};
MatchUpdate() : TeamGameMsg(MessageType::Game::MATCH_UPDATE) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;
};
/**
* Guild packets (docs/Guilds.md), written by the chat server and routed through the player's world. The client
* copies them into packed structs: every string is a fixed-size UTF-16 buffer it reads up to the first NUL, so

View File

@@ -22,7 +22,7 @@ static_assert(static_cast<int64_t>(MessageType::Auth::LEGOINTERFACE_AUTH_RESPONS
static_assert(static_cast<int64_t>(MessageType::Auth::SESSIONKEY_RECEIVED_CONFIRM) == 4);
static_assert(static_cast<int64_t>(MessageType::Auth::RUNTIME_CONFIG) == 5);
// MessageType::Chat: 69 enumerators
// MessageType::Chat: 72 enumerators
static_assert(static_cast<int64_t>(MessageType::Chat::LOGIN_SESSION_NOTIFY) == 0);
static_assert(static_cast<int64_t>(MessageType::Chat::GENERAL_CHAT_MESSAGE) == 1);
static_assert(static_cast<int64_t>(MessageType::Chat::PRIVATE_CHAT_MESSAGE) == 2);
@@ -92,6 +92,9 @@ static_assert(static_cast<int64_t>(MessageType::Chat::PRG_CSR_COMMAND) == 65);
static_assert(static_cast<int64_t>(MessageType::Chat::HEARTBEAT_REQUEST_FROM_WORLD) == 66);
static_assert(static_cast<int64_t>(MessageType::Chat::UPDATE_FREE_TRIAL_STATUS) == 67);
static_assert(static_cast<int64_t>(MessageType::Chat::CREATE_TEAM) == 68);
static_assert(static_cast<int64_t>(MessageType::Chat::GUILD_SET_RANK) == 69);
static_assert(static_cast<int64_t>(MessageType::Chat::GUILD_DISBAND) == 70);
static_assert(static_cast<int64_t>(MessageType::Chat::MATCH_TRANSFER) == 71);
// MessageType::Client: 67 enumerators
static_assert(static_cast<int64_t>(MessageType::Client::LOGIN_RESPONSE) == 0);

View File

@@ -3,6 +3,7 @@ SET(DNET_TESTS
"GuildPacketsTests.cpp"
"CommonAuthPacketsTests.cpp"
"MasterPacketsTests.cpp"
"MatchmakingPacketsTests.cpp"
"PacketCaptureTests.cpp"
"ServerTrafficTests.cpp"
"WorldPacketsTests.cpp")

View File

@@ -0,0 +1,135 @@
#include "ChatPackets.h"
#include "ClientPackets.h"
#include "ActivityMessages.h"
#include "GameDependencies.h"
#include "PacketTestUtils.h"
#include "dGameMessagesTests/GameMessageTestUtils.h"
#include <gtest/gtest.h>
using namespace PacketTestUtils;
using GameMessageTestUtils::RoundTrip;
using GameMessageTestUtils::ExpectTruncatedFails;
// Packets of the chat server's activity matchmaking (docs/Matchmaking.md)
TEST(MatchmakingPacketsTests, MessageIdsAreAppended) {
// MATCH_REQUEST is the chat service's own id; MATCH_TRANSFER is DLU's, after GUILD_DISBAND
EXPECT_EQ(static_cast<uint32_t>(MessageType::Chat::MATCH_REQUEST), 52u);
EXPECT_EQ(static_cast<uint32_t>(MessageType::Chat::MATCH_TRANSFER), static_cast<uint32_t>(MessageType::Chat::GUILD_DISBAND) + 1);
EXPECT_EQ(ChatPackets::MatchRequest().internalPacketID, static_cast<uint32_t>(MessageType::Chat::MATCH_REQUEST));
EXPECT_EQ(ChatPackets::MatchTransfer().internalPacketID, static_cast<uint32_t>(MessageType::Chat::MATCH_TRANSFER));
}
TEST(MatchmakingPacketsTests, MatchRequestRoundTrip) {
for (const auto type : { ChatPackets::eMatchRequestType::JOIN, ChatPackets::eMatchRequestType::READY, ChatPackets::eMatchRequestType::LEAVE }) {
ChatPackets::MatchRequest request;
request.playerID = 0x1000000012345678LL;
request.type = type;
request.value = 1;
request.activityID = 42;
request.playerName = "GruntMonkey";
request.playerChoices = "droppedItem=13:1152921510659010409";
request.instanceMapID = 1203;
request.minTeams = 2;
request.maxTeams = 6;
request.minTeamSize = 1;
request.maxTeamSize = 1;
request.waitTime = 60000;
request.startDelay = 3000;
const auto copy = RoundTrip(request);
EXPECT_EQ(copy.playerID, request.playerID);
EXPECT_EQ(copy.type, type);
EXPECT_EQ(copy.value, 1);
EXPECT_EQ(copy.activityID, 42);
EXPECT_EQ(copy.playerName, "GruntMonkey");
EXPECT_EQ(copy.playerChoices, request.playerChoices);
EXPECT_EQ(copy.instanceMapID, 1203u);
EXPECT_EQ(copy.minTeams, 2);
EXPECT_EQ(copy.maxTeams, 6);
EXPECT_EQ(copy.minTeamSize, 1);
EXPECT_EQ(copy.maxTeamSize, 1);
EXPECT_EQ(copy.waitTime, 60000);
EXPECT_EQ(copy.startDelay, 3000);
ExpectTruncatedFails(request);
}
}
TEST(MatchmakingPacketsTests, MatchRequestRejectsUnknownTypeAndLongName) {
ChatPackets::MatchRequest request;
request.type = static_cast<ChatPackets::eMatchRequestType>(7);
RakNet::BitStream bitStream;
request.Serialize(bitStream);
ChatPackets::MatchRequest copy;
EXPECT_FALSE(copy.Deserialize(bitStream));
request.type = ChatPackets::eMatchRequestType::JOIN;
request.playerName = std::string(ChatPackets::LoginSessionNotify::MAX_NAME_LENGTH + 1, 'x');
RakNet::BitStream longName;
request.Serialize(longName);
EXPECT_FALSE(copy.Deserialize(longName));
}
TEST(MatchmakingPacketsTests, MatchTransferRoundTrip) {
ChatPackets::MatchTransfer transfer;
transfer.activityID = 5;
transfer.zoneID = LWOZONEID(1101, 42, 0xDEADBEEF);
transfer.serverIP = "171.20.35.21";
transfer.serverPort = 2007;
transfer.mythranShift = false;
transfer.players = { 0x1000000000000001LL, 0x1000000000000002LL };
const auto copy = RoundTrip(transfer);
EXPECT_EQ(copy.activityID, 5);
EXPECT_EQ(copy.zoneID.GetMapID(), 1101);
EXPECT_EQ(copy.zoneID.GetInstanceID(), 42);
EXPECT_EQ(copy.zoneID.GetCloneID(), 0xDEADBEEFu);
EXPECT_EQ(copy.serverIP, "171.20.35.21");
EXPECT_EQ(copy.serverPort, 2007);
EXPECT_FALSE(copy.mythranShift);
EXPECT_EQ(copy.players, transfer.players);
ExpectTruncatedFails(transfer);
}
TEST(MatchmakingPacketsTests, MatchTransferRejectsTooManyPlayers) {
ChatPackets::MatchTransfer transfer;
transfer.players.assign(ChatPackets::MatchTransfer::MAX_PLAYERS + 1, 1);
RakNet::BitStream bitStream;
transfer.Serialize(bitStream);
ChatPackets::MatchTransfer copy;
EXPECT_FALSE(copy.Deserialize(bitStream));
}
// The chat server's MatchUpdate is byte for byte the world's GameMessages::MatchUpdate
TEST(MatchmakingPacketsTests, ClientMatchUpdateMatchesGameMessage) {
const std::pair<std::string, eMatchUpdate> cases[] = {
{ "player=9:1152921510436607007\nplayerName=0:GruntMonkey", eMatchUpdate::PLAYER_ADDED },
{ "droppedItem=9:1152921510659010409\nplayer=9:1152921510436607007\nplayerName=0:GruntMonkey", eMatchUpdate::PLAYER_ADDED },
{ "player=9:1152921510808957955", eMatchUpdate::PLAYER_READY },
{ "time=3:49.704", eMatchUpdate::PHASE_WAIT_READY },
{ "", eMatchUpdate::PLAYER_REMOVED },
};
for (const auto& [data, type] : cases) {
GameMessages::MatchUpdate world;
world.target = 0x1000000012345678LL;
world.data = data;
world.type = type;
RakNet::BitStream worldStream;
world.WritePacket(worldStream);
ClientPackets::MatchUpdate chat;
chat.target = world.target;
chat.data = data;
chat.type = type;
RakNet::BitStream chatStream;
chat.WritePacket(chatStream);
EXPECT_PACKET_EQ(FromBitStream(worldStream), FromBitStream(chatStream));
const auto copy = RoundTrip(chat);
EXPECT_EQ(copy.target, chat.target);
EXPECT_EQ(copy.data, data);
EXPECT_EQ(copy.type, type);
}
}