450bdecc3470df5928e12264703bcb3ead641685
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
450bdecc34 |
Клиент говорит с сервером по нашему протоколу
Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.
Как это устроено:
- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
мир, игрок, сущности и рендер, и все они работают. Не переписывать их
ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
зависит от того, что экран ещё на экране, ломается alt-tab'ом.
Вход теперь через лаунчер, а не внутри игры:
- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
(--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
пароль: это путь для своего сервера и для разработки.
Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.
Что чинилось по дороге:
- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
{"valid":true} это один и тот же ответ, и сервер, который понимает
только второй, ломает работающий лаунчер.
|
||
|
|
46784aa88f |
Клиент говорит с сервером по нашему протоколу
Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.
Как это устроено:
- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
мир, игрок, сущности и рендер, и все они работают. Не переписывать их
ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
зависит от того, что экран ещё на экране, ломается alt-tab'ом.
Вход теперь через лаунчер, а не внутри игры:
- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
(--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
пароль: это путь для своего сервера и для разработки.
Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.
Что чинилось по дороге:
- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
{"valid":true} это один и тот же ответ, и сервер, который понимает
только второй, ломает работающий лаунчер.
|
||
|
|
68dfea27b5 |
Мир на сервере: чанки, генерация, сохранение и поток игрока
Сервер умел принимать игроков и не умел ничего, что можно увидеть. Мир был не написан: ни чанков, ни генерации, ни сохранения, ни игровых пакетов. Сделано: - Пакет world: ChunkPos, Chunk, Blocks (теперь в протоколе), WorldGenerator, ChunkStorage, World. Генерация чистая функция от зерна и координат, без java.util.Random и без общего состояния, потому что чанк может появиться через месяц после соседнего, на другой машине, и обязан совпасть с ним побайтово. Сохранение — файл на чанк, запись во временный файл и перенос, версия и контрольная сумма: половина записанного файла это мир, который молча испортился. - Снимок чанка и запись блока теперь под одним замком. Раньше копия могла сняться посреди записи блока, и на диск уходила половина изменения. - Player: позиция, вид, список отправленных чанков, проверка досягаемости блока. Клиент не может переписать блок, до которого не дотянулся. - Сервер держит мир, крутит его по тику, выкидывает давно не тронутые чанки, шлет чанки вокруг игрока и рассылает изменения блоков и передвижения тем, кто их видит. - Клиентский сетевой слой в игре еще не тронут: это следующий шаг. Проверено: 45 тестов сервера. Из них 25 на мир (детерминизм, круг сохранения, автосохранение, вытеснение, отказ битому файлу) и 9 сквозных через настоящий сокет: игрок входит, получает 25 чанков, чанк совпадает с террейном сервера, блок ломается и остаётся сломанным после перезапуска сервера, два игрока видят друг друга. |
||
|
|
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 и без сети, как и всё остальное в проекте. |
||
|
|
736ef36a44 | README приведён в соответствие с тем, что реально сделано | ||
|
|
966a144a64 |
Тик мода должен идти по игровому тику, HUD должен виден при загрузке
Тик. Моды тикали из покадрового хука, то есть получали столько тиков, сколько кадров успевает нарисовать машина. На 200 fps это в семь раз быстрее игры: таймер мода уезжал, а счётчик тиков модов расходился с миром, поверх которого он нарисован. Теперь тик вызывается рядом с runTick(), внутри того же цикла, так что у мода ровно двадцать шагов в секунду независимо от частоты кадров. HUD. Экран загрузки мира рисует сам, мимо хвоста кадра. Пока генерируется ландшафт, LoadingScreenRenderer рисует в собственный framebuffer, блитит его в окно и сам вызывает updateDisplay() — до десяти раз в секунду, из потока, который занят генерацией. Всё, что нарисовано из покадрового хука, стирается следующим таким выводом, поэтому оверлей пропадал на всю загрузку и возвращался уже в мире. Добавлен отдельный вход в тот же код рисования оверлеев, вызываемый после блита и до вывода в окно. Заодно разделены две ошибки в RsxBootstrap: сбой тика и сбой отрисовки больше не выключают друг друга, у каждого своя защита. Проверено вживую: оверлей виден на экране генерации ландшафта. |
||
|
|
b6e847e981 |
Инструменты для сверки с оригинальным байткодом
struct_diff сравнивает классы по составу и показывает, каких не хватает. Но он отвечает на вопрос «того же ли набора методов», а не «тот ли код». Оба вопроса приходится задавать: прыжки и звуки шагов были сломаны именно кодом, который компилировался без ошибок и состоял из правильных методов. javap_diff сравнивает инструкции построчно, вызывая javap на обоих деревьях классификации. Наивное сравнение текста здесь бесполезно: перекомпиляция перенумеровывает constant pool, поэтому почти каждая строка отличается номером #123, хотя код совпадает. Инструмент убирает номера пула, сдвиги смещений от ldc/ldc_w и форму инструкций, после чего остаётся реальная разница. На звуке инструмент показал, что все аудио-классы совпадают с оригиналом. Остатки объясняются версией компилятора, а не правками: javac 8 собирает конкатенацию строк через StringBuilder, javac 17 через invokedynamic, а $values() у enum появился в более новых версиях. ogg_duration читает частоту дискретизации из заголовка Vorbis и число отсчётов из granule position последней страницы OggS. Отсюда видно, что файл сам по себе короче или что его просто обрывают при воспроизведении, — вопрос, который иначе приходится решать на слух. Проверено, что все 1085 ogg совпадают с индексом по SHA-1. |
||
|
|
00671e7c0d |
Звук в клиенте пропал из-за неверного каталога ассетов
Клиент запрашивал звук, движок его принимал и запускал, а на выходе было тихо. Причина оказалась не в коде игры. Minecraft 1.12.2 не умеет читать ассеты по индексу и объектному хранилищу. Он ждёт обычное дерево: <assetsDir>/minecraft/<путь>. А лаунчер хранит ассеты по-современному — indexes/1.12.json со ссылками на objects/<aa>/<sha1>. Мы передавали лаунчеру корень этого хранилища как --assetsDir. Игре это не выглядит как поломка: язык и текстуры берутся из самого jar, поэтому клиент выглядит полностью рабочим. Но звуки с 1.7.3 лежат только в ассетах, и их просто не было на диске. Движок поднимался, play() вызывался, ogg не находился, а paulscode на это молчит. - materialize_assets.py разворачивает дерево из индекса и хранилища, жёсткими ссылками где возможно, и идемпотентен - run-game.ps1 материализует ассеты перед запуском и указывает клиенту на развёрнутое дерево; --assetIndex больше не нужен - проверено: 1305 объектов, из них 1085 ogg, sounds.json на месте, ошибок загрузки в логе нет Заодно убрана временная диагностика звука и движения, добавленная ради поиска причины: она доказала, что запрос и воспроизведение доходят до OpenAL, и свою задачу выполнила. Полный prepare-game.ps1 после этого проходит целиком: 112 патчей, FORK COMPILES CLEAN, 3268 классов, RsxDebug в дереве не осталось. |
||
|
|
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 для запуска клиента |