Несколько модов одновременно и живая работа с игрой
Главное: модлоадер физически не умел запускать больше одного мода. Родной
загрузчик игры запоминает тик-хук в статическом поле, которое перезаписывается
на каждой загрузке:
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:
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
// Test stand-in for net.minecraft.client.gui.GuiScreen: the profile types
|
||||
// currentScreen as Lbsh;, and Rsx.on("screenOpen"/"screenClose") fires when that
|
||||
// field changes.
|
||||
|
||||
public class bsh {
|
||||
}
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,11 @@
|
||||
// loads the entry class `special.Main`, calls its main(String[]) and later
|
||||
// invokes its static getRunnableList() from the game tick. This stand-in has the
|
||||
// same shape so the bridge can be exercised without launching the game.
|
||||
//
|
||||
// Two details matter and are reproduced exactly, because the bridge has to cope
|
||||
// with them: the vendor builds a *new* loader per jar (so mods cannot see each
|
||||
// other), and it keeps the tick hook in a *static field* that every call
|
||||
// overwrites (so only the last mod's getRunnableList is ever called).
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
@@ -15,7 +20,9 @@ public class ilxq {
|
||||
public static boolean loadJar(byte[] jar, String[] args) throws Exception {
|
||||
System.out.println("[fake] loading mod jar, " + jar.length + " bytes, args="
|
||||
+ java.util.Arrays.toString(args));
|
||||
jarLoader(jar);
|
||||
// the vendor does `do = new ilxq()` on every call
|
||||
MemLoader loader = new MemLoader(ilxq.class.getClassLoader());
|
||||
jarLoader(loader, jar);
|
||||
Class<?> main = loader.loadClass("special.Main");
|
||||
Method m = main.getDeclaredMethod("main", String[].class);
|
||||
m.setAccessible(true);
|
||||
@@ -42,9 +49,7 @@ public class ilxq {
|
||||
}
|
||||
}
|
||||
|
||||
static MemLoader loader = new MemLoader(ilxq.class.getClassLoader());
|
||||
|
||||
static void jarLoader(byte[] jar) throws Exception {
|
||||
static void jarLoader(MemLoader loader, byte[] jar) throws Exception {
|
||||
java.util.zip.ZipInputStream zin =
|
||||
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(jar));
|
||||
java.util.zip.ZipEntry e;
|
||||
@@ -76,4 +81,4 @@ public class ilxq {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,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;
|
||||
}
|
||||
}
|
||||
@@ -4,31 +4,72 @@ import net.rustexremake.api.Rsx;
|
||||
import net.rustexremake.api.RsxMod;
|
||||
|
||||
/**
|
||||
* Example mod. It is compiled against the stable API only, so it does not have
|
||||
* to be rebuilt when the vendor re-obfuscates the client.
|
||||
* First of three mods loaded together.
|
||||
*
|
||||
* It is loaded first, so the vendor loader will not remember its
|
||||
* getRunnableList(): its work has to run through the single shared drain. That is
|
||||
* exactly the case that used to silently do nothing.
|
||||
*/
|
||||
public class ExampleMod implements RsxMod {
|
||||
|
||||
private int ticks;
|
||||
public static int ticks;
|
||||
public static int posts;
|
||||
public static int errors;
|
||||
public static int tickEvents;
|
||||
public static int screenOpens;
|
||||
|
||||
@Override
|
||||
public void onLoad() {
|
||||
Rsx.info("ExampleMod: loaded on build " + Rsx.buildId());
|
||||
Rsx.info("ExampleMod: Minecraft is " + Rsx.describe("net.minecraft.client.Minecraft"));
|
||||
|
||||
// a repeating task runs on the game thread, driven by the client's own
|
||||
// tick, so touching game state here is safe
|
||||
Rsx.info("ExampleMod: loaded on build " + Rsx.buildId()
|
||||
+ " api=" + Rsx.apiVersion());
|
||||
Rsx.on("screenOpen", new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
screenOpens++;
|
||||
Rsx.info("ExampleMod screenOpen " + screenOpens);
|
||||
}
|
||||
});
|
||||
Rsx.everyTicks(1, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
tickEvents++;
|
||||
}
|
||||
});
|
||||
Rsx.every(200L, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
ticks++;
|
||||
Object mc = Rsx.minecraft();
|
||||
Object player = Rsx.player();
|
||||
Rsx.info("ExampleMod tick " + ticks
|
||||
+ " client=" + (mc != null)
|
||||
+ " player=" + player
|
||||
+ " world=" + Rsx.world());
|
||||
+ " player=" + Rsx.player().getName()
|
||||
+ " x=" + Rsx.player().getX()
|
||||
+ " y=" + Rsx.player().getY()
|
||||
+ " time=" + Rsx.world().getTime()
|
||||
+ " screen=" + (Rsx.currentScreen() != null)
|
||||
+ " bridgeTick=" + Rsx.tick());
|
||||
if (ticks == 1) {
|
||||
// a task that always throws: the bridge must notice, report it
|
||||
// to onError, and eventually drop it instead of flooding
|
||||
Rsx.every("boom", 50L, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
throw new IllegalStateException("mod task exploded");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Rsx.onGameThread(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
posts++;
|
||||
Rsx.info("ExampleMod posted task ran, posts=" + posts);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
errors++;
|
||||
Rsx.info("ExampleMod onError #" + errors + ": " + error.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package example;
|
||||
|
||||
import net.rustexremake.api.Rsx;
|
||||
import net.rustexremake.api.RsxMod;
|
||||
|
||||
/**
|
||||
* Second mod, loaded in the middle.
|
||||
*
|
||||
* The vendor loader overwrites its static tick-hook field on every load, so this
|
||||
* mod's getRunnableList() is never called. Its task still has to run.
|
||||
*/
|
||||
public class SecondMod implements RsxMod {
|
||||
|
||||
public static int runs;
|
||||
|
||||
@Override
|
||||
public void onLoad() {
|
||||
Rsx.info("SecondMod: loaded");
|
||||
Rsx.everyTicks(1, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
runs++;
|
||||
Rsx.info("SecondMod run " + runs);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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 выгружен");
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user