Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88b90802a7 | |||
| 108c001af6 | |||
| b5ffe0e9d7 | |||
| 419b2d0a6b | |||
| 04872b21b6 | |||
| 13ddc0d0f5 | |||
| cc1eaece40 |
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()) + "%";
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 выгружен");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user