Одна сборка - много версий
На странице «Об проекте» об этом сказано в двух абзацах: один диапазон протоколов, одна сборка вместо пересборки под каждую игру. Здесь - как это устроено в пакетном слое: какие атрибуты видны на сгенерированном пакете, откуда берётся раскладка полей и что происходит, когда просят версию, которой в коде попросту нет.
Атрибуты, из которых собран пакет
Каждый пакет несёт три атрибута: ProtocolSupport - диапазон версий, на котором
пакет существует; Packet - манифестный ключ, фаза и направление; PacketField
- поле, а если оно не на всём диапазоне, то ещё
Groupи границыFrom/To.
[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 и InventoryType без Group - общие поля, есть в любой версии из
диапазона. WindowTitleJson и WindowTitle - одно и то же поле по смыслу,
заголовок открываемого окна, но на старых версиях это строка JSON, а на новых -
тег NBT: разные типы, ни одна не входит в общую часть.
Эти атрибуты читает VersionRangeGenerator, единственный генератор Roslyn в
проекте: по ProtocolSupport он достраивает IsSupportedVersion(int) и хелпер
на бросок исключения. Раскладку полей сам не строит - она уже собрана в коде
пакета.
Слой вместо отдельного пакета
Библиотека не заводит по классу на версию. У пакета один класс на весь диапазон, а версии, расходящиеся полями, оседают во вложенных структурах - слоях:
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);
Имя слоя следует диапазону: VUntil764 - от начала до версии включительно,
V765_Last - от неё до конца, V759_765 (в других пакетах) - отрезок между
двумя версиями. Общие поля лежат в записи пакета, версийные - в своём слое, и
заполнен всегда один слой.
Разбор идёт по номеру версии, а не по классу
Read и Write ветвятся по protocolVersion и строят или читают ровно тот
слой, что подходит. Вот ветка для версий, где поля WindowTitleJson уже нет и
заголовок приходит тегом NBT:
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));
}
Ветка для старых версий устроена зеркально: те же WindowId и InventoryType,
но вместо ReadNbtTag - ReadString, а слой собирается как VUntil764. Код
приложения этой ветки не видит: он получает готовый
OpenWindowPacket
и смотрит, какой слой заполнен. Запись чужого слоя не в ту версию Write
пресекает
WrongLayerException
- защита от ручной ошибки, не обычный путь.
Номер пакета меняется чаще, чем сам пакет
Заголовок окна поменялся один раз за весь диапазон, а номер пакета open_window
прыгает почти на каждой версии - разные вещи. Номер знает не библиотека целиком,
а конкретный тип, через собственный TryGetPacketId:
if (protocolVersion >= 762 && protocolVersion <= 763)
{
id = 0x30;
return true;
}
if (protocolVersion >= 764 && protocolVersion <= 765)
{
id = 0x31;
return true;
}
Таких диапазонов в одном OpenWindowPacket больше десятка. Сводку по всем
пакетам держит PacketRegistry.g.cs
(IdRange на диапазон,
PacketDescriptor
на пакет), а поверх - плоские таблицы номер-в-ordinal по фазе, направлению и
версии; через них код ищет тип пакета по чужому номеру - устройство адреса на
странице «Фаза и направление».
Если версии у пакета нет
Отказов два. Версия вне диапазона ProtocolSupport целиком - Read, Write и
типизированная отправка бросают
ProtocolNotSupportException
с версией и списком поддержанных диапазонов. Версия внутри диапазона, но без
своей ветки в Read/Write - пробел в раскладке полей, а не в поддержке
версии, - и оба метода бросают NotSupportedException с текстом «has no wire
layout for protocol version». А когда сам номер пакета не находится в чужой
версии, до этих исключений дело не доходит: PacketRegistry.TryResolve
возвращает отказ, пакет уходит в Unknown - см. «Фаза и направление».
Что менять в коде приложения при переезде
Обычно - только число: версию протокола, которую код передаёт при подключении. Диспетчеризация, номера пакетов, выбор слоя пересчитываются по новому числу сами, без правок в остальном коде.
Правки нужны там, где код приложения трогает версийный слой напрямую - читает
packet.VUntil764 вместо общих полей. На новой версии свойство станет null, а
нужное поле переедет в V765_Last, не всегда того же типа: строка JSON и тег
NBT друг в друга не конвертируются. Код, читающий только общие поля пакета,
версийных слоёв не видит вовсе.
Чего от мультиверсии ждать нельзя
Диапазон - 1.16 - 26.2, и это диапазон
конкретной сборки, а не вся история протокола: версия за его границами получает
ProtocolNotSupportException, а не приблизительный разбор. Мультиверсия здесь -
про форму пакета на проводе, не про игровые данные: числовые идентификаторы
блоков и предметов между версиями библиотекой не выравниваются. И раскладка по
слоям версию не прячет - код, которому нужны версийные поля, обязан знать, какой
слой заполнен.