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

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

    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
@@ -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);
}
}