46784aa88f99dcea314b9c021e95ec31dc53e80f
5 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
46784aa88f |
Клиент говорит с сервером по нашему протоколу
Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.
Как это устроено:
- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
мир, игрок, сущности и рендер, и все они работают. Не переписывать их
ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
зависит от того, что экран ещё на экране, ломается alt-tab'ом.
Вход теперь через лаунчер, а не внутри игры:
- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
(--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
пароль: это путь для своего сервера и для разработки.
Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.
Что чинилось по дороге:
- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
{"valid":true} это один и тот же ответ, и сервер, который понимает
только второй, ломает работающий лаунчер.
|
||
|
|
7cd074e13c |
Словарь протокола: блоки, формат чанка и игровые пакеты
Протокол до сих пор умел только рукопожатие: поздороваться, доказать пароль, получить токен и войти. Дальше играть было нечем. Добавлено: - Packet, интерфейс, который умеет написать себя сам. Раньше отправитель разбирал тип пакета в ветке instanceof, и с каждым новым видом пакета эта ветка росла. Теперь отправитель знает только, что у пакета есть write(). - Блоки и ChunkFormat переехали в протокол. Идентификаторы блоков и геометрия чанка — это словарь провода, а не внутреннее дело сервера: клиент получает чанк байт на блок и обязан знать, что означает каждый байт. Один источник правды вместо двух, которые могут разойтись. - Игровые пакеты: JoinWorld, WorldJoin, ChunkData, BlockChange, PlayerMove, EntityMove, Ping/Pong, EntitySpawn, EntityRemove. - Чанк уходит по проводу сжатым. Чанк это 65536 байт, и почти все они это воздух, поэтому дефлейт тут не украшение, а разница между «сервер принял» и «клиент вошёл». - Имена всем пакетам в таблице names(), чтобы забытый пакет был виден сразу, а не всплывал через месяц. Проверено: 22 теста протокола, включая круг чанка через сжатие, отказ битому сжатому блоку и отказ обрезанному. |
||
|
|
620f136361 |
Серверная база со своим логином
Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него. Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а запись обрезать нельзя: сервер не знает исходного пароля. Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе: соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт называется в Hello, а не в Auth. Иначе клиент получает соль не того аккаунта и не может вывести ключ. Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды лежат рядом с записью, а не в исходниках: число, существующее только в коде, однажды становится причиной того, что аккаунт невозможно проверить. Имена уникальны независимо от регистра: Player и player — это один аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его. Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает подбирать одно имя, другое не мешает перебирать много имён с разных адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на неверный пароль, и занимает то же время. Токены живут только в памяти. Записанный на диск токен пережил бы процесс, который его выдал, а украденный каталог сервера стал бы набором живых сессий. Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто зашёл с одного IP, то есть всех игроков за одним NAT. Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего мы уходим, перенося симуляцию на свой сервер. Регистрация — команда оператора, а не функция у клиента. Открытая саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь поддержки. Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий сокет: регистрация, вход, доказательство пароля, токен, его погашение и появление игроком. Отдельно проверено, что неверный пароль отвергается, а старое доказательство не принимается против нового вызова. Тик-цикл замеряется числом тиков за секунду, а не наличием потока. Всё одним JDK, без Gradle и без сети, как и весь проект. |
||
|
|
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 и без сети, как и всё остальное в проекте. |
||
|
|
bdf0ae866a |
Форк клиента собирается из исходников, модзагрузчик встроен в игру
Проект начинается с двух вещей: клиент, который мы действительно собрали, и загрузчик, который является его частью, а не чем-то рядом. Форк - prepare-game.ps1 пересобирает клиент целиком: ванильный jar, SRG и MCP, SpecialSource, FernFlower, патчи, автофиксеры, javac. Идемпотентен. - Свой код игры лежит в src-overlay, потому что src пересоздаётся при каждой сборке и всё написанное вручную там погибает. - 116 патчей в patches/decompiler-fixes.txt. Каждый сверялся с оригинальным байткодом через javap: компиляция не ловит ошибки декомпиляции. - struct_diff.py сравнивает исходный байткод с нашей сборкой по классам, интерфейсам, суперклассам, методам и полям, и отдельно проверяет, что пропавшие классы действительно никому не нужны. Загрузчик - Собирается и тестируется без игры: build.ps1 не использует classpath вообще, поэтому SelfTest из 128 проверок проходит без запущенного форка. - Клиентский API не содержит ни одного игрового типа, реализацию даёт игра. - Политика: мод меняет то, что клиент показывает, но никогда то, что истинно в мире. Мир, предметы, игроки и сеть принадлежат серверу. - Capability приведены в соответствие с этой политикой: осталось шесть, и за каждым стоит реальная проверка. Прежние WORLD, PLAYER, NETWORK, TEXT, INPUT и COMMAND выдавались, но ничего не проверяли. - Команды модов имеют область CLIENT, SERVER или BOTH. Серверные не выполняются на клиенте никогда. - Свой jar загрузчика лежит в classpath игры и сборки форка. Инструменты - add_patch.py, чтобы каждое исправление переживало передекомпиляцию - find_self_comparisons.py, find_remove_index_bugs.py, find_callers.py - stage_run.py и fetch_missing.py для запуска клиента |