Skip to main content

Packet stream

MinecraftClient.ReadPacketsAsync is a thin wrapper around one loop: the client holds one MinecraftConnection, and the method hands control to its ReadPacketAsync again and again. There is no frame queue and no read-ahead buffering: in one call the transport reads exactly one frame - the length, then the body - and hands it out as an IncomingPacket. Where a frame ends and how the length is read is in Frames.

The packet stream does not know which phase the client is in - handshaking, login, configuration, or play. Frames pass through it the same way in every phase. Application code tracks the phase (Phase and direction).

Receive buffer

The packet body is not its own copy of bytes - it is a window into a buffer that the reader rents from ArrayPool<byte>. The buffer holds until the next ReadPacketAsync starts. At that point the previous buffer returns to the pool, and the data that the old IncomingPacket.Body pointed to becomes someone else's. With compression there are two buffers, one for the compressed bytes and one for the decompressed bytes, but the first one is freed right after decompression, and the rule for Body does not change.

This is where the rule from "first bot" comes from: a packet is parsed right away, and Body never crosses an await. If the data is needed longer - for example, to sit in a queue for another thread - it is copied explicitly, with Body.ToArray() or something similar. The buffer itself is not fit for long-term storage.

var toKeep = new List<byte[]>();

await foreach (var packet in client.ReadPacketsAsync(token))
{
if (packet.Id == interestingId)
toKeep.Add(packet.Body.ToArray()); // a copy, not a window
}

Where a parsed packet goes next - which handler method it calls, and what happens with unknown ids - is described in Handlers and unknown packets.

End of session

The ReadPacketsAsync enumeration never ends quietly - it always throws an exception. The full "what happened -> which exception" table is in Cancellation, errors, closing.

Cancellation

A cancellation token does not cancel a single read: if the read has already touched the socket, cancellation closes the whole connection, and the ReadPacketsAsync loop stops with it. The full picture is in Cancellation, errors, closing.

Sending

SendAsync and SendRawAsync pass through a shared gate - a SemaphoreSlim(1, 1) inside MinecraftClient. Each call first takes the gate, then writes the frame through the connection, then releases the gate. If several tasks send packets at the same time, the frames do not mix - the calls queue up at the gate and go out to the socket one at a time, each one whole.

await Task.WhenAll(
client.SendAsync(new PlaySb.KeepAlivePacket(keepAliveId), pv).AsTask(),
client.SendRawAsync(customId, customBody).AsTask());

Reading is not guarded this way: a parallel ReadPacketAsync throws InvalidOperationException, a bug in the calling code, not a data race (Cancellation, errors, closing). ReadPacketsAsync has no way around this: until await foreach hands control back, a second read on the same connection must not start.

Closing

DisposeAsync closes the connection and releases the buffers. The order of steps and the exception table are in Cancellation, errors, closing.

When the client is not needed

The same packet stream is available without MinecraftClient: MinecraftConnection reads and writes one frame at a time over any Stream, and StreamingConnection does the same in batches. Both are covered in Connection without a client.

Next