Несколько модов одновременно и живая работа с игрой

Главное: модлоадер физически не умел запускать больше одного мода. Родной
загрузчик игры запоминает тик-хук в статическом поле, которое перезаписывается
на каждой загрузке:

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

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

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

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

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

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

Заодно: сломанный фейковый загрузчик делил один classloader на все моды,
и такой тест не мог поймать проблему с изоляцией.
This commit is contained in:
SashegDev
2026-10-17 02:25:27 +03:00
parent 2eaab88809
commit cc1eaece40
19 changed files with 1283 additions and 158 deletions
+57 -4
View File
@@ -24,16 +24,17 @@ third_party\asm\ ASM (BSD-3), зашит внутрь rsx-launcher-patch.j
uninstall.cmd вернуть всё как было uninstall.cmd вернуть всё как было
`install.cmd` копирует рантайм в `%LOCALAPPDATA%\rsx-mod`, патчит `app.jar` `install.cmd` копирует рантайм в `%LOCALAPPDATA%\rsx-mod`, патчит `app.jar`
лаунчера и прописывает `-javaagent` в его конфиг. Пути можно переопределить: и `updater.exe` лаунчера и прописывает `-javaagent` в его конфиг. Пути можно
переопределить:
install.cmd --home D:\rsx-mod --game D:\RustexRemake install.cmd --home D:\rsx-mod --game D:\RustexRemake
**Лаунчер должен быть закрыт.** Если он запущен с правами администратора, **Лаунчер должен быть закрыт.** Если он запущен с правами администратора,
`app.jar` не перезаписать — установщик об этом скажет. `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`. `Timer already cancelled`.
* **Моды** грузятся родным загрузчиком игры (`ilxq` → `special.Main`) — так же, * **Моды** грузятся родным загрузчиком игры (`ilxq` → `special.Main`) — так же,
как моды с сервера, и выполняются в игровом потоке. как моды с сервера, и выполняются в игровом потоке.
* **Несколько модов работают вместе** — см. «Почему один drain» ниже.
* **Переносимость модов.** Мод собирается один раз против стабильного API. * **Переносимость модов.** Мод собирается один раз против стабильного API.
Мост сам переводит логические имена в имена текущей сборки, поэтому Мост сам переводит логические имена в имена текущей сборки, поэтому
ре-обфускация вендора не ломает уже собранные моды. ре-обфускация вендора не ломает уже собранные моды.
@@ -108,6 +110,57 @@ jar (payload начинается с offset 65024 и идёт до EOF). Заг
лаунчер перестаёт находить игру. Все правки идут через лаунчер перестаёт находить игру. Все правки идут через
`java.util.Properties` — в патчере есть команды `getprop` и `setprop`. `java.util.Properties` — в патчере есть команды `getprop` и `setprop`.
## Моды
Мод — это jar с манифестом
Rsx-Mod-Entrypoint: com.example.MyMod
и классом, реализующим `RsxMod`. Собирается один раз против `rsx-api.jar` и
дальше не пересобирается: все обфусцированные имена мод не знает, бриф
разрешает их через профиль сборки. Готовый шаблон — в `mod-template\`.
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 testserver\start.cmd
@@ -16,9 +16,17 @@ import rsx.Resolver;
*/ */
public final class Rsx { public final class Rsx {
/** Version of this API, mirrored by RsxMod.apiVersion(). */
public static final int API_VERSION = 1;
private Rsx() { private Rsx() {
} }
/** The API version this bridge implements. */
public static int apiVersion() {
return API_VERSION;
}
// ------------------------------------------------------------- identity // ------------------------------------------------------------- identity
public static String buildId() { public static String buildId() {
return Resolver.profile() == null ? "unknown" return Resolver.profile() == null ? "unknown"
@@ -39,18 +47,156 @@ public final class Rsx {
return Resolver.get("net.minecraft.client.Minecraft", "instance"); 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() { public static Object currentScreen() {
return Resolver.get("net.minecraft.client.Minecraft", "currentScreen", minecraft()); 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) { public static Class<?> classOf(String logical) {
return Resolver.cls(logical); return Resolver.cls(logical);
} }
@@ -107,11 +253,49 @@ public final class Rsx {
ModBridge.post(task); 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) { public static void every(long millis, final Runnable task) {
ModBridge.schedule(millis, 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();
}
// ------------------------------------------------------------------ logs // ------------------------------------------------------------------ logs
public static void info(String msg) { public static void info(String msg) {
rsx.Log.info(msg); rsx.Log.info(msg);
@@ -120,4 +304,14 @@ public final class Rsx {
public static void warn(String msg) { public static void warn(String msg) {
rsx.Log.warn(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. * The whole contract a mod has to implement.
* *
* onLoad runs once, on the thread that loads the mod jar. Anything scheduled * 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 * through {@link Rsx#every}, {@link Rsx#everyTicks}, {@link Rsx#onGameThread}
* own thread, which is the only thread where touching game state is safe. * 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 { 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(); 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() { default void onUnload() {
} }
} }
+396 -48
View File
@@ -13,7 +13,8 @@ import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream; 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 * 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 * `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 * 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 * argument array the loader passes through, so no manifest parsing is needed at
* runtime. * 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 { public final class ModBridge {
private static final Map<ClassLoader, String> NAMES = /** A mod that registered itself, kept so errors and unload can reach it. */
new ConcurrentHashMap<ClassLoader, String>(); private static final class ModHandle {
private static final Map<ClassLoader, List<Runnable>> QUEUE = final ClassLoader loader;
new ConcurrentHashMap<ClassLoader, List<Runnable>>(); final String name;
private static final Map<ClassLoader, Runnable> DRAIN = final String entry;
new ConcurrentHashMap<ClassLoader, Runnable>(); 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>(); 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 = private static final java.util.concurrent.ScheduledExecutorService TIMER =
java.util.concurrent.Executors.newSingleThreadScheduledExecutor( java.util.concurrent.Executors.newSingleThreadScheduledExecutor(
new java.util.concurrent.ThreadFactory() { 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. */ /** Invoked by the injected special.Main, during jar loading. */
public static void launch(Class<?> entry, String[] args) { public static void launch(Class<?> entry, String[] args) {
ClassLoader cl = entry.getClassLoader(); ClassLoader cl = entry.getClassLoader();
String modName = args != null && args.length > 0 ? args[0] : "unknown"; String modName = args != null && args.length > 0 ? args[0] : "unknown";
String entryName = args != null && args.length > 1 ? args[1] : null; String entryName = args != null && args.length > 1 ? args[1] : null;
NAMES.put(cl, modName); ModHandle handle = new ModHandle(cl, modName, entryName);
QUEUE.put(cl, new ArrayList<Runnable>());
try { try {
if (entryName == null || entryName.isEmpty()) { if (entryName == null || entryName.isEmpty()) {
Log.warn("mod " + modName + ": no entry point"); Log.warn("mod " + modName + ": no entry point");
@@ -69,58 +171,110 @@ public final class ModBridge {
+ " does not implement RsxMod"); + " does not implement RsxMod");
return; return;
} }
api.getMethod("onLoad").invoke(impl); handle.api = api;
LOADED.add(modName); 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 + ")"); Log.info("mod loaded: " + modName + " (" + entryName + ")");
} catch (Throwable t) { } catch (Throwable t) {
OWNER.remove();
Log.error("mod " + modName + " failed to start", t); 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. */ /** Invoked by the injected special.Main, on the game thread. */
public static List<Runnable> drain(Class<?> entry) { 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); List<Runnable> one = new ArrayList<Runnable>(1);
one.add(r); one.add(DRAIN);
return one; return one;
} }
/** Queue a task to run on the game thread; callable from any thread. */ /** Queue a task to run on the game thread; callable from any thread. */
public static void post(Runnable task) { public static void post(Runnable task) {
for (List<Runnable> q : QUEUE.values()) { enqueue(OWNER.get(), null, task);
synchronized (q) { }
// coalesce: a slow tick must not let a repeating task pile up
for (Runnable r : q) { /** Queue a named task, so a failure names the mod instead of "unknown". */
if (r == task) { public static void post(String label, Runnable task) {
return; enqueue(OWNER.get(), label, task);
} }
}
q.add(task); private static void enqueue(String owner, String label, Runnable task) {
} if (task == null) {
return; 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 * 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 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) { 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 { try {
TIMER.scheduleAtFixedRate(new Runnable() { TIMER.scheduleAtFixedRate(new Runnable() {
@Override @Override
public void run() { public void run() {
post(task); enqueue(owner, label, task);
} }
}, millis, millis, java.util.concurrent.TimeUnit.MILLISECONDS); }, millis, millis, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (Throwable t) { } 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() { public static List<String> loaded() {
return LOADED; return LOADED;
} }
private static void runPending(ClassLoader cl) { /** Tell every mod the game is going away, so it can drop its work. */
List<Runnable> q = QUEUE.get(cl); public static void shutdown() {
if (q == null) { List<ModHandle> copy;
return; synchronized (LOCK) {
copy = new ArrayList<ModHandle>(MODS);
QUEUE.clear();
TICKS.clear();
HANDLERS.clear();
} }
List<Runnable> copy; for (ModHandle h : copy) {
synchronized (q) { if (h.api == null || h.instance == null) {
if (q.isEmpty()) { 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; return;
} }
copy = new ArrayList<Runnable>(q); handlers = new ArrayList<Handler>(HANDLERS);
q.clear();
} }
for (Runnable r : copy) { fire(handlers, "tick");
try {
r.run(); Object world = read("net.minecraft.client.Minecraft", "world");
} catch (Throwable t) { Object player = read("net.minecraft.client.Minecraft", "player");
Log.error("mod task failed in " + NAMES.get(cl), t); 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;
} }
} }
} }
+5
View File
@@ -279,6 +279,11 @@ public final class Resolver {
} }
// ------------------------------------------------------------- public API // ------------------------------------------------------------- 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) { public static Object get(String logical, String member) {
Class<?> owner = cls(logical); Class<?> owner = cls(logical);
Field f = field(logical, member, owner); Field f = field(logical, member, owner);
+25 -5
View File
@@ -1,5 +1,10 @@
// Drives the fake client the way the game would, so the bridge can be tested // 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. // 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.
public class FakeMain { public class FakeMain {
@@ -9,8 +14,15 @@ public class FakeMain {
wwk client = new wwk(); wwk client = new wwk();
wwk.WA = client; wwk.WA = client;
client.zb = "PLAYER"; ddvqd player = new ddvqd("Steve", 35789);
client.ta = "WORLD"; 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.S = null;
client.Zb = "TIMER"; client.Zb = "TIMER";
client.jC = "RENDER"; client.jC = "RENDER";
@@ -18,11 +30,19 @@ public class FakeMain {
System.out.println("[fake] client up, telemetry scheduled"); System.out.println("[fake] client up, telemetry scheduled");
Thread.sleep(2500); Thread.sleep(2500);
System.out.println("[fake] ticking 5 times"); System.out.println("[fake] ticking 8 times");
for (int i = 0; i < 5; i++) { 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(); client.tick();
Thread.sleep(200); Thread.sleep(200);
} }
System.out.println("[fake] done"); System.out.println("[fake] done");
} }
} }
+6
View File
@@ -0,0 +1,6 @@
// Test stand-in for net.minecraft.client.gui.GuiScreen: the profile types
// currentScreen as Lbsh;, and Rsx.on("screenOpen"/"screenClose") fires when that
// field changes.
public class bsh {
}
+34
View File
@@ -0,0 +1,34 @@
// Test stand-in for net.minecraft.entity.Entity.
//
// The real build obfuscates these to g/F/a/e/super/for/switch. `super` is a Java
// keyword, so the stand-in uses the *named* members and the test profile points
// at those instead - the mechanism the wrapper relies on (logical name -> profile
// -> reflective field) is identical either way.
public class ddvqd {
public double g; // posX
public double F; // posY
public double a; // posZ
public boolean e; // isDead
private final String name;
private final int id;
public ddvqd(String name, int id) {
this.name = name;
this.id = id;
}
public String getName() { // func_70005_c_
return name;
}
public int getEntityId() { // func_145782_y
return id;
}
public boolean isEntityAlive() { // func_70089_S
return !e;
}
}
+10 -5
View File
@@ -4,6 +4,11 @@
// loads the entry class `special.Main`, calls its main(String[]) and later // 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 // 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. // 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; import java.lang.reflect.Method;
@@ -15,7 +20,9 @@ public class ilxq {
public static boolean loadJar(byte[] jar, String[] args) throws Exception { public static boolean loadJar(byte[] jar, String[] args) throws Exception {
System.out.println("[fake] loading mod jar, " + jar.length + " bytes, args=" System.out.println("[fake] loading mod jar, " + jar.length + " bytes, args="
+ java.util.Arrays.toString(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"); Class<?> main = loader.loadClass("special.Main");
Method m = main.getDeclaredMethod("main", String[].class); Method m = main.getDeclaredMethod("main", String[].class);
m.setAccessible(true); m.setAccessible(true);
@@ -42,9 +49,7 @@ public class ilxq {
} }
} }
static MemLoader loader = new MemLoader(ilxq.class.getClassLoader()); static void jarLoader(MemLoader loader, byte[] jar) throws Exception {
static void jarLoader(byte[] jar) throws Exception {
java.util.zip.ZipInputStream zin = java.util.zip.ZipInputStream zin =
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(jar)); new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(jar));
java.util.zip.ZipEntry e; java.util.zip.ZipEntry e;
@@ -76,4 +81,4 @@ public class ilxq {
} }
} }
} }
} }
+17
View File
@@ -0,0 +1,17 @@
// Test stand-in for net.minecraft.world.World.
//
// The real build obfuscates the fields to byte / super, both Java keywords, so
// the stand-in uses a legal name and the test profile points at that instead.
// What the wrapper does - read a boolean field and call a no-arg long method by
// logical name - is identical.
public class oisywi {
public boolean isRemoteFlag; // field_72995_K
public long time;
public long getWorldTime() { // func_72820_D
return time;
}
}
+55 -14
View File
@@ -4,31 +4,72 @@ import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod; import net.rustexremake.api.RsxMod;
/** /**
* Example mod. It is compiled against the stable API only, so it does not have * First of three mods loaded together.
* to be rebuilt when the vendor re-obfuscates the client. *
* 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 { 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 @Override
public void onLoad() { public void onLoad() {
Rsx.info("ExampleMod: loaded on build " + Rsx.buildId()); Rsx.info("ExampleMod: loaded on build " + Rsx.buildId()
Rsx.info("ExampleMod: Minecraft is " + Rsx.describe("net.minecraft.client.Minecraft")); + " api=" + Rsx.apiVersion());
Rsx.on("screenOpen", new Runnable() {
// a repeating task runs on the game thread, driven by the client's own @Override
// tick, so touching game state here is safe public void run() {
screenOpens++;
Rsx.info("ExampleMod screenOpen " + screenOpens);
}
});
Rsx.everyTicks(1, new Runnable() {
@Override
public void run() {
tickEvents++;
}
});
Rsx.every(200L, new Runnable() { Rsx.every(200L, new Runnable() {
@Override @Override
public void run() { public void run() {
ticks++; ticks++;
Object mc = Rsx.minecraft();
Object player = Rsx.player();
Rsx.info("ExampleMod tick " + ticks Rsx.info("ExampleMod tick " + ticks
+ " client=" + (mc != null) + " player=" + Rsx.player().getName()
+ " player=" + player + " x=" + Rsx.player().getX()
+ " world=" + Rsx.world()); + " 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());
}
}
+27
View File
@@ -0,0 +1,27 @@
package example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
/**
* Second mod, loaded in the middle.
*
* The vendor loader overwrites its static tick-hook field on every load, so this
* mod's getRunnableList() is never called. Its task still has to run.
*/
public class SecondMod implements RsxMod {
public static int runs;
@Override
public void onLoad() {
Rsx.info("SecondMod: loaded");
Rsx.everyTicks(1, new Runnable() {
@Override
public void run() {
runs++;
Rsx.info("SecondMod run " + runs);
}
});
}
}
+25
View File
@@ -0,0 +1,25 @@
package example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
/**
* 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");
Rsx.everyTicks(1, new Runnable() {
@Override
public void run() {
runs++;
Rsx.info("ThirdMod run " + runs);
}
});
}
}
+86 -27
View File
@@ -8,12 +8,18 @@ takes a jar, calls `special.Main.main(String[])`, and drains
`special.Main.getRunnableList()` from its tick - plus a client class whose `special.Main.getRunnableList()` from its tick - plus a client class whose
field names are the ones the real profile uses. 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: It verifies, in one run:
* the profile is found and the build hash matches * the profile is found and the build hash matches
* patch A2 sets joml.fastmath before anything reads it * patch A2 sets joml.fastmath before anything reads it
* patch A6 cancels the telemetry timer (the stand-in prints if it fires) * patch A6 cancels the telemetry timer (the stand prints if it fires)
* a mod jar is handed to the client's own loader * three mod jars are handed to the client's own loader
* the mod's onLoad runs and its repeating task runs on the game thread * 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] Usage: python run_test.py [--keep]
""" """
@@ -33,6 +39,13 @@ MOD_SRC = os.path.join(os.path.dirname(HERE), "mod")
# work outside the source tree: the stand writes jars and logs # work outside the source tree: the stand writes jars and logs
RUN = os.path.join(REPO, "build", "testrun") 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"),
]
def sh(*args, **kw): def sh(*args, **kw):
return subprocess.run(args, capture_output=True, text=True, **kw) return subprocess.run(args, capture_output=True, text=True, **kw)
@@ -78,43 +91,61 @@ def zip_dir(src, jar):
z.write(full, rel) 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(): def main():
global MOD_CLASSES
if not os.path.exists(BRIDGE_JAR): if not os.path.exists(BRIDGE_JAR):
raise SystemExit("bridge jar not built: " + BRIDGE_JAR) raise SystemExit("bridge jar not built: " + BRIDGE_JAR)
classes = os.path.join(RUN, "fakeclasses") classes = os.path.join(RUN, "fakeclasses")
modclasses = os.path.join(RUN, "modclasses") MOD_CLASSES = os.path.join(RUN, "modclasses")
for d in (classes, modclasses): for d in (classes, MOD_CLASSES):
shutil.rmtree(d, ignore_errors=True) shutil.rmtree(d, ignore_errors=True)
os.makedirs(classes, exist_ok=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 ==") print("== compiling the stand-in client ==")
javac([os.path.join(SRC, f) for f in os.listdir(SRC) if f.endswith(".java")], javac([os.path.join(SRC, f) for f in os.listdir(SRC) if f.endswith(".java")],
classes) classes)
print("== compiling the example mod ==") print("== compiling the mods ==")
javac([os.path.join(MOD_SRC, f) for f in os.listdir(MOD_SRC) 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)
game = os.path.join(RUN, "game.jar") game = os.path.join(RUN, "game.jar")
zip_dir(classes, game) zip_dir(classes, game)
mods = os.path.join(RUN, "mods") mods = os.path.join(RUN, "mods")
shutil.rmtree(mods, ignore_errors=True)
os.makedirs(mods, exist_ok=True) os.makedirs(mods, exist_ok=True)
modjar = os.path.join(mods, "example-mod.jar") for name, entry in MODS:
if os.path.exists(modjar): mod_jar(os.path.join(mods, name), entry)
os.remove(modjar) print(" %d mod jar(s): %s" % (len(MODS),
with zipfile.ZipFile(modjar, "w", zipfile.ZIP_DEFLATED) as z: ", ".join(n for n, _ in MODS)))
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("\\", "/"))
import hashlib import hashlib
h = hashlib.sha256() h = hashlib.sha256()
@@ -133,14 +164,26 @@ def main():
"patch.a2 wsygzaib joml.fastmath", "patch.a2 wsygzaib joml.fastmath",
"patch.a6 pvdafug snooper pC", "patch.a6 pvdafug snooper pC",
"class net.minecraft.client.Minecraft wwk", "class net.minecraft.client.Minecraft wwk",
"field net.minecraft.client.Minecraft player zb Lwpqgx;", "field net.minecraft.client.Minecraft player zb Lddvqd;",
"field net.minecraft.client.Minecraft world ta Lwwhgz;", "field net.minecraft.client.Minecraft world ta Loisywi;",
"field net.minecraft.client.Minecraft currentScreen S Lbsh;", "field net.minecraft.client.Minecraft currentScreen S Lbsh;",
"field net.minecraft.client.Minecraft timer Zb Lmlyn;", "field net.minecraft.client.Minecraft timer Zb Lmlyn;",
"field net.minecraft.client.Minecraft instance WA Lwwk;", "field net.minecraft.client.Minecraft instance WA Lwwk;",
"field net.minecraft.client.Minecraft renderGlobal jC Ldqtrv;", "field net.minecraft.client.Minecraft renderGlobal jC Ldqtrv;",
"field net.minecraft.client.Minecraft snooper pC Lpvdafug;", "field net.minecraft.client.Minecraft snooper pC Lpvdafug;",
"method net.minecraft.client.Minecraft getMinecraft getMinecraftStatic ()Lwwk;", "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",
]) + "\n" ]) + "\n"
with open(os.path.join(bridge, "profiles", "latest.rsxp"), "w", with open(os.path.join(bridge, "profiles", "latest.rsxp"), "w",
encoding="utf-8") as fh: encoding="utf-8") as fh:
@@ -169,9 +212,25 @@ def main():
("A2 applied", "A2 applied" in out and "joml.fastmath = true" in out), ("A2 applied", "A2 applied" in out and "joml.fastmath = true" in out),
("A6 disabled the telemetry", ("A6 disabled the telemetry",
"A6 applied: telemetry timers replaced in 1 object(s)" in out), "A6 applied: telemetry timers replaced in 1 object(s)" in out),
("mod handed to the client loader", "loading mod jar" in out), ("all three jars reached the loader",
("mod loaded", "mod loaded: example-mod.jar" in out), all("loading mod jar" in out for _ in MODS)
("mod task ran on the game thread", "ExampleMod tick 1" in out), 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 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),
("telemetry never fired", "[fake-snooper] posting" not in out), ("telemetry never fired", "[fake-snooper] posting" not in out),
] ]
bad = 0 bad = 0
@@ -187,4 +246,4 @@ def main():
if __name__ == "__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 выгружен");
}
}
+216
View File
@@ -0,0 +1,216 @@
{
"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.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.network.NetworkManager"
],
"build": {
"id": "9ad8c4aac5d835fc",
"gameJarSha256": "9ad8c4aac5d835fc38f68d0648ffd67c809f8342e88b9636ee36205f8f96bf35",
"jarSize": 59020857,
"jarMtime": 1792011090,
"classes": 3759,
"totalClasses": 7695,
"generated": "2026-10-17 02:15"
}
}
+12
View File
@@ -17,9 +17,21 @@ method net.minecraft.client.Minecraft displayGuiScreen super (Lbsh;)V
class net.minecraft.client.entity.EntityPlayerSP wpqgx class net.minecraft.client.entity.EntityPlayerSP wpqgx
class net.minecraft.entity.player.EntityPlayer obzsyig class net.minecraft.entity.player.EntityPlayer obzsyig
class net.minecraft.world.World oisywi 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.client.multiplayer.WorldClient wwhgz
class net.minecraft.entity.Entity ddvqd 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 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.network.PacketBuffer dcuglw
class net.minecraft.client.multiplayer.PlayerControllerMP tdsh class net.minecraft.client.multiplayer.PlayerControllerMP tdsh
class net.minecraft.client.gui.GuiScreen bsh class net.minecraft.client.gui.GuiScreen bsh
+3 -3
View File
@@ -119,11 +119,11 @@ Say "rsx-launcher-patch.jar ($((Get-Item (Join-Path $Dist 'rsx-launcher-patch.ja
# ---------------------------------------------------------------- mod template # ---------------------------------------------------------------- mod template
$tpl = Join-Path $Dist 'mod-template' $tpl = Join-Path $Dist 'mod-template'
Remove-Item $tpl -Recurse -Force -ErrorAction SilentlyContinue 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\build.gradle') $tpl -Force
Copy-Item (Join-Path $Repo 'mod-template\settings.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') ` Copy-Item (Join-Path $Repo 'mod-template\src\main\java\com\example\ExampleMod.java') `
"$tpl\src\main\java\example\" -Force "$tpl\src\main\java\com\example\" -Force
Copy-Item (Join-Path $Dist 'rsx-api.jar') "$tpl\libs\" -Force Copy-Item (Join-Path $Dist 'rsx-api.jar') "$tpl\libs\" -Force
@' @'
@echo off @echo off