Skip to content

Packets and Serialization

irrld edited this page Aug 20, 2026 · 5 revisions

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.

Defining a packet

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. PacketId is uint64_t, and znet's own handshake packets sit at the top of that space, (PacketId)(-2) for the handshake and (PacketId)(-3) for the connection-ready packet, which is the only range worth avoiding. They never coexist with yours: the handshake runs on a codec and a handler znet installs itself, and it clears both before the session becomes ready, so the connected event finds the two slots empty and fills them with yours.

Until it does, the session has neither, and a session with no codec or no handler drops what arrives: the buffer is decrypted and decompressed, then discarded without a word. Setting both inside the connected event closes that window, since nothing dispatches on the session until the event returns. See Events.

One codec, many sessions

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.

The serializer contract

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 or 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

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; the read gives a unique_ptr<InetAddress>, null on a family it does not know
WritePort / ReadPort PortNumber
Write(const T*, size_t) / Read(T*, size_t) Raw arrays
WriteVector / ReadVector Count-prefixed, element written by a member you name
WriteMap / ReadMap Count-prefixed pairs
WriteArray / ReadArray Count-prefixed, into a unique_ptr<T[]> or a fixed std::array

Reads never throw

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() is the only thing that clears the flag; a later successful read does not. One check after the last read of a DeserializeTyped therefore catches any failure inside it. The buffer holds one value rather than a set, so that check names the most recent failure, and checking after each read is only worth it when you want to know which one.

A build that defines DEBUG asserts when a fixed-width read runs past the end rather than returning quietly, unless it also defines DISABLE_ASSERT_READABLE_BYTES. znet defines neither itself.

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, and readable_bytes() reports what is left of that frame rather than of the whole buffer. It still returns garbage for that message: the limit protects the stream, not the message.

Read limits

Every length in a message is chosen by whoever sent it. A string, vector, map or array read is refused when the count it claims is larger than the bytes left in the frame could possibly back: one byte an element at the very least, two for a map entry since an entry is a key and a value, sizeof(T) for the unique_ptr<T[]> array. A short packet asking for four billion of them is cheap to reject, and the error is ReadOutOfBounds. Nothing configures this and nothing turns it off. The fixed std::array<T, N> overload needs no such rule: a count that is not exactly N is CorruptedFormat.

On top of that sits a ceiling on the count itself, in case a peer is willing to pay the bytes. Exceeding it is ReadLimitExceeded.

Default
ZNET_MAX_READ_ELEMENTS 65536 Vector, map and array counts
ZNET_MAX_READ_STRING_LENGTH 65536 String length in bytes

Set either to 0 to remove that ceiling, which is reasonable on a trusted link where a legitimate message really is larger. The bytes-on-hand check still holds.

When a frame does not decode

A payload is walked frame by frame, and what a bad frame costs depends on whether the framing after it can still be located.

Unknown packet id Warns, skips the declared length, carries on with the next frame. Not counted against the session: an id it does not know can be honest version skew
Serializer returned nullptr Rewinds to the start of the frame, skips its declared length, carries on. Counted
Serializer read less than it declared Warns, skips to the end of the frame, and still delivers the packet. Not counted
Serializer read more than it declared Drops that packet and the rest of the buffer. Counted
Unreadable header, or a declared size larger than what is left Drops the rest of the buffer. Counted

The session adds what was counted to invalid_frames() and closes once that reaches max_invalid_frames; dump_on_decode_failure logs the bytes of the first frame in a payload that fails. Both are in Configuration Reference.

The under-read is the one worth watching, because it is the one that still delivers: the fields the serializer did not read keep their defaults, so a serializer out of step with the sender hands your handler plausible garbage rather than failing. This is what the version check under Versioning between builds avoids.

Handling packets

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_;
};

void OnPacket(const Ping&) is accepted in place of the shared_ptr overload, and a handler that defines both for one type has both called. Dispatch is one lookup in a table built once per handler type, so listing more packet types never makes a single message cost more.

CallbackPacketHandler is the same thing without a class per handler: register one lambda per packet type with AddShared<T> or AddRef<T>, and a type with no callback is dropped just the same.

A session's handler can be replaced from its own thread, which is what an OnPacket or an event gives you, and that 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.

Handlers hold their session

The handler above keeps a shared_ptr to the session it replies on, and the session owns the handler, so the two keep each other alive and neither is ever destroyed. znet breaks that cycle for the sessions it owns: a server calls ReleaseHandler() on a session as it leaves the worker's map, and a client does the same once its loop has joined. A session you own yourself, one kept past the object that produced it, is yours to release, from the thread that drives it.

Sending

session->SendPacket(packet);

SendPacket queues and returns; it does not block and it does not encode on your thread. Check the return value. It is a Result, and only Result::Success means the packet was queued. QueueFull is the backpressure signal, which is how you learn you are producing faster than the link drains; NotReady means the handshake has not settled yet, NotConnected that the session is gone, and InvalidArgument that the packet was null. Every refusal happens before anything is encoded, so the packet is still yours and retrying or dropping it are both fine.

Queue depth is send_queue_capacity, 512 by default. See Configuration Reference.

The optional second argument is a SendOptions, which controls delivery. Its three options, reliable, ordered and channel, are read by ZDT alone. TCP is a single reliable ordered stream with no channels and ignores all three, silently. Build the combinations you need once as constants and reuse them; both that pattern and the options themselves are covered in Choosing a Transport.

Versioning between builds

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.

Clone this wiki locally