Commit Graph

3 Commits

Author SHA1 Message Date
sasheg dev 46784aa88f Клиент говорит с сервером по нашему протоколу
Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.

Как это устроено:

- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
  переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
  мир, игрок, сущности и рендер, и все они работают. Не переписывать их
  ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
  наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
  кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
  зависит от того, что экран ещё на экране, ломается alt-tab'ом.

Вход теперь через лаунчер, а не внутри игры:

- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
  (--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
  ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
  бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
  дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
  пароль: это путь для своего сервера и для разработки.

Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.

Что чинилось по дороге:

- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
  получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
  экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
  {"valid":true} это один и тот же ответ, и сервер, который понимает
  только второй, ломает работающий лаунчер.
2026-10-19 20:10:19 +03:00
sasheg dev 7cd074e13c Словарь протокола: блоки, формат чанка и игровые пакеты
Протокол до сих пор умел только рукопожатие: поздороваться, доказать
пароль, получить токен и войти. Дальше играть было нечем.

Добавлено:

- Packet, интерфейс, который умеет написать себя сам. Раньше отправитель
  разбирал тип пакета в ветке instanceof, и с каждым новым видом пакета эта
  ветка росла. Теперь отправитель знает только, что у пакета есть write().
- Блоки и ChunkFormat переехали в протокол. Идентификаторы блоков и
  геометрия чанка — это словарь провода, а не внутреннее дело сервера:
  клиент получает чанк байт на блок и обязан знать, что означает каждый
  байт. Один источник правды вместо двух, которые могут разойтись.
- Игровые пакеты: JoinWorld, WorldJoin, ChunkData, BlockChange,
  PlayerMove, EntityMove, Ping/Pong, EntitySpawn, EntityRemove.
- Чанк уходит по проводу сжатым. Чанк это 65536 байт, и почти все они это
  воздух, поэтому дефлейт тут не украшение, а разница между «сервер
  принял» и «клиент вошёл».
- Имена всем пакетам в таблице names(), чтобы забытый пакет был виден
  сразу, а не всплывал через месяц.

Проверено: 22 теста протокола, включая круг чанка через сжатие, отказ
битому сжатому блоку и отказ обрезанному.
2026-10-19 18:58:07 +03:00
sasheg dev 7fbb26092f Каркас протокола: кадрирование, буфер и рукопожатие
Это первый настоящий кусок сервера. Пока он ничего не умеет, кроме того что
умеет правильно: принимать байты от чужого человека и не дать ему ничего
сделать с нами.

Формат кадра — четыре байта магии RSX1, версия, id пакета, длина тела и
само тело. Магия стоит в начале, чтобы порт мог сразу ответить, а не ждать
объяснений.

Главное здесь — границы. Сервер живёт тем, что вынимает числа из сокетов
чужих людей, и разница между дешёвой проверкой и аллокацией — это разница
между «противник стоил нам пары тактов» и «противник стоил нам кучи».

- Длина тела проверяется до того, как под неё что-то зарезервировано.
  Единственное, на что полезна вредная длина, — заставить сервер выделить
  память.
- Varint ограничен пятью байтами для int и десятью для long. Без этого
  десять байт 0xFF превращают пятибайтовое целое в бесконечный цикл.
- Длина строки ограничена до того, как под неё читают байты.
- Декодер держит не больше одного максимального кадра плюс неполный
  заголовок.

Неполные данные и испорченные — это разные вещи, и раньше это не было
разделено: конверт, разрезанный сетью посреди varint, выглядел как
испорченный и рвал соединение. IncompleteException означает «остальное
уже в пути», ProtocolException — «потока больше не будет». TCP режет кадры
почти всегда, так что первое поведение было бы отказом от большинства
соединений.

Позиция упакована одним varint, но доли у осей не равные: три поля по 26 бит
это 78 бит, они не влезают в 64. X и Z получают по 26 бит с шагом 1/32
блока, Y — оставшиеся 12 бит целыми блоками. Со смещением на 2048 не
вышло бы: для диапазона 0..4095 нужен тринадцатый бит, и это ровно тот бит,
по которому значение читается обратно отрицательным.

Первый прогон тестов нашёл все три ошибки сразу, две из них — в упаковке
позиции. Проверено 17 сценариев, включая «по одному байту», пятьдесят
кадров за одно чтение, чужую магию и объявленное тело в два гигабайта.

Собирается и тестируется одним JDK, без Gradle и без сети, как и всё
остальное в проекте.
2026-10-19 01:10:26 +03:00