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

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

    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 вернуть всё как было
`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
@@ -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);
}
}
@@ -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() {
}
}
}
+396 -48
View File
@@ -13,7 +13,8 @@ import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* Loads mods through the client's own code loader.
* Loads mods through the client's own code loader and runs their work on the
* game thread.
*
* The client already ships a loader: it takes a jar, finds the entry class
* `special.Main`, calls its static main(String[]) and later invokes the static
@@ -28,17 +29,108 @@ import java.util.zip.ZipOutputStream;
* implement one interface. The entry point travels to the bridge through the
* argument array the loader passes through, so no manifest parsing is needed at
* runtime.
*
* Two properties of the vendor loader shape everything here:
*
* - it creates a *new* class loader per call, so mods cannot see each other's
* classes and never collide on `special.Main`;
* - it remembers the tick hook in a *static field* that every call overwrites,
* so only the last mod loaded ever gets its getRunnableList() called.
*
* Therefore there is exactly one queue and exactly one drain: whoever the game
* calls runs the work of every mod, which is what makes several mods work at
* all.
*/
public final class ModBridge {
private static final Map<ClassLoader, String> NAMES =
new ConcurrentHashMap<ClassLoader, String>();
private static final Map<ClassLoader, List<Runnable>> QUEUE =
new ConcurrentHashMap<ClassLoader, List<Runnable>>();
private static final Map<ClassLoader, Runnable> DRAIN =
new ConcurrentHashMap<ClassLoader, Runnable>();
/** A mod that registered itself, kept so errors and unload can reach it. */
private static final class ModHandle {
final ClassLoader loader;
final String name;
final String entry;
Class<?> api;
Object instance;
int version = 1;
ModHandle(ClassLoader loader, String name, String entry) {
this.loader = loader;
this.name = name;
this.entry = entry;
}
}
/** A unit of work queued for the game thread. */
private static final class Task {
final String mod;
final String label;
final Runnable body;
Task(String mod, String label, Runnable body) {
this.mod = mod;
this.label = label;
this.body = body;
}
String display() {
return label == null ? mod : mod + "/" + label;
}
}
/** Work measured in ticks instead of milliseconds. */
private static final class TickTask {
final String owner;
final Runnable body;
final int every;
long next;
TickTask(String owner, Runnable body, int every) {
this.owner = owner;
this.body = body;
this.every = Math.max(1, every);
}
}
/** A named callback fired when game state changes. */
private static final class Handler {
final String owner;
final String event;
final Runnable body;
Handler(String owner, String event, Runnable body) {
this.owner = owner;
this.event = event;
this.body = body;
}
}
/** A task is dropped after this many failures, so a broken mod cannot flood the log. */
private static final int MAX_FAILURES = 5;
private static final Object LOCK = new Object();
private static final List<ModHandle> MODS = new ArrayList<ModHandle>();
private static final List<Task> QUEUE = new ArrayList<Task>();
private static final List<TickTask> TICKS = new ArrayList<TickTask>();
private static final List<Handler> HANDLERS = new ArrayList<Handler>();
private static final List<String> LOADED = new ArrayList<String>();
// Keyed by the task itself, not by the queue entry: the queue is emptied
// every tick, so a counter living there would reset before it ever reached
// the limit and a failing mod would be re-queued forever.
private static final java.util.Map<Runnable, Integer> FAILURES =
new java.util.IdentityHashMap<Runnable, Integer>();
private static final java.util.Set<Runnable> DROPPED =
java.util.Collections.newSetFromMap(
new java.util.IdentityHashMap<Runnable, Boolean>());
/** Whoever is running right now, so a task knows which mod it belongs to. */
private static final ThreadLocal<String> OWNER = new ThreadLocal<String>();
private static long tickCount;
private static Object lastWorld;
private static Object lastPlayer;
private static Object lastScreen;
private static boolean stateReady;
private static final java.util.concurrent.ScheduledExecutorService TIMER =
java.util.concurrent.Executors.newSingleThreadScheduledExecutor(
new java.util.concurrent.ThreadFactory() {
@@ -50,13 +142,23 @@ public final class ModBridge {
}
});
/**
* The single drain the game may call. It is the same object for every mod,
* so whichever getRunnableList() the vendor kept still runs all of them.
*/
private static final Runnable DRAIN = new Runnable() {
@Override
public void run() {
tick();
}
};
/** Invoked by the injected special.Main, during jar loading. */
public static void launch(Class<?> entry, String[] args) {
ClassLoader cl = entry.getClassLoader();
String modName = args != null && args.length > 0 ? args[0] : "unknown";
String entryName = args != null && args.length > 1 ? args[1] : null;
NAMES.put(cl, modName);
QUEUE.put(cl, new ArrayList<Runnable>());
ModHandle handle = new ModHandle(cl, modName, entryName);
try {
if (entryName == null || entryName.isEmpty()) {
Log.warn("mod " + modName + ": no entry point");
@@ -69,58 +171,110 @@ public final class ModBridge {
+ " does not implement RsxMod");
return;
}
api.getMethod("onLoad").invoke(impl);
LOADED.add(modName);
handle.api = api;
handle.instance = impl;
synchronized (LOCK) {
MODS.add(handle);
}
OWNER.set(modName);
try {
checkApiVersion(handle);
api.getMethod("onLoad").invoke(impl);
} finally {
OWNER.remove();
}
synchronized (LOCK) {
LOADED.add(modName);
}
Log.info("mod loaded: " + modName + " (" + entryName + ")");
} catch (Throwable t) {
OWNER.remove();
Log.error("mod " + modName + " failed to start", t);
}
}
private static void checkApiVersion(ModHandle handle) {
try {
int v = ((Integer) handle.api.getMethod("apiVersion")
.invoke(handle.instance)).intValue();
handle.version = v;
if (v != 1) {
Log.warn("mod " + handle.name + " targets API version " + v
+ " but this bridge speaks version 1; some calls may fail");
}
} catch (Throwable ignored) {
// a mod that does not override apiVersion inherits 1
}
}
/** Invoked by the injected special.Main, on the game thread. */
public static List<Runnable> drain(Class<?> entry) {
final ClassLoader cl = entry.getClassLoader();
Runnable r = DRAIN.get(cl);
if (r == null) {
r = new Runnable() {
@Override
public void run() {
runPending(cl);
}
};
DRAIN.put(cl, r);
}
List<Runnable> one = new ArrayList<Runnable>(1);
one.add(r);
one.add(DRAIN);
return one;
}
/** Queue a task to run on the game thread; callable from any thread. */
public static void post(Runnable task) {
for (List<Runnable> q : QUEUE.values()) {
synchronized (q) {
// coalesce: a slow tick must not let a repeating task pile up
for (Runnable r : q) {
if (r == task) {
return;
}
}
q.add(task);
}
enqueue(OWNER.get(), null, task);
}
/** Queue a named task, so a failure names the mod instead of "unknown". */
public static void post(String label, Runnable task) {
enqueue(OWNER.get(), label, task);
}
private static void enqueue(String owner, String label, Runnable task) {
if (task == null) {
return;
}
if (owner == null) {
owner = "?";
}
synchronized (LOCK) {
if (DROPPED.contains(task)) {
return;
}
for (Task t : QUEUE) {
// coalesce: a slow tick must not let a repeating task pile up
if (t.body == task) {
return;
}
}
QUEUE.add(new Task(owner, label, task));
}
}
/**
* Repeating task on the game thread. A timer thread only ever *posts*, so
* the callback itself always runs where touching game state is legal.
*
* The interval is wall-clock, so a lagging game thread makes it drift.
* Prefer everyTicks(int) for anything that has to stay in step with the game.
*/
public static void schedule(long millis, final Runnable task) {
schedule(null, millis, task);
}
/**
* Repeating task on the game thread. A timer thread only ever *posts*, so
* the callback itself always runs where touching game state is legal.
*
* The interval is wall-clock, so a lagging game thread makes it drift.
* Prefer everyTicks(int) for anything that has to stay in step with the game.
*/
public static void schedule(String label, long millis, final Runnable task) {
if (millis < 1L) {
millis = 1L;
}
// remember who asked while we are still inside their onLoad: by the time
// the timer fires we are on the timer thread, where nobody is "current"
final String owner = OWNER.get();
try {
TIMER.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
post(task);
enqueue(owner, label, task);
}
}, millis, millis, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (Throwable t) {
@@ -128,28 +282,222 @@ public final class ModBridge {
}
}
/** Repeating task counted in game ticks; never drifts, never bursts. */
public static void scheduleTicks(int every, Runnable task) {
if (task == null) {
return;
}
String owner = OWNER.get();
synchronized (LOCK) {
TICKS.add(new TickTask(owner == null ? "?" : owner, task, every));
}
}
/** Subscribe to a game event: tick, worldChange, playerJoin, playerLeave, screenOpen, screenClose. */
public static void on(String event, Runnable body) {
if (event == null || body == null) {
return;
}
String owner = OWNER.get();
synchronized (LOCK) {
HANDLERS.add(new Handler(owner == null ? "?" : owner, event, body));
}
}
/** Current game tick, counted by the bridge since the game came up. */
public static long tickCount() {
return tickCount;
}
public static List<String> loaded() {
return LOADED;
}
private static void runPending(ClassLoader cl) {
List<Runnable> q = QUEUE.get(cl);
if (q == null) {
return;
/** Tell every mod the game is going away, so it can drop its work. */
public static void shutdown() {
List<ModHandle> copy;
synchronized (LOCK) {
copy = new ArrayList<ModHandle>(MODS);
QUEUE.clear();
TICKS.clear();
HANDLERS.clear();
}
List<Runnable> copy;
synchronized (q) {
if (q.isEmpty()) {
for (ModHandle h : copy) {
if (h.api == null || h.instance == null) {
continue;
}
try {
OWNER.set(h.name);
h.api.getMethod("onUnload").invoke(h.instance);
} catch (Throwable t) {
Log.error("mod " + h.name + " failed to unload", t);
} finally {
OWNER.remove();
}
}
TIMER.shutdownNow();
Log.info("mods shut down: " + copy.size() + " notified");
}
// -------------------------------------------------------------- game tick
private static void tick() {
tickCount++;
fireEvents();
runTickTasks();
runQueuedTasks();
}
private static void fireEvents() {
List<Handler> handlers;
synchronized (LOCK) {
if (HANDLERS.isEmpty()) {
return;
}
copy = new ArrayList<Runnable>(q);
q.clear();
handlers = new ArrayList<Handler>(HANDLERS);
}
for (Runnable r : copy) {
try {
r.run();
} catch (Throwable t) {
Log.error("mod task failed in " + NAMES.get(cl), t);
fire(handlers, "tick");
Object world = read("net.minecraft.client.Minecraft", "world");
Object player = read("net.minecraft.client.Minecraft", "player");
Object screen = read("net.minecraft.client.Minecraft", "currentScreen");
if (world == null && player == null) {
// the client class is not resolvable yet, do not latch a wrong state
return;
}
if (!stateReady) {
lastWorld = world;
lastPlayer = player;
lastScreen = screen;
stateReady = true;
return;
}
if (world != lastWorld) {
lastWorld = world;
fire(handlers, "worldChange");
}
if (player != lastPlayer) {
lastPlayer = player;
fire(handlers, player == null ? "playerLeave" : "playerJoin");
}
if (screen != lastScreen) {
lastScreen = screen;
fire(handlers, screen == null ? "screenClose" : "screenOpen");
}
}
private static void fire(List<Handler> handlers, String event) {
for (Handler h : handlers) {
if (!h.event.equals(event)) {
continue;
}
run(h.owner, null, h.body);
}
}
private static Object read(String logical, String member) {
try {
if (Resolver.profile() == null) {
return null;
}
return Resolver.get(logical, member, Resolver.minecraftInstance());
} catch (Throwable ignored) {
return null;
}
}
private static void runTickTasks() {
List<TickTask> due = null;
synchronized (LOCK) {
if (TICKS.isEmpty()) {
return;
}
for (TickTask t : TICKS) {
if (tickCount < t.next) {
continue;
}
t.next = tickCount + t.every;
if (due == null) {
due = new ArrayList<TickTask>();
}
due.add(t);
}
}
for (TickTask t : due) {
run(t.owner, null, t.body);
}
}
private static void runQueuedTasks() {
List<Task> batch;
synchronized (LOCK) {
if (QUEUE.isEmpty()) {
return;
}
batch = new ArrayList<Task>(QUEUE);
QUEUE.clear();
}
for (Task t : batch) {
run(t.mod, t.label, t.body);
}
}
/**
* Run one piece of mod work on the game thread, counting its failures.
*
* After MAX_FAILURES consecutive throws the task is dropped for good: a mod
* that throws every tick would otherwise fill the log faster than anyone
* could read it.
*/
private static void run(String mod, String label, Runnable body) {
String previous = OWNER.get();
OWNER.set(mod);
try {
body.run();
synchronized (LOCK) {
FAILURES.remove(body);
}
} catch (Throwable t) {
String who = label == null ? mod : mod + "/" + label;
report(mod, who, t);
int n;
synchronized (LOCK) {
Integer had = FAILURES.get(body);
n = had == null ? 1 : had.intValue() + 1;
if (n >= MAX_FAILURES) {
DROPPED.add(body);
FAILURES.remove(body);
} else {
FAILURES.put(body, Integer.valueOf(n));
}
}
if (n >= MAX_FAILURES) {
Log.error("task of " + who + " failed " + n
+ " times in a row, dropping it", null);
}
} finally {
if (previous == null) {
OWNER.remove();
} else {
OWNER.set(previous);
}
}
}
private static void report(String mod, String who, Throwable t) {
Log.error("mod task failed in " + who, t);
synchronized (LOCK) {
for (ModHandle h : MODS) {
if (!h.name.equals(mod) || h.api == null) {
continue;
}
try {
h.api.getMethod("onError", Throwable.class)
.invoke(h.instance, t);
} catch (Throwable ignored) {
// the default implementation only logs, and it already did
}
return;
}
}
}
+5
View File
@@ -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);
+25 -5
View File
@@ -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");
}
}
}
+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
// 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 {
}
}
}
}
}
+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;
/**
* Example mod. It is compiled against the stable API only, so it does not have
* to be rebuilt when the vendor re-obfuscates the client.
* First of three mods loaded together.
*
* It is loaded first, so the vendor loader will not remember its
* getRunnableList(): its work has to run through the single shared drain. That is
* exactly the case that used to silently do nothing.
*/
public class ExampleMod implements RsxMod {
private int ticks;
public static int ticks;
public static int posts;
public static int errors;
public static int tickEvents;
public static int screenOpens;
@Override
public void onLoad() {
Rsx.info("ExampleMod: loaded on build " + Rsx.buildId());
Rsx.info("ExampleMod: Minecraft is " + Rsx.describe("net.minecraft.client.Minecraft"));
// a repeating task runs on the game thread, driven by the client's own
// tick, so touching game state here is safe
Rsx.info("ExampleMod: loaded on build " + Rsx.buildId()
+ " api=" + Rsx.apiVersion());
Rsx.on("screenOpen", new Runnable() {
@Override
public void run() {
screenOpens++;
Rsx.info("ExampleMod screenOpen " + screenOpens);
}
});
Rsx.everyTicks(1, new Runnable() {
@Override
public void run() {
tickEvents++;
}
});
Rsx.every(200L, new Runnable() {
@Override
public void run() {
ticks++;
Object mc = Rsx.minecraft();
Object player = Rsx.player();
Rsx.info("ExampleMod tick " + ticks
+ " client=" + (mc != null)
+ " player=" + player
+ " world=" + Rsx.world());
+ " player=" + Rsx.player().getName()
+ " x=" + Rsx.player().getX()
+ " y=" + Rsx.player().getY()
+ " time=" + Rsx.world().getTime()
+ " screen=" + (Rsx.currentScreen() != null)
+ " bridgeTick=" + Rsx.tick());
if (ticks == 1) {
// a task that always throws: the bridge must notice, report it
// to onError, and eventually drop it instead of flooding
Rsx.every("boom", 50L, new Runnable() {
@Override
public void run() {
throw new IllegalStateException("mod task exploded");
}
});
}
}
});
Rsx.onGameThread(new Runnable() {
@Override
public void run() {
posts++;
Rsx.info("ExampleMod posted task ran, posts=" + posts);
}
});
}
}
@Override
public void onError(Throwable error) {
errors++;
Rsx.info("ExampleMod onError #" + errors + ": " + error.getMessage());
}
}
+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
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())
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.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
+3 -3
View File
@@ -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