223 lines
14 KiB
Markdown
223 lines
14 KiB
Markdown
# rsx-modloader
|
||
|
||
Модлоадер для RustexRemake: Java-моды в папке `mods`, подключаются через
|
||
`-javaagent`, `game.jar` не патчится. Один раз поставил — моды переживают
|
||
обновление игры.
|
||
|
||
В лаунчере появляется выбор режима прямо на кнопке «Играть»: **с модами** или
|
||
**ванилла**.
|
||
|
||
```
|
||
mod-template\ пример мода, собирается один раз
|
||
bridge\ агент (-javaagent) и его исходники
|
||
launcher-patch\ патч лаунчера: окно выбора и защита от автоапдейта
|
||
testserver\ локальный сервер, принимает любой ник и токен
|
||
scripts\ сборка, установка, запуск, удаление
|
||
third_party\asm\ ASM (BSD-3), зашит внутрь rsx-launcher-patch.jar
|
||
```
|
||
|
||
## Установка
|
||
|
||
build.cmd собрать dist\ (нужен только JDK)
|
||
install.cmd поставить модлоадер
|
||
start-launcher.cmd запускать лаунчер
|
||
uninstall.cmd вернуть всё как было
|
||
|
||
`install.cmd` копирует рантайм в `%LOCALAPPDATA%\rsx-mod`, патчит `app.jar`
|
||
и `updater.exe` лаунчера и прописывает `-javaagent` в его конфиг. Пути можно
|
||
переопределить:
|
||
|
||
install.cmd --home D:\rsx-mod --game D:\RustexRemake
|
||
|
||
**Лаунчер должен быть закрыт.** Если он запущен с правами администратора,
|
||
`app.jar` не перезаписать — установщик об этом скажет.
|
||
|
||
Обычный ярлык работает: патч лаунчера и патч апдейтера рассчитаны на то, что
|
||
обновление придёт и не снесёт модлоадер. `start-launcher.cmd` остаётся как
|
||
запасной путь в обход апдейтера.
|
||
|
||
## Как это работает
|
||
|
||
* **Агент** (`rsx-bridge.jar`) подключается через `-javaagent` и в `premain`
|
||
читает профиль сборки игры (`profiles/latest.rsxp`): хеш jar, обфусцированные
|
||
имена нужных классов и полей. Ничего не хардкодится под конкретную сборку.
|
||
* **Патчи клиента** — A2 ставит `joml.fastmath` до загрузки JOML, A6
|
||
подменяет таймер телеметрии на «глухой». Оба сделаны на живом клиенте:
|
||
первые версии A6 роняли игру с `NullPointerException` и
|
||
`Timer already cancelled`.
|
||
* **Моды** грузятся родным загрузчиком игры (`ilxq` → `special.Main`) — так же,
|
||
как моды с сервера, и выполняются в игровом потоке.
|
||
* **Несколько модов работают вместе** — см. «Почему один drain» ниже.
|
||
* **Переносимость модов.** Мод собирается один раз против стабильного API.
|
||
Мост сам переводит логические имена в имена текущей сборки, поэтому
|
||
ре-обфускация вендора не ломает уже собранные моды.
|
||
|
||
## Патч лаунчера
|
||
|
||
Лаунчер — сторонний продукт, и у него три особенности, которые мешают:
|
||
|
||
* **Гонка за состоянием кнопки.** `xa.run()` вызывает `ha.y()` (ставит `PLAY`),
|
||
а `fa.loadedWeb()` позже ставит `ERROR`. Состояния — это индексы кнопок в
|
||
`web/launcher/main.html`, их шесть, поэтому `ERROR=6` скрывает все кнопки.
|
||
При медленной загрузке страницы `loadedWeb` затирает `PLAY` — кнопки просто
|
||
нет. Патч восстанавливает состояние после `loadedWeb`.
|
||
* **Автоапдейтер.** Патченый `app.jar` отличается от манифеста, и лаунчер
|
||
считает себя устаревшим в двух местах: `sb.q()` (размер и хеш файла) и
|
||
`ha` (хеш рабочего jar, отсюда «обновление найдено, перезапустите лаунчер»).
|
||
Оба закрыты через `rsx.Patch`, который действует только на файл с именем
|
||
`app.jar` и только при наличии маркера `.rsx-patched`.
|
||
* **Ремонт своего апдейтера.** При старте лаунчер проверяет хеш `updater.exe`
|
||
и, не найдя вендорскую сборку, скачивает чистую копию поверх
|
||
(`jb` → `l.ALLATORIxDEMO()` → «updater.exe»). Метод `jb` заменён на
|
||
`rsx.Patch.skipUpdaterRepair()`.
|
||
|
||
Возврат к обычному поведению: удалить `.rsx-patched` или запустить лаунчер с
|
||
`-Drsx.allowLauncherUpdate=true`.
|
||
|
||
## Патч апдейтера
|
||
|
||
`updater.exe` — это PE-заглушка Launch4j, к которой обычным zip просто пришит
|
||
jar (payload начинается с offset 65024 и идёт до EOF). Заглушка запускает jar из
|
||
себя, поэтому патчить надо именно хвост, а PE-заголовок не трогаем.
|
||
|
||
В payload вставляются два хука:
|
||
|
||
* `UpdaterGui.runLauncher(Z)V` → `rsx.UpdaterHook.beforeLauncher(...)` —
|
||
перед запуском лаунчера `app.jar` патчится заново, если он оказался
|
||
вендорским (апдейтер достаёт его из своего локального кэша даже когда скачивать
|
||
нечего).
|
||
* `DownloadEngine.findFilesToDownload` → `rsx.UpdaterHook.filterUpdates(...)` —
|
||
`app.jar` убирается из списка на скачивание, **только** если хеш в манифесте
|
||
совпадает с `baseSha256` из `.rsx-patched`. Хеш другой — значит настоящее
|
||
обновление, и файл скачивается как обычно.
|
||
|
||
`baseSha256` — это хеш нетронутого `app.jar`; патчер пишет его в `.rsx-patched`
|
||
сам, до того как перезаписать файл. При настоящем обновлении маркер
|
||
перезаписывается новым значением.
|
||
|
||
Хук идемпотентен: повторный `patchupdater` берёт pristine-копию из
|
||
`updater.exe.orig`, а если `updater.exe` заменил вендор — обновляет бэкап.
|
||
Перед записью собранный jar проверяется загрузкой классов, так что битый патч
|
||
никогда не попадёт на диск.
|
||
|
||
Лог: `%APPDATA%\kjstudio\rsx-launcher\updater-patch.log`.
|
||
|
||
## Конфиг лаунчера — осторожно
|
||
|
||
`launcher_config.properties` — это properties-файл: слэш экранирует следующий
|
||
символ, а `\r` читается как перевод строки. Значение `gamePath1=G:\rustexremake`
|
||
без экранирования превращается в `G:` + перевод строки + `ustexremake`, и
|
||
лаунчер перестаёт находить игру. Все правки идут через
|
||
`java.util.Properties` — в патчере есть команды `getprop` и `setprop`.
|
||
|
||
## Моды
|
||
|
||
Мод — это jar с манифестом
|
||
|
||
Rsx-Mod-Entrypoint: com.example.MyMod
|
||
|
||
и классом, реализующим `RsxMod`. Собирается один раз против `rsx-api.jar` и
|
||
дальше не пересобирается: все обфусцированные имена мод не знает, бриф
|
||
разрешает их через профиль сборки.
|
||
|
||
Общая библиотека кладётся в `%LOCALAPPDATA%\rsx-mod\mods\..\libs\` — то есть
|
||
`%LOCALAPPDATA%\rsx-mod\libs\`: агент подмешивает эти jar в системный class
|
||
path при старте, и моды видят библиотеку, не вшивая её в себя.
|
||
|
||
Репозитории:
|
||
|
||
| что | где |
|
||
|---|---|
|
||
| модлоадер | [sasheg/rsx-modloader](https://git.swe.zernmc.ru/sasheg/rsx-modloader) |
|
||
| общая библиотека | [sasheg/rsxm-lib](https://git.swe.zernmc.ru/sasheg/rsxm-lib) |
|
||
| эталонный мод | [sasheg/rsxm-example](https://git.swe.zernmc.ru/sasheg/rsxm-example) |
|
||
| HUD | [sasheg/rsxm-hud](https://git.swe.zernmc.ru/sasheg/rsxm-hud) |
|
||
|
||
Готовый шаблон простого мода — в `mod-template\`, пример на библиотеке — в
|
||
[rsxm-example](https://git.swe.zernmc.ru/sasheg/rsxm-example).
|
||
|
||
Rsx.everyTicks(20, this::onSecond); // раз в 20 игровых тиков
|
||
Rsx.every(5000L, this::poll); // раз в 5 секунд реального времени
|
||
Rsx.onGameThread(this::doIt); // на следующем тике
|
||
Rsx.on("playerJoin", this::hello); // по событию
|
||
|
||
События: `tick`, `worldChange`, `playerJoin`, `playerLeave`, `screenOpen`,
|
||
`screenClose`. Игрок и мир — типизированные обёртки, а не `Object`:
|
||
|
||
Rsx.Player p = Rsx.player();
|
||
p.getName(); p.getX(); p.getY(); p.getZ(); p.getHealth(); p.isAlive();
|
||
Rsx.World w = Rsx.world();
|
||
w.getTime(); w.isClient();
|
||
|
||
`everyTicks` стоит предпочитать `every`: миллисекунды — это часы, а тик — это
|
||
тик, и при лагах миллисекундный таймер прыгает, а тиковый нет.
|
||
|
||
Если задача мода падает, мост зовёт `RsxMod.onError`, а после пяти подряд
|
||
падений вообще отбрасывает задачу — чтобы сломанный мод не залил лог.
|
||
|
||
### Почему один drain
|
||
|
||
Родной загрузчик игры (`ilxq`) на каждый jar создаёт **новый** загрузчик
|
||
классов, поэтому моды не видят друг друга и не конфликтуют за `special.Main`.
|
||
Но тик-хук он запоминает в **статическом поле**, которое перезаписывается при
|
||
каждой загрузке:
|
||
|
||
do = new ilxq(); // свой загрузчик на мод
|
||
...
|
||
int = method2; // статическое поле: помнит только ПОСЛЕДНИЙ мод
|
||
|
||
Из-за этого `getRunnableList()` вызывается у одного мода — последнего
|
||
загруженного. Мод, чей хук не запомнили, молча ничего не делает: его `onLoad`
|
||
отработал, его задачи висят в очереди, и их никто не разбирает.
|
||
|
||
Поэтому в мосте **одна очередь на всех и один drain**: какой бы `Main` игра ни
|
||
позвала, он выполняет работу всех модов. Собственность задачи запоминается в
|
||
`ThreadLocal` в момент `onLoad`, поэтому в логе ошибки видно, чей это код.
|
||
|
||
Проверяется тестом: `bridge\src\test\python\run_test.py` грузит три мода и
|
||
требует, чтобы работу всех трёх видно было в логе.
|
||
|
||
## Тест-сервер
|
||
|
||
testserver\start.cmd
|
||
|
||
Локальный сервер: принимает любой ник и токен, отвечает на `/server-ping`,
|
||
слушает порты 25566–25573 и закидывает в пустой мир. Проверка без игры:
|
||
|
||
python testserver\selftest.py
|
||
|
||
Адрес сервера зашит в коде клиента, поэтому для полного захода в мир нужен
|
||
редирект (PowerShell **от администратора**):
|
||
|
||
scripts\install-hosts.ps1 прописать 127.0.0.1 для адреса клиента
|
||
scripts\uninstall-hosts.ps1 откатить
|
||
|
||
## Сборка
|
||
|
||
build.cmd
|
||
|
||
Нужен только JDK. ASM лежит в `third_party\asm` и зашивается внутрь
|
||
`rsx-launcher-patch.jar`, поэтому устанавливать модлоадер можно без Gradle и
|
||
без сети.
|
||
|
||
Готовые файлы кладутся в `dist\`:
|
||
|
||
| файл | что это |
|
||
|---|---|
|
||
| `rsx-bridge.jar` | агент, Java 8 |
|
||
| `rsx-api.jar` | API, против которого собирается мод |
|
||
| `rsx-launcher-patch.jar` | патчер лаунчера, всё внутри |
|
||
| `mod-template\` | заготовка мода |
|
||
|
||
## Тесты
|
||
|
||
python bridge\src\test\python\run_test.py
|
||
|
||
Поднимает фейковый клиент, проверяет профиль, A2, A6, загрузку мода и то, что
|
||
мод выполняется в игровом потоке, а телеметрия не срабатывает.
|
||
|
||
---
|
||
|
||
RustexRemake и KJST Launcher — сторонние продукты, они не входят в этот проект.
|
||
Инструмент работает только на машине пользователя. ASM распространяется по
|
||
лицензии BSD-3, см. `LICENSE`. |