Reading and writing primitives
A packet body is just bytes. The protocol defines its own types on top of them
(full list on the
Data types page):
variable-length VarInt and VarLong, strings with a VarInt length before the
bytes, a 16-byte UUID, NBT tags of arbitrary nesting. Encoding and decoding them
the same way on read and on write, without extra allocations, is the job of a
separate layer:
MinecraftPrimitiveReader
and
MinecraftPrimitiveWriter
from McProtoNet.Primitives.
What gets read and written
The reader and the writer carry symmetric method sets:
ReadVarInt/WriteVarInt and ReadVarLong/WriteVarLong for variable-length
values; ReadBoolean for one byte; signed and unsigned byte, short, int, long,
float, and double go big-endian, except VarInt and VarLong.
ReadString/WriteString encode a string as UTF-8 with the length in bytes in
front, using the same VarInt, and cap the length with the maxLength parameter
(short.MaxValue by default). ReadUUID/WriteUUID read and write a Guid as
16 big-endian bytes. ReadNbtTag and WriteNbt, along with their variants with
a presence flag byte, ReadOptionalNbtTag/WriteOptionalNbt, work with the NBT
tree. ReadBuffer/ReadRestBuffer/WriteBuffer copy raw bytes without a
length prefix - for when the length is known from outside.
ReadVarInt shows the shape of a typical signature, and where the error goes
when data runs short:
public int ReadVarInt()
{
if (!_reader.TryReadVarInt(out int res, out _))
{
ThrowHelper.ThrowNotEnoughData();
}
return res;
}
Span<byte> without extra copies
MinecraftPrimitiveReader is a ref struct over SequenceReader<byte>. The
constructor wraps the given ReadOnlyMemory<byte> or ReadOnlySequence<byte>,
without copying anything:
public ref struct MinecraftPrimitiveReader
{
private SequenceReader<byte> _reader;
public MinecraftPrimitiveReader(ReadOnlyMemory<byte> data)
: this(new ReadOnlySequence<byte>(data))
{
}
}
A typical source of this memory is IncomingPacket.Body: a packet body is a
window into a buffer that lives until the next read; it must be parsed right
away, not across an await
(Receive buffer). Read(Span<byte> output) copies bytes straight into the caller's buffer and allocates nothing on
its own.
MinecraftPrimitiveWriter makes the same saving in the other direction: it
holds an ArrayBufferWriter<byte>, and WrittenSpan and WrittenMemory are
windows into that buffer, which the next write invalidates: the buffer can move
when it grows, and the old window will not know it.
Who owns the memory
MemoryOwner<T>
is a struct over an array rented from ArrayPool<T>.Shared. Allocate takes an
array of the needed length, Dispose returns it to the pool:
public static MemoryOwner<T> Allocate(int length)
{
if (length == 0) return default;
var array = ArrayPool<T>.Shared.Rent(length);
return new MemoryOwner<T>(array, length);
}
public void Dispose()
{
var arr = _array;
if (arr is not null)
{
_array = null;
ArrayPool<T>.Shared.Return(arr);
}
}
MemoryOwner<T> is a mutable struct: a copy makes sense only when it passes
ownership onward, otherwise both holders return the same array to the pool.
The writer hands out finished bytes through GetWrittenMemory, and this is
already a copy - not a window into the writer's own buffer:
public MemoryOwner<byte> GetWrittenMemory()
{
var written = _writer.WrittenSpan;
var owner = MemoryOwner<byte>.Allocate(written.Length);
written.CopyTo(owner.Span);
return owner;
}
The copy here is not wasted: the writer's buffer gets reused through
MinecraftPrimitiveWriterCache
(Rent/Return, one writer per thread, writers larger than 64 kilobytes get
dropped), and it must not be touched after Return.
OutgoingPacket
takes the finished MemoryOwner<byte> and must be disposed exactly once, so the
buffer returns to the pool.
Errors on reading broken data
Running out of data is the most common read error, and it is the same
everywhere: InvalidDataException through the internal
ThrowHelper.ThrowNotEnoughData. A VarInt longer than 5 bytes and a VarLong
longer than 10 bytes also throw InvalidDataException, with a separate message
about the length. Strings carry more checks: a negative length prefix, a byte
count above maxLength * 3, a final string length above maxLength - each
through ThrowHelper.ThrowInvalidData with its own text. Broken NBT surfaces as
NbtFormatException
from inside ReadNbtTag. For the methods that read a VarInt straight from a
Stream (Stream.ReadVarInt, ReadVarIntAsync) - extensions over the stream,
not over the reader itself - a data cutoff throws EndOfStreamException.
When this touches application code
Usually, not directly: generated packets carry their own Read/Write for each
protocol version, and those are already written on top of this layer. Direct
access to MinecraftPrimitiveReader/Writer is needed in two cases: when
application code implements its own packet with its own Read/Write, and when
the body is parsed by hand - the packet type is not known in advance, or only
the first few fields are needed, without decoding the whole packet.
Next
- From a raw packet - where a packet body becomes a typed object
- NBT - the tag format this same layer works with
- Frames - where the body that
MinecraftPrimitiveReadersees comes from