Skip to main content

One build - many versions

The "About the project" page states this in two paragraphs: one range of protocols, one build instead of a rebuild for each game version. This page covers how it works in the packet layer: which attributes appear on a generated packet, where the field layout comes from, and what happens when code asks for a version the build does not have.

Attributes that build a packet

Every packet carries three attributes: ProtocolSupport - the range of versions where the packet exists; Packet - the manifest key, phase, and direction; PacketField - a field, and if it does not span the whole range, also Group and the From/To bounds.

[ProtocolSupport(
MinecraftVersion.StartProtocol, MinecraftVersion.LatestProtocol)]
[Packet("play.toClient.open_window",
PacketPhase.Play, PacketDirection.Clientbound)]
[PacketField("WindowId", "int")]
[PacketField("InventoryType", "int")]
[PacketField("WindowTitleJson", "string", Group = "VUntil764", To = 764)]
[PacketField("WindowTitle", "NbtTag", Group = "V765_Last", From = 765)]

WindowId and InventoryType carry no Group - they are shared fields, present in every version of the range. WindowTitleJson and WindowTitle are the same field in meaning, the title of the window being opened, but on older versions it is a JSON string, and on newer versions it is an NBT tag: different types, and neither belongs in the shared part.

VersionRangeGenerator, the only Roslyn generator in the project, reads these attributes: from ProtocolSupport it builds IsSupportedVersion(int) and a helper that throws. It does not build the field layout itself - that is already assembled in the packet code.

A layer instead of a separate packet

The library does not create one class per version. A packet has one class for the whole range, and versions that diverge in their fields settle into nested structures - layers:

public sealed partial record OpenWindowPacket(
int WindowId, int InventoryType,
OpenWindowPacket.VUntil764Layer? VUntil764 = null,
OpenWindowPacket.V765_LastLayer? V765_Last = null)
: IPacket<OpenWindowPacket>, IPacket
{
public readonly record struct VUntil764Layer(string WindowTitleJson);
public readonly record struct V765_LastLayer(NbtTag WindowTitle);

The layer name follows the range: VUntil764 runs from the start up to and including that version, V765_Last runs from it to the end, and V759_765 (in other packets) is a segment between two versions. Shared fields live in the packet record, version-specific fields live in their own layer, and exactly one layer is ever filled.

Parsing follows the version number, not the class

Read and Write branch on protocolVersion and build or read exactly the layer that fits. Here is the branch for versions where the WindowTitleJson field is gone and the title arrives as an NBT tag:

if (protocolVersion >= 765)
{
var windowId = reader.ReadVarInt();
var inventoryType = reader.ReadVarInt();
var windowTitle = reader.ReadNbtTag(false)!;
return new OpenWindowPacket(windowId, inventoryType,
V765_Last: new V765_LastLayer(windowTitle));
}

The branch for older versions is a mirror: the same WindowId and InventoryType, but ReadString instead of ReadNbtTag, and the layer is built as VUntil764. Application code never sees this branch: it gets a ready OpenWindowPacket and checks which layer is filled. Writing the wrong layer for the wrong version is blocked by Write with a WrongLayerException

  • a guard against a manual mistake, not the normal path.

The packet number changes more often than the packet itself

The window title changed once across the whole range, while the number of the open_window packet jumps on almost every version - different things. The number is not known by the library as a whole, but by the specific type, through its own TryGetPacketId:

if (protocolVersion >= 762 && protocolVersion <= 763)
{
id = 0x30;
return true;
}

if (protocolVersion >= 764 && protocolVersion <= 765)
{
id = 0x31;
return true;
}

A single OpenWindowPacket holds more than a dozen ranges like this. PacketRegistry.g.cs keeps the summary for every packet (IdRange for a range, PacketDescriptor for a packet), and on top of it sit flat number-to-ordinal tables by phase, direction, and version. Code uses them to find the packet type from a foreign number - the layout of the address is on the "Phase and direction" page.

When a packet has no such version

There are two kinds of rejection. A version outside the whole ProtocolSupport range: Read, Write, and typed sending throw ProtocolNotSupportException with the version and the list of supported ranges. A version inside the range but with no matching branch in Read/Write is a gap in the field layout, not in version support, and both methods throw NotSupportedException with the text "has no wire layout for protocol version". When the packet number itself is not found for a foreign version, it never reaches these exceptions: PacketRegistry.TryResolve returns a failure, and the packet goes to Unknown

  • see "Phase and direction".

What to change in application code on a version move

Usually only a number: the protocol version that the code passes on connect. Dispatch, packet numbers, and layer selection recompute themselves from the new number, with no changes anywhere else in the code.

Changes are needed where application code touches a version layer directly - reads packet.VUntil764 instead of the shared fields. On a newer version, that property becomes null, and the needed field moves to V765_Last, not always of the same type: a JSON string and an NBT tag do not convert into each other. Code that reads only the shared fields of a packet never sees the version layers at all.

What multi-version support does not cover

The range is 1.16 - 26.2, and it is the range of a specific build, not the whole history of the protocol: a version outside its bounds gets ProtocolNotSupportException, not an approximate parse. Multi-version support here is about the shape of the packet on the wire, not about game data: the library does not align numeric block and item ids between versions. And the layer layout does not hide the version either - code that needs version-specific fields must know which layer is filled.

Next