Этот цикл статей о разработке серверного ПО совместимого с протоколом Minecraft: Java Edition.
Я не буду приводить много примеров кода здесь в статье, постараюсь описать всё словами и схемами. Если же вам интересен результат в виде кода, я прикрепил ниже ссылку на репозиторий на гитхабе.
В этой части я акцентирую внимание на основных вещах: типы данных, структура пакетов и как клиент получает информацию о сервере
Ссылки
Вся информация о протоколе Майнкрафта взята из сообщества людей, которые занимаются реверс инжинирингом и документированием протокола.
Ссылка на их вики
mine-rs – Мой репозиторий сервера на расте
mclib – Утилиты для протокола Майнкрафта, которые я вынес в отдельную репу
Основа, так сказать, база
Люди, игравшие в Майнкрафт, и тем более хотя бы поднимавшие сервер чтобы поиграть с друзьями, не узнают ничего нового из этой главы, но для всех остальных объясню, возможно, это будет полезно.
У Майнкрафта есть две независимые версии игры, разрабатываемые отдельно внутри компании Mojang, дочерней компании Microsoft:
-
Minecraft: Java Edition – “Классическая” версия игры, разработана в далеком 2011 году Маркусом “Нотчем” Персоном. Разрабатывается, как не удивительно, на языке программирования Java. Именно на эту версию игры написано большинство модов и плагинов. Если вы когда-то на заре зарождения Майнкрафта играли в него, вы играли в эту версию.
-
Minecraft: Bedrock Edition (ранее Pocket Edition) – Более новая версия игры, разработана изначально под смартфоны, откуда и было такое название Pocket Edition, потом была переименована и портирована на консоли и ПК.
Bedrock Edition нам совершенно не интересен в контексте этой статьи, так как сервер разрабатывается под совместимость с Java Edition. Эти версии имеют разные протоколы. То есть клиент “Джавы” не сможет играть на одном сервере с клиентом “Бедрока”.
Протокол
Перед тем как я расскажу о протоколе более конкретно нужно еще вкратце объяснить про такие основные вещи как сериализация типов данных и структура пересылаемых пакетов.
Типы данных
Начнем с типов данных. В принципе тут нет ничего сложного, перечислим основные:
-
Целочисленные типы данных сериализуются в порядке от старшего к младшему байту (big-endian). То есть восьмибитная без знака единица будет сериализована как
0000_0001
. Размер в байтах и наличие знака “-” определяется по контексту -
Числа с плавающей запятой бывают двух видов: float32 (float) и float64 (double). Так же как у целочисленных типов сериализация происходит в прямом порядке байт, то есть от старшего к младшему
-
VarInt – это целочисленный тип данных с переменной длиной в байтах. То есть числа меньшие по значению занимают меньшее количество байтов, но максимальная длина этого типа ограничена 5 байтами. В этом типе данных 7 младших бит отвечают за значение числа, а восьмой бит указывает, есть ли в следующем байте данные. Для десериализации VarInt берется первый байт из него читаются 7 младших бит (например с помощью операции побитового И к
0x7F
) получившиеся число записываем в результат. Потом проверяем оставшийся восьмой байт (например также с помощью побитового И к0x80
) и если там1
, то мы снова читаем следующий байт, высчитываем из него 7 младших бит и записываем в результат, но уже левее на 7 бит. Повторяем до тех пор пока в старшем бите не будет0
. Сериализация происходит точно так же, но в обратную сторонуСтруктура байта в VarInt
-
Строки сериализуются в UTF-8 с префиксом размера строки в байтах в виде VarInt
Пример сериализации и десериализации VarInt
/// Сериализация fn pack(&self) -> Vec<u8> { let mut value = self.0; let mut result = Vec::new(); for _ in 0..100 { if (value & !(SEGMENT_BITS as i32)) == 0 { result.push(value as u8); break; } result.push(((value as u8) & SEGMENT_BITS) | CONTINUE_BIT); value >>= 7; value &= i32::MAX >> 6; } result } /// Десериализация fn unpack(src: &mut dyn Read) -> Self { let mut value = 0i32; for i in 0..5 { let current_byte = src.read_byte(); value |= ((current_byte & SEGMENT_BITS) as i32) << (i * 7); if (current_byte & CONTINUE_BIT) == 0 { break; } } Self(value) }
Структура пакета
Сразу оговорюсь, что нижеописанная структура пакета относится к режиму работы сервера без сжатия. Сжатие пакетов это отдельная тема и я еще не реализовал это в своем проекте.
Любой несжатый пакет Майнкрафта состоит из трех основных полей. Эти поля не делятся специальными символами, они просто идут по порядку в виде байтов.
-
Длина пакета – VarInt поле, которое указывает размер остальной части пакета (ID пакета + тело пакета) в байтах
-
ID пакета – VarInt поле с уникальным идентификатором структуры пакета. Благодаря нему можно определить какая структура будет находиться в следующем поле. Этот ID уникален для каждого этапа соединения. Например, в версии 1.20.4, при подключении клиент направляет пакет с идентификатором
0x00
. Так сервер понимает, как десериализовать последующие данные -
Тело пакета – структура тела пакета, которая зависит от этапа соединения и ID пакета
Этапы соединения
ID пакета уникальны внутри каждого этапа соединения. Всего этих этапов 5:
-
Рукопожатие – всегда первый этап соединения. В нем клиент объясняет свое намерение: хочет ли он подключиться к серверу и играть либо просто хочет узнать информацию об игроках онлайн
-
Статус – в этот этап переходит соединение в случае если клиент запрашивает информацию о сервере: краткое описание, количество доступных мест для игроков, количество игроков онлайн и подобное. После получения этой информации соединение разрывается.
-
Логин – этот этап активируется после рукопожатия в случае если клиент намерен зайти в игру, на этом этапе происходит передается информация о сжатии пакетов, о шифровании соединения. После этого активируется этап конфигурации
-
Конфигурация – на этом этапе сервер передает клиенту информацию об игровом мире, такую информацию как наличие тех или иных биомов в игре. После этого активируется этап игры
-
Игра – этап активной игры, когда игрок спавнится в игровом мире
Схема этапов соединения
Окно выбора серверов
Когда игрок открывает окно выбора сервера либо нажимает кнопку “Обновить”, клиент создает соединение с каждым сохраненным адресом сервера запрашивая этап Статус. В ответ сервер, если он доступен, отправляет клиенту поле описание, количество игроков онлайн и максимальное доступное количество слотов для игроков. После получения этой информации соединение разрывается.
Если же игрок нажимает кнопку “Подключиться”, то клиент создает соединение запрашивая этап Логин. Сервер и клиент обмениваются необходимой информацией и игрок заходит в игру в онлайн.
Рукопожатие
С этого этапа начинается любое взаимодействие клиента и сервера. Клиент создает TCP соединение и направляет серверу первый пакет. На этом этапе есть только один пакет.
Пакет рукопожатия – Client -> Server (ID пакета – 0x00
)
Поле |
Тип |
Описание |
---|---|---|
Версия протокола |
VarInt |
Версия протокола, которая зависит от версии игры. С помощью этого поля клиент и сервер понимают совместимы ли они между собой по версии. Текущая на данный момент версия игры 1.20.4 имеет версию протокола 765 |
Адрес сервера |
Строка (макс длина 255 символов) |
Имя хоста или IP адрес сервера, к которому подключается клиент. Например |
Порт сервера |
16 битное целое без знака |
Порт сервера |
Следующий этап соединения |
VarInt |
В этом поле возможно только два значения ( |
Статус
Клиент указал в Пакете рукопожатия, что запрашивает у сервера его статус и информацию о нем. Мы уже находимся на этапе соединения Статус после предыдущего пакета, т.к. клиент указал 1
в последнем поле. Все переключение между статусами происходят без отправки дополнительных пакетов, это всего лишь абстракция, которая отделяет одни пакеты от других
Сразу после пакета рукопожатия клиент отправляет серверу еще один пакет.
Запрос статуса Client -> Server (ID пакета – 0x00
)
У этого пакета нет никакой полезной нагрузки. Клиент передаст только ID пакета и длину пакета
В ответ на это сервер отвечает клиенту следующим пакетом
Ответ статуса Server -> Client (ID пакета – 0x00
)
Поле |
Тип |
---|---|
Информация о сервере |
JSON, сериализованная в строку Информация о сервере в формате JSON имеющая структуру описанную ниже, которая была сериализована в строку. |
Информация о сервере имеет следующую структуру в формате JSON
{ "version": { "name": "1.20.4", "protocol": 765 }, "players": { "max": 32, "online": 0, "sample": [ { "name": "player username", "id": "00000000-0000-0000-0000-000000000000" } ] }, "description": { "text": "Server description" }, "favicon": "data:image/png;base64,<data>", "enforcesSecureChat": true, "previewsChat": true }
В поле version
передается поддерживаемая сервером версия. Клиент сравнивает свою версию протокола с той, что передана в version.protocol
и если они не совпадают, то в списке серверов выводится информация о несовпадении версий.
В поле players.max
передается доступное количество игроков на сервере, а в players.online
текущее их количество онлайн.
Насколько мне известно, поле players.sample
не обрабатывается клиентом игры визуально, поэтому передавать честные данные о наличии игроков на сервере не обязательно, можно передавать пустой массив даже при значении players.online
отличной от 0.
Поле description
является специальной структурой, позволяющей выводить форматированный текст в поле описания сервера.
favicon
является необязательным полем, которое может содержать иконку сервера. Оно должно быть PNG изображением размером 64 на 64 закодированным в base64.
За что отвечают поля enforcesSecureChat
и previewsChat
мне не известно. В своем коде я просто не использовал их и пакет считался валидным. Так что эти поля являются необязательными.
Итог
Благодарю за прочтение! В следующей части я подробнее опишу процесс авторизации клиента на сервере и что нужно сделать чтобы игрок вошел в игровой мир.