From cc1eaece40f1e5ca7645cb94d67aceb31a332b85 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Sat, 17 Oct 2026 02:25:27 +0300 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF=D0=9D=D0=B5=D1=81=D0=BA=D0=BE=D0=BB?= =?UTF-8?q?=D1=8C=D0=BA=D0=BE=20=D0=BC=D0=BE=D0=B4=D0=BE=D0=B2=20=D0=BE?= =?UTF-8?q?=D0=B4=D0=BD=D0=BE=D0=B2=D1=80=D0=B5=D0=BC=D0=B5=D0=BD=D0=BD?= =?UTF-8?q?=D0=BE=20=D0=B8=20=D0=B6=D0=B8=D0=B2=D0=B0=D1=8F=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B1=D0=BE=D1=82=D0=B0=20=D1=81=20=D0=B8=D0=B3=D1=80=D0=BE?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Главное: модлоадер физически не умел запускать больше одного мода. Родной загрузчик игры запоминает тик-хук в статическом поле, которое перезаписывается на каждой загрузке: 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 на все моды, и такой тест не мог поймать проблему с изоляцией. --- README.md | 61 ++- .../main/java/net/rustexremake/api/Rsx.java | 212 ++++++++- .../java/net/rustexremake/api/RsxMod.java | 33 +- bridge/src/main/java/rsx/ModBridge.java | 444 ++++++++++++++++-- bridge/src/main/java/rsx/Resolver.java | 5 + bridge/src/test/fakeclient/FakeMain.java | 30 +- bridge/src/test/fakeclient/bsh.java | 6 + bridge/src/test/fakeclient/ddvqd.java | 34 ++ bridge/src/test/fakeclient/ilxq.java | 15 +- bridge/src/test/fakeclient/oisywi.java | 17 + bridge/src/test/mod/ExampleMod.java | 69 ++- bridge/src/test/mod/SecondMod.java | 27 ++ bridge/src/test/mod/ThirdMod.java | 25 + bridge/src/test/python/run_test.py | 113 +++-- .../src/main/java/com/example/ExampleMod.java | 77 +++ .../src/main/java/example/ExampleMod.java | 39 -- profiles/latest.json | 216 +++++++++ profiles/latest.rsxp | 12 + scripts/build.ps1 | 6 +- 19 files changed, 1283 insertions(+), 158 deletions(-) create mode 100644 bridge/src/test/fakeclient/bsh.java create mode 100644 bridge/src/test/fakeclient/ddvqd.java create mode 100644 bridge/src/test/fakeclient/oisywi.java create mode 100644 bridge/src/test/mod/SecondMod.java create mode 100644 bridge/src/test/mod/ThirdMod.java create mode 100644 mod-template/src/main/java/com/example/ExampleMod.java delete mode 100644 mod-template/src/main/java/example/ExampleMod.java create mode 100644 profiles/latest.json diff --git a/README.md b/README.md index ce44988..a6e3008 100644 --- a/README.md +++ b/README.md @@ -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,57 @@ jar (payload начинается с offset 65024 и идёт до EOF). Заг лаунчер перестаёт находить игру. Все правки идут через `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 diff --git a/bridge/src/main/java/net/rustexremake/api/Rsx.java b/bridge/src/main/java/net/rustexremake/api/Rsx.java index ada92b7..dc86d90 100644 --- a/bridge/src/main/java/net/rustexremake/api/Rsx.java +++ b/bridge/src/main/java/net/rustexremake/api/Rsx.java @@ -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,49 @@ 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(); + } + // ------------------------------------------------------------------ logs public static void info(String msg) { rsx.Log.info(msg); @@ -120,4 +304,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); + } } \ No newline at end of file diff --git a/bridge/src/main/java/net/rustexremake/api/RsxMod.java b/bridge/src/main/java/net/rustexremake/api/RsxMod.java index 4ab58e8..8d5d759 100644 --- a/bridge/src/main/java/net/rustexremake/api/RsxMod.java +++ b/bridge/src/main/java/net/rustexremake/api/RsxMod.java @@ -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() { } -} \ No newline at end of file +} diff --git a/bridge/src/main/java/rsx/ModBridge.java b/bridge/src/main/java/rsx/ModBridge.java index 122f5fa..3e173d2 100644 --- a/bridge/src/main/java/rsx/ModBridge.java +++ b/bridge/src/main/java/rsx/ModBridge.java @@ -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 NAMES = - new ConcurrentHashMap(); - private static final Map> QUEUE = - new ConcurrentHashMap>(); - private static final Map DRAIN = - new ConcurrentHashMap(); + /** 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 MODS = new ArrayList(); + private static final List QUEUE = new ArrayList(); + private static final List TICKS = new ArrayList(); + private static final List HANDLERS = new ArrayList(); private static final List LOADED = new ArrayList(); + // 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 FAILURES = + new java.util.IdentityHashMap(); + private static final java.util.Set DROPPED = + java.util.Collections.newSetFromMap( + new java.util.IdentityHashMap()); + + /** Whoever is running right now, so a task knows which mod it belongs to. */ + private static final ThreadLocal OWNER = new ThreadLocal(); + + 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()); + 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 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 one = new ArrayList(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 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 loaded() { return LOADED; } - private static void runPending(ClassLoader cl) { - List 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 copy; + synchronized (LOCK) { + copy = new ArrayList(MODS); + QUEUE.clear(); + TICKS.clear(); + HANDLERS.clear(); } - List 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 handlers; + synchronized (LOCK) { + if (HANDLERS.isEmpty()) { return; } - copy = new ArrayList(q); - q.clear(); + handlers = new ArrayList(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 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 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(); + } + due.add(t); + } + } + for (TickTask t : due) { + run(t.owner, null, t.body); + } + } + + private static void runQueuedTasks() { + List batch; + synchronized (LOCK) { + if (QUEUE.isEmpty()) { + return; + } + batch = new ArrayList(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; } } } diff --git a/bridge/src/main/java/rsx/Resolver.java b/bridge/src/main/java/rsx/Resolver.java index a10b17e..4b5032f 100644 --- a/bridge/src/main/java/rsx/Resolver.java +++ b/bridge/src/main/java/rsx/Resolver.java @@ -279,6 +279,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); diff --git a/bridge/src/test/fakeclient/FakeMain.java b/bridge/src/test/fakeclient/FakeMain.java index a6c7886..6542906 100644 --- a/bridge/src/test/fakeclient/FakeMain.java +++ b/bridge/src/test/fakeclient/FakeMain.java @@ -1,5 +1,10 @@ // 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. public class FakeMain { @@ -9,8 +14,15 @@ 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"; @@ -18,11 +30,19 @@ public class FakeMain { System.out.println("[fake] client up, telemetry scheduled"); 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); } System.out.println("[fake] done"); } -} \ No newline at end of file +} diff --git a/bridge/src/test/fakeclient/bsh.java b/bridge/src/test/fakeclient/bsh.java new file mode 100644 index 0000000..3898c79 --- /dev/null +++ b/bridge/src/test/fakeclient/bsh.java @@ -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 { +} diff --git a/bridge/src/test/fakeclient/ddvqd.java b/bridge/src/test/fakeclient/ddvqd.java new file mode 100644 index 0000000..7715d13 --- /dev/null +++ b/bridge/src/test/fakeclient/ddvqd.java @@ -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; + } +} diff --git a/bridge/src/test/fakeclient/ilxq.java b/bridge/src/test/fakeclient/ilxq.java index 62913c1..5e669ec 100644 --- a/bridge/src/test/fakeclient/ilxq.java +++ b/bridge/src/test/fakeclient/ilxq.java @@ -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 { } } } -} \ No newline at end of file +} diff --git a/bridge/src/test/fakeclient/oisywi.java b/bridge/src/test/fakeclient/oisywi.java new file mode 100644 index 0000000..7fe90a1 --- /dev/null +++ b/bridge/src/test/fakeclient/oisywi.java @@ -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; + } +} diff --git a/bridge/src/test/mod/ExampleMod.java b/bridge/src/test/mod/ExampleMod.java index e50402e..39cd9fe 100644 --- a/bridge/src/test/mod/ExampleMod.java +++ b/bridge/src/test/mod/ExampleMod.java @@ -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); } }); } -} \ No newline at end of file + + @Override + public void onError(Throwable error) { + errors++; + Rsx.info("ExampleMod onError #" + errors + ": " + error.getMessage()); + } +} diff --git a/bridge/src/test/mod/SecondMod.java b/bridge/src/test/mod/SecondMod.java new file mode 100644 index 0000000..fae8005 --- /dev/null +++ b/bridge/src/test/mod/SecondMod.java @@ -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); + } + }); + } +} diff --git a/bridge/src/test/mod/ThirdMod.java b/bridge/src/test/mod/ThirdMod.java new file mode 100644 index 0000000..d5ae309 --- /dev/null +++ b/bridge/src/test/mod/ThirdMod.java @@ -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); + } + }); + } +} diff --git a/bridge/src/test/python/run_test.py b/bridge/src/test/python/run_test.py index 9a63103..8752f77 100644 --- a/bridge/src/test/python/run_test.py +++ b/bridge/src/test/python/run_test.py @@ -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,13 @@ 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"), +] + def sh(*args, **kw): return subprocess.run(args, capture_output=True, text=True, **kw) @@ -78,43 +91,61 @@ 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 ==") + print("== compiling the mods ==") 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") 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 +164,26 @@ 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", ]) + "\n" with open(os.path.join(bridge, "profiles", "latest.rsxp"), "w", encoding="utf-8") as fh: @@ -169,9 +212,25 @@ 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 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), ] bad = 0 @@ -187,4 +246,4 @@ def main(): if __name__ == "__main__": - sys.exit(main()) \ No newline at end of file + sys.exit(main()) diff --git a/mod-template/src/main/java/com/example/ExampleMod.java b/mod-template/src/main/java/com/example/ExampleMod.java new file mode 100644 index 0000000..3d8074f --- /dev/null +++ b/mod-template/src/main/java/com/example/ExampleMod.java @@ -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 выгружается"); + } +} diff --git a/mod-template/src/main/java/example/ExampleMod.java b/mod-template/src/main/java/example/ExampleMod.java deleted file mode 100644 index 2e6301c..0000000 --- a/mod-template/src/main/java/example/ExampleMod.java +++ /dev/null @@ -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 выгружен"); - } -} \ No newline at end of file diff --git a/profiles/latest.json b/profiles/latest.json new file mode 100644 index 0000000..d1fecbd --- /dev/null +++ b/profiles/latest.json @@ -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" + } +} \ No newline at end of file diff --git a/profiles/latest.rsxp b/profiles/latest.rsxp index b319bd8..7974800 100644 --- a/profiles/latest.rsxp +++ b/profiles/latest.rsxp @@ -17,9 +17,21 @@ 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.multiplayer.PlayerControllerMP tdsh class net.minecraft.client.gui.GuiScreen bsh diff --git a/scripts/build.ps1 b/scripts/build.ps1 index ff046ab..dc5a75a 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -119,11 +119,11 @@ 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