6 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
31 changed files with 2027 additions and 21 deletions
+17 -1
View File
@@ -118,7 +118,23 @@ jar (payload начинается с offset 65024 и идёт до EOF). Заг
и классом, реализующим `RsxMod`. Собирается один раз против `rsx-api.jar` и
дальше не пересобирается: все обфусцированные имена мод не знает, бриф
разрешает их через профиль сборки. Готовый шаблон — в `mod-template\`.
разрешает их через профиль сборки.
Общая библиотека кладётся в `%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 секунд реального времени
@@ -296,6 +296,132 @@ public final class Rsx {
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);
+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;
}
}
+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))
+6 -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<?>>();
+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);
}
}
+48
View File
@@ -5,6 +5,10 @@
// 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 {
@@ -29,6 +33,19 @@ public class FakeMain {
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 8 times");
for (int i = 0; i < 8; i++) {
@@ -43,6 +60,37 @@ public class FakeMain {
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");
}
}
+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
}
}
+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;
}
}
@@ -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;
}
}
+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() {
+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());
}
}
});
}
}
+4
View File
@@ -2,6 +2,7 @@ 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,
@@ -14,6 +15,9 @@ public class ThirdMod implements RsxMod {
@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() {
+65 -1
View File
@@ -44,6 +44,7 @@ MODS = [
("example-mod.jar", "example.ExampleMod"),
("second-mod.jar", "example.SecondMod"),
("third-mod.jar", "example.ThirdMod"),
("hud-mod.jar", "example.HudMod"),
]
@@ -79,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)
@@ -132,9 +137,21 @@ def main():
javac([os.path.join(SRC, f) for f in os.listdir(SRC) if f.endswith(".java")],
classes)
# 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")], MOD_CLASSES, 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)
@@ -184,6 +201,30 @@ def main():
"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:
@@ -196,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"]
@@ -218,6 +261,10 @@ def main():
("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),
@@ -231,6 +278,23 @@ def main():
("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
+19 -1
View File
@@ -171,6 +171,23 @@
"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"
},
@@ -202,6 +219,7 @@
},
"missing": [
"net.minecraft.client.Minecraft#snooper",
"net.minecraft.client.network.NetHandlerPlayClient#handleCustomPayload",
"net.minecraft.network.NetworkManager"
],
"build": {
@@ -211,6 +229,6 @@
"jarMtime": 1792011090,
"classes": 3759,
"totalClasses": 7695,
"generated": "2026-10-17 02:15"
"generated": "2026-10-17 03:30"
}
}
+24
View File
@@ -33,7 +33,31 @@ 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
+54 -4
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
@@ -127,13 +139,51 @@ Copy-Item (Join-Path $Repo 'mod-template\src\main\java\com\example\ExampleMod.ja
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