diff --git a/dCommon/dEnums/MessageType/Chat.h b/dCommon/dEnums/MessageType/Chat.h index 09f4ef69e..67dad5805 100644 --- a/dCommon/dEnums/MessageType/Chat.h +++ b/dCommon/dEnums/MessageType/Chat.h @@ -74,5 +74,7 @@ namespace MessageType { UPDATE_FREE_TRIAL_STATUS, // CUSTOM DLU MESSAGE ID FOR INTERNAL USE CREATE_TEAM, + GUILD_SET_RANK, // world -> chat: /gpromote, /gdemote, /gleader + GUILD_DISBAND, // world -> chat: /gdisband }; } diff --git a/dCommon/dEnums/eGuildCreateResponse.h b/dCommon/dEnums/eGuildCreateResponse.h new file mode 100644 index 000000000..ccbbc5ef4 --- /dev/null +++ b/dCommon/dEnums/eGuildCreateResponse.h @@ -0,0 +1,14 @@ +#ifndef EGUILDCREATERESPONSE_H +#define EGUILDCREATERESPONSE_H + +#include + +// MSG_CLIENT_GUILD_CREATE_RESPONSE's result (docs/Guilds.md) +enum class eGuildCreateResponse : uint8_t { + CREATED, // "MSG_GUILD_GUILD_NAME_CREATED", then the client asks for the guild's data + BAD_NAME, // "MSG_GUILD_GUILD_NAME_CANT_BE_USED" + EXISTS, // "MSG_GUILD_GUILD_NAME_ALREADY_IN_USE" + FAILED, // anything else: "MSG_GUILD_GUILD_COULD_NOT_BE_CREATED" +}; + +#endif // EGUILDCREATERESPONSE_H diff --git a/dCommon/dEnums/eGuildInviteResponse.h b/dCommon/dEnums/eGuildInviteResponse.h new file mode 100644 index 000000000..86207b883 --- /dev/null +++ b/dCommon/dEnums/eGuildInviteResponse.h @@ -0,0 +1,23 @@ +#ifndef EGUILDINVITERESPONSE_H +#define EGUILDINVITERESPONSE_H + +#include + +// MSG_CLIENT_GUILD_INVITE_INITIAL_RESPONSE's code: what happened to the invite the player sent (docs/Guilds.md) +enum class eGuildInviteResponse : uint8_t { + SENT, // "MSG_GUILD_GUILD_INVITE_SENT_TO_NAME" + NOT_ONLINE, // "MSG_GENERIC_NAME_IS_NOT_ONLINE" + ALREADY_IN_GUILD, // "MSG_GUILD_NAME_IS_ALREADY_IN_A_GUILD" + INVITE_PENDING, // "MSG_GUILD_NAME_ALREADY_HAS_A_GUILD_INVITE_PENDING" + FAILED, // anything else: "MSG_GUILD_COULD_NOT_INVITE_NAME" +}; + +// MSG_CLIENT_GUILD_INVITE_FINAL_RESPONSE's code: the invited player's answer, told to the inviter +enum class eGuildInviteFinalResponse : uint8_t { + JOINED, // "MSG_GUILD_NAME_HAS_JOINED_THE_GUILD" + DECLINED, // "MSG_GUILD_NAME_DECLINED_YOUR_INVITATION" + NOT_ONLINE, // "MSG_GENERIC_NAME_IS_NOT_ONLINE" + FAILED, // anything else: "CLIENTMSG_COULD_NOT_INVITE_NAME" +}; + +#endif // EGUILDINVITERESPONSE_H diff --git a/dCommon/dEnums/eGuildRank.h b/dCommon/dEnums/eGuildRank.h new file mode 100644 index 000000000..0734cb921 --- /dev/null +++ b/dCommon/dEnums/eGuildRank.h @@ -0,0 +1,21 @@ +#ifndef EGUILDRANK_H +#define EGUILDRANK_H + +#include + +// A guild member's rank, as the client names it in the guild list (0 and anything above 4: no name) +enum class eGuildRank : uint8_t { + NONE, + LEADER, + OFFICER, + VETERAN, + RECRUIT, +}; + +// MSG_CLIENT_GUILD_REMOVE_PLAYER's reason +enum class eGuildLeaveReason : uint8_t { + LEFT, + KICKED, +}; + +#endif // EGUILDRANK_H diff --git a/dGame/dGameMessages/PlayerMessages.cpp b/dGame/dGameMessages/PlayerMessages.cpp index e495a82d0..52bff2c8c 100644 --- a/dGame/dGameMessages/PlayerMessages.cpp +++ b/dGame/dGameMessages/PlayerMessages.cpp @@ -251,4 +251,12 @@ namespace GameMessages { bool Help::Deserialize(RakNet::BitStream& bitStream) { return bitStream.Read(helpId); } + + void DisplayGuildCreateBox::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(bShow); + } + + bool DisplayGuildCreateBox::Deserialize(RakNet::BitStream& bitStream) { + return bitStream.Read(bShow); + } } diff --git a/dGame/dGameMessages/PlayerMessages.h b/dGame/dGameMessages/PlayerMessages.h index e44476d84..7b7908d01 100644 --- a/dGame/dGameMessages/PlayerMessages.h +++ b/dGame/dGameMessages/PlayerMessages.h @@ -183,6 +183,16 @@ namespace GameMessages { eHelpType helpId{ eHelpType::NONE }; }; + + // Server -> client, to one client. Opens the guild create box (the client ignores bShow and always opens it). + // Live sent it from the Guild Master NPC's script (LOT 3001, L_GUILD_CREATE.lua); see docs/Guilds.md. + struct DisplayGuildCreateBox : public NetGameMsg { + DisplayGuildCreateBox() : NetGameMsg(MessageType::Game::DISPLAY_GUILD_CREATE_BOX) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + + bool bShow{ true }; + }; } #endif // PLAYERMESSAGES_H diff --git a/dNet/ChatPackets.cpp b/dNet/ChatPackets.cpp index 0d3b6259c..d023e0261 100644 --- a/dNet/ChatPackets.cpp +++ b/dNet/ChatPackets.cpp @@ -539,3 +539,112 @@ bool ChatPackets::RoutedFromClient::Deserialize(RakNet::BitStream& bitStream) { if (!data.empty()) VALIDATE_READ(bitStream.ReadBits(data.data(), BYTES_TO_BITS(data.size()), true)); return true; } + +// ---- Guilds ---- + +void ChatPackets::GuildInvite::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); + bitStream.Write(unknown); + bitStream.Write(invitedPlayer); +} + +bool ChatPackets::GuildInvite::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(unknown)); + VALIDATE_READ(bitStream.Read(invitedPlayer)); + return true; +} + +void ChatPackets::GuildInviteResponse::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); + bitStream.Write(unknown); + bitStream.Write(declined); +} + +bool ChatPackets::GuildInviteResponse::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(unknown)); + VALIDATE_READ(bitStream.Read(declined)); + return true; +} + +void ChatPackets::GuildLeave::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); + bitStream.Write(unknown); +} + +bool ChatPackets::GuildLeave::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(unknown)); + return true; +} + +void ChatPackets::GuildGetAll::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); + bitStream.Write(unknown); +} + +bool ChatPackets::GuildGetAll::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(unknown)); + return true; +} + +void ChatPackets::GuildCreate::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); + bitStream.Write(guildName); +} + +bool ChatPackets::GuildCreate::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(guildName)); + return true; +} + +void ChatPackets::GuildKick::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); + bitStream.Write(unknown); + bitStream.Write(kickedPlayer); +} + +bool ChatPackets::GuildKick::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(unknown)); + VALIDATE_READ(bitStream.Read(kickedPlayer)); + return true; +} + +void ChatPackets::GuildSetRank::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); + bitStream.Write(targetPlayer); + bitStream.Write(rank); +} + +bool ChatPackets::GuildSetRank::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(targetPlayer)); + VALIDATE_READ(bitStream.Read(rank)); + return true; +} + +void ChatPackets::GuildDisband::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(playerID); +} + +bool ChatPackets::GuildDisband::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(playerID)); + return true; +} + +void ChatPackets::GuildStatus::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(characterID); + bitStream.Write(guildID); + bitStream.Write(guildName); +} + +bool ChatPackets::GuildStatus::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(characterID)); + VALIDATE_READ(bitStream.Read(guildID)); + VALIDATE_READ(bitStream.Read(guildName)); + return true; +} diff --git a/dNet/ChatPackets.h b/dNet/ChatPackets.h index 0406a389d..a7fbedb1e 100644 --- a/dNet/ChatPackets.h +++ b/dNet/ChatPackets.h @@ -375,6 +375,104 @@ namespace ChatPackets { bool Deserialize(RakNet::BitStream& bitStream) override; }; + // Guilds (docs/Guilds.md). The client sends GUILD_INVITE, GUILD_INVITE_RESPONSE, GUILD_LEAVE and GUILD_GET_ALL + // (routed like the ones above); the others are DLU's world <-> chat packets. + + // Invite a player (by name) to the sender's guild + struct GuildInvite : public LUBitStream { + LWOOBJID playerID{}; + uint32_t unknown{}; + LUWString invitedPlayer{ 33 }; + + GuildInvite() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_INVITE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // The answer to the invite in the message box (button 1 accepts: declined 0) + struct GuildInviteResponse : public LUBitStream { + LWOOBJID playerID{}; + uint32_t unknown{}; + uint8_t declined{}; + + GuildInviteResponse() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_INVITE_RESPONSE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Leave the guild. The client sends 66 more bytes of whatever was on its stack; they are not read. + struct GuildLeave : public LUBitStream { + LWOOBJID playerID{}; + uint32_t unknown{}; + + GuildLeave() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_LEAVE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // The player's guild and its members (answered with ClientPackets::GuildData) + struct GuildGetAll : public LUBitStream { + LWOOBJID playerID{}; + uint32_t unknown{}; + + GuildGetAll() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_GET_ALL) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // World -> chat: the name from the client's TMP_GUILD_CREATE + struct GuildCreate : public LUBitStream { + LWOOBJID playerID{}; + LUWString guildName{ 31 }; + + GuildCreate() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_CREATE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // World -> chat: /gkick (the client never sends GUILD_KICK; the layout is the team kick's) + struct GuildKick : public LUBitStream { + LWOOBJID playerID{}; + uint32_t unknown{}; + LUWString kickedPlayer{ 33 }; + + GuildKick() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_KICK) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // World -> chat: change a member's rank (eGuildRank; LEADER hands the guild over) + struct GuildSetRank : public LUBitStream { + LWOOBJID playerID{}; + LUWString targetPlayer{ 33 }; + uint8_t rank{}; + + GuildSetRank() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_SET_RANK) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // World -> chat: the leader disbands the guild + struct GuildDisband : public LUBitStream { + LWOOBJID playerID{}; + + GuildDisband() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_DISBAND) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Chat -> the character's world: its guild changed; the world puts it in the character component (guild 0: none). + // guildName is what other players may see (empty while the name waits for moderation). + struct GuildStatus : public LUBitStream { + LWOOBJID characterID{}; + LWOOBJID guildID{}; + LUWString guildName{ 31 }; + + GuildStatus() : LUBitStream(ServiceType::CHAT, MessageType::Chat::GUILD_GET_STATUS) {} + 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. */ diff --git a/dNet/ClientPackets.cpp b/dNet/ClientPackets.cpp index 95ad72d46..94339a9be 100644 --- a/dNet/ClientPackets.cpp +++ b/dNet/ClientPackets.cpp @@ -913,3 +913,175 @@ namespace ClientPackets { return true; } } + +namespace ClientPackets { + LUWString Guild::FixedName(const std::string& name, const uint32_t size) { + auto wide = GeneralUtils::UTF8ToUTF16(name); + if (size > 0 && wide.size() >= size) wide.resize(size - 1); + return LUWString(wide, size); + } + + namespace { + void WriteName(RakNet::BitStream& bitStream, const std::string& name, const uint32_t size) { + bitStream.Write(Guild::FixedName(name, size)); + } + + bool ReadName(RakNet::BitStream& bitStream, std::string& name, const uint32_t size) { + LUWString value(size); + VALIDATE_READ(bitStream.Read(value)); + name = value.GetAsString(); + return true; + } + + bool ReadByteBool(RakNet::BitStream& bitStream, bool& value) { + uint8_t byte{}; + VALIDATE_READ(bitStream.Read(byte)); + value = byte != 0; + return true; + } + } + + void GuildCreateResponse::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(result); + bitStream.Write(guildID); + WriteName(bitStream, guildName, Guild::NAME_SIZE); + } + + bool GuildCreateResponse::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(result)); + VALIDATE_READ(bitStream.Read(guildID)); + return ReadName(bitStream, guildName, Guild::NAME_SIZE); + } + + void GuildInvite::Serialize(RakNet::BitStream& bitStream) const { + WriteName(bitStream, inviterName, Guild::PLAYER_NAME_SIZE); + WriteName(bitStream, guildName, Guild::NAME_SIZE); + } + + bool GuildInvite::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(ReadName(bitStream, inviterName, Guild::PLAYER_NAME_SIZE)); + return ReadName(bitStream, guildName, Guild::NAME_SIZE); + } + + void GuildInviteInitialResponse::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(response); + WriteName(bitStream, playerName, Guild::PLAYER_NAME_SIZE); + } + + bool GuildInviteInitialResponse::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(response)); + return ReadName(bitStream, playerName, Guild::PLAYER_NAME_SIZE); + } + + void GuildInviteFinalResponse::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(response); + WriteName(bitStream, playerName, Guild::PLAYER_NAME_SIZE); + } + + bool GuildInviteFinalResponse::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(response)); + return ReadName(bitStream, playerName, Guild::PLAYER_NAME_SIZE); + } + + void GuildInviteConfirm::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(failed); + WriteName(bitStream, guildName, Guild::PLAYER_NAME_SIZE); + } + + bool GuildInviteConfirm::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(ReadByteBool(bitStream, failed)); + return ReadName(bitStream, guildName, Guild::PLAYER_NAME_SIZE); + } + + void GuildAddPlayer::Serialize(RakNet::BitStream& bitStream) const { + WriteName(bitStream, playerName, Guild::PLAYER_NAME_SIZE); + bitStream.Write(playerID); + bitStream.Write(rank); + WriteZone(bitStream, zoneID); + bitStream.Write(online); + } + + bool GuildAddPlayer::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(ReadName(bitStream, playerName, Guild::PLAYER_NAME_SIZE)); + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(rank)); + VALIDATE_READ(ReadZone(bitStream, zoneID)); + return ReadByteBool(bitStream, online); + } + + void GuildRemovePlayer::Serialize(RakNet::BitStream& bitStream) const { + bitStream.Write(reason); + WriteName(bitStream, playerName, Guild::PLAYER_NAME_SIZE); + bitStream.Write(playerID); + bitStream.Write(newLeaderID); + } + + bool GuildRemovePlayer::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(bitStream.Read(reason)); + VALIDATE_READ(ReadName(bitStream, playerName, Guild::PLAYER_NAME_SIZE)); + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(bitStream.Read(newLeaderID)); + return true; + } + + void GuildLoginLogout::Serialize(RakNet::BitStream& bitStream) const { + WriteName(bitStream, playerName, Guild::PLAYER_NAME_SIZE); + bitStream.Write(playerID); + bitStream.Write(online); + WriteZone(bitStream, zoneID); + bitStream.Write(worldUpdateOnly); + } + + bool GuildLoginLogout::Deserialize(RakNet::BitStream& bitStream) { + VALIDATE_READ(ReadName(bitStream, playerName, Guild::PLAYER_NAME_SIZE)); + VALIDATE_READ(bitStream.Read(playerID)); + VALIDATE_READ(ReadByteBool(bitStream, online)); + VALIDATE_READ(ReadZone(bitStream, zoneID)); + return ReadByteBool(bitStream, worldUpdateOnly); + } + + void GuildData::Serialize(RakNet::BitStream& bitStream) const { + constexpr uint32_t DATE_SIZE = 11; + bitStream.Write(status); + WriteName(bitStream, guildName, Guild::NAME_SIZE); + WriteName(bitStream, joinDate, DATE_SIZE); + WriteName(bitStream, foundDate, DATE_SIZE); + bitStream.Write(reputation); + bitStream.Write(unknown1); + bitStream.Write(unknown2); + bitStream.Write(unknown3); + bitStream.Write(members.size()); + for (const auto& member : members) { + bitStream.Write(member.rank); + bitStream.Write(member.online); + WriteZone(bitStream, member.zoneID); + bitStream.Write(member.playerID); + WriteName(bitStream, member.name, Guild::PLAYER_NAME_SIZE); + } + } + + bool GuildData::Deserialize(RakNet::BitStream& bitStream) { + constexpr uint32_t DATE_SIZE = 11; + VALIDATE_READ(bitStream.Read(status)); + VALIDATE_READ(ReadName(bitStream, guildName, Guild::NAME_SIZE)); + VALIDATE_READ(ReadName(bitStream, joinDate, DATE_SIZE)); + VALIDATE_READ(ReadName(bitStream, foundDate, DATE_SIZE)); + VALIDATE_READ(bitStream.Read(reputation)); + VALIDATE_READ(bitStream.Read(unknown1)); + VALIDATE_READ(bitStream.Read(unknown2)); + VALIDATE_READ(bitStream.Read(unknown3)); + uint16_t count{}; + VALIDATE_READ(bitStream.Read(count)); + members.clear(); + for (uint16_t i = 0; i < count; i++) { + Member member; + VALIDATE_READ(bitStream.Read(member.rank)); + VALIDATE_READ(ReadByteBool(bitStream, member.online)); + VALIDATE_READ(ReadZone(bitStream, member.zoneID)); + VALIDATE_READ(bitStream.Read(member.playerID)); + VALIDATE_READ(ReadName(bitStream, member.name, Guild::PLAYER_NAME_SIZE)); + members.push_back(std::move(member)); + } + return true; + } +} diff --git a/dNet/ClientPackets.h b/dNet/ClientPackets.h index 1e876fa01..42ae588e6 100644 --- a/dNet/ClientPackets.h +++ b/dNet/ClientPackets.h @@ -19,6 +19,9 @@ #include "eAddFriendResponseType.h" #include "eAddIgnoreResponse.h" #include "eBlueprintSaveResponseType.h" +#include "eGuildCreateResponse.h" +#include "eGuildInviteResponse.h" +#include "eGuildRank.h" #include "eUgcResourceType.h" #include "MessageType/Client.h" #include "MessageType/Game.h" @@ -549,6 +552,133 @@ namespace ClientPackets { 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 + * DLU writes at most size - 1 characters. + */ + namespace Guild { + // Guild names are 31 characters on the wire (30 and a NUL); player names 33 + constexpr uint32_t NAME_SIZE = 31; + constexpr uint32_t PLAYER_NAME_SIZE = 33; + // A UTF-8 name as a buffer of `size` characters, cut so a NUL is always left + LUWString FixedName(const std::string& name, uint32_t size); + } + + // The answer to the player's TMP_GUILD_CREATE + struct GuildCreateResponse : public LUBitStream { + eGuildCreateResponse result{}; + LWOOBJID guildID{}; // the client doesn't read it + std::string guildName; // 31 characters on the wire + + GuildCreateResponse() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_CREATE_RESPONSE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Someone invites the player to their guild; the client asks the player with a message box + struct GuildInvite : public LUBitStream { + std::string inviterName; // 33 characters on the wire + std::string guildName; // 31 + + GuildInvite() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_INVITE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // What happened to the invite the player sent + struct GuildInviteInitialResponse : public LUBitStream { + eGuildInviteResponse response{}; + std::string playerName; // the invited player; 33 characters on the wire + + GuildInviteInitialResponse() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_INVITE_INITIAL_RESPONSE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // The invited player's answer, to the inviter + struct GuildInviteFinalResponse : public LUBitStream { + eGuildInviteFinalResponse response{}; + std::string playerName; // the invited player; 33 characters on the wire + + GuildInviteFinalResponse() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_INVITE_FINAL_RESPONSE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // To the player who accepted an invite: whether they are in the guild now (the client then asks for its data) + struct GuildInviteConfirm : public LUBitStream { + bool failed{}; // one byte + std::string guildName; // 33 characters on the wire + + GuildInviteConfirm() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_INVITE_CONFIRM) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Someone joined the player's guild + struct GuildAddPlayer : public LUBitStream { + std::string playerName; // 33 characters on the wire + LWOOBJID playerID{}; + eGuildRank rank{}; + LWOZONEID zoneID{}; + bool online{}; // one byte + + GuildAddPlayer() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_ADD_PLAYER) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // Someone left or was kicked from the player's guild (the player themself: the client drops the guild) + struct GuildRemovePlayer : public LUBitStream { + eGuildLeaveReason reason{}; + std::string playerName; // 33 characters on the wire + LWOOBJID playerID{}; + LWOOBJID newLeaderID{}; // 0: the leader didn't change; otherwise that member's rank becomes leader + + GuildRemovePlayer() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_REMOVE_PLAYER) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // A guildmate logged in or out, or changed worlds (worldUpdateOnly: no chat line) + struct GuildLoginLogout : public LUBitStream { + std::string playerName; // 33 characters on the wire + LWOOBJID playerID{}; + bool online{}; // one byte + LWOZONEID zoneID{}; + bool worldUpdateOnly{}; // one byte + + GuildLoginLogout() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_LOGIN_LOGOUT) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + + // The player's guild and every member (the answer to GUILD_GET_ALL) + struct GuildData : public LUBitStream { + struct Member { + eGuildRank rank{}; + bool online{}; // one byte + LWOZONEID zoneID{}; // the client reads it only for online members + LWOOBJID playerID{}; + std::string name; // 33 characters on the wire + }; + + uint8_t status{}; // the client only takes 0 (DLU sends 1 to a player in no guild) + std::string guildName; // 31 characters on the wire + std::string joinDate; // 11 characters on the wire; the client doesn't show it + std::string foundDate; // 11 + int32_t reputation{}; // the client doesn't use it + int32_t unknown1{}; + int32_t unknown2{}; + uint16_t unknown3{}; + std::vector members; // u16 count; the client ignores data with none + + GuildData() : LUBitStream(ServiceType::CLIENT, MessageType::Client::GUILD_DATA) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; } #endif // CLIENTPACKETS_H diff --git a/dNet/WorldPackets.cpp b/dNet/WorldPackets.cpp index 379959066..b72dfb807 100644 --- a/dNet/WorldPackets.cpp +++ b/dNet/WorldPackets.cpp @@ -227,6 +227,19 @@ namespace WorldPackets { return true; } + void TmpGuildCreate::Serialize(RakNet::BitStream& bitStream) const { + auto name = guildName; + if (name.size() >= NAME_SIZE) name.resize(NAME_SIZE - 1); + bitStream.Write(LUWString(name, NAME_SIZE)); + } + + bool TmpGuildCreate::Deserialize(RakNet::BitStream& bitStream) { + LUWString name(NAME_SIZE); + VALIDATE_READ(bitStream.Read(name)); + guildName = name.string; + return true; + } + void MailPacket::Serialize(RakNet::BitStream& bitStream) const { WriteAll(bitStream, data); } diff --git a/dNet/WorldPackets.h b/dNet/WorldPackets.h index b1addcab2..1c3921be7 100644 --- a/dNet/WorldPackets.h +++ b/dNet/WorldPackets.h @@ -192,6 +192,17 @@ namespace WorldPackets { bool Deserialize(RakNet::BitStream& bitStream) override; }; + // Client -> server. The name the player typed in the guild create box (docs/Guilds.md); the client sends it only + // when it is not in a guild. The client's name for it: MSG_WORLD_CLIENT_TMP_GUILD_CREATE. + struct TmpGuildCreate : public WorldLUBitStream { + static constexpr uint32_t NAME_SIZE = 31; // characters on the wire; the client puts at most 30 and a NUL + std::u16string guildName; + + TmpGuildCreate() : WorldLUBitStream(MessageType::World::TMP_GUILD_CREATE) {} + void Serialize(RakNet::BitStream& bitStream) const override; + bool Deserialize(RakNet::BitStream& bitStream) override; + }; + // Client -> server. The client's anti cheat noticed something. struct HandleFunness : public WorldLUBitStream { float cheatInfo{}; diff --git a/tests/dGameTests/dNetTests/CMakeLists.txt b/tests/dGameTests/dNetTests/CMakeLists.txt index 514d1ad44..c6fe0d086 100644 --- a/tests/dGameTests/dNetTests/CMakeLists.txt +++ b/tests/dGameTests/dNetTests/CMakeLists.txt @@ -1,5 +1,6 @@ SET(DNET_TESTS "ChatPacketsTests.cpp" + "GuildPacketsTests.cpp" "CommonAuthPacketsTests.cpp" "MasterPacketsTests.cpp" "ServerTrafficTests.cpp" diff --git a/tests/dGameTests/dNetTests/GuildPacketsTests.cpp b/tests/dGameTests/dNetTests/GuildPacketsTests.cpp new file mode 100644 index 000000000..f47a6b59c --- /dev/null +++ b/tests/dGameTests/dNetTests/GuildPacketsTests.cpp @@ -0,0 +1,293 @@ +// Guild packets (docs/Guilds.md): the bytes the 1.10.64 client reads (offsets from its packet handlers), the bytes it +// sends, and DLU's world <-> chat packets. +#include "ChatPackets.h" +#include "ClientPackets.h" +#include "WorldPackets.h" +#include "PlayerMessages.h" +#include "GameDependencies.h" +#include "PacketTestUtils.h" +#include "dGameMessagesTests/GameMessageTestUtils.h" + +#include + +using namespace PacketTestUtils; +using GameMessageTestUtils::RoundTrip; +using GameMessageTestUtils::ExpectTruncatedFails; + +namespace { + // Little-endian bytes, the way the client's packed structs lie in memory + struct Bytes { + std::vector data; + Bytes& U8(uint8_t value) { data.push_back(value); return *this; } + Bytes& U16(uint16_t value) { return U8(value & 0xFF).U8(value >> 8); } + Bytes& U32(uint32_t value) { return U16(value & 0xFFFF).U16(value >> 16); } + Bytes& U64(uint64_t value) { return U32(value & 0xFFFFFFFF).U32(value >> 32); } + // A fixed buffer of `size` UTF-16 characters, zero filled + Bytes& WStr(const std::u16string& text, size_t size) { + for (size_t i = 0; i < size; i++) U16(i < text.size() ? text[i] : 0); + return *this; + } + Bytes& Zone(uint16_t map, uint16_t instance, uint32_t clone) { return U16(map).U16(instance).U32(clone); } + // 0x53, connection type, packet id, padding + static Bytes Header(uint16_t connection, uint32_t id) { Bytes bytes; bytes.U8(0x53).U16(connection).U32(id).U8(0); return bytes; } + PacketBytes Packet() const { return { data, static_cast(data.size() * 8) }; } + }; + + PacketBytes StructPacket(const LUBitStream& msg) { + RakNet::BitStream bitStream; + msg.WritePacket(bitStream); + return FromBitStream(bitStream); + } + + constexpr uint16_t CLIENT = 5; + constexpr uint16_t CHAT = 2; + constexpr uint16_t WORLD = 4; + constexpr LWOOBJID PLAYER = 0x1000000000000123LL; + constexpr LWOOBJID OTHER = 0x1000000000000456LL; +} + +class GuildPacketsTests : public GameDependenciesTest { +protected: + void SetUp() override { SetUpDependencies(); } + void TearDown() override { TearDownDependencies(); } +}; + +// ---- Server -> client. Offsets in the comments are the client's, from the byte after 0x53. ---- + +TEST_F(GuildPacketsTests, CreateResponse) { + ClientPackets::GuildCreateResponse response; + response.result = eGuildCreateResponse::CREATED; + response.guildID = 7; + response.guildName = "Brick Builders"; + // +7 result, +8 u64, +16 wchar[31]: 78 bytes after 0x53 + const auto expected = Bytes::Header(CLIENT, 37).U8(0).U64(7).WStr(u"Brick Builders", 31); + EXPECT_EQ(expected.data.size(), 79u); + EXPECT_PACKET_EQ(expected.Packet(), StructPacket(response)); + const auto copy = RoundTrip(response); + EXPECT_EQ(copy.guildName, "Brick Builders"); + ExpectTruncatedFails(response); +} + +TEST_F(GuildPacketsTests, NamesAlwaysKeepANul) { + ClientPackets::GuildCreateResponse response; + response.guildName = std::string(40, 'x'); + const auto packet = StructPacket(response); + ASSERT_EQ(packet.bytes.size(), 79u); + // the last character of the 31 is 0 + EXPECT_EQ(packet.bytes[77], 0); + EXPECT_EQ(packet.bytes[78], 0); + EXPECT_EQ(packet.bytes[75], 'x'); +} + +TEST_F(GuildPacketsTests, Invite) { + ClientPackets::GuildInvite invite; + invite.inviterName = "Alice"; + invite.guildName = "Brick Builders"; + // +7 wchar[33] inviter, +73 wchar[31] guild + const auto expected = Bytes::Header(CLIENT, 39).WStr(u"Alice", 33).WStr(u"Brick Builders", 31); + EXPECT_PACKET_EQ(expected.Packet(), StructPacket(invite)); + RoundTrip(invite); +} + +TEST_F(GuildPacketsTests, InviteResponses) { + ClientPackets::GuildInviteInitialResponse initial; + initial.response = eGuildInviteResponse::INVITE_PENDING; + initial.playerName = "Bob"; + EXPECT_PACKET_EQ(Bytes::Header(CLIENT, 40).U8(3).WStr(u"Bob", 33).Packet(), StructPacket(initial)); + RoundTrip(initial); + + ClientPackets::GuildInviteFinalResponse final; + final.response = eGuildInviteFinalResponse::DECLINED; + final.playerName = "Bob"; + EXPECT_PACKET_EQ(Bytes::Header(CLIENT, 41).U8(1).WStr(u"Bob", 33).Packet(), StructPacket(final)); + RoundTrip(final); + + ClientPackets::GuildInviteConfirm confirm; + confirm.failed = true; + confirm.guildName = "Brick Builders"; + EXPECT_PACKET_EQ(Bytes::Header(CLIENT, 42).U8(1).WStr(u"Brick Builders", 33).Packet(), StructPacket(confirm)); + EXPECT_TRUE(RoundTrip(confirm).failed); +} + +TEST_F(GuildPacketsTests, AddPlayer) { + ClientPackets::GuildAddPlayer add; + add.playerName = "Bob"; + add.playerID = OTHER; + add.rank = eGuildRank::RECRUIT; + add.zoneID = LWOZONEID(1200, 3, 0); + add.online = true; + // +7 name, +73 id, +81 rank, +82 zone, +90 online: 91 bytes after 0x53 + const auto expected = Bytes::Header(CLIENT, 43).WStr(u"Bob", 33).U64(OTHER).U8(4).Zone(1200, 3, 0).U8(1); + EXPECT_EQ(expected.data.size(), 92u); + EXPECT_PACKET_EQ(expected.Packet(), StructPacket(add)); + const auto copy = RoundTrip(add); + EXPECT_EQ(copy.zoneID, add.zoneID); + ExpectTruncatedFails(add); +} + +TEST_F(GuildPacketsTests, RemovePlayer) { + ClientPackets::GuildRemovePlayer remove; + remove.reason = eGuildLeaveReason::KICKED; + remove.playerName = "Bob"; + remove.playerID = OTHER; + remove.newLeaderID = PLAYER; + // +7 reason, +8 name, +74 id, +82 new leader + const auto expected = Bytes::Header(CLIENT, 44).U8(1).WStr(u"Bob", 33).U64(OTHER).U64(PLAYER); + EXPECT_EQ(expected.data.size(), 91u); + EXPECT_PACKET_EQ(expected.Packet(), StructPacket(remove)); + RoundTrip(remove); +} + +TEST_F(GuildPacketsTests, LoginLogout) { + ClientPackets::GuildLoginLogout login; + login.playerName = "Bob"; + login.playerID = OTHER; + login.online = true; + login.zoneID = LWOZONEID(1100, 2, 0); + login.worldUpdateOnly = true; + // +7 name, +73 id, +81 online, +82 zone, +90 world update only + const auto expected = Bytes::Header(CLIENT, 45).WStr(u"Bob", 33).U64(OTHER).U8(1).Zone(1100, 2, 0).U8(1); + EXPECT_EQ(expected.data.size(), 92u); + EXPECT_PACKET_EQ(expected.Packet(), StructPacket(login)); + RoundTrip(login); +} + +TEST_F(GuildPacketsTests, Data) { + ClientPackets::GuildData data; + data.guildName = "Brick Builders"; + data.joinDate = "01/02/2026"; + data.foundDate = "12/31/2025"; + data.members.push_back({ eGuildRank::LEADER, true, LWOZONEID(1200, 1, 0), PLAYER, "Alice" }); + data.members.push_back({ eGuildRank::RECRUIT, false, LWOZONEID(0, 0, 0), OTHER, "Bob" }); + // +7 status, +8 name, +70 date, +92 date, +114 reputation, +118..127, +128 count, +130 members of 84 bytes + auto expected = Bytes::Header(CLIENT, 47).U8(0).WStr(u"Brick Builders", 31).WStr(u"01/02/2026", 11).WStr(u"12/31/2025", 11) + .U32(0).U32(0).U32(0).U16(0).U16(2); + EXPECT_EQ(expected.data.size(), 131u); + expected.U8(1).U8(1).Zone(1200, 1, 0).U64(PLAYER).WStr(u"Alice", 33); + expected.U8(4).U8(0).Zone(0, 0, 0).U64(OTHER).WStr(u"Bob", 33); + EXPECT_EQ(expected.data.size(), 131u + 2 * 84u); + EXPECT_PACKET_EQ(expected.Packet(), StructPacket(data)); + const auto copy = RoundTrip(data); + ASSERT_EQ(copy.members.size(), 2u); + EXPECT_EQ(copy.members[0].name, "Alice"); + EXPECT_EQ(copy.members[1].playerID, OTHER); + ExpectTruncatedFails(data); +} + +// ---- Client -> server ---- + +TEST_F(GuildPacketsTests, TmpGuildCreateFromClient) { + // What SendTMPGuildCreate sends (69 bytes after 0x53): header, wchar[31] + const auto bytes = Bytes::Header(WORLD, 20).WStr(u"Brick Builders", 31); + RakNet::BitStream stream(const_cast(bytes.data.data()), bytes.data.size(), false); + LUBitStream header; + ASSERT_TRUE(header.ReadHeader(stream)); + EXPECT_EQ(header.connectionType, ServiceType::WORLD); + EXPECT_EQ(header.internalPacketID, static_cast(MessageType::World::TMP_GUILD_CREATE)); + WorldPackets::TmpGuildCreate create; + ASSERT_TRUE(create.Deserialize(stream)); + EXPECT_EQ(create.guildName, u"Brick Builders"); + EXPECT_EQ(stream.GetNumberOfUnreadBits(), 0u); + EXPECT_PACKET_EQ(bytes.Packet(), StructPacket(create)); +} + +TEST_F(GuildPacketsTests, RoutedFromClientToChat) { + // The client's GUILD_INVITE (81 bytes after 0x53): header, u64 0, wchar[33]; the world routes it to chat with the + // sender in front and the first 4 bytes of the data dropped + WorldPackets::RoutePacket route; + const auto routed = Bytes().U64(0).WStr(u"Bob", 33); + route.size = static_cast(routed.data.size()); + route.routedService = ServiceType::CHAT; + route.routedMessageID = static_cast(MessageType::Chat::GUILD_INVITE); + route.routedData = routed.data; + RakNet::BitStream bitStream; + route.ToChat(PLAYER).WritePacket(bitStream); + bitStream.ResetReadPointer(); + LUBitStream header; + ASSERT_TRUE(header.ReadHeader(bitStream)); + EXPECT_EQ(header.internalPacketID, static_cast(MessageType::Chat::GUILD_INVITE)); + ChatPackets::GuildInvite invite; + ASSERT_TRUE(invite.Deserialize(bitStream)); + EXPECT_EQ(invite.playerID, PLAYER); + EXPECT_EQ(invite.invitedPlayer.GetAsString(), "Bob"); + + // GUILD_INVITE_RESPONSE (16 bytes): u64 0, u8 declined + route.routedMessageID = static_cast(MessageType::Chat::GUILD_INVITE_RESPONSE); + route.routedData = Bytes().U64(0).U8(1).data; + route.size = 9; + RakNet::BitStream responseStream; + route.ToChat(PLAYER).WritePacket(responseStream); + ASSERT_TRUE(header.ReadHeader(responseStream)); + ChatPackets::GuildInviteResponse response; + ASSERT_TRUE(response.Deserialize(responseStream)); + EXPECT_EQ(response.playerID, PLAYER); + EXPECT_EQ(response.declined, 1); + + // GUILD_LEAVE: the client says it is 81 bytes long; everything after the u64 is ignored + route.routedMessageID = static_cast(MessageType::Chat::GUILD_LEAVE); + route.routedData = Bytes().U64(0).WStr(u"garbage", 33).data; + route.size = 74; + RakNet::BitStream leaveStream; + route.ToChat(PLAYER).WritePacket(leaveStream); + ASSERT_TRUE(header.ReadHeader(leaveStream)); + ChatPackets::GuildLeave leave; + ASSERT_TRUE(leave.Deserialize(leaveStream)); + EXPECT_EQ(leave.playerID, PLAYER); + + // GUILD_GET_ALL (15 bytes): u64 0 + route.routedMessageID = static_cast(MessageType::Chat::GUILD_GET_ALL); + route.routedData = Bytes().U64(0).data; + route.size = 8; + RakNet::BitStream getAllStream; + route.ToChat(PLAYER).WritePacket(getAllStream); + ASSERT_TRUE(header.ReadHeader(getAllStream)); + ChatPackets::GuildGetAll getAll; + ASSERT_TRUE(getAll.Deserialize(getAllStream)); + EXPECT_EQ(getAll.playerID, PLAYER); + EXPECT_EQ(getAllStream.GetNumberOfUnreadBits(), 0u); +} + +// ---- DLU's world <-> chat packets ---- + +TEST_F(GuildPacketsTests, WorldChatPackets) { + ChatPackets::GuildCreate create; + create.playerID = PLAYER; + create.guildName = LUWString(u"Brick Builders", 31); + EXPECT_PACKET_EQ(Bytes::Header(CHAT, 22).U64(PLAYER).WStr(u"Brick Builders", 31).Packet(), StructPacket(create)); + RoundTrip(create); + + ChatPackets::GuildKick kick; + kick.playerID = PLAYER; + kick.kickedPlayer = LUWString(u"Bob", 33); + EXPECT_PACKET_EQ(Bytes::Header(CHAT, 26).U64(PLAYER).U32(0).WStr(u"Bob", 33).Packet(), StructPacket(kick)); + RoundTrip(kick); + + ChatPackets::GuildSetRank rank; + rank.playerID = PLAYER; + rank.targetPlayer = LUWString(u"Bob", 33); + rank.rank = 2; + EXPECT_EQ(static_cast(MessageType::Chat::GUILD_SET_RANK), static_cast(MessageType::Chat::CREATE_TEAM) + 1); + RoundTrip(rank); + + ChatPackets::GuildDisband disband; + disband.playerID = PLAYER; + EXPECT_EQ(static_cast(MessageType::Chat::GUILD_DISBAND), static_cast(MessageType::Chat::CREATE_TEAM) + 2); + RoundTrip(disband); + + ChatPackets::GuildStatus status; + status.characterID = PLAYER; + status.guildID = 7; + status.guildName = LUWString(u"Brick Builders", 31); + EXPECT_PACKET_EQ(Bytes::Header(CHAT, 27).U64(PLAYER).U64(7).WStr(u"Brick Builders", 31).Packet(), StructPacket(status)); + RoundTrip(status); +} + +// ---- Game message ---- + +TEST_F(GuildPacketsTests, DisplayGuildCreateBox) { + GameMessages::DisplayGuildCreateBox box; + box.target = PLAYER; + // header 53 05 00 0c 00 00 00 00, target, msgId 626 (u16), bShow bit + EXPECT_PACKET_EQ(FromHex("5305000c00000000" "2301000000000010" "7202" "80", 145), GameMessageTestUtils::StructPacket(box)); + RoundTrip(box); +}