-
Notifications
You must be signed in to change notification settings - Fork 4
Packets and Serialization
A message is three pieces: a Packet subclass holding the data, a
PacketSerializer that converts it to and from bytes, and a registration in a
Codec that maps the id to the serializer.
enum : PacketId { kChatMessage = 1 };
class ChatMessage : public Packet {
public:
ChatMessage() : Packet(kChatMessage) {}
std::string author;
std::string text;
};
class ChatMessageSerializer : public PacketSerializer<ChatMessage> {
public:
std::shared_ptr<Buffer> SerializeTyped(
std::shared_ptr<ChatMessage> packet,
std::shared_ptr<Buffer> buffer) override {
buffer->WriteString(packet->author);
buffer->WriteString(packet->text);
return buffer;
}
std::shared_ptr<ChatMessage> DeserializeTyped(
std::shared_ptr<Buffer> buffer) override {
auto packet = std::make_shared<ChatMessage>();
packet->author = buffer->ReadString();
packet->text = buffer->ReadString();
return packet;
}
};
std::shared_ptr<Codec> MakeCodec() {
auto codec = std::make_shared<Codec>();
codec->Add(kChatMessage, std::make_unique<ChatMessageSerializer>());
return codec;
}Read in the same order you wrote. Nothing checks the field layout for you — the codec verifies the length the serializer declared, not its contents.
Ids are yours to assign and must match on both ends. Zero is fine. znet's own
handshake runs on a codec it installs itself and which yours replaces once the
session is ready, so the two never coexist; its packets also sit at the top of
the id space (PacketId(-2) and PacketId(-3)), which is the only range worth
avoiding.
Serializers hold no per-connection state, so build the codec once and hand the
same shared_ptr to every session. Building one per connection works but
allocates a serializer set per client for nothing.
Write into the buffer you were given and return it. The codec has already
written the frame header into that buffer, and Buffer grows on write, so there
is no size to respect and no reason to allocate.
A serializer that already holds the bytes — a cached encoding, a payload being forwarded — may instead return a buffer of its own. The codec copies its readable range in behind the header, so the frame comes out identical either way. That costs one copy, which is why writing in place is still the default.
Returning nullptr refuses the packet: it is dropped, logged, and nothing goes
on the wire.
Buffer is a growable byte buffer with separate read and write cursors, so a
buffer being filled and one being drained use the same type without interfering.
WriteInt<T> / ReadInt<T>
|
Fixed-width integers |
WriteVarInt<T> / ReadVarInt<T>
|
Variable-length, smaller for small values |
WriteString / ReadString
|
Length-prefixed |
WriteBool, WriteFloat, WriteDouble, WriteChar
|
And their Read counterparts |
WriteBitset<N> / ReadBitset<N>
|
std::bitset |
WriteInetAddress / ReadInetAddress
|
Addresses |
Write(const T*, size_t) |
Raw arrays |
A read past the end sets an error flag and returns a default value instead of throwing. Check it when the buffer came from the network:
auto value = buffer->ReadInt<uint32_t>();
if (buffer->GetAndClearLastError() != BufferError::None) {
return nullptr; // truncated or malformed, refuse it
}GetAndClearLastError() clears as it reads, so call it once per thing you want
to check rather than once at the end.
The codec limits each serializer to its own frame while deserializing, so a serializer that reads too far hits the limit rather than the next packet. It still returns garbage for that message — the limit protects the stream, not the message.
PacketHandler takes the handler type and the packet types it accepts, then one
OnPacket overload each:
class GameHandler : public PacketHandler<GameHandler, Ping, Pong> {
public:
explicit GameHandler(std::shared_ptr<PeerSession> session)
: session_(std::move(session)) {}
void OnPacket(std::shared_ptr<Ping> packet) {
(void)packet;
session_->SendPacket(std::make_shared<Pong>());
}
void OnPacket(std::shared_ptr<Pong> packet) { (void)packet; }
private:
std::shared_ptr<PeerSession> session_;
};Dispatch is by type, resolved once at construction, so adding packet types does not add per-message branching.
A session's handler can be replaced at any time, which is the usual way to model
connection state: a login handler that accepts two packet types, swapped for a
gameplay handler once authenticated. A packet the current handler has no
OnPacket overload for is dropped, so an unauthenticated client cannot reach
gameplay messages.
That drop is silent. An id with no serializer registered on the codec warns, but a packet that deserialized fine and simply found no handler does not, so a handler missing an overload looks exactly like a peer that never sent it. If you are debugging a message that seems not to arrive, check the handler's type list before suspecting the network.
session->SendPacket(packet);SendPacket queues and returns; it does not block and it does not encode on
your thread. Check the return value. false means the queue was full, which
is how you learn you are producing faster than the link drains. The packet is
still yours, so retrying or dropping it are both fine.
Queue depth is send_queue_capacity, 512 by default. See
Configuration Reference.
The second argument controls delivery and only means anything on ZDT — see Choosing a Transport.
Two builds that disagree about a packet's fields will misread each other, since nothing on the wire describes the layout. The multiversion example shows the usual fix: exchange a version during the handshake, then register a different serializer for the same id depending on what the peer reported.