chore: foundations for struct based packets and game messages

First step of moving hand written bitstream code to struct based
messages (see docs/PacketArchitecture.md). No wire changes.

- GameMsg is now only a server-internal event (delivered to handlers
  registered with RegisterMsg); NetGameMsg is a wire message with
  Send(sysAddr) (UNASSIGNED broadcasts), SendToClient(sysAddr) (one
  client, never broadcasts), WritePacket, Serialize, Deserialize and
  Handle. Mixing them up is now a compile error. NetGameMsgEvent<T> /
  DeliverLocally carry a wire message to local handlers; loot drops,
  pickup, the object debugger, GM invisibility and model RequestUse
  use them. The GM invisibility message keeps its target, so its bytes
  are unchanged.
- Behaviour change: GameMessageHandler logs and drops messages whose
  Deserialize fails instead of handling them with default fields (the
  4 messages already registered: RequestUse, RequestServerObjectInfo,
  ShootingGalleryFire, PickupItem).
- LUBitStream (kept as on main) gets a virtual destructor (Mail deleted
  derived packets through the base) and WritePacket, also used by
  ChatPackets::SendRoutedMsg with identical bytes.
- BitStreamUtils::WriteOptional/ReadOptional for default-flag fields
  and WriteLengthPrefixed/ReadLengthPrefixed for length prefixed
  strings.
- Every message ID enumerator (MessageType::*, ServiceType, Mail's
  wire enums) is pinned with static_asserts, so renumbering or removing
  one fails to compile.
- Tests: dServerMock copies each sent packet (it kept a pointer to the
  caller's destroyed BitStream); PacketTestUtils.h compares packets bit
  for bit; helper tests use hand computed golden bytes and equality
  with the hand written patterns they replace; compile-time checks
  keep the wire/internal split in place.
- docs/PacketArchitecture.md: survey, target architecture,
  conventions, verification method and migration plan.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-26 10:25:27 -05:00
parent 66e2790193
commit 350d35dab5
27 changed files with 2796 additions and 81 deletions

View File

@@ -2222,7 +2222,8 @@ void Entity::RegisterMsg(const MessageType::Game msgId, std::function<bool(GameM
m_MsgHandlers.emplace(msgId, handler);
}
bool Entity::MsgRequestServerObjectInfo(GameMessages::RequestServerObjectInfo& requestInfo) {
bool Entity::MsgRequestServerObjectInfo(GameMessages::RequestServerObjectInfoEvent& event) {
const auto& requestInfo = event.msg;
AMFArrayValue response;
response.Insert("visible", true);
response.Insert("objectID", std::to_string(m_ObjectID));
@@ -2264,7 +2265,8 @@ bool Entity::MsgRequestServerObjectInfo(GameMessages::RequestServerObjectInfo& r
return true;
}
bool Entity::MsgDropClientLoot(GameMessages::DropClientLoot& dropLootMsg) {
bool Entity::MsgDropClientLoot(GameMessages::DropClientLootEvent& event) {
const auto& dropLootMsg = event.msg;
if (dropLootMsg.item != LOT_NULL && dropLootMsg.item != 0) {
Loot::Info info{
.id = dropLootMsg.lootID,
@@ -2308,7 +2310,8 @@ bool Entity::MsgGetFactionTokenType(GameMessages::GetFactionTokenType& tokenMsg)
return tokenMsg.tokenType != LOT_NULL;
}
bool Entity::MsgPickupItem(GameMessages::PickupItem& pickupItemMsg) {
bool Entity::MsgPickupItem(GameMessages::PickupItemEvent& event) {
const auto& pickupItemMsg = event.msg;
if (GetObjectID() == pickupItemMsg.lootOwnerID) {
PickupItem(pickupItemMsg.lootID);
} else {

View File

@@ -16,6 +16,7 @@
namespace GameMessages {
struct GameMsg;
template<typename Msg> struct NetGameMsgEvent;
struct ActivityNotify;
struct ShootingGalleryFire;
struct ChildLoaded;
@@ -182,11 +183,11 @@ public:
void AddComponent(eReplicaComponentType componentId, Component* component);
bool MsgRequestServerObjectInfo(GameMessages::RequestServerObjectInfo& msg);
bool MsgDropClientLoot(GameMessages::DropClientLoot& msg);
bool MsgRequestServerObjectInfo(GameMessages::NetGameMsgEvent<GameMessages::RequestServerObjectInfo>& event);
bool MsgDropClientLoot(GameMessages::NetGameMsgEvent<GameMessages::DropClientLoot>& event);
bool MsgGetFlag(GameMessages::GetFlag& msg);
bool MsgGetFactionTokenType(GameMessages::GetFactionTokenType& msg);
bool MsgPickupItem(GameMessages::PickupItem& msg);
bool MsgPickupItem(GameMessages::NetGameMsgEvent<GameMessages::PickupItem>& event);
bool MsgChildRemoved(GameMessages::ChildRemoved& msg);
// This is expceted to never return nullptr, an assert checks this.

View File

@@ -783,7 +783,7 @@ void DestroyableComponent::Smash(const LWOOBJID source, const eKillType killType
lootMsg.spawnPos = m_Parent->GetPosition();
lootMsg.sourceID = source;
lootMsg.item = LOT_NULL;
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
character->SetCoins(coinsTotal, eLootSourceType::DELETION);
}
}
@@ -1022,17 +1022,17 @@ void DestroyableComponent::DoHardcoreModeDrops(const LWOOBJID source) {
lootMsg.spawnPos = m_Parent->GetPosition();
lootMsg.sourceID = source;
lootMsg.item = LOT_NULL;
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
lootMsg.Send(m_Parent->GetSystemAddress());
while (coinsToDrop > MAX_TO_DROP_PER_GM) {
LOG("Dropping 100,000, %llu left", coinsToDrop);
lootMsg.currency = 100'000;
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
lootMsg.Send(m_Parent->GetSystemAddress());
coinsToDrop -= 100'000;
}
lootMsg.currency = coinsToDrop;
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
lootMsg.Send(m_Parent->GetSystemAddress());
}
return;

View File

@@ -68,7 +68,9 @@ void GhostComponent::GhostEntity(LWOOBJID id) {
m_ObservedEntities.erase(id);
}
bool GhostComponent::OnToggleGMInvis(GameMessages::ToggleGMInvis& gmInvisMsg) {
bool GhostComponent::OnToggleGMInvis(GameMessages::ToggleGMInvisEvent& event) {
auto& gmInvisMsg = event.msg;
gmInvisMsg.target = event.target; // the wire message goes out with the target the event was delivered to
gmInvisMsg.bStateOut = !m_IsGMInvisible;
m_IsGMInvisible = !m_IsGMInvisible;
LOG_DEBUG("GM Invisibility toggled to: %s", m_IsGMInvisible ? "true" : "false");

View File

@@ -43,7 +43,7 @@ public:
void GhostEntity(const LWOOBJID id);
bool OnToggleGMInvis(GameMessages::ToggleGMInvis& msg);
bool OnToggleGMInvis(GameMessages::ToggleGMInvisEvent& event);
bool OnGetGMInvis(GameMessages::GetGMInvis& msg);

View File

@@ -59,7 +59,8 @@ bool ModelComponent::OnResetModelToDefaults(GameMessages::ResetModelToDefaults&
return true;
}
bool ModelComponent::OnRequestUse(GameMessages::RequestUse& requestUse) {
bool ModelComponent::OnRequestUse(GameMessages::RequestUseEvent& event) {
auto& requestUse = event.msg;
bool toReturn = false;
if (!m_IsPaused) {
for (auto& behavior : m_Behaviors) behavior.HandleMsg(requestUse);

View File

@@ -32,7 +32,7 @@ public:
void LoadBehaviors();
void Update(float deltaTime) override;
bool OnRequestUse(GameMessages::RequestUse& requestUse);
bool OnRequestUse(GameMessages::RequestUseEvent& event);
bool OnResetModelToDefaults(GameMessages::ResetModelToDefaults& resetModelToDefaults);
bool OnGetObjectReportInfo(GameMessages::GetObjectReportInfo& reportInfo);

View File

@@ -43,7 +43,7 @@
namespace {
using enum MessageType::Game;
using namespace GameMessages;
using MessageCreator = std::function<std::unique_ptr<GameMessages::GameMsg>()>;
using MessageCreator = std::function<std::unique_ptr<GameMessages::NetGameMsg>()>;
std::map<MessageType::Game, MessageCreator> g_MessageHandlers = {
{ REQUEST_USE, []() { return std::make_unique<RequestUse>(); }},
{ REQUEST_SERVER_OBJECT_INFO, []() { return std::make_unique<RequestServerObjectInfo>(); } },
@@ -87,7 +87,10 @@ void GameMessageHandler::HandleMessage(RakNet::BitStream& inStream, const System
}
}
msg->Deserialize(inStream);
if (!msg->Deserialize(inStream)) {
LOG("Dropping malformed GM %4i, %s from (%llu) targeting (%llu)", messageID, StringifiedEnum::ToString(messageID).data(), usr->GetLoggedInChar(), objectID);
return;
}
msg->Handle(*entity, sysAddr);
return;
}

View File

@@ -6285,14 +6285,24 @@ namespace GameMessages {
return Send();
}
void GameMsg::Send(const SystemAddress& sysAddr) const {
CBITSTREAM;
void NetGameMsg::WritePacket(RakNet::BitStream& bitStream) const {
CMSGHEADER;
bitStream.Write(target); // Who this message will be sent to on the (a) client
bitStream.Write(msgId); // the ID of this message
Serialize(bitStream); // write the message data
}
void NetGameMsg::SendToClient(const SystemAddress& sysAddr) const {
CBITSTREAM;
WritePacket(bitStream);
SEND_PACKET;
}
void NetGameMsg::Send(const SystemAddress& sysAddr) const {
CBITSTREAM;
WritePacket(bitStream);
// Send to everyone if someone sent unassigned system address, or to one specific client.
if (sysAddr == UNASSIGNED_SYSTEM_ADDRESS) {
@@ -6378,8 +6388,10 @@ namespace GameMessages {
void RequestServerObjectInfo::Handle(Entity& entity, const SystemAddress& sysAddr) {
auto* handlingEntity = Game::entityManager->GetEntity(targetForReport);
if (handlingEntity) handlingEntity->HandleMsg(*this);
else LOG("Failed to find target %llu", targetForReport);
if (handlingEntity) {
RequestServerObjectInfoEvent event(*this);
handlingEntity->HandleMsg(event);
} else LOG("Failed to find target %llu", targetForReport);
}
bool RequestUse::Deserialize(RakNet::BitStream& stream) {
@@ -6418,7 +6430,8 @@ namespace GameMessages {
interactedObject->OnUse(&entity);
}
interactedObject->HandleMsg(*this);
RequestUseEvent event(*this);
interactedObject->HandleMsg(event);
//Perform use task if possible:
auto missionComponent = entity.GetComponent<MissionComponent>();
@@ -6480,7 +6493,8 @@ namespace GameMessages {
LOG("Has team %i picking up %llu:%llu", team != nullptr, lootID, lootOwnerID);
if (team) {
for (const auto memberId : team->members) {
this->Send(memberId);
PickupItemEvent event(*this);
event.Send(memberId);
TeamPickupItem teamPickupMsg{};
teamPickupMsg.target = lootID;
teamPickupMsg.lootID = lootID;

View File

@@ -3,6 +3,7 @@
#include "dCommonVars.h"
#include <map>
#include <type_traits>
#include <string>
#include <vector>
#include "eMovementPlatformState.h"
@@ -53,26 +54,78 @@ enum class eCameraTargetCyclingMode : int32_t {
};
namespace GameMessages {
/**
* A server-internal event, delivered only to the handlers entities/components/scripts registered with
* RegisterMsg. It never goes on the wire: it has no Serialize and no way to send it to a client.
*/
struct GameMsg {
GameMsg(MessageType::Game gmId, eGameMasterLevel lvl) : msgId{ gmId }, requiredGmLevel{ lvl } {}
GameMsg(MessageType::Game gmId) : GameMsg(gmId, eGameMasterLevel::CIVILIAN) {}
GameMsg(MessageType::Game gmId) : msgId{ gmId } {}
virtual ~GameMsg() = default;
// Sends a message to the entity manager to route to the target
// Delivers the message to the handlers registered on the target entity.
bool Send();
bool Send(const LWOOBJID _target);
MessageType::Game msgId;
LWOOBJID target{ LWOOBJID_EMPTY };
};
/**
* A game message that goes on the wire, to (Serialize) or from (Deserialize + Handle) a client.
* It is not a GameMsg, so it cannot be delivered to local handlers by mistake. When local handlers need to
* observe a network message, wrap it in a NetGameMsgEvent.
*/
struct NetGameMsg {
NetGameMsg(MessageType::Game gmId, eGameMasterLevel lvl) : msgId{ gmId }, requiredGmLevel{ lvl } {}
NetGameMsg(MessageType::Game gmId) : NetGameMsg(gmId, eGameMasterLevel::CIVILIAN) {}
virtual ~NetGameMsg() = default;
// Sends the message to the specified client or
// all clients if UNASSIGNED_SYSTEM_ADDRESS is specified
void Send(const SystemAddress& sysAddr) const;
// Sends the message to the specified client only and never broadcasts. Given UNASSIGNED_SYSTEM_ADDRESS
// nothing is delivered (RakNet rejects a non-broadcast send without an address).
void SendToClient(const SystemAddress& sysAddr) const;
// Writes the complete client packet (CLIENT/GAME_MSG header, target, msgId, then Serialize()) into bitStream.
// This is exactly what Send(sysAddr) puts on the wire; tests use it to compare bytes without a server.
void WritePacket(RakNet::BitStream& bitStream) const;
virtual void Serialize(RakNet::BitStream& bitStream) const {}
virtual bool Deserialize(RakNet::BitStream& bitStream) { return true; }
// Called for messages received from a client, after a successful Deserialize.
virtual void Handle(Entity& entity, const SystemAddress& sysAddr) {};
MessageType::Game msgId;
LWOOBJID target{ LWOOBJID_EMPTY };
// Minimum GM level the sending client needs for Handle to be called.
eGameMasterLevel requiredGmLevel;
};
/**
* A server-internal event carrying a network message, so entity/component handlers can react to a message
* received from (or about to be sent to) a client. Uses the network message's msgId for local dispatch.
*/
template<typename Msg>
struct NetGameMsgEvent : public GameMsg {
static_assert(std::is_base_of_v<NetGameMsg, Msg>, "NetGameMsgEvent carries a NetGameMsg");
NetGameMsgEvent() : GameMsg(Msg{}.msgId) {}
NetGameMsgEvent(const Msg& message) : GameMsg(message.msgId), msg{ message } { target = message.target; }
Msg msg;
};
// Delivers a copy of a network message to the local handlers registered on msg.target (never to a client).
// Returns whether a handler handled it. Handlers see (and may modify) only the copy.
template<typename Msg>
bool DeliverLocally(const Msg& msg) {
NetGameMsgEvent<Msg> event(msg);
return event.Send();
}
class PropertyDataMessage;
void SendFireEventClientSide(const LWOOBJID& objectID, const SystemAddress& sysAddr, std::u16string args, const LWOOBJID& object, int64_t param1, int param2, const LWOOBJID& sender);
void SendTeleport(const LWOOBJID& objectID, const NiPoint3& pos, const NiQuaternion& rot, const SystemAddress& sysAddr, bool bSetRotation = false);
@@ -705,8 +758,8 @@ namespace GameMessages {
// This is a client gm however its default values are exactly what we need to get around the invisible inventory item issues.
void SendUpdateInventoryUi(LWOOBJID objectId, const SystemAddress& sysAddr);
struct DisplayTooltip : public GameMsg {
DisplayTooltip() : GameMsg(MessageType::Game::DISPLAY_TOOLTIP) {}
struct DisplayTooltip : public NetGameMsg {
DisplayTooltip() : NetGameMsg(MessageType::Game::DISPLAY_TOOLTIP) {}
bool doOrDie{};
bool noRepeat{};
bool noRevive{};
@@ -721,8 +774,8 @@ namespace GameMessages {
void Serialize(RakNet::BitStream& bitStream) const override;
};
struct UseItemOnClient : public GameMsg {
UseItemOnClient() : GameMsg(MessageType::Game::USE_ITEM_ON_CLIENT) {}
struct UseItemOnClient : public NetGameMsg {
UseItemOnClient() : NetGameMsg(MessageType::Game::USE_ITEM_ON_CLIENT) {}
LWOOBJID playerId{};
LWOOBJID itemToUse{};
uint32_t itemType{};
@@ -741,14 +794,14 @@ namespace GameMessages {
LwoNameValue racingSettings{};
};
struct SetModelToBuild : public GameMsg {
SetModelToBuild() : GameMsg(MessageType::Game::SET_MODEL_TO_BUILD) {}
struct SetModelToBuild : public NetGameMsg {
SetModelToBuild() : NetGameMsg(MessageType::Game::SET_MODEL_TO_BUILD) {}
void Serialize(RakNet::BitStream& bitStream) const override;
LOT modelLot{ -1 };
};
struct SpawnModelBricks : public GameMsg {
SpawnModelBricks() : GameMsg(MessageType::Game::SPAWN_MODEL_BRICKS) {}
struct SpawnModelBricks : public NetGameMsg {
SpawnModelBricks() : NetGameMsg(MessageType::Game::SPAWN_MODEL_BRICKS) {}
void Serialize(RakNet::BitStream& bitStream) const override;
float amount{ 0.0f };
@@ -761,8 +814,8 @@ namespace GameMessages {
LwoNameValue notification{};
};
struct ShootingGalleryFire : public GameMsg {
ShootingGalleryFire() : GameMsg(MessageType::Game::SHOOTING_GALLERY_FIRE) {}
struct ShootingGalleryFire : public NetGameMsg {
ShootingGalleryFire() : NetGameMsg(MessageType::Game::SHOOTING_GALLERY_FIRE) {}
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle(Entity& entity, const SystemAddress& sysAddr) override;
@@ -781,15 +834,16 @@ namespace GameMessages {
PlayerResurrectionFinished() : GameMsg(MessageType::Game::PLAYER_RESURRECTION_FINISHED) {}
};
struct RequestServerObjectInfo : public GameMsg {
struct RequestServerObjectInfo : public NetGameMsg {
bool bVerbose{};
LWOOBJID clientId{};
LWOOBJID targetForReport{};
RequestServerObjectInfo() : GameMsg(MessageType::Game::REQUEST_SERVER_OBJECT_INFO, eGameMasterLevel::DEVELOPER) {}
RequestServerObjectInfo() : NetGameMsg(MessageType::Game::REQUEST_SERVER_OBJECT_INFO, eGameMasterLevel::DEVELOPER) {}
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle(Entity& entity, const SystemAddress& sysAddr) override;
};
using RequestServerObjectInfoEvent = NetGameMsgEvent<RequestServerObjectInfo>;
struct GetObjectReportInfo : public GameMsg {
AMFArrayValue* info{};
@@ -797,11 +851,11 @@ namespace GameMessages {
bool bVerbose{};
LWOOBJID clientID{};
GetObjectReportInfo() : GameMsg(MessageType::Game::GET_OBJECT_REPORT_INFO, eGameMasterLevel::DEVELOPER) {}
GetObjectReportInfo() : GameMsg(MessageType::Game::GET_OBJECT_REPORT_INFO) {}
};
struct RequestUse : public GameMsg {
RequestUse() : GameMsg(MessageType::Game::REQUEST_USE) {}
struct RequestUse : public NetGameMsg {
RequestUse() : NetGameMsg(MessageType::Game::REQUEST_USE) {}
bool Deserialize(RakNet::BitStream& stream) override;
void Handle(Entity& entity, const SystemAddress& sysAddr) override;
@@ -819,11 +873,12 @@ namespace GameMessages {
// Used only for multi-interaction, is of the enum type InteractionType
int multiInteractType{};
};
using RequestUseEvent = NetGameMsgEvent<RequestUse>;
struct Smash : public GameMsg {
Smash() : GameMsg(MessageType::Game::SMASH) {}
struct Smash : public NetGameMsg {
Smash() : NetGameMsg(MessageType::Game::SMASH) {}
void Serialize(RakNet::BitStream& stream) const;
void Serialize(RakNet::BitStream& stream) const override;
bool bIgnoreObjectVisibility{};
bool force{};
@@ -831,19 +886,19 @@ namespace GameMessages {
LWOOBJID killerID{};
};
struct UnSmash : public GameMsg {
UnSmash() : GameMsg(MessageType::Game::UN_SMASH) {}
struct UnSmash : public NetGameMsg {
UnSmash() : NetGameMsg(MessageType::Game::UN_SMASH) {}
void Serialize(RakNet::BitStream& stream) const;
void Serialize(RakNet::BitStream& stream) const override;
LWOOBJID builderID{ LWOOBJID_EMPTY };
float duration{ 3.0f };
};
struct PlayBehaviorSound : public GameMsg {
PlayBehaviorSound() : GameMsg(MessageType::Game::PLAY_BEHAVIOR_SOUND) {}
struct PlayBehaviorSound : public NetGameMsg {
PlayBehaviorSound() : NetGameMsg(MessageType::Game::PLAY_BEHAVIOR_SOUND) {}
void Serialize(RakNet::BitStream& stream) const;
void Serialize(RakNet::BitStream& stream) const override;
int32_t soundID{ -1 };
};
@@ -857,8 +912,8 @@ namespace GameMessages {
bool bResetBehaviors{ true };
};
struct EmotePlayed : public GameMsg {
EmotePlayed() : GameMsg(MessageType::Game::EMOTE_PLAYED), emoteID(0), targetID(0) {}
struct EmotePlayed : public NetGameMsg {
EmotePlayed() : NetGameMsg(MessageType::Game::EMOTE_PLAYED), emoteID(0), targetID(0) {}
void Serialize(RakNet::BitStream& stream) const override;
@@ -880,8 +935,8 @@ namespace GameMessages {
bool bIgnoreChecks{ false };
};
struct DropClientLoot : public GameMsg {
DropClientLoot() : GameMsg(MessageType::Game::DROP_CLIENT_LOOT) {}
struct DropClientLoot : public NetGameMsg {
DropClientLoot() : NetGameMsg(MessageType::Game::DROP_CLIENT_LOOT) {}
void Serialize(RakNet::BitStream& stream) const override;
LWOOBJID sourceID{ LWOOBJID_EMPTY };
@@ -894,6 +949,7 @@ namespace GameMessages {
LWOOBJID lootID{ LWOOBJID_EMPTY };
LWOOBJID ownerID{ LWOOBJID_EMPTY };
};
using DropClientLootEvent = NetGameMsgEvent<DropClientLoot>;
struct GetMissionState : public GameMsg {
GetMissionState() : GameMsg(MessageType::Game::GET_MISSION_STATE) {}
@@ -923,17 +979,18 @@ namespace GameMessages {
LOT item{};
};
struct PickupItem : public GameMsg {
PickupItem() : GameMsg(MessageType::Game::PICKUP_ITEM) {}
struct PickupItem : public NetGameMsg {
PickupItem() : NetGameMsg(MessageType::Game::PICKUP_ITEM) {}
void Handle(Entity& entity, const SystemAddress& sysAddr) override;
bool Deserialize(RakNet::BitStream& stream) override;
LWOOBJID lootID{};
LWOOBJID lootOwnerID{};
};
using PickupItemEvent = NetGameMsgEvent<PickupItem>;
struct TeamPickupItem : public GameMsg {
TeamPickupItem() : GameMsg(MessageType::Game::TEAM_PICKUP_ITEM) {}
struct TeamPickupItem : public NetGameMsg {
TeamPickupItem() : NetGameMsg(MessageType::Game::TEAM_PICKUP_ITEM) {}
void Serialize(RakNet::BitStream& stream) const override;
LWOOBJID lootID{};
@@ -946,13 +1003,14 @@ namespace GameMessages {
bool bDead{};
};
struct ToggleGMInvis : public GameMsg {
ToggleGMInvis() : GameMsg(MessageType::Game::TOGGLE_GM_INVIS) {}
struct ToggleGMInvis : public NetGameMsg {
ToggleGMInvis() : NetGameMsg(MessageType::Game::TOGGLE_GM_INVIS) {}
void Serialize(RakNet::BitStream& stream) const override;
bool bStateOut{ false };
};
using ToggleGMInvisEvent = NetGameMsgEvent<ToggleGMInvis>;
struct GetGMInvis : public GameMsg {
GetGMInvis() : GameMsg(MessageType::Game::GET_GM_INVIS) {}
@@ -966,8 +1024,8 @@ namespace GameMessages {
LWOOBJID childID{};
};
struct UseSkillSet : public GameMsg {
UseSkillSet() : GameMsg(MessageType::Game::USE_SKILL_SET) {}
struct UseSkillSet : public NetGameMsg {
UseSkillSet() : NetGameMsg(MessageType::Game::USE_SKILL_SET) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool bRemove{};

View File

@@ -136,7 +136,7 @@ void DropFactionLoot(Entity& player, GameMessages::DropClientLoot& lootMsg) {
lootMsg.lootID = ObjectIDManager::GenerateObjectID();
CalcFinalDropPos(lootMsg);
// Register the drop on the player
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
// Visually drop it for the player
lootMsg.Send(player.GetSystemAddress());
}
@@ -165,7 +165,7 @@ void DropFactionLoot(const Team& team, GameMessages::DropClientLoot& lootMsg, co
lootMsg.lootID = ObjectIDManager::GenerateObjectID();
CalcFinalDropPos(lootMsg);
// Register the drop on this team member
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
// Show the rewards on all connected members of the team. Only the loot owner will be able to pick the tokens up.
DistrbuteMsgToTeam(lootMsg, team);
}
@@ -184,7 +184,7 @@ void DropPowerupLoot(Entity& player, GameMessages::DropClientLoot& lootMsg) {
lootMsg.target = playerID;
// Register the drop on the player
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
// Visually drop it for the player
lootMsg.Send(player.GetSystemAddress());
}
@@ -210,7 +210,7 @@ void DropPowerupLoot(const Team& team, GameMessages::DropClientLoot& lootMsg, co
lootMsg.target = member;
// By sending this message with the same ID to all players on the team, all players on the team are allowed to pick it up.
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
// No need to send to all members in a loop since that will happen by using the outer loop above and also since there is no owner
// sending to all will do nothing.
const auto* const memberEntity = Game::entityManager->GetEntity(member);
@@ -232,7 +232,7 @@ void DropMissionLoot(Entity& player, GameMessages::DropClientLoot& lootMsg) {
lootMsg.lootID = ObjectIDManager::GenerateObjectID();
CalcFinalDropPos(lootMsg);
// Register the drop with the player
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
// Visually drop the loot to be picked up
lootMsg.Send(player.GetSystemAddress());
}
@@ -261,7 +261,7 @@ void DropMissionLoot(const Team& team, GameMessages::DropClientLoot& lootMsg, co
lootMsg.lootID = ObjectIDManager::GenerateObjectID();
CalcFinalDropPos(lootMsg);
// Register the drop with the player
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
DistrbuteMsgToTeam(lootMsg, team);
}
}
@@ -279,7 +279,7 @@ void DropRegularLoot(Entity& player, GameMessages::DropClientLoot& lootMsg) {
lootMsg.target = playerID;
lootMsg.ownerID = playerID;
// Register the drop with the player
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
// Visually drop the loot to be picked up
lootMsg.Send(player.GetSystemAddress());
@@ -309,14 +309,14 @@ void DropRegularLoot(Team& team, GameMessages::DropClientLoot& lootMsg, const bo
if (team.lootOption == 0 /* Shared loot */) {
lootMsg.target = earningPlayer;
lootMsg.ownerID = earningPlayer;
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
} else /* Free for all loot */ {
lootMsg.ownerID = LWOOBJID_EMPTY;
// By sending the loot with NO owner and to ALL members of the team,
// its a first come, first serve with who picks the item up.
for (const auto ffaMember : team.members) {
lootMsg.target = ffaMember;
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
}
}
@@ -405,7 +405,7 @@ void DropLoot(Entity* player, const LWOOBJID source, const std::map<LOT, LootDro
lootMsg.sourceID = source;
lootMsg.item = LOT_NULL;
CalcFinalDropPos(lootMsg);
lootMsg.Send();
GameMessages::DeliverLocally(lootMsg);
const auto* const memberEntity = Game::entityManager->GetEntity(member);
if (memberEntity) lootMsg.Send(memberEntity->GetSystemAddress());
}

View File

@@ -91,7 +91,7 @@ namespace DEVGMCommands {
GameMessages::SendChatModeUpdate(entity->GetObjectID(), eGameMasterLevel::CIVILIAN);
entity->SetGMLevel(eGameMasterLevel::CIVILIAN);
GameMessages::ToggleGMInvis msg;
GameMessages::ToggleGMInvisEvent msg;
msg.Send(entity->GetObjectID());
GameMessages::SendSlashCommandFeedbackText(entity, u"Your game master level has been changed, you may not be able to use all commands.");
@@ -1593,7 +1593,8 @@ namespace DEVGMCommands {
objectInfo.target = closest->GetObjectID();
objectInfo.targetForReport = closest->GetObjectID();
objectInfo.clientId = entity->GetObjectID();
closest->HandleMsg(objectInfo);
GameMessages::RequestServerObjectInfoEvent objectInfoEvent(objectInfo);
closest->HandleMsg(objectInfoEvent);
Game::entityManager->SerializeEntity(closest);

View File

@@ -275,7 +275,7 @@ namespace GMGreaterThanZeroCommands {
}
void GmInvis(Entity* entity, const SystemAddress& sysAddr, const std::string args) {
GameMessages::ToggleGMInvis msg;
GameMessages::ToggleGMInvisEvent msg;
msg.Send(entity->GetObjectID());
}

View File

@@ -21,9 +21,13 @@ bool LUBitStream::ReadHeader(RakNet::BitStream& bitStream) {
return true;
}
void LUBitStream::Send(const SystemAddress& sysAddr) const {
RakNet::BitStream bitStream;
void LUBitStream::WritePacket(RakNet::BitStream& bitStream) const {
this->WriteHeader(bitStream);
this->Serialize(bitStream);
}
void LUBitStream::Send(const SystemAddress& sysAddr) const {
RakNet::BitStream bitStream;
this->WritePacket(bitStream);
Game::server->Send(bitStream, sysAddr, sysAddr == UNASSIGNED_SYSTEM_ADDRESS);
}

View File

@@ -7,6 +7,7 @@
#include "ServiceType.h"
#include <string>
#include <algorithm>
#include <type_traits>
#define VALIDATE_READ(x) do { if (!x) return false; } while (0)
@@ -51,6 +52,7 @@ struct LUBitStream {
uint32_t internalPacketID = 0xFFFFFFFF;
LUBitStream() = default;
virtual ~LUBitStream() = default;
template <typename T>
LUBitStream(ServiceType connectionType, T internalPacketID) {
@@ -63,6 +65,10 @@ struct LUBitStream {
void Send(const SystemAddress& sysAddr) const;
void Broadcast() const { Send(UNASSIGNED_SYSTEM_ADDRESS); };
// Writes the complete packet (WriteHeader() then Serialize()) into bitStream.
// This is exactly what Send puts on the wire; tests use it to compare bytes without a server.
void WritePacket(RakNet::BitStream& bitStream) const;
virtual void Serialize(RakNet::BitStream& bitStream) const {}
virtual bool Deserialize(RakNet::BitStream& bitStream) { return true; }
virtual void Handle() {};
@@ -77,6 +83,59 @@ namespace BitStreamUtils {
bitStream.Write(static_cast<uint32_t>(internalPacketID));
bitStream.Write<uint8_t>(0);
}
/**
* Writes an optional ("default flag") field: one bit saying whether value differs from defaultValue, then
* the value itself only if it does. This is how the client encodes game message parameters that have a default.
*/
template<typename T>
void WriteOptional(RakNet::BitStream& bitStream, const T& value, const T& defaultValue) {
const bool isNotDefault = value != defaultValue;
bitStream.Write(isNotDefault);
if (isNotDefault) bitStream.Write(value);
}
/**
* Reads a field written by WriteOptional. If the flag bit is not set, value is set to defaultValue.
*/
template<typename T>
bool ReadOptional(RakNet::BitStream& bitStream, T& value, const T& defaultValue) {
bool isNotDefault = false;
if (!bitStream.Read(isNotDefault)) return false;
if (!isNotDefault) {
value = defaultValue;
return true;
}
return bitStream.Read(value);
}
/**
* Writes a length prefixed string: a LenT holding the number of characters, followed by the raw
* characters (1 byte each for std::string, 2 bytes each for std::u16string) with no null terminator.
* This is the layout the client uses for std::string / std::wstring game message fields.
*/
template<typename LenT = uint32_t, typename StringT>
void WriteLengthPrefixed(RakNet::BitStream& bitStream, const StringT& value) {
bitStream.Write<LenT>(static_cast<LenT>(value.size()));
bitStream.WriteBits(reinterpret_cast<const unsigned char*>(value.data()), BYTES_TO_BITS(value.size() * sizeof(typename StringT::value_type)));
}
/**
* Reads a string written by WriteLengthPrefixed. Fails (returns false) if the stream runs out of data or
* if the length is negative or larger than maxLength characters.
*/
template<typename LenT = uint32_t, typename StringT>
bool ReadLengthPrefixed(RakNet::BitStream& bitStream, StringT& value, const uint32_t maxLength = 0x500000 /* MAX_MESSAGE_LENGTH */) {
LenT length{};
if (!bitStream.Read(length)) return false;
if constexpr (std::is_signed_v<LenT>) {
if (length < 0) return false;
}
if (static_cast<uint64_t>(length) > maxLength) return false;
value.resize(length);
if (length == 0) return true;
return bitStream.ReadBits(reinterpret_cast<unsigned char*>(value.data()), BYTES_TO_BITS(value.size() * sizeof(typename StringT::value_type)), true);
}
}
namespace RakNet {

View File

@@ -147,7 +147,6 @@ void ChatPackets::SendRoutedMsg(const LUBitStream& msg, const LWOOBJID targetID,
bitStream.Write(targetID);
// Now write the actual packet
msg.WriteHeader(bitStream);
msg.Serialize(bitStream);
msg.WritePacket(bitStream);
Game::server->Send(bitStream, sysAddr, sysAddr == UNASSIGNED_SYSTEM_ADDRESS);
}

271
docs/PacketArchitecture.md Normal file
View File

@@ -0,0 +1,271 @@
# Packet and Game Message Architecture
This document describes how DLU reads and writes network packets and game messages, the struct-based
architecture everything is being moved to, and the plan for getting there.
**Hard rule: the wire format never changes as part of a conversion.** The LEGO Universe 1.10.64 client depends
on every message ID, enum value, field order, field width and bit of padding. A conversion may only change how
the server *produces* the bytes, never the bytes. Every conversion proves this with byte-equality tests against
the old code before the old code is deleted (see [Verification](#3-verification)). Where DLU's bytes are known to
be wrong, the fix is its own clearly labelled change, verified against the client, never part of a conversion.
Sources for layouts, in order of trust: the 1.10.64 client in Ghidra, then lu_packets, then lcdr-utils packet
definitions. When they disagree, the client wins.
## 1. Where we are (survey, `origin/main` @ 129199e4)
### 1.1 The newer style already in the tree
| Family | Base | Header written by | Handler signature | Dispatch | Users on main |
|---|---|---|---|---|---|
| Game messages | `GameMessages::GameMsg` (`dGame/dGameMessages/GameMessages.h`) | `GameMsg::Send(sysAddr)` writes `CLIENT/GAME_MSG` header, `target`, `msgId`, then `Serialize()` | `Handle(Entity&, const SystemAddress&)` | `g_MessageHandlers` map in `GameMessageHandler.cpp` (4 entries), with a GM-level check; everything else is a 112-case switch | 34 structs: 12 outbound (`Serialize`), 4 inbound (`Deserialize` + `Handle`), 18 server-internal events used via `Entity::RegisterMsg`/`HandleMsg` and never sent |
| Packets | `LUBitStream` (`dNet/BitStreamUtils.h`) | `LUBitStream::WriteHeader` (`0x53`, `ServiceType` u16, packet id u32, pad u8) | `Handle()` (context comes from members such as `sysAddr`, `player`) | `g_Handlers` map in `Mail.cpp`; nothing else | Mail (6 requests + 5 responses, with a `MailLUBitStream` sub-header), `ChatPackets::{Announcement, AchievementNotify, TeamInviteInitialResponse}` |
| Old one-off classes | none | the class writes its own `msgId` in a non-virtual `Serialize` | deserialized in a constructor, result ignored | switch | `EchoStartSkill`, `EchoSyncSkill`, `StartSkill`, `SyncSkill`, `RequestServerProjectileImpact`, `DoClientProjectileImpact`, `PropertyDataMessage`, `PropertySelectQueryProperty`, `ShowAllRequest`, `FindPlayerRequest`, `HTTPMonitorInfo` |
Helpers: `LUString`/`LUWString` (fixed-width, zero-padded strings, default 33 chars) with `BitStream`
specializations; `BitStreamUtils::WriteHeader`; `VALIDATE_READ`; macros `CBITSTREAM`, `CMSGHEADER`,
`CINSTREAM(_SKIP_HEADER)`, `SEND_PACKET(_BROADCAST)` in `dCommonVars.h`.
### 1.2 What is left in the old style
Hand-written `bitStream.Write`/`Read` sequences inside functions:
| Area | Where | Count |
|---|---|---|
| Outbound game messages | `GameMessages::Send*` in `GameMessages.cpp` (6.5k lines) | **161** functions (162 `CMSGHEADER` uses) |
| Inbound game messages | `GameMessages::Handle*` + `GameMessageHandler` switch | **101** functions, **112** switch cases |
| Game messages written outside `GameMessages.cpp` | `dChatServer/TeamContainer.cpp` (routed through chat), the 6 one-off skill/projectile classes | 6 + 6 |
| Auth + Common | `AuthPackets.cpp` (`HandleHandshake`, `SendHandshake`, `HandleLoginRequest`, `SendLoginResponse`), `dServer::Disconnect`, `AuthServer` if-chain | 4 functions + 1 |
| World | `WorldPackets.cpp` (11 `Send*`), `ClientPackets.cpp` (4 parse functions), `UserManager.cpp` (char list/create/delete/rename), `WorldServer::HandlePacket` inline (15 World + 4 Chat + 4 Master cases) | ~40 |
| Chat | `ChatPacketHandler.cpp` (15), `TeamContainer.cpp` (7 handlers + 7 sends), `ChatIgnoreList.cpp` (3), `PlayerContainer.cpp`, `ChatPackets.cpp` (4 functions + 2 structs), `ChatServer` switch (26 handled cases) | ~55 |
| Master | `MasterPackets.cpp` (7), `MasterServer` switch (13 inline cases), `InstanceManager.cpp` (2), `ZoneInstanceManager.cpp` | ~25 |
| Raw header sites | `BitStreamUtils::WriteHeader(` calls | 90 in 25 files |
| Raw read sites | `CINSTREAM*` uses / `packet->data[i]` peeks | 58 / 29 |
Out of scope: replica/component serialization (`Component::Serialize`) and LDF/AMF, which are separate formats
with their own tests.
### 1.3 Inconsistencies inside the new style, and how they are settled
| # | Inconsistency | Decision |
|---|---|---|
| 1 | Three packet base designs in flight: `LUBitStream` on main, the unmerged `auth-packet-re-write` redesign (per-service bases), the old local `packet-refactor` branch (`dNet/packets/*`). | **`LUBitStream` as on main.** The other branches are not used. |
| 2 | `GameMessageHandler` ignored the result of `Deserialize`. | **Drop and log** messages that fail to deserialize (PR 0). |
| 3 | The same `GameMsg` type was used for wire messages and server-internal events; `msg.Send()` and `msg.Send(sysAddr)` went to different places. | **Split** into `GameMsg` (internal) and `NetGameMsg` (wire); mixing them is a compile error (PR 0). |
| 4 | Registration is ad hoc: one GM map + switch, a Mail map, switches or if-chains in every server. | One map per message family (see 2.1, 2.2). |
| 5 | Struct locations: 34 structs in the 1000-line `GameMessages.h`, 6 one-off headers, Mail in `Mail.h`, chat structs partly outside any namespace. | Per-domain files (see 2.3). |
| 6 | Naming: some fields use the client's names, some don't; stream parameters are `stream`/`bitStream`/`bitstream`/`inStream`; some overrides lack `override`. | Client names, `bitStream`, always `override` (new code; old structs as they are touched). |
| 7 | Optional ("default flag") fields written by hand; this produced a wire bug in `UnSmash` (flag checks `duration`, value guarded by `builderID != 3.0f`). | `BitStreamUtils::WriteOptional`/`ReadOptional` (PR 0). `UnSmash` fixed in its own wire-change commit. |
| 8 | Length-prefixed strings written with per-character loops. | `BitStreamUtils::WriteLengthPrefixed`/`ReadLengthPrefixed` (PR 0). |
| 9 | 73 `Send*` functions broadcast and then call `Send(UNASSIGNED, broadcast = false)`, which RakNet rejects (harmless). | Goes away with `NetGameMsg::Send`. |
| 10 | `dServerMock::Send` kept a pointer to the caller's stack `BitStream`; `LUBitStream` had virtual functions but no virtual destructor while dispatchers delete through the base. | Fixed in PR 0. |
## 2. Target architecture
### 2.1 Game messages
There are two kinds of game message and they are different types:
- **`GameMessages::NetGameMsg`** - goes on the wire. `Send(sysAddr)` (UNASSIGNED broadcasts), `SendToClient(sysAddr)`
(one client only, never broadcasts; used where the old function only did `SEND_PACKET`), `WritePacket`,
`Serialize`, `Deserialize`, `Handle(Entity&, sysAddr)`, `requiredGmLevel`.
- **`GameMessages::GameMsg`** - a server-internal event. `Send()` / `Send(target)` deliver it to the handlers
entities, components and scripts registered with `RegisterMsg`. It cannot reach a client.
- **`GameMessages::NetGameMsgEvent<Msg>`** (aliases such as `RequestUseEvent`, `DropClientLootEvent`) wraps a copy of
a wire message so local handlers can react to it. `GameMessages::DeliverLocally(msg)` sends one to `msg.target`.
Every wire message is a `NetGameMsg` subclass in a per-domain file `dGame/dGameMessages/<Domain>Messages.{h,cpp}`
(pilot: `ActivityMessages`). Fields use the client's names and are declared in wire order. Every wire message
implements **both** `Serialize` and `Deserialize`, even if DLU only uses one direction, so it can be round-trip
tested.
```cpp
// dGame/dGameMessages/ActivityMessages.h
namespace GameMessages {
// Server -> client.
struct ShowActivityCountdown : public NetGameMsg {
ShowActivityCountdown() : NetGameMsg(MessageType::Game::SHOW_ACTIVITY_COUNTDOWN) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;
bool bPlayAdditionalSound{};
bool bPlayCountdownSound{};
std::u16string sndName{};
int32_t stateToPlaySoundOn{};
};
// Client -> server.
struct RequestActivityExit : public NetGameMsg {
RequestActivityExit() : NetGameMsg(MessageType::Game::REQUEST_ACTIVITY_EXIT) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;
void Handle(Entity& entity, const SystemAddress& sysAddr) override;
bool bUserCancel{};
LWOOBJID userID{};
};
}
// dGame/dGameMessages/ActivityMessages.cpp
void ShowActivityCountdown::Serialize(RakNet::BitStream& bitStream) const {
bitStream.Write(bPlayAdditionalSound);
bitStream.Write(bPlayCountdownSound);
BitStreamUtils::WriteLengthPrefixed<uint32_t>(bitStream, sndName);
bitStream.Write(stateToPlaySoundOn);
}
bool ShowActivityCountdown::Deserialize(RakNet::BitStream& bitStream) {
VALIDATE_READ(bitStream.Read(bPlayAdditionalSound));
VALIDATE_READ(bitStream.Read(bPlayCountdownSound));
VALIDATE_READ(BitStreamUtils::ReadLengthPrefixed<uint32_t>(bitStream, sndName));
VALIDATE_READ(bitStream.Read(stateToPlaySoundOn));
return true;
}
```
Sending replaces the `Send*` free function:
```cpp
GameMessages::ShowActivityCountdown countdown;
countdown.target = self->GetObjectID();
countdown.Send(sender->GetSystemAddress()); // UNASSIGNED_SYSTEM_ADDRESS broadcasts
```
Inbound messages are registered with one line in `GameMessageHandler.cpp`; the GM-level gate comes from the
struct, and a message whose `Deserialize` fails is logged and dropped before `Handle`:
```cpp
std::map<MessageType::Game, MessageCreator> g_MessageHandlers = {
...
{ REQUEST_ACTIVITY_EXIT, []() { return std::make_unique<RequestActivityExit>(); } },
};
```
When every case is converted the switch is deleted and `GameMessages.cpp` goes away.
### 2.2 Packets (`LUBitStream`, as on main)
Every non-game-message packet is an `LUBitStream` subclass. The base owns the full header:
- `LUBitStream(ServiceType, packetId)` - `WriteHeader` writes `0x53`, the `ServiceType` (u16), the packet id (u32)
and one pad byte; `ReadHeader` reads and checks the same.
- `WritePacket(bitStream)` = `WriteHeader` + `Serialize`; `Send(sysAddr)` sends that (`UNASSIGNED` broadcasts via
`Broadcast()`). Tests use `WritePacket`.
- Leaf structs only (de)serialize their own fields. A feature with its own sub-header (Mail's `eMessageID`) has an
intermediate base whose `Serialize`/`Deserialize` handle the sub-header, and leaf structs call it first
(`MailLUBitStream` is the model).
- `Handle()` takes no arguments; the dispatcher sets context members (`sysAddr`, and per-service extras such as
Mail's `player`) before calling `Deserialize` and `Handle`.
- Each service has one dispatch function with a `std::map<MessageType::X, factory>`, exactly like
`Mail::HandleMail`: read the id, create, set context, `Deserialize` (log and drop on failure), `Handle`. Each
server's `HandlePacket` only routes by `ServiceType`.
- Structs live in their service's namespace and file: `AuthPackets`, `ChatPackets`, `ClientPackets`,
`CommonPackets` (new, for `ServiceType::COMMON`), `MasterPackets`, `WorldPackets` in `dNet/`; Mail stays in
`dGame/dUtilities/Mail.*`. Handlers that need game or server state stay in the server that owns that state
(`dChatServer`, `dMasterServer`, `dWorldServer`, `dAuthServer`) and are attached to the struct by overriding
`Handle` in that server's translation unit.
- Fixed-width strings use `LUString`/`LUWString` with the width spelled out (`LUWString password(41)`).
```cpp
// dNet/ChatPackets.h
struct AchievementNotify : public LUBitStream {
LUWString targetPlayerName{};
uint32_t missionEmailID{};
LWOOBJID earningPlayerID{};
LUWString earnerName{};
AchievementNotify() : LUBitStream(ServiceType::CHAT, MessageType::Chat::ACHIEVEMENT_NOTIFY) {}
void Serialize(RakNet::BitStream& bitStream) const override;
bool Deserialize(RakNet::BitStream& bitStream) override;
};
```
### 2.3 Conventions
- Wire order = declaration order. Comment the direction (`// Server -> client.`) and anything odd (padding,
unknown fields: write them as named constants, never drop them).
- Explicit widths for everything written: `bitStream.Write<uint32_t>(x.size())`, never `size_t`, `int`, `long` or
`float_t`. Enums keep their declared underlying type.
- `Deserialize` returns `false` on the first failed read (`VALIDATE_READ`) and bounds-checks lengths
(`ReadLengthPrefixed` rejects negative sizes and anything above `MAX_MESSAGE_LENGTH` by default).
- Optional/default-flag fields: `BitStreamUtils::WriteOptional(bs, value, default)` / `ReadOptional`.
- Length-prefixed strings: `BitStreamUtils::WriteLengthPrefixed<LenT>` / `ReadLengthPrefixed<LenT>`.
- No new `CBITSTREAM` / `CMSGHEADER` / `SEND_PACKET` macros or `Send*` wrapper functions; build the struct at the
call site.
- Message IDs are append-only: never renumber, reuse or delete a value. `tests/dCommonTests/MessageIdPinTests.cpp`
and `tests/dGameTests/MailIdPinTests.cpp` pin every enumerator at compile time.
- Behaviour changes and wire fixes are separate, clearly labelled commits, never part of a conversion.
### 2.4 Testing helpers
- `tests/dGameTests/GameDependencies.h`: `dServerMock` copies every sent packet (`GetSentPackets()`, each with
bytes, exact bit count, address and broadcast flag).
- `tests/dGameTests/PacketTestUtils.h`: `Capture(fn)`, `FromBitStream`, `FromHex`, `ToHex`, `EXPECT_PACKET_EQ`
(compares bit count and bytes and prints both as hex on failure).
- `NetGameMsg::WritePacket` / `LUBitStream::WritePacket` produce exactly what `Send` puts on the wire.
## 3. Verification
Every conversion carries these tests, in `tests/dGameTests/dGameMessagesTests/<Domain>MessagesTests.cpp` (or a
`dNet` test directory for packets):
1. **Frozen oracle.** Copy the old functions *verbatim* into `tests/.../Legacy/<Domain>Legacy.h` (namespace
`Legacy*`; only the namespace changes). A temporary test sends the same inputs through the production function
and the oracle and requires identical bytes; it is deleted in the commit that deletes the production code. From
then on the oracle pins the old bytes.
2. **Byte equality.** For a grid of inputs (every bool combination, empty/ASCII/non-ASCII/long strings,
0/negative/max numbers, default and non-default values of every optional field) send through the oracle and
through the struct, to one client and as a broadcast. Require identical bits, identical bytes and the same
effective destination.
3. **Golden bytes.** At least one hand-computed hex packet per message family, independent of both
implementations.
4. **Round trip.** `Serialize` -> `Deserialize` -> `Serialize` gives the same bytes and fields and consumes every
bit.
5. **Inbound.** For handlers, compare the struct's `Deserialize` with the oracle's read sequence, and check a
truncated stream returns `false`.
6. **Mutation check** while writing the tests: break one field width and confirm the tests fail.
7. **Live captures, locally only.** Decoding real captured packets with the struct and re-serializing them is a
useful extra check, but it is done with a local script outside the repository. **No capture-derived bytes are
ever committed, not even anonymized.**
8. **Smoke test** the affected feature with the real 1.10.64 client before merging (tests cannot cover handler
behaviour).
## 4. Migration plan
Small, self-contained PRs by subsystem: add structs + oracle + tests, switch callers, delete old code. Each PR is a
clean range of commits on the working branch. Target: under ~1.5k changed lines per PR.
| # | PR | Contents | Size |
|---|---|---|---|
| 0 | Foundations | Test infra (`dServerMock`, `PacketTestUtils`, `WritePacket`), string and optional helpers, compile-time pins of every message ID enum, drop GMs that fail to deserialize, `LUBitStream` virtual destructor and `WritePacket`, the `GameMsg`/`NetGameMsg` split, this document. | S |
| W1 | Wire fix: `UnSmash` | The one intended wire change: stop writing `duration` when it has its default, matching the client. | XS |
| 1 | Activity GMs | `ActivityMessages`: 8 outbound + `RequestActivityExit` inbound. | S |
| 2 | GMs: racing and vehicles | `SendVehicle*`, `SendRacing*`, `SendNotifyRacingClient`, `HandleRacing*`, `HandleVehicle*`, module assembly | M |
| 3 | GMs: effects, audio, animation, UI text | FX, animations, ND audio, 2D ambient, UI messages, message boxes, chat bubbles, billboards, cinematics, tooltips | M |
| 4 | GMs: missions, flags, levels | Offer/notify mission(+task), respond, dialog OK, linked missions, flags, collectibles, level rewards | M |
| 5 | GMs: inventory and items | Add/remove/move/equip/unequip, inventory size and groups, use/consume item, `UpdateInventoryUi` | M |
| 6 | GMs: vendors, donation, trade | Vendor window/status/transactions, buyback, donation vendor, trade | M |
| 7 | GMs: combat and skills | Skill add/remove, stun, buffs, die/resurrect/smash, knockback; the one-off `EchoStartSkill`/`EchoSyncSkill`/`StartSkill`/`SyncSkill`/projectile classes become `NetGameMsg`s | M |
| 8 | GMs: pets | Taming minigame, pet naming, commands | M |
| 9 | GMs: property and building | Property management, models, BBB, modular build, `PropertyDataMessage`, `PropertySelectQueryProperty`, `ControlBehaviors` (may split in two) | L |
| 10 | GMs: remaining + switch removal | Movement, teleport, platforms, rails, camera, control scheme, misc, team GMs sent from `TeamContainer`; delete the switch and `GameMessages.cpp` | M |
| 11 | Common + Auth packets | `CommonPackets` (version confirm, disconnect notify, general notify; `dServer::Disconnect`), `AuthPackets` login request, `ClientPackets` login response + stamps; `AuthServer` dispatch map | S |
| 12 | World packets | Validation, character list/create/delete/rename, world login, level load complete, position update, string check, general chat, route packet, top-5, funness, and the `ClientPackets` responses they send; handlers move out of the `WorldServer.cpp` switch into a dispatch map | M |
| 13 | Chat packets | Friends, ignore list, teams, who/show-all, private/general chat, routing (`WORLD_ROUTE_PACKET` wraps an `LUBitStream`), achievement notify, GM announce/mute, plus the chat-side of `WorldServer`'s chat cases (may split friends/teams) | L |
| 14 | Master packets | Session keys, zone transfer, private zones, player added/removed, world ready, prep zone, shutdown; `MasterServer` switch becomes a dispatch map; `InstanceManager`/`ZoneInstanceManager` senders | M |
| 15 | Cleanup | Remove `CBITSTREAM`/`CMSGHEADER`/`SEND_PACKET*`/`CINSTREAM*`, the free `BitStreamUtils::WriteHeader`, `PacketUtils`; update this document | S |
Game-message PRs (2-10) are independent of each other and of the packet PRs (11-14), so they can be reviewed in
any order after PR 0.
### Risks
- **Silent wire fixes.** A struct written from the client's definition can differ from what DLU has been sending
(e.g. `UnSmash`). The oracle test catches it; the conversion must reproduce the old bytes, and the fix goes in its
own commit.
- **Implicit widths at old call sites** (`int`, `char`, `size_t`, `float_t`, enum underlying types, 1-bit bools vs
`uint8_t`). The byte-equality grid catches these; that is why the oracle is copied verbatim.
- **Default arguments.** Old `Send*` defaults must become the struct's member initializers exactly.
- **Handler behaviour.** Keep each handler's logic verbatim in the conversion; behaviour changes are separate.
- **Merge conflicts** with feature work in `GameMessages.cpp`: small PRs, landed domain by domain.
- **Personal data.** Captures contain account names, chat and IDs; they stay out of the repository entirely.

View File

@@ -9,6 +9,8 @@ set(DCOMMONTEST_SOURCES
"TestEncoding.cpp"
"TestLUString.cpp"
"TestLUWString.cpp"
"TestBitStreamUtils.cpp"
"MessageIdPinTests.cpp"
"dCommonDependencies.cpp"
"LxfmlTests.cpp"
)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,114 @@
#include <gtest/gtest.h>
#include "BitStreamUtils.h"
#include <vector>
namespace {
std::vector<uint8_t> Bytes(RakNet::BitStream& bitStream) {
return { bitStream.GetData(), bitStream.GetData() + bitStream.GetNumberOfBytesUsed() };
}
}
// The helper must produce exactly what the hand written "size then one char at a time" loops produce.
TEST(BitStreamUtilsTests, WriteLengthPrefixedU16MatchesPerCharacterLoop) {
const std::u16string value = u"sfx/countdown";
RakNet::BitStream expected;
expected.Write(true); // misalign the stream by one bit, like a leading bool field would
expected.Write<uint32_t>(value.size());
for (const auto character : value) expected.Write(character);
RakNet::BitStream actual;
actual.Write(true);
BitStreamUtils::WriteLengthPrefixed(actual, value);
ASSERT_EQ(actual.GetNumberOfBitsUsed(), expected.GetNumberOfBitsUsed());
ASSERT_EQ(Bytes(actual), Bytes(expected));
}
TEST(BitStreamUtilsTests, WriteLengthPrefixedStringGolden) {
RakNet::BitStream bitStream;
BitStreamUtils::WriteLengthPrefixed<uint16_t>(bitStream, std::string("ab"));
const std::vector<uint8_t> golden = { 0x02, 0x00, 'a', 'b' };
ASSERT_EQ(Bytes(bitStream), golden);
}
TEST(BitStreamUtilsTests, LengthPrefixedRoundTrip) {
const std::u16string value = u"Hello ü";
RakNet::BitStream bitStream;
bitStream.Write(false);
BitStreamUtils::WriteLengthPrefixed(bitStream, value);
BitStreamUtils::WriteLengthPrefixed(bitStream, std::u16string());
bool leading = true;
std::u16string read = u"garbage";
std::u16string empty = u"garbage";
ASSERT_TRUE(bitStream.Read(leading));
ASSERT_TRUE(BitStreamUtils::ReadLengthPrefixed(bitStream, read));
ASSERT_TRUE(BitStreamUtils::ReadLengthPrefixed(bitStream, empty));
EXPECT_FALSE(leading);
EXPECT_EQ(read, value);
EXPECT_TRUE(empty.empty());
EXPECT_EQ(bitStream.GetNumberOfUnreadBits(), 0);
}
TEST(BitStreamUtilsTests, ReadLengthPrefixedRejectsBadLengths) {
{
RakNet::BitStream bitStream;
bitStream.Write<int32_t>(-1);
std::u16string out;
EXPECT_FALSE(BitStreamUtils::ReadLengthPrefixed<int32_t>(bitStream, out));
}
{
RakNet::BitStream bitStream;
bitStream.Write<uint32_t>(11);
std::string out;
EXPECT_FALSE(BitStreamUtils::ReadLengthPrefixed(bitStream, out, 10));
}
{
// Claims 4 characters but only has 1.
RakNet::BitStream bitStream;
bitStream.Write<uint32_t>(4);
bitStream.Write<char16_t>(u'a');
std::u16string out;
EXPECT_FALSE(BitStreamUtils::ReadLengthPrefixed(bitStream, out));
}
}
// WriteOptional must match the hand written "flag, then value if not default" pattern.
TEST(BitStreamUtilsTests, WriteOptionalMatchesHandWrittenPattern) {
for (const int32_t value : { 0, -1, 7 }) {
RakNet::BitStream expected;
expected.Write(value != -1);
if (value != -1) expected.Write(value);
RakNet::BitStream actual;
BitStreamUtils::WriteOptional<int32_t>(actual, value, -1);
ASSERT_EQ(actual.GetNumberOfBitsUsed(), expected.GetNumberOfBitsUsed());
ASSERT_EQ(Bytes(actual), Bytes(expected));
int32_t read = 12345;
ASSERT_TRUE(BitStreamUtils::ReadOptional<int32_t>(actual, read, -1));
EXPECT_EQ(read, value);
EXPECT_EQ(actual.GetNumberOfUnreadBits(), 0);
}
}
TEST(BitStreamUtilsTests, WriteOptionalGolden) {
RakNet::BitStream bitStream;
BitStreamUtils::WriteOptional(bitStream, 3.0f, 3.0f); // default: a single 0 bit
BitStreamUtils::WriteOptional(bitStream, 1.0f, 3.0f); // 1 bit, then 00 00 80 3f
// 0 1 00000000 00000000 10000000 00111111 (MSB first) padded
const std::vector<uint8_t> golden = { 0x40, 0x00, 0x20, 0x0f, 0xc0 };
ASSERT_EQ(bitStream.GetNumberOfBitsUsed(), 34);
ASSERT_EQ(Bytes(bitStream), golden);
}
TEST(BitStreamUtilsTests, ReadOptionalFailsOnTruncatedValue) {
RakNet::BitStream bitStream;
bitStream.Write(true);
bitStream.Write<uint8_t>(1);
uint32_t out{};
EXPECT_FALSE(BitStreamUtils::ReadOptional<uint32_t>(bitStream, out, 0));
}

View File

@@ -1,5 +1,7 @@
set(DGAMETEST_SOURCES
"GameDependencies.cpp"
"LUBitStreamTests.cpp"
"MailIdPinTests.cpp"
)
add_subdirectory(dComponentsTests)

View File

@@ -12,17 +12,42 @@
#include "GameDatabase/TestSQL/TestSQLDatabase.h"
#include "Database.h"
#include <gtest/gtest.h>
#include <vector>
class dZoneManager;
class AssetManager;
// A packet captured by dServerMock::Send. The bytes are copied, so the capture stays valid
// after the sender's local BitStream goes out of scope.
struct CapturedPacket {
std::vector<uint8_t> bytes;
uint32_t bits = 0;
SystemAddress sysAddr = UNASSIGNED_SYSTEM_ADDRESS;
bool broadcast = false;
};
class dServerMock : public dServer {
RakNet::BitStream* sentBitStream = nullptr;
std::vector<CapturedPacket> sentPackets;
RakNet::BitStream mostRecent;
public:
dServerMock() {};
~dServerMock() {};
RakNet::BitStream* GetMostRecentBitStream() { return sentBitStream; };
void Send(RakNet::BitStream& bitStream, const SystemAddress& sysAddr, bool broadcast) override { sentBitStream = &bitStream; };
// Returns a copy of the most recently sent packet, with its read pointer at the start, or nullptr if nothing was sent.
RakNet::BitStream* GetMostRecentBitStream() { return sentPackets.empty() ? nullptr : &mostRecent; };
const std::vector<CapturedPacket>& GetSentPackets() const { return sentPackets; }
void ClearSentPackets() { sentPackets.clear(); mostRecent.Reset(); }
void Send(RakNet::BitStream& bitStream, const SystemAddress& sysAddr, bool broadcast) override {
CapturedPacket& packet = sentPackets.emplace_back();
packet.bits = bitStream.GetNumberOfBitsUsed();
packet.bytes.assign(bitStream.GetData(), bitStream.GetData() + bitStream.GetNumberOfBytesUsed());
packet.sysAddr = sysAddr;
packet.broadcast = broadcast;
mostRecent.Reset();
mostRecent.WriteBits(packet.bytes.data(), packet.bits, false);
};
void SetZoneId(unsigned int zoneId) { mZoneID = zoneId; }
};

View File

@@ -0,0 +1,56 @@
#include <gtest/gtest.h>
#include "BitStreamUtils.h"
#include "GameDependencies.h"
#include "PacketTestUtils.h"
#include <vector>
namespace {
std::vector<uint8_t> Bytes(RakNet::BitStream& bitStream) {
return { bitStream.GetData(), bitStream.GetData() + bitStream.GetNumberOfBytesUsed() };
}
}
namespace {
struct TestPacket : public LUBitStream {
uint32_t value = 0;
TestPacket() : LUBitStream(ServiceType::CHAT, 0x1234u) {}
void Serialize(RakNet::BitStream& bitStream) const override { bitStream.Write(value); }
bool Deserialize(RakNet::BitStream& bitStream) override { return bitStream.Read(value); }
};
}
TEST(LUBitStreamStructTests, WritePacketGoldenAndRoundTrip) {
TestPacket packet;
packet.value = 0xAABBCCDD;
RakNet::BitStream bitStream;
packet.WritePacket(bitStream);
// 0x53 | service u16 | packet id u32 | pad u8 | payload
const std::vector<uint8_t> golden = { 0x53, 0x02, 0x00, 0x34, 0x12, 0x00, 0x00, 0x00, 0xDD, 0xCC, 0xBB, 0xAA };
ASSERT_EQ(Bytes(bitStream), golden);
TestPacket read;
ASSERT_TRUE(read.ReadHeader(bitStream));
EXPECT_EQ(read.connectionType, ServiceType::CHAT);
EXPECT_EQ(read.internalPacketID, 0x1234u);
ASSERT_TRUE(read.Deserialize(bitStream));
EXPECT_EQ(read.value, 0xAABBCCDD);
}
class LUBitStreamTests : public GameDependenciesTest {
protected:
void SetUp() override { SetUpDependencies(); }
void TearDown() override { TearDownDependencies(); }
};
TEST_F(LUBitStreamTests, SendWritesWhatWritePacketWrites) {
TestPacket packet;
packet.value = 7;
RakNet::BitStream expected;
packet.WritePacket(expected);
const auto sent = PacketTestUtils::Capture([&] { packet.Send(UNASSIGNED_SYSTEM_ADDRESS); });
ASSERT_EQ(sent.size(), 1);
EXPECT_TRUE(sent[0].broadcast);
EXPECT_PACKET_EQ(PacketTestUtils::FromBitStream(expected), PacketTestUtils::FromCapture(sent[0]));
}

View File

@@ -0,0 +1,94 @@
// GENERATED by a one-off script from the enum definitions at the time this file was added.
// Message IDs are sent on the wire and are append-only: never renumber, reuse or remove one.
// If one of these fails to compile, the change to the enum is wrong, not this file.
// New enumerators may be added freely; add a pin for them here too.
#include "Mail.h"
#include <cstdint>
#include <gtest/gtest.h>
// Mail::eMessageID: 21 enumerators
static_assert(static_cast<int64_t>(Mail::eMessageID::SendRequest) == 0);
static_assert(static_cast<int64_t>(Mail::eMessageID::SendResponse) == 1);
static_assert(static_cast<int64_t>(Mail::eMessageID::NotificationResponse) == 2);
static_assert(static_cast<int64_t>(Mail::eMessageID::DataRequest) == 3);
static_assert(static_cast<int64_t>(Mail::eMessageID::DataResponse) == 4);
static_assert(static_cast<int64_t>(Mail::eMessageID::AttachmentCollectRequest) == 5);
static_assert(static_cast<int64_t>(Mail::eMessageID::AttachmentCollectResponse) == 6);
static_assert(static_cast<int64_t>(Mail::eMessageID::DeleteRequest) == 7);
static_assert(static_cast<int64_t>(Mail::eMessageID::DeleteResponse) == 8);
static_assert(static_cast<int64_t>(Mail::eMessageID::ReadRequest) == 9);
static_assert(static_cast<int64_t>(Mail::eMessageID::ReadResponse) == 10);
static_assert(static_cast<int64_t>(Mail::eMessageID::NotificationRequest) == 11);
static_assert(static_cast<int64_t>(Mail::eMessageID::AuctionCreate) == 12);
static_assert(static_cast<int64_t>(Mail::eMessageID::AuctionCreationResponse) == 13);
static_assert(static_cast<int64_t>(Mail::eMessageID::AuctionCancel) == 14);
static_assert(static_cast<int64_t>(Mail::eMessageID::AuctionCancelResponse) == 15);
static_assert(static_cast<int64_t>(Mail::eMessageID::AuctionList) == 16);
static_assert(static_cast<int64_t>(Mail::eMessageID::AuctionListResponse) == 17);
static_assert(static_cast<int64_t>(Mail::eMessageID::AuctionBid) == 18);
static_assert(static_cast<int64_t>(Mail::eMessageID::AuctionBidResponse) == 19);
static_assert(static_cast<int64_t>(Mail::eMessageID::UnknownError) == 20);
// Mail::eSendResponse: 15 enumerators
static_assert(static_cast<int64_t>(Mail::eSendResponse::Success) == 0);
static_assert(static_cast<int64_t>(Mail::eSendResponse::NotEnoughCoins) == 1);
static_assert(static_cast<int64_t>(Mail::eSendResponse::AttachmentNotFound) == 2);
static_assert(static_cast<int64_t>(Mail::eSendResponse::ItemCannotBeMailed) == 3);
static_assert(static_cast<int64_t>(Mail::eSendResponse::CannotMailSelf) == 4);
static_assert(static_cast<int64_t>(Mail::eSendResponse::RecipientNotFound) == 5);
static_assert(static_cast<int64_t>(Mail::eSendResponse::RecipientDifferentFaction) == 6);
static_assert(static_cast<int64_t>(Mail::eSendResponse::UnHandled7) == 7);
static_assert(static_cast<int64_t>(Mail::eSendResponse::ModerationFailure) == 8);
static_assert(static_cast<int64_t>(Mail::eSendResponse::SenderAccountIsMuted) == 9);
static_assert(static_cast<int64_t>(Mail::eSendResponse::UnHandled10) == 10);
static_assert(static_cast<int64_t>(Mail::eSendResponse::RecipientIsIgnored) == 11);
static_assert(static_cast<int64_t>(Mail::eSendResponse::UnHandled12) == 12);
static_assert(static_cast<int64_t>(Mail::eSendResponse::RecipientIsFTP) == 13);
static_assert(static_cast<int64_t>(Mail::eSendResponse::UnknownError) == 14);
// Mail::eDeleteResponse: 5 enumerators
static_assert(static_cast<int64_t>(Mail::eDeleteResponse::Success) == 0);
static_assert(static_cast<int64_t>(Mail::eDeleteResponse::HasAttachments) == 1);
static_assert(static_cast<int64_t>(Mail::eDeleteResponse::NotFound) == 2);
static_assert(static_cast<int64_t>(Mail::eDeleteResponse::Throttled) == 3);
static_assert(static_cast<int64_t>(Mail::eDeleteResponse::UnknownError) == 4);
// Mail::eAttachmentCollectResponse: 6 enumerators
static_assert(static_cast<int64_t>(Mail::eAttachmentCollectResponse::Success) == 0);
static_assert(static_cast<int64_t>(Mail::eAttachmentCollectResponse::AttachmentNotFound) == 1);
static_assert(static_cast<int64_t>(Mail::eAttachmentCollectResponse::NoSpaceInInventory) == 2);
static_assert(static_cast<int64_t>(Mail::eAttachmentCollectResponse::MailNotFound) == 3);
static_assert(static_cast<int64_t>(Mail::eAttachmentCollectResponse::Throttled) == 4);
static_assert(static_cast<int64_t>(Mail::eAttachmentCollectResponse::UnknownError) == 5);
// Mail::eNotificationResponse: 9 enumerators
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::NewMail) == 0);
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::UnHandled) == 1);
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::AuctionWon) == 2);
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::AuctionSold) == 3);
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::AuctionOutbided) == 4);
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::AuctionExpired) == 5);
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::AuctionCancelled) == 6);
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::AuctionUpdated) == 7);
static_assert(static_cast<int64_t>(Mail::eNotificationResponse::UnknownError) == 8);
// Mail::eReadResponse: 2 enumerators
static_assert(static_cast<int64_t>(Mail::eReadResponse::Success) == 0);
static_assert(static_cast<int64_t>(Mail::eReadResponse::UnknownError) == 1);
// Mail::eAuctionCreateResponse: 5 enumerators
static_assert(static_cast<int64_t>(Mail::eAuctionCreateResponse::Success) == 0);
static_assert(static_cast<int64_t>(Mail::eAuctionCreateResponse::NotEnoughMoney) == 1);
static_assert(static_cast<int64_t>(Mail::eAuctionCreateResponse::ItemNotFound) == 2);
static_assert(static_cast<int64_t>(Mail::eAuctionCreateResponse::ItemNotSellable) == 3);
static_assert(static_cast<int64_t>(Mail::eAuctionCreateResponse::UnknownError) == 4);
// Mail::eAuctionCancelResponse: 5 enumerators
static_assert(static_cast<int64_t>(Mail::eAuctionCancelResponse::NotFound) == 0);
static_assert(static_cast<int64_t>(Mail::eAuctionCancelResponse::NotYours) == 1);
static_assert(static_cast<int64_t>(Mail::eAuctionCancelResponse::HasBid) == 2);
static_assert(static_cast<int64_t>(Mail::eAuctionCancelResponse::NoLongerExists) == 3);
static_assert(static_cast<int64_t>(Mail::eAuctionCancelResponse::UnknownError) == 4);
TEST(MailIdPinTests, MailEnumValuesArePinned) { SUCCEED(); }

View File

@@ -0,0 +1,76 @@
#ifndef PACKETTESTUTILS_H
#define PACKETTESTUTILS_H
// Helpers for byte-for-byte packet comparisons used while migrating packets / game messages
// to the struct based architecture (see docs/PacketArchitecture.md).
#include "BitStream.h"
#include "GameDependencies.h"
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
#include <gtest/gtest.h>
namespace PacketTestUtils {
struct PacketBytes {
std::vector<uint8_t> bytes;
uint32_t bits = 0;
};
inline PacketBytes FromBitStream(const RakNet::BitStream& bitStream) {
auto& nonConst = const_cast<RakNet::BitStream&>(bitStream); // GetData is not const in RakNet
return { { nonConst.GetData(), nonConst.GetData() + nonConst.GetNumberOfBytesUsed() }, static_cast<uint32_t>(nonConst.GetNumberOfBitsUsed()) };
}
inline PacketBytes FromCapture(const CapturedPacket& packet) {
return { packet.bytes, packet.bits };
}
inline std::string ToHex(const PacketBytes& packet) {
std::string out;
char buf[4];
for (size_t i = 0; i < packet.bytes.size(); i++) {
std::snprintf(buf, sizeof(buf), "%02x", packet.bytes[i]);
if (i != 0) out += ' ';
out += buf;
}
return out + " (" + std::to_string(packet.bits) + " bits)";
}
inline PacketBytes FromHex(const std::string& hex, uint32_t bits = 0) {
PacketBytes packet;
for (size_t i = 0; i + 1 < hex.size();) {
if (hex[i] == ' ') { i++; continue; }
packet.bytes.push_back(static_cast<uint8_t>(std::stoul(hex.substr(i, 2), nullptr, 16)));
i += 2;
}
packet.bits = bits == 0 ? static_cast<uint32_t>(packet.bytes.size() * 8) : bits;
return packet;
}
// Compares the exact number of bits and every byte, printing both packets as hex on failure.
inline ::testing::AssertionResult PacketsEqual(const PacketBytes& expected, const PacketBytes& actual) {
if (expected.bits == actual.bits && expected.bytes == actual.bytes) return ::testing::AssertionSuccess();
return ::testing::AssertionFailure()
<< "\n expected: " << ToHex(expected)
<< "\n actual: " << ToHex(actual);
}
// Runs sendFunction against the mock server and returns every packet it sent.
inline std::vector<CapturedPacket> Capture(const std::function<void()>& sendFunction) {
auto* server = static_cast<dServerMock*>(Game::server);
server->ClearSentPackets();
sendFunction();
auto packets = server->GetSentPackets();
server->ClearSentPackets();
return packets;
}
}
#define EXPECT_PACKET_EQ(expected, actual) EXPECT_TRUE(PacketTestUtils::PacketsEqual(expected, actual))
#define ASSERT_PACKET_EQ(expected, actual) ASSERT_TRUE(PacketTestUtils::PacketsEqual(expected, actual))
#endif // PACKETTESTUTILS_H

View File

@@ -1,5 +1,6 @@
SET(DGAMEMESSAGES_TESTS
"GameMessageTests.cpp"
"GameMsgSplitTests.cpp"
"LegacyGameMessageTests.cpp")
# Get the folder name and prepend it to the files above

View File

@@ -0,0 +1,91 @@
#include "GameMessages.h"
#include "GameDependencies.h"
#include "PacketTestUtils.h"
#include "Entity.h"
#include "EntityManager.h"
#include <type_traits>
#include <gtest/gtest.h>
// Wire messages (NetGameMsg) and server-internal events (GameMsg) are separate types, so a message can only be
// sent where it belongs. These checks fail to compile if the split is undone.
namespace {
template<typename T>
concept CanSendLocally = requires(T msg) { msg.Send(); msg.Send(LWOOBJID{}); };
template<typename T>
concept CanSendToClient = requires(const T msg) { msg.Send(UNASSIGNED_SYSTEM_ADDRESS); };
template<typename T>
concept CanHandleLocally = requires(Entity & entity, T msg) { entity.HandleMsg(msg); };
static_assert(!std::is_base_of_v<GameMessages::GameMsg, GameMessages::NetGameMsg>);
static_assert(!std::is_base_of_v<GameMessages::NetGameMsg, GameMessages::GameMsg>);
// A wire message can go to a client, never to local handlers.
static_assert(CanSendToClient<GameMessages::DropClientLoot>);
static_assert(!CanSendLocally<GameMessages::DropClientLoot>);
static_assert(!CanHandleLocally<GameMessages::DropClientLoot>);
// A local event can go to local handlers, never to a client.
static_assert(CanSendLocally<GameMessages::GetPosition>);
static_assert(!CanSendToClient<GameMessages::GetPosition>);
static_assert(CanHandleLocally<GameMessages::GetPosition>);
// A wire message wrapped for local delivery is a local event.
static_assert(CanSendLocally<GameMessages::DropClientLootEvent>);
static_assert(!CanSendToClient<GameMessages::DropClientLootEvent>);
}
class GameMsgSplitTests : public GameDependenciesTest {
protected:
void SetUp() override { SetUpDependencies(); }
void TearDown() override { TearDownDependencies(); }
};
TEST_F(GameMsgSplitTests, EventCarriesACopyWithTheTarget) {
auto entity = std::make_unique<Entity>(15, info);
LWOOBJID seenTarget = LWOOBJID_EMPTY;
LOT seenItem = LOT_NULL;
entity->RegisterMsg(MessageType::Game::DROP_CLIENT_LOOT, [&](GameMessages::GameMsg& msg) {
auto& event = static_cast<GameMessages::DropClientLootEvent&>(msg);
seenTarget = event.target;
seenItem = event.msg.item;
event.msg.item = 1; // handlers only modify the copy
return true;
});
GameMessages::DropClientLoot loot;
loot.target = 15;
loot.item = 1234;
GameMessages::DropClientLootEvent event(loot);
EXPECT_EQ(event.msgId, MessageType::Game::DROP_CLIENT_LOOT);
const auto sent = PacketTestUtils::Capture([&] { EXPECT_TRUE(entity->HandleMsg(event)); });
EXPECT_TRUE(sent.empty()); // nothing went on the wire
EXPECT_EQ(seenTarget, 15);
EXPECT_EQ(seenItem, 1234);
EXPECT_EQ(loot.item, 1234);
}
TEST_F(GameMsgSplitTests, DeliverLocallyNeverSendsToAClient) {
GameMessages::DropClientLoot loot;
loot.target = 0x7777; // no such entity
const auto sent = PacketTestUtils::Capture([&] { EXPECT_FALSE(GameMessages::DeliverLocally(loot)); });
EXPECT_TRUE(sent.empty());
}
TEST_F(GameMsgSplitTests, SendToClientNeverBroadcasts) {
GameMessages::DropClientLoot loot;
for (const auto& address : { SystemAddress(), UNASSIGNED_SYSTEM_ADDRESS }) {
const auto sent = PacketTestUtils::Capture([&] { loot.SendToClient(address); });
ASSERT_EQ(sent.size(), 1);
EXPECT_FALSE(sent[0].broadcast);
EXPECT_EQ(sent[0].sysAddr, address);
}
const auto broadcast = PacketTestUtils::Capture([&] { loot.Send(UNASSIGNED_SYSTEM_ADDRESS); });
ASSERT_EQ(broadcast.size(), 1);
EXPECT_TRUE(broadcast[0].broadcast);
}