- StatusSession: ответ на ванильный статус на том же порту.
Первый байт потока различает наш протокол и гостей: R - наш кадр,
иначе старый пинг (FE) или новый handshake + request + ping.
Логин в старом стиле получает вежливый отказ словами.
- Reader: первый байт, уже прочитанный вручную, теперь правильно
исключается из головы - раньше счётчик дважды пропускал его.
- TokenAuth: точные причины отказа (не подписано, другой сервер,
не тот вид, истёк, не тот игрок) и Verdict для каждой сессии.
- ClientSession: первое чтение смотрит на байт и отдаёт гостей статусу;
остальное идёт через прежнее расщепление.
- StatusTest: 5 проверок (json, modern, pong, legacy, отказ). Легаси-пинг
читался как знаковый байт; исправлено на беззнаковый.
- StatusRouteTest: 3 проверки через настоящий сервер и настоящие порты.
- watch-game.ps1: удаляет устаревший dev-token перед стартом сервера.
- Новый пакет LOD_REGION: сервер отдаёт участок дальнего вида — сетку высот
и цветов, по одной пробе каждые 4 блока.
- LodGenerator на сервере считает участки от начала мира, держит их в
памяти и пишет на диск рядом с миром, чтобы считать один раз.
- Клиент строит из участка сетку и рисует своим шейдером (#version 330) до
обычных чанков: чанки ближе и подробнее, поэтому закрывают дальний вид.
- Дальний вид приходит по мере того, как игрок ходит по миру: сервер шлёт
участки вокруг него раз в тик.
Проверки в сервере 96, в протоколе 24, в клиенте 128.
- WorldGenerator: круглый остров в середине квадрата, море вокруг (уровень 62),
песок у берега, дороги асфальтом. Остров и кольцо дорог считаются от начала
координат, а не от середины мира.
- Спавн: если середина острова под водой, игрок появляется на ближайшей суше
рядом с ней.
- Барьер: сервер не пускает игрока за край мира.
- Мир стал конечным: размер задаётся RoadNetwork, генератор берёт его оттуда.
Тесты мира и перезапуска переписаны под остров. Проверок в сервере 92.
- PlayerData: файл на игрока в world/players/<аккаунт>.json. Позиция, рыскание и
тангаж, 30 слотов инвентаря, 7 мест брони по два слоя, здоровье, режим игры,
выученные рецепты и открытые узлы тех-дерева.
- Запись: при выходе игрока, раз в 30 секунд и при остановке сервера. Чтение:
при входе, так что игра продолжается с того места, где игрок остановился.
- В Json добавлен писатель: quote, number, numbers, texts.
- Файл пишется через временный файл и подменяется, так что оборванная запись
не портит прошлую.
Тесты: сервер 92 (было 88), добавлен PlayerDataTest.
Аутентификация переделана по схеме пользователя: лаунчер — единственный
источник токена, сервер проверяет его сам и лаунчера не спрашивает.
- rsx-auth/GameToken: токен это base64url(payload) + "." + base64url(HMAC-SHA256),
payload {uuid, nick, aud, exp, jti, typ}, typ = session или reload.
- Сессия живёт час, reload-токен полтора часа и только внутри игры. Пакет
SessionReload продлевает сессию и выдаёт новый reload-токен.
- Сервер проверяет подпись, аудиенс, срок и совпадение uuid. HTTP-запрос к
лаунчеру из сервера убран.
- Клиент: окно пароля убрано совсем, ник берётся из токена, клиент сам
продлевает сессию за 5 минут до конца.
- Для локальной разработки сервер с --dev-token пишет свой токен в
world/dev-token.txt, клиент берёт токен оттуда.
- Права админа: свой список world/admins.txt, перечитывается раз в 5 секунд,
поэтому право снимается на лету. Пакет AdminState.
- Ванильные блоки мира ломает только админ, режим игры прав не даёт.
- F3+B только для админов: без прав показываем отказ, с правом включаются и
ванильные хитбоксы, и наш отладочный рендер. Права сняли посреди просмотра —
гаснут сразу.
- run-game.ps1 умеет -Token.
Проверено на живом сервере: own session ok, player in, rights of Player given.
Тесты: протокол 34, сервер 88, форк собирается чисто.
- Протокол: Chat (что сказал сервер), ChatSend (что сказал клиент) и PlayerList
(весь список игроков с их режимами).
- Сервер: сообщения уходят всем, а имя отправителя ставит сервер, поэтому подделать
его нельзя. Список игроков уходит всем целиком, когда он меняется. Команды
сервера: /help и /players.
- Клиент: перевод наших пакетов в игровые (SPacketChat и SPacketPlayerListItem),
уходящий чат перехватывается патчем, список игроков обновляется по разнице
(ушедших убираем, пришедших добавляем).
- Тесты: два игрока, один пишет - второй слышит с правильным именем; список
игроков приходит обоим и меняется, когда кто-то выходит.
Попутно: run-server.ps1 теперь знает про rsx-plugin-api, иначе сервер не
запускался (нет класса Hitboxes в classpath).
Тесты: протокол 34, сервер 87. Форк собирается чисто.
- В реестр заведены 46 предметов одежды ремейка: слот, слой, какие части тела
закрывает и сколько снимает из каждого вида вреда. Данные выгружены в items.csv
(extract-items.py), в коде у каждого предмета armor(...).
- Семь мест: голова, лицо, грудь (2 слоя), ноги (2 слоя), руки, стопы, полный
костюм. Пояс не делали - в ремейке его нет, есть только быстрая панель.
- Inventory сервера: семь мест по два слоя, вещи надеваются и снимаются.
- Damage: зона урона по точке попадания (голова верхние 0.4, ноги нижние 0.4),
множители голова 2, тело 1, ноги 0.75; одежда снимает вред только там, где
закрывает.
- Пакет Attack (цель, точка попадания, рука) и клиентский патч, который его
шлёт. У игрока появилась жизнь.
- В items.csv добавлены колонки armor_slot, armor_layer, armor_zones.
Тесты: протокол 24 + 10, сервер 85. Форк собирается чисто, 3279 классов.
- RsxPlayerTuning: высота при подкрадывании 1.45 (ванилла 1.65), прыжок с
места 1.5, в движении ванильные 1.25, высота глаз в шифте 1.37.
- RsxPlayer: прыжок по наличию горизонтального ввода, глаза при подкрадывании.
- Патч в EntityPlayer.updateSize: высота при подкрадывании из нашего класса.
- Импульс прыжка считается обратным счетом по физике ваниллы.
AABB: редактор хитбоксов и их отрисовка
- Протокол: Hitbox (имя, зона, min/max, поворот), пакеты Hitboxes,
HitboxInstances и HitboxesReload.
- Сервер: HitboxRegistry читает JSON-файл (world/hitboxes.json, можно
указать --hitboxes), перечитывает его по запросу клиента и шлет наборы
игрокам. Сломанный файл не стирает рабочие коробки, а объясняет причину.
- Клиент: RsxHitboxDebug рисует коробки через RenderGlobal.drawBoundingBox
с подписями DebugRenderer, красит зоны (голова красная, тело желтое, ноги
синие). Команды: /rsx hitboxes on|off|reload|list|show NAME.
- Новый модуль rsx-plugin-api с интерфейсом Hitboxes: плагин может
зарегистрировать свои коробки. У сервера появился скрипт сборки.
Тесты: протокол 34, сервер 80 (было 74). Форк собирается чисто, 3279 классов.
Формула v = 0.42 * sqrt(h / 1.25) давала 1.48 вместо 1.5, потому что движение
каждый тик теряет часть на сопротивление воздуха. Теперь высота находится
обратным счетом по физике ваниллы (гравитация 0.08, сопротивление 0.98).
Высота -> импульс: 1.25 -> 0.41961, 1.35 -> 0.43713, 1.5 -> 0.46341,
1.6 -> 0.48093, 2.0 -> 0.54485.
Задаётся: run-game.ps1 -ExtraArgs "-Drsx.jumpHeight=1.5"
Игрок:
- RsxPlayer extends EntityPlayerSP: наш игрок с нашими числами движения.
- RsxMovementInput extends MovementInputFromOptions: отменяет ванильный
множитель подкрадывания 0.3 и ставит наш.
- RsxPlayerTuning: подкрадывание 1.5 блока/с (было ~1.3), прыжок 1.35 блока
(было 1.25). Прыжок считается по высоте: v = 0.42 * sqrt(h / 1.25) = 0.43713.
Правило пользователя: значение меньше заданного не принимается, остаётся 1.5.
Переопределяется из командной строки: -Drsx.sneakSpeed, -Drsx.jumpHeight.
- Мобы прыгают как в ванилле: переопределение в RsxPlayer, а не в
EntityLivingBase.
- Три строки патчей в ванильное дерево: PlayerControllerMP создаёт RsxPlayer,
Minecraft создаёт RsxMovementInput. Вся логика в нашем коде.
- Форк собирается чисто, 3278 классов.
Каталог:
- tools/catalog-remake.py собирает assets/rustex/CATALOG.md и catalog.json.
- Что нашли у ремейка: 218 предметов (46 одежды, 44 ресурса, 33 оружия, 31
медицина, 21 инструмент, 17 построек, 11 боеприпасов, 11 разных, 4 ловушки),
91 объект nextgen (бочки, ящики, двери, верстаки, печи, руда, модели
монументов), 553 модели .obj (384 оружия с перезарядкой, 128 деталей постройки,
36 мебели, 4 руды), 133 иконки предметов, 2004 звука, текстуры монументов и
животных, скины игрока (9 файлов 256x256 плюс 9 с моргающими глазами) в
assets/rustex/player/.
- Скины игрока - текстуры ремейка, модель тела ванильная.
Декомпил ремейка лежит на диске: G:\src-decoded (5207 файлов, строки
расшифрованы). Поведение клиента можно читать, а не угадывать: конфиг оружия с
отдачей, схема viewmodel, формат данных. Значения же приходят с сервера, в
клиенте только схема и значения по умолчанию.
План дополнен разделами 2.8 (игрок) и 2.9 (декомпил).
- weapons.csv: 23 ствола с уроном, множителем в голову, темпом, магазином,
перезарядкой, разбросом (конус прицела), отдачей, типом патрона, верстаком и
ценами исследования. Источники - официальная вики и community-вики, расхождения
помечены в колонке source. Не хватает цены (2 из 23) и дальности (18 из 23).
Границы реверса ремейка, чтобы не тратить на них время снова:
- классы в game.jar помечены DE AD C0 DE и зашифрованы своим форматом, не
однобайтовым XOR как ассеты; единственный незашифрованный класс - точка входа
net/minecraft/client/main/RustexMain, в ней видны два статических дешифратора
строк (qnbt.super и vyuhke.super), а сами классы зашифрованы;
- REA не берет .class как цель (только архивы), Ghidra 12.1.4 стоит в
D:\ghidra-tools и через analyzeHeadless импортирует распакованные классы;
- главное: урона, отдачи и спрея огнестрела в клиенте нет вообще, это серверные
данные. Клиент знает только магазин, тип патрона, зум ADS, прочность и reach.
REA даст формулы, а не числа, поэтому числа берём с вики.
Инструменты в tools/: inspect-constants.py (разбор constant pool класса) и
find-decryptor.py (поиск маркера и слов дешифратора).
План дополнен этими находками.
Протокол:
- ItemData читает таблицу items/items.csv (218 предметов ремейка) и отдает
числа: урон, дальность, темп, прочность, сколько ресурса за удар.
- ItemType: поля rid, researchScrap, techTreeBench, techTreeScrap,
techTreeParent, admin. Урон по частям тела делится как в ремейке: голова
вдвое, руки и ноги четыре пятых. Число в исходнике главнее таблицы.
- ItemRegistry: 218 предметов с рецептами, ценой исследования и веткой
дерева. Новые вещи: salvage-тесак, бронедвери, гаражи, взрывчатка,
компоненты, еда, мусор, верстаки и печи.
- NodeType: узлы с запасом (дерево 500/750/1000, камень 750, металл 500,
сера 200) и меткой: крестик на дереве до двух, горячая точка на руде до
трех.
- TechTree: ветки верстаков с предками, 125/250/500 скрапа.
- Новые пакеты: Dig, BreakProgress, CraftStart, CraftUpdate, ResearchStart,
ResearchUpdate, TechTreeUnlock, KnownRecipes, TechTreeState, ContainerOpen,
ContainerTake, LearnBlueprint.
Сервер:
- Knowledge: что игрок знает - рецепты и открытые узлы деревьев.
- Crafter: проверка рецепта, уровня верстака и ингредиентов; время крафта
по формуле плана.
- ResearchTable: предмет плюс скрап, десять секунд, предмет превращается в
чертёж; чертёж изучается и открывает рецепт.
- ResourceNode: запас, метка, последний удар отдает остаток.
- JunkPile: две-четыре бочки (серая - скрап и мусор, синяя - компонент,
красная - нефть и топливо) и ящик с едой; пустая мусорка исчезает через
60-180 секунд.
- RoadNetwork: сетка дорог и кольцо, точки спавна мусорки; генератор кладет
асфальт и ровняет дорогу по ширине.
- Проверка досягаемости вынесена из changeBlock, игрок получил benchLevel.
- Сессия отвечает на новые пакеты, при входе шлет известные рецепты и
открытые узлы.
Тесты: протокол 24 + 10, сервер 74 (было 57). Форк собирается чисто.
Выгрузили из ремейка Rustex всё, на чём будем работать, и переписали план
под реальные цифры.
Ассеты:
- tools/extract-rustex-assets.py распаковывает res/ и game.jar и декодирует
однобайтовый XOR (ключ по сигнатуре: PNG ^0x89, OGG ^0x4F, JSON ^0x7B).
Итог: assets/rustex, 1.65 ГБ, 19 тыс. файлов. В репозиторий не кладём.
- Не прочитались 553 .obj, 106 .rrs, 113 .nbt: у них другой шифр, нужен
разбор кода (REA).
Таблицы (в репозитории):
- items.csv: 218 предметов с уроном, дальностью, темпом, прочностью, добычей
за удар, материалом, иконкой и моделью;
- sounds.csv: 505 звуковых событий с файлами;
- item-texts.json: 543 названия и описания предметов.
План (PLAN.md):
- Сбор теперь по балансу ремейка: камень берёт 50 дерева и 35 руды, топор 85
дерева, каменная кирка 80 руды. Дерево 750 топором рубится за 9 ударов, с
меткой за 6-7 - ровно те 7-10 ударов, которых ждали.
- Исследования - версия после 2020: два пути. Стол (15/30/60/120, нужен сам
предмет, превращается в чертёж) и тех-дерево верстака (125/250/500, ветки с
предками, разблокировка личная). Утверждение "тех-дерево бесплатно" было
ошибкой community-вики.
- Лозы больше нет: она из джунглей, а джунглей нет. Выкинута из HP-таблицы,
цен, таймеров гниения и правил шкафа.
- Новые разделы 1.10 (мусорка на дороге: серая бочка - скрап, синяя -
компоненты, красная - нефть и топливо, плюс ящики с едой) и 1.11 (дороги:
сетка 3-4 полос плюс кольцо, сегменты - точки спавна мусорки).
- Первый источник скрапа - мусорка, лутбоксы отложены: вопрос о прогрессии
крафта снят.
- Узлы: руда - своя 3D-модель с AABB, деревья - пока блоки мира.
Фундамент предметов по данным плана (срез 11.2025) и серверный инвентарь.
В протоколе (общий словарь для клиента и сервера):
- ToolClass, ResourceClass - что чем ломается и что где лежит.
- ItemType - определение предмета: класс инструмента, урон по четырем
частям тела, прочность, Gather Rates по каждому классу ресурса
(сколько снимает удар, разброс, сколько уходит прочности инструмента),
bench - требуемый уровень верстака, knownByDefault - открыт ли рецепт
с начала игры, admin - только ли админы сервера.
- ItemRegistry - таблица предметов. Числа из плана, каждое в одном
месте, чтобы игра и план не разъехались.
- Recipe - ингредиенты, количество, базовое время крафта.
- ItemStack - тип, количество, остаток прочности и свои данные на стеке
(как теги у плагина в Paper): стек - это то, что путешествует.
Пакеты: Inventory (весь инвентарь при входе) и SlotChange (один слот).
На сервере: Inventory - 30 слотов, хотбар 6, сервер владеет инвентарем и
сообщает клиенту, а не спрашивает. Стартовый набор - камень в первом
слоте и в руке.
Тесты: предметы закреплены числами из плана (камень бьёт в голову на 20,
снимает 10 дерева и 5 руды; кирка не рубит дерево; меч не ломает вообще),
инвентарь переживает провод, сумка наполняется и опустошается, полная
сумма говорит об этом.
Замечание: прогрессия крафта в Rust упирается в лутбоксы - скрап падает
только из них, а чертёж требует найденного предмета. Это отдельный
вопрос (п. 5.2 плана), он не блокирует предметы и инвентарь.
Файл на VPS пересобрал глубже, чем был: он сошёлся с официальной викой
Facepunch на дату 06.11.2025 и с логикой самой игры, а не с вики 2018-2020,
из которой брались прошлые числа.
Что изменилось по существу:
- Сбор ресурсов пересчитан. Узел - это запас (750 камней, 500 металла,
200 серы), а удар снимает часть: камнем 10 дерева или 5 руды, киркой 16
руды. Отсюда 47-75 ударов на узел, а не "2 удара" из старой вики.
- Появился "Gather Rates" по официальной вике: Gather Damage, Bonus,
Condition Loss по каждой паре "инструмент - ресурс", плюс боевая статистика
(урон, голова x2, темп, дальность) и таблица брони.
- Скрап и чертежи: цена 15/30/60/120 по редкости (не 20/75/125/500 по
уровню), верстак 1 больше не требует скрапа, тех-дерево верстака и
эксперимент на верстаке - механика 2017-2020, убрана.
- Сокеты построек расписаны конкретно: малый проём (Doorway) - одиночная
дверь и торговый автомат, большой (Wall Frame) - двойные двери, гаражные
ворота, сетка-фенси, ворота и стена клетки, витрины; окно - решётки,
стёкла, ставни, бойницы; рамка пола - решётка, люки.
- Добавлены разбор по пунктам, что было неверно и почему, и сверка данных
(суммы цен исследования, тех-дерева, уровней верстаков, ударов) - сведено.
Поправлено: G:\decompiled-rustex - НЕ источник фактов, он копировал
community-вику 2017-2020. Именно оттуда пришли неверные числа.
Решения по проекту сохранены, открытые вопросы разделены на решённые и те,
что блокируют реализацию.
Пункт ноль плана: клиент теперь просит контекст 3.3 в режиме
совместимости, а не тот, что выпадал по умолчанию.
Зачем: дальний вид будет писаться шейдером GLSL 330, и контекст должен
гарантированно быть не ниже 3.3. В профиле совместимости работают и
шейдеры игры (#version 120, их 34 штуки), и наши 330-е. LWJGL 2.9.4 выше
3.3 контекст не заказывает - ограничение в его конструкторе, а не в
драйвере. Драйвер при этом дает 4.6, и расширение GL_EXT_gpu_shader4,
которое просит FXAA-шейдер игры, в этом контексте на месте.
Замер кадров теперь пишется в лог каждую секунду: средний, медиана,
1% и 0.1% low. Одного среднего мало: клиент, который идет со 60
кадрами и дважды в секунду проваливается в 10, в среднем остается 60 и
ощущается как slideshow. Медленные кадры - это то, что чувствует
игрок, поэтому окно держит все времена кадров и говорит про хвост.
Среднее и процентили видны и в HUD, так что их не надо вылавливать из
скриншотов.
Замер: 307 fps | med 2.9 ms | 1% 6.0 ms | 0.1% 18.1 ms.
Статтеры были не на сервере, а в клиенте, и стоили дорого.
Измерено: перевод чанка в игровой формат шел через setBlockState, а он
каждый раз обновляет карту высот и карту света. Чанк стоил 10-156 мс на
главном потоке, их было 208, и переход через границу чанка упирался в
325 мс за один тик.
Сделано:
- Секции чанка пишутся прямо в формате игры. Палитра и упакованные
индексы строятся игровым же классом, так что формат совпадает по
определению, а заливка по блокам и карты высот и света не нужны.
Чанк подешевел с 25 мс до 1-2 мс, это в 15-25 раз.
- Обработка входящих пакетов ограничена тремя миллисекундами на тик.
Колонка чанков, пришедшая за один раз, теперь размазывается по тикам:
не влезшее ждет следующего, это 0.2 секунды, и глазом не видно.
- Сгенерированный чанк больше не считается грязным. Террейн есть чистая
функция от зерна и координат, он и так восстановится за пару
миллисекунд, а писать его стоило 64 килобайта на чанк. Ходьба по миру
больше не гоняет двадцать мегабайт по диску. На диск пишется только то,
во что игрок построился.
Проверено: 169 чанков при заходе, медленных (больше 4 мс) три, остальные
быстрее; на диске ноль файлов, потому что игрок ничего не строил.
Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.
Как это устроено:
- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
мир, игрок, сущности и рендер, и все они работают. Не переписывать их
ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
зависит от того, что экран ещё на экране, ломается alt-tab'ом.
Вход теперь через лаунчер, а не внутри игры:
- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
(--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
пароль: это путь для своего сервера и для разработки.
Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.
Что чинилось по дороге:
- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
{"valid":true} это один и тот же ответ, и сервер, который понимает
только второй, ломает работающий лаунчер.
Раньше клиент умел только показывать меню. Теперь он заходит на сервер,
получает наш мир и рисует его.
Как это устроено:
- Наш сетевой слой (net/rustnotmc/client/net) читает наш протокол и
переводит его пакеты в пакеты игры. Игра остаётся игрой: у неё есть
мир, игрок, сущности и рендер, и все они работают. Не переписывать их
ради того, чтобы нарисовать тот же мир иначе, незачем.
- Пакеты игры, которые не имеют соответствия у нас, не отправляются, а
наоборот: перемещение игрока и ломание блока переводятся в наши.
- Экран входа игры (GuiConnecting) заменён наш: он спрашивает у лаунчера,
кем пришёл игрок, и подставляет это в рукопожатие.
- Соединение качается из тика игры, а не из экрана. Рукопожатие, которое
зависит от того, что экран ещё на экране, ломается alt-tab'ом.
Вход теперь через лаунчер, а не внутри игры:
- Лаунчер уже умеет логинить игрока и отдаёт игре имя, uuid и токен
(--username, --uuid, --accessToken). Это та же роль, что у Yggdrasil в
ванилле, только лаунчер наш.
- Сервер спрашивает лаунчер, один из ли наш этот игрок, и заодно получает
бан-лист. Токен используется один раз и не хранится.
- Свои аккаунты сервера, когда настроен лаунчер, закрыты: сервер с двумя
дверями не имеет дверей. Открыть их можно флагом, но намеренно.
- Если игру запустили без лаунчера, экран всё равно спрашивает имя и
пароль: это путь для своего сервера и для разработки.
Проверено живьём: клиент вошёл по токену лаунчера, сервер спросил
лаунчер, лаунчер сказал "свой", игрок получил мир, 210 чанков пришли и
лежат на диске, рельеф нарисован.
Что чинилось по дороге:
- Minecraft.setIngameFocus() возвращал на главное меню, когда окно
получало фокус. Игрок как раз кликает в окно, чтобы напечатать пароль, и
экран входа исчезал в этот самый момент.
- Ответ лаунчера разбирается, а не ищется подстрокой: {"valid": true} и
{"valid":true} это один и тот же ответ, и сервер, который понимает
только второй, ломает работающий лаунчер.
Сервер умел принимать игроков и не умел ничего, что можно увидеть. Мир
был не написан: ни чанков, ни генерации, ни сохранения, ни игровых
пакетов.
Сделано:
- Пакет world: ChunkPos, Chunk, Blocks (теперь в протоколе), WorldGenerator,
ChunkStorage, World. Генерация чистая функция от зерна и координат, без
java.util.Random и без общего состояния, потому что чанк может
появиться через месяц после соседнего, на другой машине, и обязан
совпасть с ним побайтово. Сохранение — файл на чанк, запись во временный
файл и перенос, версия и контрольная сумма: половина записанного файла
это мир, который молча испортился.
- Снимок чанка и запись блока теперь под одним замком. Раньше копия могла
сняться посреди записи блока, и на диск уходила половина изменения.
- Player: позиция, вид, список отправленных чанков, проверка досягаемости
блока. Клиент не может переписать блок, до которого не дотянулся.
- Сервер держит мир, крутит его по тику, выкидывает давно не тронутые
чанки, шлет чанки вокруг игрока и рассылает изменения блоков и передвижения
тем, кто их видит.
- Клиентский сетевой слой в игре еще не тронут: это следующий шаг.
Проверено: 45 тестов сервера. Из них 25 на мир (детерминизм, круг
сохранения, автосохранение, вытеснение, отказ битому файлу) и 9 сквозных
через настоящий сокет: игрок входит, получает 25 чанков, чанк совпадает с
террейном сервера, блок ломается и остаётся сломанным после перезапуска
сервера, два игрока видят друг друга.
Протокол до сих пор умел только рукопожатие: поздороваться, доказать
пароль, получить токен и войти. Дальше играть было нечем.
Добавлено:
- Packet, интерфейс, который умеет написать себя сам. Раньше отправитель
разбирал тип пакета в ветке instanceof, и с каждым новым видом пакета эта
ветка росла. Теперь отправитель знает только, что у пакета есть write().
- Блоки и ChunkFormat переехали в протокол. Идентификаторы блоков и
геометрия чанка — это словарь провода, а не внутреннее дело сервера:
клиент получает чанк байт на блок и обязан знать, что означает каждый
байт. Один источник правды вместо двух, которые могут разойтись.
- Игровые пакеты: JoinWorld, WorldJoin, ChunkData, BlockChange,
PlayerMove, EntityMove, Ping/Pong, EntitySpawn, EntityRemove.
- Чанк уходит по проводу сжатым. Чанк это 65536 байт, и почти все они это
воздух, поэтому дефлейт тут не украшение, а разница между «сервер
принял» и «клиент вошёл».
- Имена всем пакетам в таблице names(), чтобы забытый пакет был виден
сразу, а не всплывал через месяц.
Проверено: 22 теста протокола, включая круг чанка через сжатие, отказ
битому сжатому блоку и отказ обрезанному.
Теперь можно зарегистрировать аккаунт, поднять сервер и зайти в него.
Пароль не ходит по сети и не хранится на сервере в обратимом виде. Клиент
называет аккаунт, сервер отдаёт его соль и одноразовый вызов для этого
соединения, клиент тянет из пароля ключ через PBKDF2-HMAC-SHA256 и
подписывает им вызов вместе с именем. Пароль пересекает сокет ноль раз, а
запись обрезать нельзя: сервер не знает исходного пароля.
Отсюда важное следствие, которое сразу отозвало одну ошибку в протоколе:
соль обязана прийти раньше, чем клиент что-то доказывает, поэтому аккаунт
называется в Hello, а не в Auth. Иначе клиент получает соль не того
аккаунта и не может вывести ключ.
Учётная запись хранится как PBKDF2 соль, счётчик раундов и хеш. Раунды
лежат рядом с записью, а не в исходниках: число, существующее только в
коде, однажды становится причиной того, что аккаунт невозможно проверить.
Имена уникальны независимо от регистра: Player и player — это один
аккаунт. Иначе игрок получает двух персонажей и не понимает, какой его.
Троттлинг двумя ведрами, по аккаунту и по адресу: одно не мешает
подбирать одно имя, другое не мешает перебирать много имён с разных
адресов. Ответ на несуществующий аккаунт выполняет ту же работу, что и на
неверный пароль, и занимает то же время.
Токены живут только в памяти. Записанный на диск токен пережил бы процесс,
который его выдал, а украденный каталог сервера стал бы набором живых
сессий.
Сессии различаются номером, а не адресом. Ключ по адресу вытеснял всех, кто
зашёл с одного IP, то есть всех игроков за одним NAT.
Тик-цикл: 20 раз в секунду, с ограничением догоняющих тиков. Мир, который
тикает по кадрам, тикает в зависимости от видеокарты — ровно то, от чего
мы уходим, перенося симуляцию на свой сервер.
Регистрация — команда оператора, а не функция у клиента. Открытая
саморегистрация превращает хранилище аккаунтов в спам-цель и в очередь
поддержки.
Проверено 12 сценариев, и главный из них — не юнит-тест, а настоящий
сокет: регистрация, вход, доказательство пароля, токен, его погашение и
появление игроком. Отдельно проверено, что неверный пароль отвергается, а
старое доказательство не принимается против нового вызова. Тик-цикл
замеряется числом тиков за секунду, а не наличием потока.
Всё одним JDK, без Gradle и без сети, как и весь проект.
Это первый настоящий кусок сервера. Пока он ничего не умеет, кроме того что
умеет правильно: принимать байты от чужого человека и не дать ему ничего
сделать с нами.
Формат кадра — четыре байта магии 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 и без сети, как и всё
остальное в проекте.
Тик. Моды тикали из покадрового хука, то есть получали столько тиков,
сколько кадров успевает нарисовать машина. На 200 fps это в семь раз
быстрее игры: таймер мода уезжал, а счётчик тиков модов расходился с
миром, поверх которого он нарисован. Теперь тик вызывается рядом с
runTick(), внутри того же цикла, так что у мода ровно двадцать шагов в
секунду независимо от частоты кадров.
HUD. Экран загрузки мира рисует сам, мимо хвоста кадра. Пока генерируется
ландшафт, LoadingScreenRenderer рисует в собственный framebuffer, блитит
его в окно и сам вызывает updateDisplay() — до десяти раз в секунду, из
потока, который занят генерацией. Всё, что нарисовано из покадрового хука,
стирается следующим таким выводом, поэтому оверлей пропадал на всю
загрузку и возвращался уже в мире. Добавлен отдельный вход в тот же код
рисования оверлеев, вызываемый после блита и до вывода в окно.
Заодно разделены две ошибки в RsxBootstrap: сбой тика и сбой отрисовки
больше не выключают друг друга, у каждого своя защита.
Проверено вживую: оверлей виден на экране генерации ландшафта.
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.
Клиент запрашивал звук, движок его принимал и запускал, а на выходе было
тихо. Причина оказалась не в коде игры.
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 в дереве не осталось.
Проект начинается с двух вещей: клиент, который мы действительно собрали, и
загрузчик, который является его частью, а не чем-то рядом.
Форк
- 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 для запуска клиента