Skip to main content

Handlers and unknown packets

The previous page named the handler as the third path to a typed packet - the async one, through ClientboundHandler and ServerboundHandler. It is not an alternative to the visitor for a single packet. It is a tool for the whole directional stream: one object carries the whole session, from login to the end, with no manual switch on numbers.

How it works

HandleAsync is the only public method of the handler. It finds the packet number in the registry itself, parses the body, and calls the matching virtual method On<Name>. The phase is read once, at the start of the call. A handler that changes the phase itself does so after dispatch, and the current packet is parsed with the phase it arrived in.

public ValueTask HandleAsync(in IncomingPacket raw, int protocolVersion)
{
var phase = Phase;
if (!PacketRegistry.TryGetOrdinal(raw.Id, protocolVersion, phase,
PacketDirection.Clientbound, out var ordinal))
return OnUnknown(in raw);
var reader = new MinecraftPrimitiveReader(raw.Body);

Next comes a nested switch: first by phase, then by the packet ordinal inside it, with no reflection and no boxing. Each branch runs the same sequence: a typed read of the body and a call to On<Name> with the ready packet.

Why there are many methods but few overrides

The handler carries one virtual method per packet of a phase and a direction - ClientboundHandler has 143 of them, plus OnUnknown for everything else. By default each one does nothing:

protected virtual ValueTask OnLoginCompress(
Packets.Login.Clientbound.LoginCompressPacket packet) => default;
protected virtual ValueTask OnEncryptionRequest(
Packets.Login.Clientbound.EncryptionRequestPacket packet) => default;

Application code overrides only what it needs. The rest of the packets pass through the no-op with no code on the application side. MinimalBot inherits ClientboundHandler and overrides 16 packet methods out of 143, plus OnUnknown - login, configuration, keep-alive, teleport, health.

Who sets Phase

Phase has a public getter and a setter open only to the subclass. This is the owner's decision: application code drives the phases (see Phase and direction). The handler starts in the phase where every connection begins: login for ClientboundHandler, handshaking for ServerboundHandler, and then it moves Phase itself in response to transition packets. An example of a transition, and what happens when Phase is forgotten, is on that same page.

Unknown packet

OnUnknown runs when the packet number is not in the registry for the current pair of phase and direction. This is not a failure and not a reason for an exception. Application code does not need to parse everything - only what matters to it, and the rest legitimately stays unknown. By default OnUnknown is also a no-op. MinimalBot overrides it to log each new missed packet of a phase once, not once per instance:

protected override ValueTask OnUnknown(in IncomingPacket raw)
{
if (_unknownSeen.Add((Phase, raw.Id)))
{
var packetName = PacketRegistry.TryResolve(raw.Id, pv, Phase,
Direction, out var desc) ? desc.Identity.Name : $"0x{raw.Id:X2}";
Console.WriteLine($"[{Phase}] skipped {packetName} " +
$"({raw.Body.Length} bytes)");
}
return default;
}

raw lives only for the duration of the call: the packet body is a window into the buffer, and it lives only until the next read (see Receive buffer). If the body bytes are needed later, code must copy them right here.

Trailing bytes at the end

Another situation: the number is found, the body is parsed by the Read method, but bytes remain in the buffer after the read. The handler does not throw an exception and does not stop the call to On<Name> - that call is already running. Instead it raises the static event PacketFlow.OnTrailingBytes:

if (reader.RemainingCount != 0)
PacketFlow.RaiseTrailingBytes(raw.Id, protocolVersion,
reader.RemainingCount);
return pending;

Subscribing to OnTrailingBytes (the TrailingBytesHook delegate) is the job of application code, and it happens once per process: the event is static and shared by every handler. This is a separate channel for reporting a suspicious specification, not the DecodeError.TrailingBytes that PacketIo.TryDecode and PacketFlow.TryDispatch return. There, parsing goes through an explicit call, and the suspicion can return as a value. Here, parsing is hidden inside HandleAsync, and the only channel out is the event.

Handler or visitor

IPacketVisitor.Visit<T> returns void - there is nowhere to put the ValueTask of an async On<Name>, and the continuation would get lost silently. This is why the handler does not implement IPacketVisitor and does not go through PacketFlow.Dispatch: it does the same thing - number, ordinal, read, call - but in a single case block, with no visitor.

The visitor fits where processing is synchronous and does not depend on inheriting from one class. PacketSubscriptions is a public implementation of IPacketVisitor over a dictionary of delegates: the method On<T>(PacketHandler<T> handler) registers a handler for a packet type, Visit<T> finds it by Identity.Ordinal and calls it, and if nothing is registered for the type, it silently skips it. It is the right choice for a set of independently assembled subscriptions with synchronous processing. The handler is the right choice when one object owns the whole connection and some packets need await, as in MinimalBot.

Next

  • From a raw packet - the three parsing stages, where the handler is built in as the third path
  • Phase and direction - where the phase and direction come from, and who changes them
  • Exceptions - what reaches application code as an exception, and what does not