7 Commits

Author SHA1 Message Date
SashegDev 88b90802a7 Агент ищет свой дом, а не доверяет месту jar
Путь к агенту в конфиге лаунчера переписывает сам лаунчер, и однажды он
потерял целый сегмент пути: осталось ...\rsx-mod\rsx-bridge.jar вместо
...\rsx-mod\bridge\rsx-bridge.jar. JVM не смог загрузить агент, и игра падала с
error2 при входе в моды.

Две правки:
- агент кладём в корень модлоадера, а не в bridge\: тогда его каталог и есть
  дом, и mods\, libs\ и config\ находятся без угадывания;
- в env пишем слеши вперёд. Лаунчер переписывает конфиг своим properties-писателем
  и портит именно обратные слеши, а у прямого слеша экранировать нечего.

Заодно premain теперь поднимается вверх, пока не найдёт каталог с mods\ или
libs\, вместо того чтобы считать родителем jar единственно верным ответом.

И ещё: лаунчер вместе с env потерял gamePath1, установщик теперь проверяет и
восстанавливает его.

Заодно выяснилось, что апдейтер всё же чинит app.jar ("Final repair: app.jar") -
наш фильтр не сработал, потому что маркер с базовым хешем к тому моменту не
совпадал. Сейчас это лечится автоматически, но в логах видно.
2026-10-17 09:30:09 +03:00
SashegDev 108c001af6 HUD: размер экрана из контекста и rsxm-hud 2026-10-17 03:45:59 +03:00
SashegDev b5ffe0e9d7 Пинг и потери: считаем сами, потому что считать нечем
Вендор заменил сетевой слой на свой Netty-обработчик, поля задержки в нём нет,
а сам класс в маппингах не восстановлен. Ванильный getLag() не достать, и игра
пинг нигде не показывает.

Остаётся то, что есть всегда: сервер шлёт keep-alive раз в 15 секунд по своему
собственному расписанию. Отклонение интервала прихода - это и есть сеть, и
скользящее среднее по нему даёт оценку на любом сервере. Грубые интервалы
(потерянный пакет удваивает интервал) в среднее не попадают.

Число помечается как оценка: Rsx.pingExact() false, в строке "ping~". Эхо со
своим nonce в имени канала осталось в коде, но в этой сборке обработчик
кастомного payload у клиента не найден, поэтому профиль его не объявляет и эхо
просто недоступно - точность пока такая, какая есть.

Период keep-alive читается из -Drsx.keepalivePeriod: прокси иногда шлют иначе, и
стенду нужен короткий, чтобы проверить математику за секунды.

Заодно: Resolver игнорировал "принудительный" скан и отдавал устаревший индекс
замет позапрошенных классов. HUD-гейт читался в первый кадр и не находил экран.

Стенд кормитkeep-alive с интервалом 240 мс при периоде 200 и ждёт оценку около
50 мс - ровно то отклонение, которое он и создал.
2026-10-17 03:42:32 +03:00
SashegDev 419b2d0a6b HUD: моды рисуют через собственный API игры
Хук в отрисовку компаса. Выбор не случайный: виджет рисуется только пока виден
HUD-сло�� игры, поэтому панель бесплатно наследует и видимость, и масштаб, и
состояние горячей клавиши, которая HUD прячет. Дублировать логику клавиш не
пришлось.

Бридж зашивает ASM и регистрирует ClassFileTransformer, который дописывает вызов
rsx.HudHook.render перед возвратом метода. Профиль диктует цель, обфусцированных
имён в коде нет. Трансформер отдаёт контекст как Object: так один дескриптор
работает при любом переименовании класса контекста.

Привязка к API отрисовки трёхуровневая, и каждая ступень умеет отвалиться
отдельно: точное совпадение по дескриптору, затем по имени и числу аргументов
(класс текстуры грузится позже, чем привязка), затем по runtime-классу
билдера, который мы держим в руках. Не нашлось - пишем в лог один раз и гасим
только эту возможность.

Текст рисуется ванильной текстурой textures/font/ascii.png: у игры нет
однострочного текстового API, а для цифр и меток атласа хватает. Своим TTF
конвейером мы не занимались.

Стенд учит подделки: батч, билдер, команду, реестр текстур, масштаб, синглтон
экрана и виджет-компас. Проверяет, что панель рисует плашку и глифы с верными
координатами и что замолкает, когда игра прячет HUD. С отключённым хуком падают
пять проверок из двадцати пяти.

Профиль: hook.hud, экран HUD, батч, реестр текстур, масштаб и сетевые методы
NetHandlerPlayClient. У каждой строки - рецепт поиска после реобфускации.
2026-10-17 03:29:00 +03:00
SashegDev 04872b21b6 README: ссылки на репозитории модов и библиотеки 2026-10-17 02:56:19 +03:00
SashegDev 13ddc0d0f5 libs\ — общая библиотека для всех модов
Загрузчик игры даёт каждому моду свой class loader, поэтому моды не видят друг
друга напрямую. Но у всех этих загрузчиков родителем является системный, а
значит всё, что добавлено в его classpath, видно каждому моду.

Агент теперь на старте дописывает все jar из <home>\libs\ в системный
classpath. Одна копия библиотеки на всех, модам не нужно ничего вшивать в себя.

Проверено стендом: библиотека testlib лежит в libs\, мод её использует, и в
jar мода её нет.
2026-10-17 02:38:34 +03:00
SashegDev cc1eaece40 Несколько модов одновременно и живая работа с игрой
Главное: модлоадер физически не умел запускать больше одного мода. Родной
загрузчик игры запоминает тик-хук в статическом поле, которое перезаписывается
на каждой загрузке:

    do = new ilxq();   // свой загрузчик на мод
    int = method2;     // статическое поле: помнит только ПОСЛЕДНИЙ мод

Из-за этого getRunnableList() вызывался у одного мода. Остальные грузились,
их onLoad отрабатывал, задачи висели в очереди - и их никто никогда не
разбирал. Молча.

Чинится без обфускации: в мосте одна очередь на всех и один drain. Какой бы
special.Main игра ни позвала, он выполняет работу всех модов. Владелец задачи
запоминается в ThreadLocal в момент onLoad, поэтому в логе видно, чей код
упал. Счетчик падений тоже переехал с записи в очереди на саму задачу: очередь
чистится каждый тик, и счетчик на ней обнулялся бы раньше, чем дошел до
предела.

API:
- Rsx.Player и Rsx.World: getName/getX/getY/getZ/getHealth/getMaxHealth/
  isAlive/isDead и getTime/isClient. Раньше player() отдавал Object, и мод
  вытаскивал имя из toString() обфусцированного класса.
- События: Rsx.on("tick"|"worldChange"|"playerJoin"|"playerLeave"|
  "screenOpen"|"screenClose"). Заменяют polling.
- Rsx.everyTicks(n) - периодичность по тикам, не плывет и не двоит после лага.
- RsxMod.apiVersion() и onError(): мод заявляет версию API и сам решает, что
  делать с падением. Без этого сломанный мод заливал лог.
- Задача, упавшая 5 раз подряд, отбрасывается целиком.
- Rsx.error() и именованные every/post - чтобы в логе было видно, чё это.

Профиль перегенерирован: в него попали члены Entity (posX/posY/posZ/isDead,
getName/getEntityId/isEntityAlive), EntityLivingBase (здоровье) и World
(isRemote/getWorldTime), которых раньше не было. Сгенерированы по SRG-именам
через make_profile.py, поэтому переживут очередную реобфускацию.

Тест-стенд переписан под три мода и воспроизводит обе особенности вендорного
загрузчика: новый classloader на jar и перезаписываемое статическое поле.
Контрольный прогон с отключённым drain роняет 10 проверок из 17, так что
проверки действительно завязаны на этот механизм.

Заодно: сломанный фейковый загрузчик делил один classloader на все моды,
и такой тест не мог поймать проблему с изоляцией.
2026-10-17 02:25:27 +03:00
41 changed files with 3307 additions and 176 deletions
+73 -4
View File
@@ -24,16 +24,17 @@ third_party\asm\ ASM (BSD-3), зашит внутрь rsx-launcher-patch.j
uninstall.cmd вернуть всё как было
`install.cmd` копирует рантайм в `%LOCALAPPDATA%\rsx-mod`, патчит `app.jar`
лаунчера и прописывает `-javaagent` в его конфиг. Пути можно переопределить:
и `updater.exe` лаунчера и прописывает `-javaagent` в его конфиг. Пути можно
переопределить:
install.cmd --home D:\rsx-mod --game D:\RustexRemake
**Лаунчер должен быть закрыт.** Если он запущен с правами администратора,
`app.jar` не перезаписать — установщик об этом скажет.
Запускай лаунчер через `start-launcher.cmd`, а не ярлыком: `updater.exe`
сверяет хеш `app.jar` с манифестом и при патченом jar заново скачивает
официальный, стирая патч.
Обычный ярлык работает: патч лаунчера и патч апдейтера рассчитаны на то, что
обновление придёт и не снесёт модлоадер. `start-launcher.cmd` остаётся как
запасной путь в обход апдейтера.
## Как это работает
@@ -46,6 +47,7 @@ third_party\asm\ ASM (BSD-3), зашит внутрь rsx-launcher-patch.j
`Timer already cancelled`.
* **Моды** грузятся родным загрузчиком игры (`ilxq` → `special.Main`) — так же,
как моды с сервера, и выполняются в игровом потоке.
* **Несколько модов работают вместе** — см. «Почему один drain» ниже.
* **Переносимость модов.** Мод собирается один раз против стабильного API.
Мост сам переводит логические имена в имена текущей сборки, поэтому
ре-обфускация вендора не ломает уже собранные моды.
@@ -108,6 +110,73 @@ jar (payload начинается с offset 65024 и идёт до EOF). Заг
лаунчер перестаёт находить игру. Все правки идут через
`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
@@ -16,9 +16,17 @@ import rsx.Resolver;
*/
public final class Rsx {
/** Version of this API, mirrored by RsxMod.apiVersion(). */
public static final int API_VERSION = 1;
private Rsx() {
}
/** The API version this bridge implements. */
public static int apiVersion() {
return API_VERSION;
}
// ------------------------------------------------------------- identity
public static String buildId() {
return Resolver.profile() == null ? "unknown"
@@ -39,18 +47,156 @@ public final class Rsx {
return Resolver.get("net.minecraft.client.Minecraft", "instance");
}
public static Object player() {
return Resolver.get("net.minecraft.client.Minecraft", "player", minecraft());
}
public static Object world() {
return Resolver.get("net.minecraft.client.Minecraft", "world", minecraft());
}
public static Object currentScreen() {
return Resolver.get("net.minecraft.client.Minecraft", "currentScreen", minecraft());
}
/**
* The local player, with the members a mod actually wants.
*
* Every value goes through the profile, so a mod asks for "name" and never
* sees an obfuscated field. Anything the profile does not carry yet comes
* back as null/0 and is reported once in the log instead of throwing.
*/
public static Player player() {
return new Player(Resolver.get("net.minecraft.client.Minecraft", "player",
minecraft()));
}
/** The world the player is in, with the members a mod actually wants. */
public static World world() {
return new World(Resolver.get("net.minecraft.client.Minecraft", "world",
minecraft()));
}
// ------------------------------------------------------------- wrappers
/** Thin typed view over an obfuscated game object. */
public static final class Player {
private final Object raw;
private boolean warned;
Player(Object raw) {
this.raw = raw;
}
/** The underlying obfuscated object, for the reflective escape hatch. */
public Object raw() {
return raw;
}
public boolean isPresent() {
return raw != null;
}
public String getName() {
Object v = call("net.minecraft.entity.Entity", "getName", raw);
return v == null ? null : String.valueOf(v);
}
public int getId() {
Object v = call("net.minecraft.entity.Entity", "getEntityId", raw);
return v instanceof Number ? ((Number) v).intValue() : 0;
}
public boolean isAlive() {
Object v = call("net.minecraft.entity.Entity", "isEntityAlive", raw);
return v instanceof Boolean && ((Boolean) v).booleanValue();
}
public boolean isDead() {
return bool("net.minecraft.entity.Entity", "isDead", raw);
}
public double getX() {
return dbl("net.minecraft.entity.Entity", "posX", raw);
}
public double getY() {
return dbl("net.minecraft.entity.Entity", "posY", raw);
}
public double getZ() {
return dbl("net.minecraft.entity.Entity", "posZ", raw);
}
public float getHealth() {
Object v = call("net.minecraft.entity.EntityLivingBase", "getHealth", raw);
return v instanceof Number ? ((Number) v).floatValue() : 0f;
}
public float getMaxHealth() {
Object v = call("net.minecraft.entity.EntityLivingBase", "getMaxHealth", raw);
return v instanceof Number ? ((Number) v).floatValue() : 0f;
}
@Override
public String toString() {
String name = getName();
return name != null ? name : String.valueOf(raw);
}
private double dbl(String cls, String member, Object on) {
Object v = Resolver.get(cls, member, on);
return v instanceof Number ? ((Number) v).doubleValue() : 0d;
}
private boolean bool(String cls, String member, Object on) {
Object v = Resolver.get(cls, member, on);
return v instanceof Boolean && ((Boolean) v).booleanValue();
}
private Object call(String cls, String member, Object on) {
if (raw == null) {
return null;
}
Object v = Resolver.call(cls, member, on, new Object[0]);
if (v == null && !warned) {
warned = true;
warn("profile has no " + cls + "#" + member);
}
return v;
}
}
/** Thin typed view over the client's world. */
public static final class World {
private final Object raw;
World(Object raw) {
this.raw = raw;
}
/** The underlying obfuscated object, for the reflective escape hatch. */
public Object raw() {
return raw;
}
public boolean isPresent() {
return raw != null;
}
/** True on the client, false on a dedicated server. */
public boolean isClient() {
Object v = Resolver.get("net.minecraft.world.World", "isRemote", raw);
return v instanceof Boolean && ((Boolean) v).booleanValue();
}
/** World time in ticks, the number the in-game clock shows. */
public long getTime() {
Object v = Resolver.call("net.minecraft.world.World", "getWorldTime", raw,
new Object[0]);
return v instanceof Number ? ((Number) v).longValue() : 0L;
}
@Override
public String toString() {
return "World(present=" + (raw != null) + ", time=" + getTime() + ")";
}
}
public static Class<?> classOf(String logical) {
return Resolver.cls(logical);
}
@@ -107,11 +253,175 @@ public final class Rsx {
ModBridge.post(task);
}
/** Run a task repeatedly on the game thread. */
/** Named variant: a failing task says which one it was. */
public static void onGameThread(String name, Runnable task) {
ModBridge.post(name, task);
}
/**
* Run a task repeatedly on the game thread.
*
* The interval is wall-clock: if the game thread lags, the task runs late and
* the period drifts. Use everyTicks for anything that must stay in step.
*/
public static void every(long millis, final Runnable task) {
ModBridge.schedule(millis, task);
}
/** Named variant of every(millis). */
public static void every(String name, long millis, Runnable task) {
ModBridge.schedule(name, millis, task);
}
/**
* Run a task every N game ticks. Counted by the game's own tick, so it never
* drifts and never fires twice after a lag.
*/
public static void everyTicks(int everyTicks, Runnable task) {
ModBridge.scheduleTicks(everyTicks, task);
}
/**
* Subscribe to a game event. Handlers run on the game thread.
*
* Events: "tick" (every tick), "worldChange", "playerJoin", "playerLeave",
* "screenOpen", "screenClose".
*/
public static void on(String event, Runnable handler) {
ModBridge.on(event, handler);
}
/** Ticks counted by the bridge since the game came up. */
public static long tick() {
return ModBridge.tickCount();
}
// ------------------------------------------------------------------- ping
/**
* Latency to the server, in milliseconds, or -1 while it is still unknown.
*
* The client measures nothing itself on this build, so this is derived from
* the server's keep-alive cadence; it settles after a few samples. Use
* pingExact() to know whether the number is a real round trip.
*/
public static int ping() {
return rsx.Ping.rttMillis();
}
/** True when the number came from a measured round trip, not an estimate. */
public static boolean pingExact() {
return rsx.Ping.exact();
}
/** Packet loss in percent, measured the same way. */
public static float packetLoss() {
return rsx.Ping.lossPercent();
}
/** A ready-to-print line, e.g. "ping~ 42 ms, loss 0.4%". */
public static String pingLine() {
return rsx.Ping.describe();
}
// ------------------------------------------------------------------- hud
/**
* The game's HUD, valid only inside a draw callback.
*
* Drawing here uses the game's own command pipeline, so a panel sits in the
* same layer and the same interface scale as the compass and the health
* bars, and follows the game's own show/hide state - including the key that
* hides the whole HUD.
*/
public static Hud hud() {
rsx.Hud live = rsx.Hud.current();
return live == null ? null : new Hud(live);
}
/**
* Draw something on the game's HUD, every frame, while it is visible.
*
* Rsx.onHud(new Rsx.HudDraw() {
* public void draw(Hud hud) {
* hud.text(8, 8, "42 ms", 0xFFFFFF, 1f);
* }
* });
*
* The callback runs on the render thread. Do the cheap thing here: the whole
* HUD is drawn inside one frame, and work done per frame is work done 60
* times a second.
*/
public static void onHud(HudDraw drawer) {
rsx.HudHook.add(new rsx.HudHook.Listener() {
@Override
public void onHud(rsx.Hud h) {
drawer.draw(new Hud(h));
}
});
}
/** A HUD draw callback. */
public interface HudDraw {
void draw(Hud hud);
}
/** The game's HUD, as a mod sees it. */
public static final class Hud {
private final rsx.Hud h;
Hud(rsx.Hud h) {
this.h = h;
}
/** False while the game is not drawing its HUD. */
public boolean available() {
return h != null && h.available();
}
/** The interface scale the game draws its own widgets with. */
public float scale() {
return h.scale();
}
/** Screen width in pixels, 0 when the game did not say. */
public int width() {
return h == null ? 0 : h.width();
}
/** Screen height in pixels, 0 when the game did not say. */
public int height() {
return h == null ? 0 : h.height();
}
/** A solid rectangle; alpha 0..1. */
public void rect(float x, float y, float w, float height, int rgb,
float alpha) {
if (h == null) {
return;
}
h.rect(x, y, w, height, rgb, alpha);
}
/**
* Text from the game's own ASCII atlas: digits and latin letters come
* out in the same glyphs the game uses. Characters the atlas has no
* glyph for are skipped.
*/
public void text(float x, float y, String value, int rgb, float alpha) {
if (h == null) {
return;
}
h.text(x, y, value, rgb, alpha);
}
/** Width of a label in HUD units. */
public float textWidth(String value) {
return h == null ? 0f : h.textWidth(value);
}
}
// ------------------------------------------------------------------ logs
public static void info(String msg) {
rsx.Log.info(msg);
@@ -120,4 +430,14 @@ public final class Rsx {
public static void warn(String msg) {
rsx.Log.warn(msg);
}
/** Report a problem without throwing: the message lands in bridge.log. */
public static void error(String msg) {
rsx.Log.error(msg, null);
}
/** Report a problem with its cause. */
public static void error(String msg, Throwable cause) {
rsx.Log.error(msg, cause);
}
}
@@ -4,14 +4,39 @@ package net.rustexremake.api;
* The whole contract a mod has to implement.
*
* onLoad runs once, on the thread that loads the mod jar. Anything scheduled
* through {@link Rsx#every} or {@link Rsx#onGameThread} runs later on the game's
* own thread, which is the only thread where touching game state is safe.
* through {@link Rsx#every}, {@link Rsx#everyTicks}, {@link Rsx#onGameThread}
* or {@link Rsx#on} runs later on the game's own thread, which is the only
* thread where touching game state is safe.
*
* Every method is a default, so a mod only overrides what it needs.
*/
public interface RsxMod {
/**
* API version this mod was written against.
*
* Return {@link Rsx#API_VERSION} unless you deliberately target an older
* one. The bridge warns when the numbers differ, which turns "my mod broke
* after the update" into a line in the log.
*/
default int apiVersion() {
return Rsx.API_VERSION;
}
/** Called once, after the mod jar is loaded. */
void onLoad();
/** optional */
/**
* Called when one of this mod's tasks throws.
*
* The default only logs. Override it to stop repeating whatever fails: a mod
* that throws every tick is dropped after a few failures, but handling it
* here is better than being switched off.
*/
default void onError(Throwable error) {
}
/** Called when the game shuts down, for releasing resources. */
default void onUnload() {
}
}
}
+67
View File
@@ -0,0 +1,67 @@
package rsx;
/**
* Builds the echo packet.
*
* The nonce travels in the packet's channel name, so no payload buffer has to
* be built or parsed: a server that supports the mod echoes the channel back
* and the client recognises its own echo by the name.
*
* The packet class is vendor code with no stable name, so the profile has to
* carry it. When it does not, the echo is simply unavailable and the modloader
* falls back to the keep-alive estimate - which is why nothing here fails loudly
* on a build it does not recognise.
*/
public final class EchoPacket {
public static final String CHANNEL = "rsx_echo";
private EchoPacket() {
}
/**
* @return a packet carrying the nonce, or null when this build has no echo
*/
public static Object create(Object netHandlerPlayClient, int nonce) {
Profile p = Resolver.profile();
if (p == null) {
return null;
}
String[] ctor = p.member("rsx.net.echoPacket", "ctor");
if (ctor == null) {
return null;
}
try {
Class<?> cls = Resolver.findLoaded(p.obf("rsx.net.echoPacket"));
if (cls == null) {
return null;
}
Class<?> buffer = Resolver.findLoaded(
p.obf("rsx.net.echoBuffer"));
java.lang.reflect.Constructor<?> c = cls.getDeclaredConstructor(
String.class, buffer);
c.setAccessible(true);
return c.newInstance(CHANNEL + ":" + nonce, null);
} catch (Throwable t) {
return null;
}
}
/** The nonce from a channel name, or -1 when it is not ours. */
public static int nonceOf(String channel) {
if (channel == null || !channel.startsWith(CHANNEL + ":")) {
return -1;
}
try {
return Integer.parseInt(channel.substring(CHANNEL.length() + 1));
} catch (Throwable t) {
return -1;
}
}
/** True when this build can send an echo at all. */
public static boolean available() {
Profile p = Resolver.profile();
return p != null && p.member("rsx.net.echoPacket", "ctor") != null;
}
}
@@ -0,0 +1,188 @@
package rsx;
import java.lang.instrument.ClassFileTransformer;
import java.security.ProtectionDomain;
import org.objectweb.asm.ClassReader;
import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.Opcodes;
import org.objectweb.asm.tree.AbstractInsnNode;
import org.objectweb.asm.tree.ClassNode;
import org.objectweb.asm.tree.InsnList;
import org.objectweb.asm.tree.InsnNode;
import org.objectweb.asm.tree.MethodInsnNode;
import org.objectweb.asm.tree.MethodNode;
import org.objectweb.asm.tree.VarInsnNode;
/**
* Appends calls of our own to two places in the running game.
*
* The HUD hook goes into the compass widget's render. That target is chosen, not
* arbitrary: the widget is drawn only while the game's HUD layer is visible, so
* a hook there inherits the game's own show/hide behaviour instead of
* duplicating it, and the draw lands in the same pass, at the same scale, as the
* widget we are drawing next to.
*
* The keep-alive hook goes into the client's packet handler, which is where the
* server's 15 second cadence arrives - the only timing signal the client exposes
* on a build whose network layer the vendor rewrote.
*
* Nothing here knows an obfuscated name: the profile carries both targets. A
* build that no longer matches simply gets no hook, which is reported once
* rather than being fatal.
*/
public final class HookTransformer implements ClassFileTransformer {
private final String hudClass;
private final String hudMethod;
private final String hudDesc;
private final String pingClass;
private final String pingMethod;
private boolean hudApplied;
private boolean pingApplied;
private boolean sawHud;
public HookTransformer(String hudClass, String hudMethod, String hudDesc,
String pingClass, String pingMethod) {
this.hudClass = hudClass;
this.hudMethod = hudMethod;
this.hudDesc = hudDesc;
this.pingClass = pingClass;
this.pingMethod = pingMethod;
}
public boolean hudApplied() {
return hudApplied;
}
public boolean pingApplied() {
return pingApplied;
}
/** The class was seen but the method inside it was not. */
public boolean incomplete() {
return sawHud && !hudApplied;
}
@Override
public byte[] transform(ClassLoader loader, String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain, byte[] bytes) {
if (className == null || bytes == null) {
return null;
}
boolean isHud = className.equals(hudClass);
boolean isPing = pingClass != null && className.equals(pingClass);
if (!isHud && !isPing) {
return null;
}
try {
ClassNode cls = new ClassNode();
new ClassReader(bytes).accept(cls, 0);
boolean changed = false;
if (isHud) {
sawHud = true;
changed = hookHud(cls);
hudApplied = hudApplied || changed;
}
if (isPing) {
changed = hookKeepAlive(cls);
pingApplied = pingApplied || changed;
}
if (!changed) {
return null;
}
ClassWriter out = new ClassWriter(
ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
cls.accept(out);
return out.toByteArray();
} catch (Throwable t) {
// A broken hook must never take the game down: returning null leaves
// the original class untouched.
Log.error("hook failed, the game runs without it", t);
}
return null;
}
private boolean hookHud(ClassNode cls) {
if (hudMethod == null || hudDesc == null) {
return false;
}
for (MethodNode m : cls.methods) {
if (!m.name.equals(hudMethod) || !m.desc.equals(hudDesc)
|| m.instructions == null) {
continue;
}
InsnList call = new InsnList();
// the context is the method's first argument
call.add(new VarInsnNode(Opcodes.ALOAD,
(m.access & Opcodes.ACC_STATIC) != 0 ? 0 : 1));
// Taken as Object on purpose: whatever the game calls its widget
// context, it is assignable to Object, so one descriptor works on
// every build. Spelling out the real type would tie the call to a
// class name that changes with every update.
call.add(new MethodInsnNode(Opcodes.INVOKESTATIC, HudHook.NAME,
"render", "(Ljava/lang/Object;)V", false));
insertAtEnd(m, call);
Log.info("HUD hook applied to " + cls.name.replace('/', '.')
+ "." + hudMethod);
return true;
}
Log.warn("HUD hook: " + cls.name + " has no method " + hudMethod
+ hudDesc + ", the panel will not draw");
return false;
}
private boolean hookKeepAlive(ClassNode cls) {
String desc = keepAliveDesc();
if (pingMethod == null || desc == null) {
return false;
}
for (MethodNode m : cls.methods) {
if (!m.name.equals(pingMethod) || !m.desc.equals(desc)
|| m.instructions == null) {
continue;
}
InsnList call = new InsnList();
call.add(new MethodInsnNode(Opcodes.INVOKESTATIC, Ping.NAME,
"onKeepAlive", "()V", false));
insertAtEnd(m, call);
Log.info("keep-alive hook applied to " + cls.name.replace('/', '.')
+ "." + pingMethod);
return true;
}
Log.warn("keep-alive hook: " + cls.name + " has no method "
+ pingMethod + desc + ", ping stays unavailable");
return false;
}
private static String keepAliveDesc() {
Profile p = Resolver.profile();
if (p == null) {
return null;
}
String[] m = p.member("net.minecraft.client.network.NetHandlerPlayClient",
"handleKeepAlive");
return m == null ? null : m[1];
}
/**
* Puts the call where it will actually run.
*
* A method ends with a RETURN, so appending to the instruction list would put
* the call after it: code that verifies fine and never fires. The last
* return is the only place a "do this before leaving" call belongs.
*/
private static void insertAtEnd(MethodNode m, InsnList call) {
AbstractInsnNode last = m.instructions.getLast();
if (last instanceof InsnNode) {
int op = ((InsnNode) last).getOpcode();
if (op >= Opcodes.IRETURN && op <= Opcodes.RETURN) {
m.instructions.insertBefore(last, call);
return;
}
}
m.instructions.add(call);
}
}
+125
View File
@@ -0,0 +1,125 @@
package rsx;
/**
* The game's HUD, as a mod sees it.
*
* Coordinates are in the game's own HUD scale, so a panel drawn here sits in
* the same layer as the compass, the health bars and everything else, and
* follows the player's interface scale setting.
*
* Every method is safe to call when the game is not drawing: they quietly do
* nothing, because a mod that forgets to check should not be able to break the
* frame.
*/
public final class Hud {
/**
* The vanilla ASCII atlas is a 16x16 grid of 8x8 glyphs, which is what makes
* the game's own font possible for us without touching the TTF pipeline.
*/
private static final int GLYPHS = 16;
private static final int GLYPH = 8;
private final Object ctx;
Hud(Object ctx) {
this.ctx = ctx;
}
/** False while the game is not drawing its HUD. */
public boolean available() {
return ctx != null && Ui.ready();
}
/** The live context, for the duration of one frame's callback only. */
public static Hud current() {
Object c = HudHook.context();
return c == null ? null : new Hud(c);
}
public float scale() {
return Ui.scale();
}
/** Screen width, 0 when the game did not say. */
public int width() {
return HudHook.width();
}
/** Screen height, 0 when the game did not say. */
public int height() {
return HudHook.height();
}
/** A solid rectangle. Colour is 0xRRGGBB, alpha 0..1. */
public void rect(float x, float y, float w, float h, int argb,
float alpha) {
float[] box = box(x, y, w, h);
Object b = Ui.rect(box[0], box[1], box[2], box[3]);
if (b == null) {
return;
}
Ui.color(b, argb);
Ui.alpha(b, alpha);
HudHook.submit(Ui.build(b));
}
/**
* Text from the game's ASCII atlas.
*
* Only characters in the atlas are drawn; anything else is skipped rather
* than drawn as noise, which keeps a mod's label from turning into garbage
* when it uses a language the atlas has no glyphs for.
*/
public void text(float x, float y, String value, int argb, float alpha) {
if (value == null || value.isEmpty() || Ui.atlas() == null) {
return;
}
float s = Ui.scale();
float size = GLYPH * s;
float cx = x;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if (c == ' ') {
cx += size * 0.5f;
continue;
}
int index = c;
if (index >= 32 && index < 32 + GLYPHS * GLYPHS) {
index -= 32;
float u0 = (index % GLYPHS) / (float) GLYPHS;
float v0 = (index / GLYPHS) / (float) GLYPHS;
float u1 = u0 + 1f / GLYPHS;
float v1 = v0 + 1f / GLYPHS;
float[] box = box(cx, y, size, size);
Object b = Ui.textured(Ui.atlas(), box[0], box[1], box[2], box[3]);
if (b == null) {
return;
}
Ui.color(b, argb);
Ui.alpha(b, alpha);
Ui.uv(b, u0, v0, u1, v1);
HudHook.submit(Ui.build(b));
}
cx += size;
}
}
/** Width of a string in HUD units, so a panel can be sized to its label. */
public float textWidth(String value) {
if (value == null || value.isEmpty()) {
return 0f;
}
float size = GLYPH * Ui.scale();
float w = 0f;
for (int i = 0; i < value.length(); i++) {
w += value.charAt(i) == ' ' ? size * 0.5f : size;
}
return w;
}
private float[] box(float x, float y, float w, float h) {
float s = Ui.scale();
return new float[] {x * s, y * s, w * s, h * s};
}
}
+250
View File
@@ -0,0 +1,250 @@
package rsx;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.List;
/**
* Receives the live HUD render context every frame and hands it to mods.
*
* The injected call lands in the game's compass widget, which means this runs
* only while the game's HUD is visible: F3 hides the widget, the widget's
* render is never called, and neither is ours. The explicit gate below is a
* second line of defence for the case where a build hides part of the HUD
* differently.
*/
public final class HudHook {
/** The internal name the transformer calls, see HookTransformer. */
public static final String NAME = "rsx/HudHook";
/** Something that wants to draw while the game draws its HUD. */
public interface Listener {
void onHud(Hud hud);
}
private static final List<Listener> LISTENERS = new ArrayList<Listener>();
private static volatile Object context;
private static Method submit;
private static boolean submitTried;
private static boolean gateTried;
private static Method gate;
private static Object screen;
private static boolean screenTried;
private static boolean warned;
private HudHook() {
}
public static void add(Listener listener) {
if (listener == null) {
return;
}
synchronized (LISTENERS) {
LISTENERS.add(listener);
}
Log.info("HUD: listener registered, " + LISTENERS.size() + " total");
}
/**
* Called from inside the game's HUD render, on the render thread.
*
* Never throws: a mod that cannot draw must not be able to take the frame,
* and with it the game, down.
*/
public static void render(Object ctx) {
try {
if (ctx == null || !visible()) {
return;
}
context = ctx;
if (!warned && !Ui.ready()) {
warned = true;
return;
}
List<Listener> copy;
synchronized (LISTENERS) {
if (LISTENERS.isEmpty()) {
return;
}
copy = new ArrayList<Listener>(LISTENERS);
}
Hud hud = new Hud(ctx);
for (Listener l : copy) {
try {
l.onHud(hud);
} catch (Throwable t) {
Log.error("HUD listener failed", t);
}
}
} catch (Throwable t) {
Log.error("HUD hook failed", t);
}
}
/**
* Mirrors the game's own HUD visibility.
*
* Read off the HUD screen singleton: it carries the flag that decides
* whether widgets are drawn at all, which is the same flag the game toggles
* with F3. If it cannot be read we draw anyway - the hook already keeps us
* in step in practice, this is only the backup.
*/
private static boolean visible() {
if (gateTried) {
return screen == null || gate == null || truth(screen);
}
gateTried = true;
Profile p = Resolver.profile();
if (p == null) {
Log.warn("HUD: no profile to read the game HUD flag from");
return true;
}
try {
String obf = p.obf("hud.screen");
String[] single = p.member("hud.screen", "singleton");
String[] visible = p.member("hud.screen", "visible");
if (obf == null || single == null || visible == null) {
Log.warn("HUD: this profile has no HUD screen anchor, the panel "
+ "follows the game on its own");
return true;
}
Class<?> owner = Resolver.findLoaded(obf);
if (owner == null) {
Log.warn("HUD: the HUD screen class " + obf
+ " is not loaded yet, the panel follows the game");
return true;
}
java.lang.reflect.Field f =
owner.getDeclaredField(single[0]);
f.setAccessible(true);
screen = f.get(null);
if (screen == null) {
return true;
}
gate = owner.getDeclaredMethod(visible[0]);
gate.setAccessible(true);
Log.info("HUD: following the game HUD visibility");
return truth(screen);
} catch (Throwable t) {
Log.warn("HUD: cannot read the game HUD flag (" + t
+ "), the panel follows the game on its own");
return true;
}
}
/** The flag is inverted: the widgets are drawn while it is false. */
private static boolean truth(Object instance) {
try {
return !((Boolean) gate.invoke(instance)).booleanValue();
} catch (Throwable t) {
return true;
}
}
/**
* Screen size, read off the context.
*
* The context offers two no-argument int getters and nothing names them, so
* they are told apart by what they can only be: on any window the larger one
* is the width. Getting this wrong only mirrors the panel, it cannot break
* the frame.
*/
static int width() {
int[] s = size();
return s == null ? 0 : Math.max(s[0], s[1]);
}
static int height() {
int[] s = size();
return s == null ? 0 : Math.min(s[0], s[1]);
}
private static int[] size;
private static synchronized int[] size() {
if (size != null || context == null) {
return size;
}
java.util.List<Integer> found = new ArrayList<Integer>();
for (Class<?> c = context.getClass(); c != null; c = c.getSuperclass()) {
for (Method m : c.getDeclaredMethods()) {
if (m.getParameterTypes().length != 0
|| m.getReturnType() != int.class) {
continue;
}
try {
m.setAccessible(true);
found.add(Integer.valueOf(((Integer) m.invoke(context))
.intValue()));
} catch (Throwable ignored) {
}
}
}
if (found.size() >= 2) {
size = new int[] {found.get(0).intValue(), found.get(1).intValue()};
Log.info("HUD: screen " + width() + "x" + height());
}
return size;
}
/** True while a mod callback is running, i.e. Rsx.hud() is meaningful. */
public static boolean inside() {
return context != null;
}
static Object context() {
return context;
}
/**
* Hands a built command to the game's context.
*
* The submit method is looked up on the live context object rather than by
* name: it is the single method taking one draw-command argument, and that
* survives a rename where a hard-coded name would not.
*/
static void submit(Object command) {
if (command == null || context == null) {
return;
}
if (!submitTried) {
submitTried = true;
submit = findSubmit(context.getClass());
}
if (submit == null) {
return;
}
try {
submit.invoke(context, command);
} catch (Throwable t) {
Log.error("HUD: cannot submit a draw command", t);
}
}
private static Method findSubmit(Class<?> ctxClass) {
Class<?> target = Ui.targetClass();
for (Class<?> c = ctxClass; c != null; c = c.getSuperclass()) {
for (Method m : c.getDeclaredMethods()) {
if (m.getParameterTypes().length != 1) {
continue;
}
Class<?> p = m.getParameterTypes()[0];
if (target != null) {
if (!target.isAssignableFrom(p)) {
continue;
}
} else if (!Ui.looksLikeCommand(p)) {
continue;
}
try {
m.setAccessible(true);
return m;
} catch (Throwable ignored) {
}
}
}
Log.warn("HUD: the game context has no way to accept a draw command");
return null;
}
}
+396 -48
View File
@@ -13,7 +13,8 @@ import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* Loads mods through the client's own code loader.
* Loads mods through the client's own code loader and runs their work on the
* game thread.
*
* The client already ships a loader: it takes a jar, finds the entry class
* `special.Main`, calls its static main(String[]) and later invokes the static
@@ -28,17 +29,108 @@ import java.util.zip.ZipOutputStream;
* implement one interface. The entry point travels to the bridge through the
* argument array the loader passes through, so no manifest parsing is needed at
* runtime.
*
* Two properties of the vendor loader shape everything here:
*
* - it creates a *new* class loader per call, so mods cannot see each other's
* classes and never collide on `special.Main`;
* - it remembers the tick hook in a *static field* that every call overwrites,
* so only the last mod loaded ever gets its getRunnableList() called.
*
* Therefore there is exactly one queue and exactly one drain: whoever the game
* calls runs the work of every mod, which is what makes several mods work at
* all.
*/
public final class ModBridge {
private static final Map<ClassLoader, String> NAMES =
new ConcurrentHashMap<ClassLoader, String>();
private static final Map<ClassLoader, List<Runnable>> QUEUE =
new ConcurrentHashMap<ClassLoader, List<Runnable>>();
private static final Map<ClassLoader, Runnable> DRAIN =
new ConcurrentHashMap<ClassLoader, Runnable>();
/** A mod that registered itself, kept so errors and unload can reach it. */
private static final class ModHandle {
final ClassLoader loader;
final String name;
final String entry;
Class<?> api;
Object instance;
int version = 1;
ModHandle(ClassLoader loader, String name, String entry) {
this.loader = loader;
this.name = name;
this.entry = entry;
}
}
/** A unit of work queued for the game thread. */
private static final class Task {
final String mod;
final String label;
final Runnable body;
Task(String mod, String label, Runnable body) {
this.mod = mod;
this.label = label;
this.body = body;
}
String display() {
return label == null ? mod : mod + "/" + label;
}
}
/** Work measured in ticks instead of milliseconds. */
private static final class TickTask {
final String owner;
final Runnable body;
final int every;
long next;
TickTask(String owner, Runnable body, int every) {
this.owner = owner;
this.body = body;
this.every = Math.max(1, every);
}
}
/** A named callback fired when game state changes. */
private static final class Handler {
final String owner;
final String event;
final Runnable body;
Handler(String owner, String event, Runnable body) {
this.owner = owner;
this.event = event;
this.body = body;
}
}
/** A task is dropped after this many failures, so a broken mod cannot flood the log. */
private static final int MAX_FAILURES = 5;
private static final Object LOCK = new Object();
private static final List<ModHandle> MODS = new ArrayList<ModHandle>();
private static final List<Task> QUEUE = new ArrayList<Task>();
private static final List<TickTask> TICKS = new ArrayList<TickTask>();
private static final List<Handler> HANDLERS = new ArrayList<Handler>();
private static final List<String> LOADED = new ArrayList<String>();
// Keyed by the task itself, not by the queue entry: the queue is emptied
// every tick, so a counter living there would reset before it ever reached
// the limit and a failing mod would be re-queued forever.
private static final java.util.Map<Runnable, Integer> FAILURES =
new java.util.IdentityHashMap<Runnable, Integer>();
private static final java.util.Set<Runnable> DROPPED =
java.util.Collections.newSetFromMap(
new java.util.IdentityHashMap<Runnable, Boolean>());
/** Whoever is running right now, so a task knows which mod it belongs to. */
private static final ThreadLocal<String> OWNER = new ThreadLocal<String>();
private static long tickCount;
private static Object lastWorld;
private static Object lastPlayer;
private static Object lastScreen;
private static boolean stateReady;
private static final java.util.concurrent.ScheduledExecutorService TIMER =
java.util.concurrent.Executors.newSingleThreadScheduledExecutor(
new java.util.concurrent.ThreadFactory() {
@@ -50,13 +142,23 @@ public final class ModBridge {
}
});
/**
* The single drain the game may call. It is the same object for every mod,
* so whichever getRunnableList() the vendor kept still runs all of them.
*/
private static final Runnable DRAIN = new Runnable() {
@Override
public void run() {
tick();
}
};
/** Invoked by the injected special.Main, during jar loading. */
public static void launch(Class<?> entry, String[] args) {
ClassLoader cl = entry.getClassLoader();
String modName = args != null && args.length > 0 ? args[0] : "unknown";
String entryName = args != null && args.length > 1 ? args[1] : null;
NAMES.put(cl, modName);
QUEUE.put(cl, new ArrayList<Runnable>());
ModHandle handle = new ModHandle(cl, modName, entryName);
try {
if (entryName == null || entryName.isEmpty()) {
Log.warn("mod " + modName + ": no entry point");
@@ -69,58 +171,110 @@ public final class ModBridge {
+ " does not implement RsxMod");
return;
}
api.getMethod("onLoad").invoke(impl);
LOADED.add(modName);
handle.api = api;
handle.instance = impl;
synchronized (LOCK) {
MODS.add(handle);
}
OWNER.set(modName);
try {
checkApiVersion(handle);
api.getMethod("onLoad").invoke(impl);
} finally {
OWNER.remove();
}
synchronized (LOCK) {
LOADED.add(modName);
}
Log.info("mod loaded: " + modName + " (" + entryName + ")");
} catch (Throwable t) {
OWNER.remove();
Log.error("mod " + modName + " failed to start", t);
}
}
private static void checkApiVersion(ModHandle handle) {
try {
int v = ((Integer) handle.api.getMethod("apiVersion")
.invoke(handle.instance)).intValue();
handle.version = v;
if (v != 1) {
Log.warn("mod " + handle.name + " targets API version " + v
+ " but this bridge speaks version 1; some calls may fail");
}
} catch (Throwable ignored) {
// a mod that does not override apiVersion inherits 1
}
}
/** Invoked by the injected special.Main, on the game thread. */
public static List<Runnable> drain(Class<?> entry) {
final ClassLoader cl = entry.getClassLoader();
Runnable r = DRAIN.get(cl);
if (r == null) {
r = new Runnable() {
@Override
public void run() {
runPending(cl);
}
};
DRAIN.put(cl, r);
}
List<Runnable> one = new ArrayList<Runnable>(1);
one.add(r);
one.add(DRAIN);
return one;
}
/** Queue a task to run on the game thread; callable from any thread. */
public static void post(Runnable task) {
for (List<Runnable> q : QUEUE.values()) {
synchronized (q) {
// coalesce: a slow tick must not let a repeating task pile up
for (Runnable r : q) {
if (r == task) {
return;
}
}
q.add(task);
}
enqueue(OWNER.get(), null, task);
}
/** Queue a named task, so a failure names the mod instead of "unknown". */
public static void post(String label, Runnable task) {
enqueue(OWNER.get(), label, task);
}
private static void enqueue(String owner, String label, Runnable task) {
if (task == null) {
return;
}
if (owner == null) {
owner = "?";
}
synchronized (LOCK) {
if (DROPPED.contains(task)) {
return;
}
for (Task t : QUEUE) {
// coalesce: a slow tick must not let a repeating task pile up
if (t.body == task) {
return;
}
}
QUEUE.add(new Task(owner, label, task));
}
}
/**
* Repeating task on the game thread. A timer thread only ever *posts*, so
* the callback itself always runs where touching game state is legal.
*
* The interval is wall-clock, so a lagging game thread makes it drift.
* Prefer everyTicks(int) for anything that has to stay in step with the game.
*/
public static void schedule(long millis, final Runnable task) {
schedule(null, millis, task);
}
/**
* Repeating task on the game thread. A timer thread only ever *posts*, so
* the callback itself always runs where touching game state is legal.
*
* The interval is wall-clock, so a lagging game thread makes it drift.
* Prefer everyTicks(int) for anything that has to stay in step with the game.
*/
public static void schedule(String label, long millis, final Runnable task) {
if (millis < 1L) {
millis = 1L;
}
// remember who asked while we are still inside their onLoad: by the time
// the timer fires we are on the timer thread, where nobody is "current"
final String owner = OWNER.get();
try {
TIMER.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
post(task);
enqueue(owner, label, task);
}
}, millis, millis, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (Throwable t) {
@@ -128,28 +282,222 @@ public final class ModBridge {
}
}
/** Repeating task counted in game ticks; never drifts, never bursts. */
public static void scheduleTicks(int every, Runnable task) {
if (task == null) {
return;
}
String owner = OWNER.get();
synchronized (LOCK) {
TICKS.add(new TickTask(owner == null ? "?" : owner, task, every));
}
}
/** Subscribe to a game event: tick, worldChange, playerJoin, playerLeave, screenOpen, screenClose. */
public static void on(String event, Runnable body) {
if (event == null || body == null) {
return;
}
String owner = OWNER.get();
synchronized (LOCK) {
HANDLERS.add(new Handler(owner == null ? "?" : owner, event, body));
}
}
/** Current game tick, counted by the bridge since the game came up. */
public static long tickCount() {
return tickCount;
}
public static List<String> loaded() {
return LOADED;
}
private static void runPending(ClassLoader cl) {
List<Runnable> q = QUEUE.get(cl);
if (q == null) {
return;
/** Tell every mod the game is going away, so it can drop its work. */
public static void shutdown() {
List<ModHandle> copy;
synchronized (LOCK) {
copy = new ArrayList<ModHandle>(MODS);
QUEUE.clear();
TICKS.clear();
HANDLERS.clear();
}
List<Runnable> copy;
synchronized (q) {
if (q.isEmpty()) {
for (ModHandle h : copy) {
if (h.api == null || h.instance == null) {
continue;
}
try {
OWNER.set(h.name);
h.api.getMethod("onUnload").invoke(h.instance);
} catch (Throwable t) {
Log.error("mod " + h.name + " failed to unload", t);
} finally {
OWNER.remove();
}
}
TIMER.shutdownNow();
Log.info("mods shut down: " + copy.size() + " notified");
}
// -------------------------------------------------------------- game tick
private static void tick() {
tickCount++;
fireEvents();
runTickTasks();
runQueuedTasks();
}
private static void fireEvents() {
List<Handler> handlers;
synchronized (LOCK) {
if (HANDLERS.isEmpty()) {
return;
}
copy = new ArrayList<Runnable>(q);
q.clear();
handlers = new ArrayList<Handler>(HANDLERS);
}
for (Runnable r : copy) {
try {
r.run();
} catch (Throwable t) {
Log.error("mod task failed in " + NAMES.get(cl), t);
fire(handlers, "tick");
Object world = read("net.minecraft.client.Minecraft", "world");
Object player = read("net.minecraft.client.Minecraft", "player");
Object screen = read("net.minecraft.client.Minecraft", "currentScreen");
if (world == null && player == null) {
// the client class is not resolvable yet, do not latch a wrong state
return;
}
if (!stateReady) {
lastWorld = world;
lastPlayer = player;
lastScreen = screen;
stateReady = true;
return;
}
if (world != lastWorld) {
lastWorld = world;
fire(handlers, "worldChange");
}
if (player != lastPlayer) {
lastPlayer = player;
fire(handlers, player == null ? "playerLeave" : "playerJoin");
}
if (screen != lastScreen) {
lastScreen = screen;
fire(handlers, screen == null ? "screenClose" : "screenOpen");
}
}
private static void fire(List<Handler> handlers, String event) {
for (Handler h : handlers) {
if (!h.event.equals(event)) {
continue;
}
run(h.owner, null, h.body);
}
}
private static Object read(String logical, String member) {
try {
if (Resolver.profile() == null) {
return null;
}
return Resolver.get(logical, member, Resolver.minecraftInstance());
} catch (Throwable ignored) {
return null;
}
}
private static void runTickTasks() {
List<TickTask> due = null;
synchronized (LOCK) {
if (TICKS.isEmpty()) {
return;
}
for (TickTask t : TICKS) {
if (tickCount < t.next) {
continue;
}
t.next = tickCount + t.every;
if (due == null) {
due = new ArrayList<TickTask>();
}
due.add(t);
}
}
for (TickTask t : due) {
run(t.owner, null, t.body);
}
}
private static void runQueuedTasks() {
List<Task> batch;
synchronized (LOCK) {
if (QUEUE.isEmpty()) {
return;
}
batch = new ArrayList<Task>(QUEUE);
QUEUE.clear();
}
for (Task t : batch) {
run(t.mod, t.label, t.body);
}
}
/**
* Run one piece of mod work on the game thread, counting its failures.
*
* After MAX_FAILURES consecutive throws the task is dropped for good: a mod
* that throws every tick would otherwise fill the log faster than anyone
* could read it.
*/
private static void run(String mod, String label, Runnable body) {
String previous = OWNER.get();
OWNER.set(mod);
try {
body.run();
synchronized (LOCK) {
FAILURES.remove(body);
}
} catch (Throwable t) {
String who = label == null ? mod : mod + "/" + label;
report(mod, who, t);
int n;
synchronized (LOCK) {
Integer had = FAILURES.get(body);
n = had == null ? 1 : had.intValue() + 1;
if (n >= MAX_FAILURES) {
DROPPED.add(body);
FAILURES.remove(body);
} else {
FAILURES.put(body, Integer.valueOf(n));
}
}
if (n >= MAX_FAILURES) {
Log.error("task of " + who + " failed " + n
+ " times in a row, dropping it", null);
}
} finally {
if (previous == null) {
OWNER.remove();
} else {
OWNER.set(previous);
}
}
}
private static void report(String mod, String who, Throwable t) {
Log.error("mod task failed in " + who, t);
synchronized (LOCK) {
for (ModHandle h : MODS) {
if (!h.name.equals(mod) || h.api == null) {
continue;
}
try {
h.api.getMethod("onError", Throwable.class)
.invoke(h.instance, t);
} catch (Throwable ignored) {
// the default implementation only logs, and it already did
}
return;
}
}
}
+201
View File
@@ -0,0 +1,201 @@
package rsx;
import java.lang.reflect.Method;
/**
* Latency and loss, measured from the packets the game already sends.
*
* The vendor replaced the network layer with their own Netty handler and left
* no latency field anywhere, so there is nothing to read: it has to be measured.
* Two sources, because no single one works everywhere:
*
* echo the mod sends a packet with a nonce and the server sends it back.
* Exact to a millisecond or two, and the only way to know which packets
* were actually lost. It needs a server that answers, and an anti-cheat
* may object to unknown packets, so it is off unless asked for.
*
* keep-alive the server sends one every 15 seconds and the client answers
* at once. Those arrivals carry no round trip, but the *spacing* between
* them does: the server keeps a 15 s cadence measured on its own clock,
* so the jitter of the arrivals is the network's. Averaging many
* samples gives a usable number on any server, slowly.
*/
public final class Ping {
/** The internal name the transformer calls, see HookTransformer. */
public static final String NAME = "rsx/Ping";
/**
* The server's keep-alive cadence.
*
* 15 seconds is what the game sends. Proxies and some servers use another
* value, and the test needs a short one, so it is settable; a wrong value
* only makes the estimate wrong, never wrong in a dangerous way.
*/
private static final long KEEPALIVE_PERIOD = Long.parseLong(
System.getProperty("rsx.keepalivePeriod", "15000"));
private static final int WINDOW = 50;
private static long lastKeepAlive;
private static int echoSent;
private static int echoAnswered;
private static int keepAliveSeen;
private static int keepAliveGap;
// moving average of the keep-alive derived latency, in tenths of a ms so a
// float is not needed and nothing drifts
private static long echoSum;
private static long echoCount;
private static long keepSum;
private static int keepSamples;
private static int lastRttTenths = -1;
private static Method sendPacket;
private Ping() {
}
/** True when the echo measurement is available and running. */
public static boolean echoReady() {
return sendPacket != null;
}
public static void bindSendPacket(Method m) {
sendPacket = m;
}
// ------------------------------------------------------------- keep-alive
/** A server keep-alive arrived. */
public static void onKeepAlive() {
long now = System.currentTimeMillis();
int gap = lastKeepAlive == 0L ? -1 : (int) (now - lastKeepAlive);
lastKeepAlive = now;
keepAliveSeen++;
if (gap > 0) {
keepAliveGap = gap;
// the deviation from the server's own cadence is the network's
int off = gap - (int) KEEPALIVE_PERIOD;
// one lost packet shows up as a double interval: skip those samples
// rather than counting a 15 s "latency" into the average
if (off > -KEEPALIVE_PERIOD / 4 && off < KEEPALIVE_PERIOD / 2) {
if (off < 0) {
off = -off;
}
// tenths of a millisecond, the same unit the echo uses, so the
// two sources can be averaged together
long tenths = off * 10L;
keepSum += tenths;
keepSamples++;
if (keepSamples > WINDOW) {
keepSum -= tenths;
keepSamples--;
}
}
}
}
// ------------------------------------------------------------------ echo
private static int nextNonce = 1;
private static long sentAt;
private static int pendingNonce;
private static boolean pending;
/**
* Sends one echo. Returns false when the game has no such packet or the
* previous echo is still unanswered, which is itself a loss signal.
*/
public static boolean sendEcho(Object netHandlerPlayClient) {
if (sendPacket == null) {
return false;
}
if (pending) {
loss();
return false;
}
try {
int nonce = nextNonce++;
pendingNonce = nonce;
pending = true;
sentAt = System.currentTimeMillis();
echoSent++;
Object packet = EchoPacket.create(netHandlerPlayClient, nonce);
if (packet == null) {
pending = false;
return false;
}
sendPacket.invoke(netHandlerPlayClient, packet);
return true;
} catch (Throwable t) {
pending = false;
Log.warn("echo failed: " + t);
return false;
}
}
/** The server sent our echo back. */
public static void onEcho(int nonce) {
if (!pending || nonce != pendingNonce) {
return;
}
pending = false;
echoAnswered++;
long rtt = System.currentTimeMillis() - sentAt;
lastRttTenths = (int) (rtt * 10L);
echoSum += lastRttTenths;
echoCount++;
if (echoCount > WINDOW) {
echoSum -= lastRttTenths;
echoCount--;
}
}
/** Called when an echo is known to be lost. */
public static void loss() {
if (pending) {
pending = false;
// counted by the missing answer: sent minus answered
return;
}
}
// -------------------------------------------------------------- readouts
/** Round trip in milliseconds, or -1 when it is not known yet. */
public static int rttMillis() {
if (echoCount > 0) {
return (int) (echoSum / echoCount / 10L);
}
if (keepSamples > 0) {
return (int) (keepSum / keepSamples / 10L);
}
return -1;
}
/** True when the number came from the exact measurement. */
public static boolean exact() {
return echoCount > 0;
}
/** Packet loss in percent, 0 when nothing was lost. */
public static float lossPercent() {
int total = echoSent + keepAliveSeen;
int lost = (echoSent - echoAnswered) + Math.max(0, keepAliveGap == 0
? 0 : (keepAliveGap > (int) (KEEPALIVE_PERIOD * 1.5) ? 1 : 0));
if (total <= 0) {
return 0f;
}
return 100f * lost / total;
}
public static String describe() {
int rtt = rttMillis();
if (rtt < 0) {
return "ping: measuring";
}
return (exact() ? "ping" : "ping~") + " " + rtt + " ms"
+ ", loss " + String.format(java.util.Locale.US, "%.1f", lossPercent()) + "%";
}
}
+17 -3
View File
@@ -12,6 +12,7 @@ package rsx;
* class <logicalName> <obfClass>
* field <logicalName> <member> <obfMember> <descriptor>
* method <logicalName> <member> <obfMember> <descriptor>
* hook.hud <obfClass> <obfMethod> <descriptor>
*/
public final class Profile {
@@ -29,9 +30,18 @@ public final class Profile {
public String a2Class;
public String a2Property;
public String a6Class;
public String a6FieldName;
public String a6FieldObf;
public String serverOverrideField;
public String a6FieldName;
public String a6FieldObf;
public String serverOverrideField;
/**
* Where the bridge appends its own draw call. The method is the compass
* widget's render, so the panel is only ever drawn while the game draws the
* compass, which is how it follows the game's own HUD visibility.
*/
public String hudHookClass;
public String hudHookMethod;
public String hudHookDesc;
/** logical name -> obfuscated class name */
public final java.util.Map<String, String> classes =
@@ -80,6 +90,10 @@ public String a6FieldName;
p.a6FieldObf = f[3];
} else if ("class".equals(kind) && f.length >= 3) {
p.classes.put(f[1], f[2]);
} else if ("hook.hud".equals(kind) && f.length >= 4) {
p.hudHookClass = f[1];
p.hudHookMethod = f[2];
p.hudHookDesc = f[3];
} else if ("override.server".equals(kind) && f.length >= 2) {
p.serverOverrideField = f[1];
} else if (("field".equals(kind) || "method".equals(kind))
+11 -4
View File
@@ -47,10 +47,12 @@ public final class Resolver {
*/
private static Map<String, Class<?>> loadedIndex(boolean force) {
long now = System.currentTimeMillis();
if (loaded != null && !force && now - lastScan < 1000L) {
return loaded;
}
if (loaded != null && now - lastScan < 1000L) {
// A forced lookup has to actually see classes that arrived since the
// last scan - the HUD gate is read on the first frame, long after the
// index was built - but a full scan is tens of thousands of classes, so
// even a forced call is rate limited rather than free.
long minGap = force ? 50L : 1000L;
if (loaded != null && now - lastScan < minGap) {
return loaded;
}
Map<String, Class<?>> next = new HashMap<String, Class<?>>();
@@ -279,6 +281,11 @@ public final class Resolver {
}
// ------------------------------------------------------------- public API
/** The running client instance, or null before the game is up. */
public static Object minecraftInstance() {
return get("net.minecraft.client.Minecraft", "instance");
}
public static Object get(String logical, String member) {
Class<?> owner = cls(logical);
Field f = field(logical, member, owner);
+95 -2
View File
@@ -26,8 +26,8 @@ public final class RsxAgent {
public static void premain(String args, java.lang.instrument.Instrumentation inst) {
try {
home = new File(RsxAgent.class.getProtectionDomain()
.getCodeSource().getLocation().toURI()).getParentFile();
home = findHome(new File(RsxAgent.class.getProtectionDomain()
.getCodeSource().getLocation().toURI()));
} catch (Throwable t) {
home = new File(".");
}
@@ -47,6 +47,8 @@ public final class RsxAgent {
Resolver.setInstrumentation(inst);
Log.info("rsx bridge starting (home=" + home + ")");
appendLibraries(inst, new File(home, "libs"));
String mode = readMode();
if ("vanilla".equals(mode)) {
Log.info("mode is vanilla: no mods, no patches, agent does nothing");
@@ -75,6 +77,9 @@ public final class RsxAgent {
Log.info("loader anchor: " + profile.describe(
"net.rustexremake.loader.RemoteClassLoader"));
// now that the profile says where the HUD hook goes
installHudHook(inst);
File game = findGameJar();
if (game == null) {
fail("cannot locate the client jar on java.class.path");
@@ -487,7 +492,95 @@ public final class RsxAgent {
}
}
/**
* Let mods draw on the game's HUD.
*
* The hook goes into the compass widget, so a panel drawn through it lives in
* the same pass, the same scale, and the same show/hide state as the widgets
* around it. A build that no longer has the anchor in its profile simply
* runs without a HUD - reported once, never fatal.
*/
private static void installHudHook(
java.lang.instrument.Instrumentation inst) {
Profile p = profile;
if (inst == null || p == null || p.hudHookClass == null) {
Log.info("no HUD hook in this profile, the panel is unavailable");
return;
}
try {
Profile p2 = profile;
String[] ka = p2.member(
"net.minecraft.client.network.NetHandlerPlayClient",
"handleKeepAlive");
HookTransformer t = new HookTransformer(p2.hudHookClass,
p2.hudHookMethod, p2.hudHookDesc,
ka == null ? null : p2.obf(
"net.minecraft.client.network.NetHandlerPlayClient"),
ka == null ? null : ka[0]);
inst.addTransformer(t, false);
Log.info("HUD hook armed on " + p2.hudHookClass + "."
+ p2.hudHookMethod);
} catch (Throwable e) {
Log.error("cannot arm the HUD hook", e);
}
}
// ----------------------------------------------------------------- utils
/**
* Where the modloader lives.
*
* The agent jar may sit in the home itself or in its bridge\ folder, and
* nothing here may assume a fixed depth: walk up until a directory looks
* like a modloader home, so libs\, mods\ and config\ resolve the same way
* whoever installed it.
*/
private static File findHome(File jarOrDir) throws Exception {
File at = jarOrDir.isDirectory() ? jarOrDir : jarOrDir.getParentFile();
for (int up = 0; at != null && up < 4; up++) {
if (new File(at, "mods").isDirectory()
|| new File(at, "libs").isDirectory()
|| new File(at, "bridge").isDirectory()) {
return at;
}
at = at.getParentFile();
}
return jarOrDir.isDirectory() ? jarOrDir : jarOrDir.getParentFile();
}
/**
* Put every jar from libs\ on the system class path.
*
* The client gives each mod its own class loader, so mods cannot see each
* other - but every one of those loaders has the system loader as its
* parent. Appending here therefore makes a shared library visible to all
* mods, in one copy, and mods never have to bundle it.
*/
private static void appendLibraries(
java.lang.instrument.Instrumentation inst, File dir) {
if (inst == null || dir == null || !dir.isDirectory()) {
return;
}
File[] jars = dir.listFiles();
if (jars == null || jars.length == 0) {
Log.info("no libraries in " + dir);
return;
}
java.util.Arrays.sort(jars);
for (File jar : jars) {
if (!jar.isFile()
|| !jar.getName().toLowerCase().endsWith(".jar")) {
continue;
}
try {
inst.appendToSystemClassLoaderSearch(new java.util.jar.JarFile(jar));
Log.info("library on the class path: " + jar.getName());
} catch (Throwable t) {
Log.error("cannot load library " + jar.getName(), t);
}
}
}
private static File findGameJar() {
String loader = profile.loaderClass;
if (loader == null) {
+419
View File
@@ -0,0 +1,419 @@
package rsx;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
/**
* Binds to the game's own drawing API.
*
* Everything here is resolved from the profile by descriptor, never by name, and
* every resolution can fail on its own: a build that changed one factory takes
* that one capability away, logs it once, and leaves the rest working. That is
* deliberate - a vendor update should cost a panel, not the modloader.
*/
public final class Ui {
/** Vanilla ASCII font atlas: the same glyphs the game uses for numbers. */
public static final String ASCII_ATLAS = "textures/font/ascii.png";
private static boolean checked;
private static Class<?> batch;
private static Method rectFactory;
private static Method texturedFactory;
private static final java.util.Map<String, Method> BUILDER =
new java.util.HashMap<String, Method>();
private static Class<?> targetClass;
private static Method byPath;
private static Field scaleField;
private static Object atlas;
private static boolean atlasTried;
private Ui() {
}
public static synchronized boolean ready() {
if (!checked) {
checked = true;
bind();
}
return rectFactory != null;
}
private static void bind() {
Profile p = Resolver.profile();
if (p == null) {
Log.warn("HUD: no profile, the panel will not draw");
return;
}
batch = resolve("rsx.ui.batch", null);
targetClass = resolve("rsx.ui.target", null);
rectFactory = method(p, "rsx.ui.batch", "rect");
texturedFactory = method(p, "rsx.ui.batch", "textured");
byPath = method(p, "rsx.ui.textures", "byPath");
scaleField = field(p, "rsx.ui.scale", "factor");
if (!ready()) {
Log.warn("HUD: the draw API is not in this build, "
+ "the panel will not draw");
} else {
Log.info("HUD: draw API bound, scale=" + scale());
}
BUILDER.clear();
}
private static Class<?> resolve(String logical, String fallback) {
String obf = Resolver.profile() == null ? null
: Resolver.profile().obf(logical);
if (obf == null) {
return null;
}
Class<?> known = Resolver.findLoaded(obf);
if (known != null) {
return known;
}
// not loaded yet: the game may still be able to find it
ClassLoader cl = Resolver.loader();
if (cl == null) {
return null;
}
try {
return Class.forName(obf, false, cl);
} catch (Throwable ignored) {
return null;
}
}
private static Method method(Profile p, String logical, String member) {
String[] m = p.member(logical, member);
Class<?> owner = resolve(logical, null);
if (m == null || owner == null) {
return null;
}
try {
Method found = owner.getDeclaredMethod(m[0], typesOf(m[1]));
found.setAccessible(true);
return found;
} catch (Throwable exactFailed) {
// A descriptor names classes that may not be loaded yet - the
// texture type, for one, only appears once something asks the
// registry for a texture. Fall back to the method's own signature:
// same name, same number of arguments.
Method loose = byShape(owner, m[0], arityOf(m[1]));
if (loose == null) {
Log.warn("HUD: " + logical + "#" + member + " -> " + m[0] + m[1]
+ " is not there: " + exactFailed);
return null;
}
Log.info("HUD: " + logical + "#" + member
+ " bound by shape, the exact type is not loaded yet");
return loose;
}
}
private static Method byShape(Class<?> owner, String name, int arity) {
for (Method m : owner.getDeclaredMethods()) {
if (!m.getName().equals(name) || m.getParameterTypes().length != arity) {
continue;
}
try {
m.setAccessible(true);
return m;
} catch (Throwable ignored) {
}
}
return null;
}
private static int arityOf(String descriptor) {
int n = 0;
if (descriptor == null || !descriptor.startsWith("(")) {
return 0;
}
for (int i = 1; i < descriptor.length() && descriptor.charAt(i) != ')';) {
if (descriptor.charAt(i) == 'L' || descriptor.charAt(i) == '[') {
while (i < descriptor.length()
&& descriptor.charAt(i) != ';') {
i++;
}
i++;
} else {
i++;
}
n++;
}
return n;
}
private static Field field(Profile p, String logical, String member) {
String[] m = p.member(logical, member);
Class<?> owner = resolve(logical, null);
if (m == null || owner == null) {
return null;
}
try {
Field f = owner.getDeclaredField(m[0]);
f.setAccessible(true);
return f;
} catch (Throwable t) {
Log.warn("HUD: " + logical + "#" + member + " -> " + m[0]
+ " is not there: " + t);
return null;
}
}
/** The game writes these by hand, so they need parsing rather than Class.forName. */
private static Class<?>[] typesOf(String descriptor) {
java.util.List<Class<?>> out = new java.util.ArrayList<Class<?>>();
if (descriptor == null || !descriptor.startsWith("(")) {
return out.toArray(new Class<?>[0]);
}
int i = 1;
while (i < descriptor.length() && descriptor.charAt(i) != ')') {
int end = i;
// a reference type runs to its ';', a primitive is exactly one char
if (descriptor.charAt(i) == 'L' || descriptor.charAt(i) == '[') {
end = i;
while (end < descriptor.length()
&& descriptor.charAt(end) != ';') {
end++;
}
out.add(Resolver.findLoaded(
descriptor.substring(i + 1, end)));
i = end + 1;
} else {
out.add(typeOf(descriptor.substring(i, i + 1)));
i++;
}
}
return out.toArray(new Class<?>[0]);
}
private static Class<?> typeOf(String t) {
if (t.length() == 1) {
switch (t.charAt(0)) {
case 'F': return float.class;
case 'I': return int.class;
case 'J': return long.class;
case 'Z': return boolean.class;
case 'B': return byte.class;
case 'D': return double.class;
default: return null;
}
}
return Resolver.findLoaded(t);
}
/** The multiplier the game's own widgets use; 1.0 when it cannot be read. */
public static float scale() {
if (scaleField == null) {
return 1f;
}
try {
return scaleField.getFloat(null);
} catch (Throwable t) {
return 1f;
}
}
public static Object atlas() {
if (!atlasTried) {
atlasTried = true;
Method lookup = byPath;
if (lookup == null) {
// the registry is resolved before the game has loaded it, so
// bind it now, when a texture is actually wanted
lookup = method(Resolver.profile(), "rsx.ui.textures", "byPath");
}
if (lookup == null) {
Log.warn("HUD: no texture registry, text will not draw");
} else {
byPath = lookup;
try {
atlas = lookup.invoke(null, ASCII_ATLAS);
Log.info("HUD: font atlas " + ASCII_ATLAS + " -> "
+ (atlas != null ? "ok" : "not found"));
} catch (Throwable t) {
Log.warn("HUD: cannot load " + ASCII_ATLAS + ": " + t);
}
}
}
return atlas;
}
/**
* A fresh command builder for a solid rectangle.
*
* The box is passed to the factory, not set afterwards: the factory
* signature is (x, y, width, height) and that is the only shape every
* version of this API agrees on.
*/
public static Object rect(float x, float y, float w, float h) {
if (!ready()) {
return null;
}
try {
return rectFactory.invoke(null, Float.valueOf(x), Float.valueOf(y),
Float.valueOf(w), Float.valueOf(h));
} catch (Throwable t) {
return null;
}
}
public static Object textured(Object tex, float x, float y, float w,
float h) {
if (!ready() || texturedFactory == null || tex == null) {
return null;
}
try {
return texturedFactory.invoke(null, tex, Float.valueOf(x),
Float.valueOf(y), Float.valueOf(w), Float.valueOf(h));
} catch (Throwable t) {
return null;
}
}
/**
* The builder's own methods are resolved on the class we actually got back
* from a factory, not on the profile's class name.
*
* That class does not exist until something asks the game for a command, so
* resolving it up front would always fail; holding the instance makes the
* question moot and takes one more rename off the list of things that can
* break.
*/
private static Method on(Object builder, Profile p, String member,
int arity) {
if (builder == null) {
return null;
}
String key = builder.getClass().getName() + "#" + member;
Method cached = BUILDER.get(key);
if (cached != null) {
return cached;
}
String[] m = p.member("rsx.ui.command", member);
if (m == null) {
return null;
}
for (Method f : builder.getClass().getDeclaredMethods()) {
if (!f.getName().equals(m[0])
|| f.getParameterTypes().length != arity) {
continue;
}
try {
f.setAccessible(true);
} catch (Throwable ignored) {
}
BUILDER.put(key, f);
return f;
}
return null;
}
public static void texture(Object builder, Object tex) {
if (builder == null || tex == null) {
return;
}
Method m = on(builder, Resolver.profile(), "texture", 1);
if (m == null) {
return;
}
try {
m.invoke(builder, tex);
} catch (Throwable ignored) {
}
}
public static void color(Object builder, int argb) {
if (builder == null) {
return;
}
Method m = on(builder, Resolver.profile(), "color", 1);
if (m == null) {
return;
}
try {
m.invoke(builder, Integer.valueOf(argb));
} catch (Throwable ignored) {
}
}
public static void alpha(Object builder, float value) {
if (builder == null) {
return;
}
Method m = on(builder, Resolver.profile(), "alpha", 1);
if (m == null) {
return;
}
try {
m.invoke(builder, Float.valueOf(value));
} catch (Throwable ignored) {
}
}
public static void uv(Object builder, float u0, float v0, float u1,
float v1) {
if (builder == null) {
return;
}
Method m = on(builder, Resolver.profile(), "uv", 4);
if (m == null) {
return;
}
try {
m.invoke(builder, Float.valueOf(u0), Float.valueOf(v0),
Float.valueOf(u1), Float.valueOf(v1));
} catch (Throwable ignored) {
}
}
public static Object build(Object builder) {
if (builder == null) {
return null;
}
Method m = on(builder, Resolver.profile(), "build", 0);
if (m == null) {
return null;
}
try {
Object built = m.invoke(builder);
rememberCommand(built);
return built;
} catch (Throwable t) {
return null;
}
}
public static Class<?> targetClass() {
return targetClass;
}
/** The class of the last command we built: the one a context accepts. */
private static volatile Class<?> lastCommand;
public static void rememberCommand(Object command) {
if (command != null) {
lastCommand = command.getClass();
}
}
/**
* A stand-in test for "this class is a draw command" when the profile has
* no anchor: it must be handed out by the game's own batch, and the batch's
* factory return type is the command builder, whose build() gives one.
*/
public static boolean looksLikeCommand(Class<?> c) {
if (c == null) {
return false;
}
if (lastCommand != null && c.isAssignableFrom(lastCommand)) {
return true;
}
Class<?> target = targetClass;
return target != null && target.isAssignableFrom(c);
}
}
+73 -5
View File
@@ -1,5 +1,14 @@
// Drives the fake client the way the game would, so the bridge can be tested
// end to end: bring the client up, hand the mods to the vendor loader, then tick.
//
// The mod jars are NOT loaded here - the agent finds them in build/testrun/mods
// and feeds them to ilxq one by one, exactly as it does in the game. The last
// one loaded is the only one whose getRunnableList() the vendor keeps, so the
// first two only work if the bridge really has a single shared drain.
//
// The HUD part mimics the one path that matters: the compass widget renders, and
// only while the HUD screen's gate is open. Toggling the gate is what the game's
// own hide key does, so the test can check the panel follows it.
public class FakeMain {
@@ -9,20 +18,79 @@ public class FakeMain {
wwk client = new wwk();
wwk.WA = client;
client.zb = "PLAYER";
client.ta = "WORLD";
ddvqd player = new ddvqd("Steve", 35789);
player.g = 10.5;
player.F = 64.0;
player.a = -3.25;
client.zb = player;
oisywi world = new oisywi();
world.isRemoteFlag = true;
world.time = 1000L;
client.ta = world;
client.S = null;
client.Zb = "TIMER";
client.jC = "RENDER";
client.pC = new pvdafug("http://snoop.minecraft.net/report");
System.out.println("[fake] client up, telemetry scheduled");
// ---- the latency estimate, from the server's keep-alive cadence.
//
// The server keeps a 15 s cadence measured on its own clock, so the
// deviation of the arrival interval is the network's. The stand compress
// that into milliseconds; what matters is that a constant offset shows
// up in the estimate.
cxaddrz play = new cxaddrz();
System.out.println("[fake] feeding keep-alive arrivals, 240 ms apart, period 200");
for (int i = 0; i < 6; i++) {
Thread.sleep(240);
play.onKeepAlive(new Object());
}
System.out.println("[fake] keep-alives fed");
Thread.sleep(2500);
System.out.println("[fake] ticking 5 times");
for (int i = 0; i < 5; i++) {
System.out.println("[fake] ticking 8 times");
for (int i = 0; i < 8; i++) {
if (i == 2) {
client.S = new bsh();
System.out.println("[fake] opened a screen");
}
if (i == 4) {
client.S = null;
System.out.println("[fake] closed the screen");
}
client.tick();
Thread.sleep(200);
}
// ---- the HUD, drawn by the compass widget the way the game draws it
fakecompass compass = new fakecompass();
fakectx ctx = new fakectx();
fakescreen screen = fakescreen.SINGLE;
System.out.println("[fake] HUD visible for 3 frames");
for (int i = 0; i < 3; i++) {
fakebatch.reset();
if (!screen.isHidden()) {
compass.render(ctx);
}
System.out.println("[fake] frame " + i + " commands=" + fakebatch.DRAWN.size()
+ (fakebatch.DRAWN.isEmpty() ? ""
: " first=" + fakebatch.DRAWN.get(0)
+ " last=" + fakebatch.DRAWN.get(
fakebatch.DRAWN.size() - 1)));
}
System.out.println("[fake] HUD hidden (what F3 does) for 3 frames");
screen.setHidden(true);
for (int i = 0; i < 3; i++) {
fakebatch.reset();
if (!screen.isHidden()) {
compass.render(ctx);
}
System.out.println("[fake] frame " + i + " commands=" + fakebatch.DRAWN.size());
}
screen.setHidden(false);
System.out.println("[fake] done");
}
}
}
+6
View File
@@ -0,0 +1,6 @@
// Test stand-in for net.minecraft.client.gui.GuiScreen: the profile types
// currentScreen as Lbsh;, and Rsx.on("screenOpen"/"screenClose") fires when that
// field changes.
public class bsh {
}
+12
View File
@@ -0,0 +1,12 @@
// Test stand-in for the client's play packet handler.
//
// The real one has a keep-alive handler, which the bridge hooks to time the
// server's 15 second cadence. Here the same shape lets the test feed arrivals
// with a chosen delay and see what the estimator makes of them.
public class cxaddrz {
public void onKeepAlive(Object keepAlivePacket) {
// the real one answers the server
}
}
+34
View File
@@ -0,0 +1,34 @@
// Test stand-in for net.minecraft.entity.Entity.
//
// The real build obfuscates these to g/F/a/e/super/for/switch. `super` is a Java
// keyword, so the stand-in uses the *named* members and the test profile points
// at those instead - the mechanism the wrapper relies on (logical name -> profile
// -> reflective field) is identical either way.
public class ddvqd {
public double g; // posX
public double F; // posY
public double a; // posZ
public boolean e; // isDead
private final String name;
private final int id;
public ddvqd(String name, int id) {
this.name = name;
this.id = id;
}
public String getName() { // func_70005_c_
return name;
}
public int getEntityId() { // func_145782_y
return id;
}
public boolean isEntityAlive() { // func_70089_S
return !e;
}
}
+31
View File
@@ -0,0 +1,31 @@
// Test stand-in for the game's draw batch.
//
// Two static factories - one for a solid rectangle, one for a textured one -
// each taking the box directly, and each returning a builder. This is the shape
// the bridge binds to by descriptor.
public final class fakebatch {
private fakebatch() {
}
public static final java.util.List<fakecmd> DRAWN =
new java.util.ArrayList<fakecmd>();
public static fakecmdbuilder rect(float x, float y, float w, float h) {
return new fakecmdbuilder(new fakecmd(x, y, w, h, null));
}
public static fakecmdbuilder textured(Object tex, float x, float y,
float w, float h) {
return new fakecmdbuilder(new fakecmd(x, y, w, h, tex));
}
public static void submit(fakecmd command) {
DRAWN.add(command);
}
public static void reset() {
DRAWN.clear();
}
}
+37
View File
@@ -0,0 +1,37 @@
// Test stand-in for the game's draw command.
//
// The real build calls it lthzuq$b; here it has a legal name because the real
// one uses the Java keywords "class" and "super", which javac will not accept.
// What matters for the bridge is the shape - public fields, built by a builder,
// accepted by a context - and the test profile points at these names.
public class fakecmd {
public final float x;
public final float y;
public final float w;
public final float h;
public final Object tex;
public int color;
public float alpha = 1f;
public float u0;
public float v0;
public float u1 = 1f;
public float v1 = 1f;
public fakecmd(float x, float y, float w, float h, Object tex) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
this.tex = tex;
}
@Override
public String toString() {
return "cmd(" + x + "," + y + " " + w + "x" + h
+ " tex=" + (tex == null ? "-" : tex.toString())
+ " color=" + Integer.toHexString(color)
+ " a=" + alpha
+ " uv=" + u0 + "," + v0 + ".." + u1 + "," + v1 + ")";
}
}
@@ -0,0 +1,36 @@
// Test stand-in for the game's command builder: chainable setters, then build.
public final class fakecmdbuilder {
private final fakecmd cmd;
fakecmdbuilder(fakecmd cmd) {
this.cmd = cmd;
}
public fakecmdbuilder texture(Object tex) {
return this;
}
public fakecmdbuilder color(int argb) {
cmd.color = argb;
return this;
}
public fakecmdbuilder alpha(float value) {
cmd.alpha = value;
return this;
}
public fakecmdbuilder uv(float u0, float v0, float u1, float v1) {
cmd.u0 = u0;
cmd.v0 = v0;
cmd.u1 = u1;
cmd.v1 = v1;
return this;
}
public fakecmd build() {
return cmd;
}
}
@@ -0,0 +1,12 @@
// Test stand-in for the compass widget: the hook target.
//
// Its render does nothing; the bridge appends its own draw right after. The
// caller only invokes it while the HUD screen's gate is open, so hiding the HUD
// means this method is never called and neither is the panel.
public class fakecompass {
public void render(fakectx ctx) {
// the real one draws the compass here
}
}
+20
View File
@@ -0,0 +1,20 @@
// Test stand-in for the game's HUD render context.
//
// One method takes a draw command, and two no-argument int getters give the
// screen size. The bridge finds the submit method by shape and tells the two
// getters apart by which is larger, so the names here do not matter.
public class fakectx {
public int screenWidth() {
return 1920;
}
public int screenHeight() {
return 1080;
}
public void accept(fakecmd command) {
fakebatch.submit(command);
}
}
+11
View File
@@ -0,0 +1,11 @@
// Test stand-in for the game's texture registry: a static String -> texture.
public final class fakereg {
private fakereg() {
}
public static faketex byPath(String path) {
return new faketex(path);
}
}
+10
View File
@@ -0,0 +1,10 @@
// Test stand-in for the HUD scale holder: one public static float that the
// game's widgets multiply their geometry by.
public final class fakescale {
public static volatile float factor = 0.5f;
private fakescale() {
}
}
@@ -0,0 +1,19 @@
// Test stand-in for the HUD screen singleton.
//
// The gate lives here and nowhere else, exactly as in the real build: while it
// is set, the game draws no widgets at all, which is what the hide key flips.
public class fakescreen {
public static final fakescreen SINGLE = new fakescreen();
private boolean hidden;
public boolean isHidden() {
return hidden;
}
public void setHidden(boolean hidden) {
this.hidden = hidden;
}
}
+15
View File
@@ -0,0 +1,15 @@
// Test stand-in for the game's texture handle and its registry.
public final class faketex {
public final String path;
public faketex(String path) {
this.path = path;
}
@Override
public String toString() {
return "tex:" + path;
}
}
+10 -5
View File
@@ -4,6 +4,11 @@
// loads the entry class `special.Main`, calls its main(String[]) and later
// invokes its static getRunnableList() from the game tick. This stand-in has the
// same shape so the bridge can be exercised without launching the game.
//
// Two details matter and are reproduced exactly, because the bridge has to cope
// with them: the vendor builds a *new* loader per jar (so mods cannot see each
// other), and it keeps the tick hook in a *static field* that every call
// overwrites (so only the last mod's getRunnableList is ever called).
import java.lang.reflect.Method;
@@ -15,7 +20,9 @@ public class ilxq {
public static boolean loadJar(byte[] jar, String[] args) throws Exception {
System.out.println("[fake] loading mod jar, " + jar.length + " bytes, args="
+ java.util.Arrays.toString(args));
jarLoader(jar);
// the vendor does `do = new ilxq()` on every call
MemLoader loader = new MemLoader(ilxq.class.getClassLoader());
jarLoader(loader, jar);
Class<?> main = loader.loadClass("special.Main");
Method m = main.getDeclaredMethod("main", String[].class);
m.setAccessible(true);
@@ -42,9 +49,7 @@ public class ilxq {
}
}
static MemLoader loader = new MemLoader(ilxq.class.getClassLoader());
static void jarLoader(byte[] jar) throws Exception {
static void jarLoader(MemLoader loader, byte[] jar) throws Exception {
java.util.zip.ZipInputStream zin =
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(jar));
java.util.zip.ZipEntry e;
@@ -76,4 +81,4 @@ public class ilxq {
}
}
}
}
}
@@ -0,0 +1,22 @@
package rsxtest;
// Test stand-in for a shared library that lives in libs\ and is appended to the
// system class path. Every mod sees this class through its loader's parent, so
// one copy serves all of them - which is the whole point of the libs\ folder.
public final class testlib {
private static int calls;
private testlib() {
}
public static String greet(String who) {
calls++;
return "hello " + who;
}
public static int calls() {
return calls;
}
}
+17
View File
@@ -0,0 +1,17 @@
// Test stand-in for net.minecraft.world.World.
//
// The real build obfuscates the fields to byte / super, both Java keywords, so
// the stand-in uses a legal name and the test profile points at that instead.
// What the wrapper does - read a boolean field and call a no-arg long method by
// logical name - is identical.
public class oisywi {
public boolean isRemoteFlag; // field_72995_K
public long time;
public long getWorldTime() { // func_72820_D
return time;
}
}
+3 -1
View File
@@ -11,7 +11,9 @@ public class pvdafug {
public pvdafug(String url) {
this.url = url;
do_.schedule(new TimerTask0(), 1000L);
// wide enough that A6 always wins; in the real client the first post is
// seconds away, and a stand that fired first would only test timing
do_.schedule(new TimerTask0(), 5000L);
}
public String url() {
+55 -14
View File
@@ -4,31 +4,72 @@ import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
/**
* Example mod. It is compiled against the stable API only, so it does not have
* to be rebuilt when the vendor re-obfuscates the client.
* First of three mods loaded together.
*
* It is loaded first, so the vendor loader will not remember its
* getRunnableList(): its work has to run through the single shared drain. That is
* exactly the case that used to silently do nothing.
*/
public class ExampleMod implements RsxMod {
private int ticks;
public static int ticks;
public static int posts;
public static int errors;
public static int tickEvents;
public static int screenOpens;
@Override
public void onLoad() {
Rsx.info("ExampleMod: loaded on build " + Rsx.buildId());
Rsx.info("ExampleMod: Minecraft is " + Rsx.describe("net.minecraft.client.Minecraft"));
// a repeating task runs on the game thread, driven by the client's own
// tick, so touching game state here is safe
Rsx.info("ExampleMod: loaded on build " + Rsx.buildId()
+ " api=" + Rsx.apiVersion());
Rsx.on("screenOpen", new Runnable() {
@Override
public void run() {
screenOpens++;
Rsx.info("ExampleMod screenOpen " + screenOpens);
}
});
Rsx.everyTicks(1, new Runnable() {
@Override
public void run() {
tickEvents++;
}
});
Rsx.every(200L, new Runnable() {
@Override
public void run() {
ticks++;
Object mc = Rsx.minecraft();
Object player = Rsx.player();
Rsx.info("ExampleMod tick " + ticks
+ " client=" + (mc != null)
+ " player=" + player
+ " world=" + Rsx.world());
+ " player=" + Rsx.player().getName()
+ " x=" + Rsx.player().getX()
+ " y=" + Rsx.player().getY()
+ " time=" + Rsx.world().getTime()
+ " screen=" + (Rsx.currentScreen() != null)
+ " bridgeTick=" + Rsx.tick());
if (ticks == 1) {
// a task that always throws: the bridge must notice, report it
// to onError, and eventually drop it instead of flooding
Rsx.every("boom", 50L, new Runnable() {
@Override
public void run() {
throw new IllegalStateException("mod task exploded");
}
});
}
}
});
Rsx.onGameThread(new Runnable() {
@Override
public void run() {
posts++;
Rsx.info("ExampleMod posted task ran, posts=" + posts);
}
});
}
}
@Override
public void onError(Throwable error) {
errors++;
Rsx.info("ExampleMod onError #" + errors + ": " + error.getMessage());
}
}
+63
View File
@@ -0,0 +1,63 @@
package example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
/**
* Checks that a mod can draw on the game's HUD.
*
* The bridge hooks the compass widget, so this only ever runs while the game is
* drawing its HUD, and it must stop the moment the game says the HUD is hidden -
* that is the same state the game's own hide key flips. The stand prints one line
* per frame so the test can count them both ways.
*/
public class HudMod implements RsxMod {
public static int frames;
public static int rects;
public static int glyphs;
private final Runnable draw = new Runnable() {
private int reported;
@Override
public void run() {
frames++;
Rsx.Hud hud = Rsx.hud();
if (hud == null || !hud.available()) {
return;
}
if (reported == 0 && hud.width() > 0) {
reported = 1;
Rsx.info("HudMod screen: " + hud.width() + "x" + hud.height()
+ " scale=" + hud.scale());
}
hud.rect(8, 8, 40, 12, 0x20201C, 0.85f);
rects++;
hud.text(8, 8, "42 ms", 0xECE3DB, 1f);
glyphs++;
}
};
@Override
public void onLoad() {
Rsx.info("HudMod: registering a draw callback");
Rsx.onHud(new Rsx.HudDraw() {
@Override
public void draw(Rsx.Hud hud) {
draw.run();
}
});
// the latency readout, printed once the estimates have something to say
Rsx.everyTicks(20, new Runnable() {
@Override
public void run() {
frames++;
if (Rsx.ping() >= 0) {
Rsx.info("HudMod ping: " + Rsx.pingLine()
+ " exact=" + Rsx.pingExact());
}
}
});
}
}
+27
View File
@@ -0,0 +1,27 @@
package example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
/**
* Second mod, loaded in the middle.
*
* The vendor loader overwrites its static tick-hook field on every load, so this
* mod's getRunnableList() is never called. Its task still has to run.
*/
public class SecondMod implements RsxMod {
public static int runs;
@Override
public void onLoad() {
Rsx.info("SecondMod: loaded");
Rsx.everyTicks(1, new Runnable() {
@Override
public void run() {
runs++;
Rsx.info("SecondMod run " + runs);
}
});
}
}
+29
View File
@@ -0,0 +1,29 @@
package example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
import rsxtest.testlib;
/**
* Third and last mod: the one whose getRunnableList() the vendor actually keeps,
* and therefore the one that runs the shared drain.
*/
public class ThirdMod implements RsxMod {
public static int runs;
@Override
public void onLoad() {
Rsx.info("ThirdMod: loaded, this is the drain the vendor keeps");
// testlib lives in libs\ and is appended to the system class path, so it
// is visible here without being bundled into this mod's jar
Rsx.info("ThirdMod sees the library: " + testlib.greet("third"));
Rsx.everyTicks(1, new Runnable() {
@Override
public void run() {
runs++;
Rsx.info("ThirdMod run " + runs);
}
});
}
}
+150 -27
View File
@@ -8,12 +8,18 @@ takes a jar, calls `special.Main.main(String[])`, and drains
`special.Main.getRunnableList()` from its tick - plus a client class whose
field names are the ones the real profile uses.
The stand reproduces the two properties of the vendor loader that make several
mods work at all: a new class loader per jar, and a static tick-hook field that
every load overwrites. Three mod jars are loaded to prove it.
It verifies, in one run:
* the profile is found and the build hash matches
* patch A2 sets joml.fastmath before anything reads it
* patch A6 cancels the telemetry timer (the stand-in prints if it fires)
* a mod jar is handed to the client's own loader
* the mod's onLoad runs and its repeating task runs on the game thread
* patch A6 cancels the telemetry timer (the stand prints if it fires)
* three mod jars are handed to the client's own loader
* every mod's onLoad runs, and every mod's work runs on the game thread
* the typed Player/World wrappers read through the profile
* a failing task reaches onError and is dropped instead of flooding the log
Usage: python run_test.py [--keep]
"""
@@ -33,6 +39,14 @@ MOD_SRC = os.path.join(os.path.dirname(HERE), "mod")
# work outside the source tree: the stand writes jars and logs
RUN = os.path.join(REPO, "build", "testrun")
# (jar name, entry class) - load order matters: the last one keeps the tick hook
MODS = [
("example-mod.jar", "example.ExampleMod"),
("second-mod.jar", "example.SecondMod"),
("third-mod.jar", "example.ThirdMod"),
("hud-mod.jar", "example.HudMod"),
]
def sh(*args, **kw):
return subprocess.run(args, capture_output=True, text=True, **kw)
@@ -66,6 +80,10 @@ def javac(sources, out, cp=None):
raise SystemExit("javac failed")
def bridge_dir():
return os.path.join(RUN, "bridge")
def zip_dir(src, jar):
if os.path.exists(jar):
os.remove(jar)
@@ -78,43 +96,73 @@ def zip_dir(src, jar):
z.write(full, rel)
def mod_jar(path, entry):
"""One jar per mod: the vendor loader gives each its own class loader, so a
class name may repeat across mods but must not be shared inside one."""
if os.path.exists(path):
os.remove(path)
# "example.ExampleMod" -> "example/ExampleMod"; inner classes follow the
# same prefix ("example/ExampleMod$1.class") and have to travel with it
package, _, simple = entry.rpartition(".")
prefix = (package.replace(".", "/") + "/" + simple) if package else simple
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("META-INF/MANIFEST.MF",
"Manifest-Version: 1.0\r\n"
"Rsx-Mod-Entrypoint: %s\r\n\r\n" % entry)
for dp, _dn, fns in os.walk(MOD_CLASSES):
for fn in fns:
if not fn.endswith(".class"):
continue
full = os.path.join(dp, fn)
rel = os.path.relpath(full, MOD_CLASSES).replace("\\", "/")
if os.path.splitext(rel)[0] != prefix \
and not rel.startswith(prefix + "$"):
continue
z.write(full, rel)
def main():
global MOD_CLASSES
if not os.path.exists(BRIDGE_JAR):
raise SystemExit("bridge jar not built: " + BRIDGE_JAR)
classes = os.path.join(RUN, "fakeclasses")
modclasses = os.path.join(RUN, "modclasses")
for d in (classes, modclasses):
MOD_CLASSES = os.path.join(RUN, "modclasses")
for d in (classes, MOD_CLASSES):
shutil.rmtree(d, ignore_errors=True)
os.makedirs(classes, exist_ok=True)
os.makedirs(modclasses, exist_ok=True)
os.makedirs(MOD_CLASSES, exist_ok=True)
print("== compiling the stand-in client ==")
javac([os.path.join(SRC, f) for f in os.listdir(SRC) if f.endswith(".java")],
classes)
print("== compiling the example mod ==")
# the shared library lives in libs\ and is appended to the system class path
libdir = os.path.join(bridge_dir(), "libs")
shutil.rmtree(libdir, ignore_errors=True)
os.makedirs(libdir, exist_ok=True)
libclasses = os.path.join(RUN, "libclasses")
shutil.rmtree(libclasses, ignore_errors=True)
javac([os.path.join(SRC, "lib", "rsxtest", "testlib.java")], libclasses)
zip_dir(libclasses, os.path.join(libdir, "testlib.jar"))
print("== compiling the mods ==")
# mods compile against the API and the shared library, but package neither:
# the library is on the system class path at runtime
javac([os.path.join(MOD_SRC, f) for f in os.listdir(MOD_SRC)
if f.endswith(".java")], modclasses, cp=BRIDGE_JAR)
if f.endswith(".java")], MOD_CLASSES,
cp=BRIDGE_JAR + os.pathsep + libclasses)
game = os.path.join(RUN, "game.jar")
zip_dir(classes, game)
mods = os.path.join(RUN, "mods")
shutil.rmtree(mods, ignore_errors=True)
os.makedirs(mods, exist_ok=True)
modjar = os.path.join(mods, "example-mod.jar")
if os.path.exists(modjar):
os.remove(modjar)
with zipfile.ZipFile(modjar, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("META-INF/MANIFEST.MF",
"Manifest-Version: 1.0\r\n"
"Rsx-Mod-Entrypoint: example.ExampleMod\r\n\r\n")
for dp, _dn, fns in os.walk(modclasses):
for fn in fns:
if fn.endswith(".class"):
full = os.path.join(dp, fn)
z.write(full, os.path.relpath(full, modclasses)
.replace("\\", "/"))
for name, entry in MODS:
mod_jar(os.path.join(mods, name), entry)
print(" %d mod jar(s): %s" % (len(MODS),
", ".join(n for n, _ in MODS)))
import hashlib
h = hashlib.sha256()
@@ -133,14 +181,50 @@ def main():
"patch.a2 wsygzaib joml.fastmath",
"patch.a6 pvdafug snooper pC",
"class net.minecraft.client.Minecraft wwk",
"field net.minecraft.client.Minecraft player zb Lwpqgx;",
"field net.minecraft.client.Minecraft world ta Lwwhgz;",
"field net.minecraft.client.Minecraft player zb Lddvqd;",
"field net.minecraft.client.Minecraft world ta Loisywi;",
"field net.minecraft.client.Minecraft currentScreen S Lbsh;",
"field net.minecraft.client.Minecraft timer Zb Lmlyn;",
"field net.minecraft.client.Minecraft instance WA Lwwk;",
"field net.minecraft.client.Minecraft renderGlobal jC Ldqtrv;",
"field net.minecraft.client.Minecraft snooper pC Lpvdafug;",
"method net.minecraft.client.Minecraft getMinecraft getMinecraftStatic ()Lwwk;",
# the stand-in uses the named members, see ddvqd.java / oisywi.java
"class net.minecraft.entity.Entity ddvqd",
"field net.minecraft.entity.Entity posX g D",
"field net.minecraft.entity.Entity posY F D",
"field net.minecraft.entity.Entity posZ a D",
"field net.minecraft.entity.Entity isDead e Z",
"method net.minecraft.entity.Entity getName getName ()Ljava/lang/String;",
"method net.minecraft.entity.Entity getEntityId getEntityId ()I",
"method net.minecraft.entity.Entity isEntityAlive isEntityAlive ()Z",
"class net.minecraft.world.World oisywi",
"field net.minecraft.world.World isRemote isRemoteFlag Z",
"method net.minecraft.world.World getWorldTime getWorldTime ()J",
# the game's own HUD draw API and the widget the bridge hooks; the
# stand-in uses legal names because the real ones are Java keywords
"class hud.screen fakescreen",
"field hud.screen singleton SINGLE Lfakescreen;",
"method hud.screen visible isHidden ()Z",
"hook.hud fakecompass render (Lfakectx;)V",
"class rsx.ui.batch fakebatch",
"method rsx.ui.batch rect rect (FFFF)Lfakecmdbuilder;",
"method rsx.ui.batch textured textured (Lfaketex;FFFF)Lfakecmdbuilder;",
"class rsx.ui.command fakecmdbuilder",
"method rsx.ui.command build build ()Lfakecmd;",
"method rsx.ui.command texture texture (Ljava/lang/Object;)Lfakecmdbuilder;",
"method rsx.ui.command color color (I)Lfakecmdbuilder;",
"method rsx.ui.command alpha alpha (F)Lfakecmdbuilder;",
"method rsx.ui.command uv uv (FFFF)Lfakecmdbuilder;",
"class rsx.ui.target fakecmd",
"class rsx.ui.textures fakereg",
"method rsx.ui.textures byPath byPath (Ljava/lang/String;)Lfaketex;",
"class rsx.ui.scale fakescale",
"field rsx.ui.scale factor factor F",
# the client packet handler, for the latency estimate
"class net.minecraft.client.network.NetHandlerPlayClient cxaddrz",
"method net.minecraft.client.network.NetHandlerPlayClient handleKeepAlive onKeepAlive (Ljava/lang/Object;)V",
"method net.minecraft.client.network.NetHandlerPlayClient sendPacket sendPacket (Ljava/lang/Object;)V",
]) + "\n"
with open(os.path.join(bridge, "profiles", "latest.rsxp"), "w",
encoding="utf-8") as fh:
@@ -153,6 +237,8 @@ def main():
print("== running with the agent attached ==")
cmd = [java(),
"-javaagent:" + os.path.join(bridge, "rsx-bridge.jar"),
# the stand compresses the 15 s cadence into milliseconds
"-Drsx.keepalivePeriod=200",
"-Dminecraft.applet.TargetDirectory=" + RUN,
"-cp", os.path.join(bridge, "rsx-bridge.jar") + ";" + game,
"FakeMain"]
@@ -169,9 +255,46 @@ def main():
("A2 applied", "A2 applied" in out and "joml.fastmath = true" in out),
("A6 disabled the telemetry",
"A6 applied: telemetry timers replaced in 1 object(s)" in out),
("mod handed to the client loader", "loading mod jar" in out),
("mod loaded", "mod loaded: example-mod.jar" in out),
("mod task ran on the game thread", "ExampleMod tick 1" in out),
("all three jars reached the loader",
all("loading mod jar" in out for _ in MODS)
and out.count("loading mod jar") == len(MODS)),
("first mod loaded", "mod loaded: example-mod.jar" in out),
("second mod loaded", "mod loaded: second-mod.jar" in out),
("third mod loaded", "mod loaded: third-mod.jar" in out),
("mod sees the shared library",
"ThirdMod sees the library: hello third" in out),
("library was appended to the class path",
"library on the class path: testlib.jar" in out),
("mod 1 work runs", "ExampleMod tick 1" in out),
("mod 2 work runs (not the kept drain)",
"SecondMod run 1" in out),
("mod 3 work runs", "ThirdMod run 1" in out),
("posted task ran on the game thread",
"ExampleMod posted task ran" in out),
("Player wrapper read the name", "player=Steve" in out),
("Player wrapper read the position", "x=10.5" in out),
("World wrapper read the time", "time=1000" in out),
("screenOpen event fired", "ExampleMod screenOpen" in out),
("failing task reported to onError", "ExampleMod onError" in out),
("failing task eventually dropped",
"times in a row, dropping it" in out),
("HUD hook armed", "HUD hook armed on" in out),
("HUD hook applied to the widget", "HUD hook applied to" in out),
("HUD follows the game visibility",
"HUD: following the game HUD visibility" in out),
("panel drew while visible",
"HUD visible for 3 frames" in out
and out.count("commands=5") == 3),
("panel drew a rectangle and a label",
"color=20201c" in out and "textures/font/ascii.png" in out
and "color=ece3db" in out),
("screen size read from the context", "HudMod screen: 1920x1080" in out),
("panel stops when the game hides the HUD",
"HUD hidden (what F3 does)" in out
and "frame 0 commands=0" in out and "frame 2 commands=0" in out),
("keep-alive hook applied", "keep-alive hook applied to" in out),
("latency estimated from the cadence", "HudMod ping: ping~" in out),
("estimate is not claimed exact", "exact=false" in out),
("telemetry never fired", "[fake-snooper] posting" not in out),
]
bad = 0
@@ -187,4 +310,4 @@ def main():
if __name__ == "__main__":
sys.exit(main())
sys.exit(main())
@@ -0,0 +1,77 @@
package com.example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
/**
* Example mod.
*
* Note what is NOT here: no obfuscated class name, no field name, no reflection.
* The mod asks for logical names, and the bridge maps them onto the build that
* is installed. Build this once - it keeps working across vendor updates.
*
* Everything below runs on the game's own thread, so touching game state is
* legal. The mod sees a typed view of the player and the world, never the
* obfuscated objects behind them.
*/
public class ExampleMod implements RsxMod {
private int ticks;
@Override
public int apiVersion() {
// what this mod was written against; the bridge warns if the numbers
// ever stop matching, instead of the mod breaking quietly
return Rsx.API_VERSION;
}
@Override
public void onLoad() {
Rsx.info("ExampleMod загружен, сборка " + Rsx.buildId()
+ ", API " + Rsx.apiVersion());
// Events beat polling: this fires only when something actually changes.
Rsx.on("worldChange", () -> Rsx.info("мир сменился"));
Rsx.on("playerJoin", () -> Rsx.info("вошли: " + Rsx.player().getName()));
Rsx.on("playerLeave", () -> Rsx.info("вышли"));
// Counted in game ticks, so it never drifts and never fires twice after
// a lag. This is the one to reach for by default.
Rsx.everyTicks(20, this::onSecond);
// Milliseconds are available for things that are not gameplay:
// the interval is wall-clock, so a lagging game thread makes it drift.
Rsx.every(5000L, () -> Rsx.info("тиков: " + ticks + ", bridge tick: " + Rsx.tick()));
}
/** Runs once per game second, on the game thread. */
private void onSecond() {
ticks++;
Rsx.Player player = Rsx.player();
if (!player.isPresent()) {
return;
}
Rsx.World world = Rsx.world();
Rsx.info(String.format(
"секунда %d | игрок: %s (%.1f/%.1f/%.1f) hp=%.1f/%.1f | мир: t=%d%s",
ticks,
player.getName(),
player.getX(), player.getY(), player.getZ(),
player.getHealth(), player.getMaxHealth(),
world.getTime(),
world.isClient() ? "" : " [сервер]"));
}
@Override
public void onError(Throwable error) {
// вызывается, когда задача мода упала; по умолчанию только пишет в лог
Rsx.error("ExampleMod: упало, " + error);
}
@Override
public void onUnload() {
Rsx.info("ExampleMod выгружается");
}
}
@@ -1,39 +0,0 @@
package com.example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
/**
* Example mod.
*
* Note what is NOT here: no obfuscated class name, no field name, no reflection.
* The mod asks for logical names, and the bridge maps them onto the build that
* is installed. Build this once - it keeps working across vendor updates.
*/
public class ExampleMod implements RsxMod {
private int ticks;
@Override
public void onLoad() {
Rsx.info("ExampleMod загружен, билд " + Rsx.buildId());
// Repeating task. It is queued by a timer thread but always executed by
// the game's own tick, so touching game state here is safe.
Rsx.every(1000L, new Runnable() {
@Override
public void run() {
ticks++;
Rsx.info("tick " + ticks
+ " | игрок: " + Rsx.player()
+ " | мир: " + Rsx.world()
+ " | экран: " + Rsx.currentScreen());
}
});
}
@Override
public void onUnload() {
Rsx.info("ExampleMod выгружен");
}
}
+234
View File
@@ -0,0 +1,234 @@
{
"schema": 1,
"classes": {
"net.minecraft.client.Minecraft": {
"obf": "wwk",
"fields": {
"player": {
"obf": "zb",
"desc": "Lwpqgx;",
"kind": "f",
"srg": "field_71439_g"
},
"world": {
"obf": "ta",
"desc": "Lwwhgz;",
"kind": "f",
"srg": "field_71441_e"
},
"currentScreen": {
"obf": "S",
"desc": "Lbsh;",
"kind": "f",
"srg": "field_71462_r"
},
"timer": {
"obf": "Zb",
"desc": "Lmlyn;",
"kind": "f",
"srg": "field_71428_T"
},
"instance": {
"obf": "WA",
"desc": "Lwwk;",
"kind": "f",
"srg": "field_71432_P"
},
"renderGlobal": {
"obf": "jC",
"desc": "Ldqtrv;",
"kind": "f",
"srg": "field_71438_f"
},
"gameSettings": {
"obf": "y",
"desc": "Ldmoadr;",
"kind": "f",
"srg": "field_71474_y"
},
"playerController": {
"obf": "fa",
"desc": "Ltdsh;",
"kind": "f",
"srg": "field_71442_b"
}
},
"methods": {
"getMinecraft": {
"obf": "super",
"desc": "()Lwwk;",
"kind": "m",
"srg": "func_71410_x"
},
"displayGuiScreen": {
"obf": "super",
"desc": "(Lbsh;)V",
"kind": "m",
"srg": "func_147108_a"
}
}
},
"net.minecraft.client.entity.EntityPlayerSP": {
"obf": "wpqgx"
},
"net.minecraft.entity.player.EntityPlayer": {
"obf": "obzsyig"
},
"net.minecraft.world.World": {
"obf": "oisywi",
"fields": {
"isRemote": {
"obf": "byte",
"desc": "Z",
"kind": "f",
"srg": "field_72995_K"
}
},
"methods": {
"getWorldTime": {
"obf": "super",
"desc": "()J",
"kind": "m",
"srg": "func_72820_D"
},
"getWorldInfo": {
"obf": "super",
"desc": "()Lcilc;",
"kind": "m",
"srg": "func_72912_H"
}
}
},
"net.minecraft.client.multiplayer.WorldClient": {
"obf": "wwhgz"
},
"net.minecraft.entity.Entity": {
"obf": "ddvqd",
"fields": {
"posX": {
"obf": "g",
"desc": "D",
"kind": "f",
"srg": "field_70165_t"
},
"posY": {
"obf": "F",
"desc": "D",
"kind": "f",
"srg": "field_70163_u"
},
"posZ": {
"obf": "a",
"desc": "D",
"kind": "f",
"srg": "field_70161_v"
},
"isDead": {
"obf": "e",
"desc": "Z",
"kind": "f",
"srg": "field_70128_L"
}
},
"methods": {
"getName": {
"obf": "super",
"desc": "()Ljava/lang/String;",
"kind": "m",
"srg": "func_70005_c_"
},
"getEntityId": {
"obf": "for",
"desc": "()I",
"kind": "m",
"srg": "func_145782_y"
},
"isEntityAlive": {
"obf": "switch",
"desc": "()Z",
"kind": "m",
"srg": "func_70089_S"
}
}
},
"net.minecraft.entity.EntityLivingBase": {
"obf": "bgtojhxo",
"methods": {
"getHealth": {
"obf": "byte",
"desc": "()F",
"kind": "m",
"srg": "func_110143_aJ"
},
"getMaxHealth": {
"obf": "if",
"desc": "()F",
"kind": "m",
"srg": "func_110138_aP"
}
}
},
"net.minecraft.network.PacketBuffer": {
"obf": "dcuglw"
},
"net.minecraft.client.network.NetHandlerPlayClient": {
"obf": "cxaddrz",
"methods": {
"handleKeepAlive": {
"obf": "super",
"desc": "(Lysfj;)V",
"kind": "m",
"srg": "func_147272_a"
},
"sendPacket": {
"obf": "super",
"desc": "(Lffoox;)V",
"kind": "m",
"srg": "func_147297_a"
}
}
},
"net.minecraft.client.multiplayer.PlayerControllerMP": {
"obf": "tdsh"
},
"net.minecraft.client.gui.GuiScreen": {
"obf": "bsh"
},
"net.minecraft.client.settings.GameSettings": {
"obf": "qoa"
}
},
"bridge": {
"loader": {
"obf": "ilxq",
"loadMethod": "super",
"entry": "special.Main"
},
"patches": {
"a2": {
"class": "wsygzaib",
"property": "joml.fastmath"
},
"a6": {
"class": "pvdafug",
"clientField": "snooper",
"obf": "pC",
"clientFieldObf": "pC"
}
}
},
"missing": [
"net.minecraft.client.Minecraft#snooper",
"net.minecraft.client.network.NetHandlerPlayClient#handleCustomPayload",
"net.minecraft.network.NetworkManager"
],
"build": {
"id": "9ad8c4aac5d835fc",
"gameJarSha256": "9ad8c4aac5d835fc38f68d0648ffd67c809f8342e88b9636ee36205f8f96bf35",
"jarSize": 59020857,
"jarMtime": 1792011090,
"classes": 3759,
"totalClasses": 7695,
"generated": "2026-10-17 03:30"
}
}
+36
View File
@@ -17,11 +17,47 @@ method net.minecraft.client.Minecraft displayGuiScreen super (Lbsh;)V
class net.minecraft.client.entity.EntityPlayerSP wpqgx
class net.minecraft.entity.player.EntityPlayer obzsyig
class net.minecraft.world.World oisywi
field net.minecraft.world.World isRemote byte Z
method net.minecraft.world.World getWorldTime super ()J
method net.minecraft.world.World getWorldInfo super ()Lcilc;
class net.minecraft.client.multiplayer.WorldClient wwhgz
class net.minecraft.entity.Entity ddvqd
field net.minecraft.entity.Entity posX g D
field net.minecraft.entity.Entity posY F D
field net.minecraft.entity.Entity posZ a D
field net.minecraft.entity.Entity isDead e Z
method net.minecraft.entity.Entity getName super ()Ljava/lang/String;
method net.minecraft.entity.Entity getEntityId for ()I
method net.minecraft.entity.Entity isEntityAlive switch ()Z
class net.minecraft.entity.EntityLivingBase bgtojhxo
method net.minecraft.entity.EntityLivingBase getHealth byte ()F
method net.minecraft.entity.EntityLivingBase getMaxHealth if ()F
class net.minecraft.network.PacketBuffer dcuglw
class net.minecraft.client.network.NetHandlerPlayClient cxaddrz
method net.minecraft.client.network.NetHandlerPlayClient handleKeepAlive super (Lysfj;)V
method net.minecraft.client.network.NetHandlerPlayClient sendPacket super (Lffoox;)V
class net.minecraft.client.multiplayer.PlayerControllerMP tdsh
class net.minecraft.client.gui.GuiScreen bsh
class net.minecraft.client.settings.GameSettings qoa
override.server catch
class hud.screen aogu
field hud.screen singleton try Laogu;
method hud.screen visible do ()Z
hook.hud jzojqxa super (Lzmtuealk$a;)V
class rsx.ui.batch lthzuq
method rsx.ui.batch rect super (FFFF)Llthzuq$b$b;
method rsx.ui.batch textured super (Lwyw;FFFF)Llthzuq$b$b;
class rsx.ui.command lthzuq$b$b
method rsx.ui.command build super ()Llthzuq$b;
method rsx.ui.command texture super (Lwyw;)Llthzuq$b$b;
method rsx.ui.command color super (I)Llthzuq$b$b;
method rsx.ui.command alpha super (F)Llthzuq$b$b;
method rsx.ui.command uv super (FFFF)Llthzuq$b$b;
class rsx.ui.target lthzuq$b
class rsx.ui.textures mhemp
method rsx.ui.textures byPath super (Ljava/lang/String;)Lwyw;
class rsx.ui.scale avpajdfu
field rsx.ui.scale factor break F
class rsx.net.echoPacket fgoftjl
method rsx.net.echoPacket ctor <init> (Ljava/lang/String;Ldcuglw;)V
class rsx.net.echoBuffer dcuglw
+57 -7
View File
@@ -35,10 +35,22 @@ $agentOut = Join-Path $Out 'agent'
Remove-Item $agentOut -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $agentOut | Out-Null
$agentFiles = Get-ChildItem $agentSrc -Recurse -Filter '*.java' | ForEach-Object { $_.FullName }
& javac -encoding UTF-8 --release 8 -nowarn -d $agentOut $agentFiles
& javac -encoding UTF-8 --release 8 -nowarn -cp ($Asm -join ';') -d $agentOut $agentFiles
if ($LASTEXITCODE -ne 0) { Write-Host ' Агент не собрался.' -ForegroundColor Red; exit 1 }
Say "агент скомпилирован ($($agentFiles.Count) файлов)"
# The agent rewrites bytecode at runtime to give mods the game's own HUD, so it
# carries ASM inside rather than making every install provide it.
foreach ($jar in $Asm) {
Push-Location $agentOut
& jar xf $jar
Pop-Location
}
Get-ChildItem (Join-Path $agentOut 'META-INF') -Recurse -ErrorAction SilentlyContinue |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item (Join-Path $agentOut 'module-info.class') -Force -ErrorAction SilentlyContinue
Say 'ASM зашит в агент'
$manifest = Join-Path $Out 'agent-manifest.txt'
@"
Manifest-Version: 1.0
@@ -119,21 +131,59 @@ Say "rsx-launcher-patch.jar ($((Get-Item (Join-Path $Dist 'rsx-launcher-patch.ja
# ---------------------------------------------------------------- mod template
$tpl = Join-Path $Dist 'mod-template'
Remove-Item $tpl -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path "$tpl\libs", "$tpl\src\main\java\example" | Out-Null
New-Item -ItemType Directory -Force -Path "$tpl\libs", "$tpl\src\main\java\com\example" | Out-Null
Copy-Item (Join-Path $Repo 'mod-template\build.gradle') $tpl -Force
Copy-Item (Join-Path $Repo 'mod-template\settings.gradle') $tpl -Force
Copy-Item (Join-Path $Repo 'mod-template\src\main\java\example\ExampleMod.java') `
"$tpl\src\main\java\example\" -Force
Copy-Item (Join-Path $Repo 'mod-template\src\main\java\com\example\ExampleMod.java') `
"$tpl\src\main\java\com\example\" -Force
Copy-Item (Join-Path $Dist 'rsx-api.jar') "$tpl\libs\" -Force
@'
@echo off
rem Builds this mod and drops the jar into the client's mods folder.
rem Builds the mod and drops the jar into the client's mods folder.
rem Only a JDK is needed: no Gradle, no network, nothing but javac and jar.
setlocal
cd /d "%~dp0"
call gradle jar
if not exist "build\classes" mkdir "build\classes"
if not exist "build\mods" mkdir "build\mods"
set ENTRY=com.example.ExampleMod
rem where the modloader looks for mods; override with set MODS_DIR=...
if "%MODS_DIR%"=="" set MODS_DIR=%LOCALAPPDATA%\rsx-mod\mods
echo [1/3] compiling...
dir /s /b src\main\java\*.java > build\sources.txt
javac -encoding UTF-8 --release 8 -nowarn -cp libs\rsx-api.jar -d build\classes @build\sources.txt
if errorlevel 1 (
echo.
echo Сборка не удалась.
exit /b 1
)
echo [2/3] packaging example-mod.jar...
if exist "build\mods\example-mod.jar" del "build\mods\example-mod.jar"
jar cfm "build\mods\example-mod.jar" manifest.txt -C build/classes .
if errorlevel 1 exit /b 1
echo [3/3] installing...
if not exist "%MODS_DIR%" (
echo.
echo Папка модов не найдена: %MODS_DIR%
echo Скопируй build\mods\example-mod.jar в неё сам.
exit /b 0
)
copy /y "build\mods\example-mod.jar" "%MODS_DIR%\" >nul
echo.
echo Скопируй build\libs\example-mod.jar в working_dir\mods
echo Готово: build\mods\example-mod.jar скопирован в %MODS_DIR%
'@ | Set-Content -LiteralPath (Join-Path $tpl 'build.cmd') -Encoding ASCII
# the manifest carries the entry point the bridge looks for
$entry = (Select-String -LiteralPath "$tpl\src\main\java\com\example\ExampleMod.java" `
-Pattern '^package\s+([\w.]+)' | ForEach-Object { $_.Matches[0].Groups[1].Value })
"Manifest-Version: 1.0`r`nRsx-Mod-Entrypoint: $entry.ExampleMod`r`n`r`n" |
Set-Content -LiteralPath (Join-Path $tpl 'manifest.txt') -Encoding ASCII
Say 'mod-template готов'
Write-Host ''
+11 -4
View File
@@ -135,12 +135,15 @@ Say "Модлоадер: $ModHome"
Say ''
# --------------------------------------------------------------- copy the runtime
New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'bridge\profiles') | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'profiles') | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'mods') | Out-Null
Copy-Item (Join-Path $Dist 'rsx-bridge.jar') (Join-Path $ModHome 'bridge') -Force
New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'libs') | Out-Null
# The agent sits in the home itself, not in a subfolder: then its own directory
# IS the home, and mods\, libs\ and config\ resolve without any guessing.
Copy-Item (Join-Path $Dist 'rsx-bridge.jar') $ModHome -Force
Say 'скопирован rsx-bridge.jar'
$existingProfile = Join-Path $ModHome 'bridge\profiles\latest.rsxp'
$existingProfile = Join-Path $ModHome 'profiles\latest.rsxp'
$repoProfile = Join-Path $Repo 'profiles\latest.rsxp'
$oldProfile = 'G:\rsx-mod\bridge\profiles\latest.rsxp'
if (Test-Path $existingProfile) {
@@ -182,7 +185,11 @@ if (-not (Test-Path -LiteralPath $Marker)) {
}
# --------------------------------------------------------------- wire the agent
$agentArg = "-javaagent:`"$ModHome\bridge\rsx-bridge.jar`""
# Forward slashes on purpose. The launcher rewrites this config through a
# properties writer that mangles Windows paths - it dropped a whole path segment
# from ours once - and a forward slash has nothing to escape. The JVM takes this
# form happily on Windows.
$agentArg = '-javaagent:"' + ($ModHome -replace '\\', '/') + '/rsx-bridge.jar"'
$ErrorActionPreference = 'Continue'
$out = & $java -jar $patcher setenv $Config $agentArg 2>&1
$code = $LASTEXITCODE