commit b8e5df778e92a6d166041f4dcf44feea14152398 Author: SashegDev Date: Sat Oct 17 00:32:10 2026 +0300 Модлоадер RustexRemake: агент, патч лаунчера, установщик и сборка Моды грузятся через -javaagent, game.jar не патчится, переживают ре-обфускацию. Что внутри: - агент rsx-bridge.jar: профиль сборки игры, патчи A2/A6, загрузка модов через родной загрузчик игры (ilxq -> special.Main) - патч лаунчера: окно выбора «С модами / Ванилла» по кнопке «Играть» и защита от автоапдейта лаунчера - install/uninstall: всё ставится в %LOCALAPPDATA%\rsx-mod, пути не хардкодятся - build: три готовых артефакта в dist, нужен только JDK - testserver: локальный сервер, принимает любой ник и токен Проверено на живом клиенте: мод грузится и выполняется в игровом потоке, в логе видно игрока и мир. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aeb77f1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Build output +build/ +dist/ +.gradle/ +*.class + +# IDE +.idea/ +*.iml +.vscode/ +.settings/ +.classpath +.project + +# OS +Thumbs.db +desktop.ini + +# Python +__pycache__/ +*.pyc + +# Nothing machine-specific belongs here: the profile is generated by +# prepare-build.cmd on the machine that has the game installed. +*.rsxp +hs_err_pid*.log +replay_pid*.log \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d9cb815 --- /dev/null +++ b/LICENSE @@ -0,0 +1,29 @@ +MIT License + +Copyright (c) 2026 rsx-modloader contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +--- + +This project bundles ASM (third_party/asm), which is distributed under its own +BSD 3-Clause license: https://asm.ow2.io/license.html + +RustexRemake and the KJST Launcher are third-party products and are not part of +this project. This tool only attaches to the client on the user's own machine. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a8a4ba --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# rsx-modloader + +Модлоадер для RustexRemake: Java-моды в папке `mods`, подключаются через +`-javaagent`, `game.jar` не патчится. Один раз поставил — моды переживают +обновление игры. + +В лаунчере появляется выбор режима прямо на кнопке «Играть»: **с модами** или +**ванилла**. + +``` +mod-template\ пример мода, собирается один раз +bridge\ агент (-javaagent) и его исходники +launcher-patch\ патч лаунчера: окно выбора и защита от автоапдейта +testserver\ локальный сервер, принимает любой ник и токен +scripts\ сборка, установка, запуск, удаление +third_party\asm\ ASM (BSD-3), зашит внутрь rsx-launcher-patch.jar +``` + +## Установка + + build.cmd собрать dist\ (нужен только JDK) + install.cmd поставить модлоадер + start-launcher.cmd запускать лаунчер + uninstall.cmd вернуть всё как было + +`install.cmd` копирует рантайм в `%LOCALAPPDATA%\rsx-mod`, патчит `app.jar` +лаунчера и прописывает `-javaagent` в его конфиг. Пути можно переопределить: + + install.cmd --home D:\rsx-mod --game D:\RustexRemake + +**Лаунчер должен быть закрыт.** Если он запущен с правами администратора, +`app.jar` не перезаписать — установщик об этом скажет. + +Запускай лаунчер через `start-launcher.cmd`, а не ярлыком: `updater.exe` +сверяет хеш `app.jar` с манифестом и при патченом jar заново скачивает +официальный, стирая патч. + +## Как это работает + +* **Агент** (`rsx-bridge.jar`) подключается через `-javaagent` и в `premain` + читает профиль сборки игры (`profiles/latest.rsxp`): хеш jar, обфусцированные + имена нужных классов и полей. Ничего не хардкодится под конкретную сборку. +* **Патчи клиента** — A2 ставит `joml.fastmath` до загрузки JOML, A6 + подменяет таймер телеметрии на «глухой». Оба сделаны на живом клиенте: + первые версии A6 роняли игру с `NullPointerException` и + `Timer already cancelled`. +* **Моды** грузятся родным загрузчиком игры (`ilxq` → `special.Main`) — так же, + как моды с сервера, и выполняются в игровом потоке. +* **Переносимость модов.** Мод собирается один раз против стабильного API. + Мост сам переводит логические имена в имена текущей сборки, поэтому + ре-обфускация вендора не ломает уже собранные моды. + +## Патч лаунчера + +Лаунчер — сторонний продукт, и у него две особенности, которые мешают: + +* **Гонка за состоянием кнопки.** `xa.run()` вызывает `ha.y()` (ставит `PLAY`), + а `fa.loadedWeb()` позже ставит `ERROR`. Состояния — это индексы кнопок в + `web/launcher/main.html`, их шесть, поэтому `ERROR=6` скрывает все кнопки. + При медленной загрузке страницы `loadedWeb` затирает `PLAY` — кнопки просто + нет. Патч восстанавливает состояние после `loadedWeb`. +* **Автоапдейтер.** Патченый `app.jar` отличается от манифеста, и лаунчер + считает себя устаревшим в двух местах: `sb.q()` (размер и хеш файла) и + `ha` (хеш рабочего jar, отсюда «обновление найдено, перезапустите лаунчер»). + Оба закрыты через `rsx.Patch`, который действует только на файл с именем + `app.jar` и только при наличии маркера `.rsx-patched`. + +Возврат к обычному поведению: удалить `.rsx-patched` или запустить лаунчер с +`-Drsx.allowLauncherUpdate=true`. + +## Конфиг лаунчера — осторожно + +`launcher_config.properties` — это properties-файл: слэш экранирует следующий +символ, а `\r` читается как перевод строки. Значение `gamePath1=G:\rustexremake` +без экранирования превращается в `G:` + перевод строки + `ustexremake`, и +лаунчер перестаёт находить игру. Все правки идут через +`java.util.Properties` — в патчере есть команды `getprop` и `setprop`. + +## Тест-сервер + + testserver\start.cmd + +Локальный сервер: принимает любой ник и токен, отвечает на `/server-ping`, +слушает порты 25566–25573 и закидывает в пустой мир. Проверка без игры: + + python testserver\selftest.py + +Адрес сервера зашит в коде клиента, поэтому для полного захода в мир нужен +редирект (PowerShell **от администратора**): + + scripts\install-hosts.ps1 прописать 127.0.0.1 для адреса клиента + scripts\uninstall-hosts.ps1 откатить + +## Сборка + + build.cmd + +Нужен только JDK. ASM лежит в `third_party\asm` и зашивается внутрь +`rsx-launcher-patch.jar`, поэтому устанавливать модлоадер можно без Gradle и +без сети. + +Готовые файлы кладутся в `dist\`: + +| файл | что это | +|---|---| +| `rsx-bridge.jar` | агент, Java 8 | +| `rsx-api.jar` | API, против которого собирается мод | +| `rsx-launcher-patch.jar` | патчер лаунчера, всё внутри | +| `mod-template\` | заготовка мода | + +## Тесты + + python bridge\src\test\python\run_test.py + +Поднимает фейковый клиент, проверяет профиль, A2, A6, загрузку мода и то, что +мод выполняется в игровом потоке, а телеметрия не срабатывает. + +--- + +RustexRemake и KJST Launcher — сторонние продукты, они не входят в этот проект. +Инструмент работает только на машине пользователя. ASM распространяется по +лицензии BSD-3, см. `LICENSE`. \ No newline at end of file diff --git a/bridge/build.gradle b/bridge/build.gradle new file mode 100644 index 0000000..176cd5e --- /dev/null +++ b/bridge/build.gradle @@ -0,0 +1,47 @@ +// rsx-bridge: the mod loader that is attached to the client through -javaagent. +// +// Compiled for Java 8 because the client ships its own 8u51 runtime and nothing +// newer is available on that JVM. No external dependencies on purpose: the jar +// is loaded into a process we do not control, so every dependency is a risk. + +plugins { + id 'java' +} + +group = 'net.rustexremake' +version = '1.0.0' + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release = 8 +} + +jar { + archiveFileName = 'rsx-bridge.jar' + manifest { + attributes( + 'Premain-Class': 'rsx.RsxAgent', + 'Agent-Class': 'rsx.RsxAgent', + 'Can-Redefine-Classes': 'false', + 'Can-Retransform-Classes': 'false', + 'Implementation-Title': 'RustexRemake mod bridge', + 'Implementation-Version': project.version + ) + } +} + +// a copy of the profile for the build that is installed, so the installer only +// has to drop two files next to the jar +tasks.register('installLayout', Copy) { + from jar + from "${rootDir}/../rsx-map/profiles" { + include 'latest.rsxp' + rename { 'profile.rsxp' } + } + into "${rootDir}/build/install" +} \ No newline at end of file diff --git a/bridge/settings.gradle b/bridge/settings.gradle new file mode 100644 index 0000000..4a60c9d --- /dev/null +++ b/bridge/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'rsx-bridge' \ No newline at end of file diff --git a/bridge/src/main/java/net/rustexremake/api/Rsx.java b/bridge/src/main/java/net/rustexremake/api/Rsx.java new file mode 100644 index 0000000..ada92b7 --- /dev/null +++ b/bridge/src/main/java/net/rustexremake/api/Rsx.java @@ -0,0 +1,123 @@ +package net.rustexremake.api; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; + +import rsx.ModBridge; +import rsx.Resolver; + +/** + * Stable API for mods. + * + * Nothing in this class mentions an obfuscated name. A mod asks for + * "net.minecraft.client.Minecraft" and the bridge looks up what that class is + * called in the build that is currently installed, so a mod compiled once keeps + * working after the vendor renames everything again. + */ +public final class Rsx { + + private Rsx() { + } + + // ------------------------------------------------------------- identity + public static String buildId() { + return Resolver.profile() == null ? "unknown" + : Resolver.profile().buildId; + } + + public static String describe(String logical) { + return Resolver.profile() == null ? logical + : Resolver.profile().describe(logical); + } + + public static boolean ready() { + return Resolver.cls("net.minecraft.client.Minecraft") != null; + } + + // ------------------------------------------------------------- accessors + public static Object minecraft() { + 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()); + } + + public static Class classOf(String logical) { + return Resolver.cls(logical); + } + + public static Object get(String logicalClass, String member) { + return Resolver.get(logicalClass, member); + } + + public static Object get(String logicalClass, String member, Object instance) { + return Resolver.get(logicalClass, member, instance); + } + + public static void set(String logicalClass, String member, Object instance, + Object value) { + Resolver.set(logicalClass, member, instance, value); + } + + public static Object call(String logicalClass, String member, Object... args) { + return Resolver.call(logicalClass, member, args); + } + + public static Object call(String logicalClass, String member, Object instance, + Object... args) { + return Resolver.call(logicalClass, member, instance, args); + } + + /** + * Reflective escape hatch for members the profile does not carry yet. + * Mods should prefer the named accessors above. + */ + public static Field field(Class owner, String name) { + try { + Field f = owner.getDeclaredField(name); + f.setAccessible(true); + return f; + } catch (Throwable t) { + return null; + } + } + + public static Method method(Class owner, String name, Class... args) { + try { + Method m = owner.getDeclaredMethod(name, args); + m.setAccessible(true); + return m; + } catch (Throwable t) { + return null; + } + } + + // -------------------------------------------------------------- threading + /** Run a task on the game thread, on the next tick. */ + public static void onGameThread(Runnable task) { + ModBridge.post(task); + } + + /** Run a task repeatedly on the game thread. */ + public static void every(long millis, final Runnable task) { + ModBridge.schedule(millis, task); + } + + // ------------------------------------------------------------------ logs + public static void info(String msg) { + rsx.Log.info(msg); + } + + public static void warn(String msg) { + rsx.Log.warn(msg); + } +} \ No newline at end of file diff --git a/bridge/src/main/java/net/rustexremake/api/RsxMod.java b/bridge/src/main/java/net/rustexremake/api/RsxMod.java new file mode 100644 index 0000000..4ab58e8 --- /dev/null +++ b/bridge/src/main/java/net/rustexremake/api/RsxMod.java @@ -0,0 +1,17 @@ +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. + */ +public interface RsxMod { + + void onLoad(); + + /** optional */ + default void onUnload() { + } +} \ No newline at end of file diff --git a/bridge/src/main/java/rsx/Log.java b/bridge/src/main/java/rsx/Log.java new file mode 100644 index 0000000..f4fd9c5 --- /dev/null +++ b/bridge/src/main/java/rsx/Log.java @@ -0,0 +1,62 @@ +package rsx; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.PrintStream; +import java.text.SimpleDateFormat; +import java.util.Date; + +/** + * Bridge log. The game runs windowed with javaw.exe, so stdout is not visible to + * anybody: everything goes to a file next to the bridge, and that file is the + * primary way to verify a mod actually ran. + */ +public final class Log { + + private static PrintStream out; + private static final SimpleDateFormat FMT = + new SimpleDateFormat("HH:mm:ss.SSS"); + + public static synchronized void open(File file) { + try { + File dir = file.getParentFile(); + if (dir != null) { + dir.mkdirs(); + } + out = new PrintStream(new FileOutputStream(file, true), true, "UTF-8"); + info("--- rsx bridge log opened: " + file.getAbsolutePath()); + } catch (Throwable t) { + out = null; + System.err.println("[rsx] cannot open log: " + t); + } + } + + public static synchronized void info(String msg) { + write("INFO", msg); + } + + public static synchronized void warn(String msg) { + write("WARN", msg); + } + + public static synchronized void error(String msg, Throwable t) { + Throwable root = t; + while (root != null && root.getCause() != null && root.getCause() != root) { + root = root.getCause(); + } + write("ERROR", msg + (t == null ? "" : " :: " + root)); + if (t != null && out != null) { + java.io.StringWriter sw = new java.io.StringWriter(); + t.printStackTrace(new java.io.PrintWriter(sw)); + out.println(sw.toString()); + } + } + + private static void write(String level, String msg) { + String line = FMT.format(new Date()) + " [" + level + "] " + msg; + System.out.println("[rsx] " + line); + if (out != null) { + out.println(line); + } + } +} \ No newline at end of file diff --git a/bridge/src/main/java/rsx/ModBridge.java b/bridge/src/main/java/rsx/ModBridge.java new file mode 100644 index 0000000..122f5fa --- /dev/null +++ b/bridge/src/main/java/rsx/ModBridge.java @@ -0,0 +1,250 @@ +package rsx; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.zip.ZipEntry; +import java.util.zip.ZipFile; +import java.util.zip.ZipOutputStream; + +/** + * Loads mods through the client's own code loader. + * + * 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 + * getRunnableList() from the game tick. That is the vendor's own supported way + * to run code, so reusing it means mods execute inside the game's class loader + * with full access - and nothing in the game jar has to change. + * + * A mod is a plain jar with a manifest entry + * Rsx-Mod-Entrypoint: com.example.MyMod + * whose class implements net.rustexremake.api.RsxMod. The bridge injects a + * generated `special.Main`, so mod authors never learn that contract: they just + * 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. + */ +public final class ModBridge { + + private static final Map NAMES = + new ConcurrentHashMap(); + private static final Map> QUEUE = + new ConcurrentHashMap>(); + private static final Map DRAIN = + new ConcurrentHashMap(); + private static final List LOADED = new ArrayList(); + + private static final java.util.concurrent.ScheduledExecutorService TIMER = + java.util.concurrent.Executors.newSingleThreadScheduledExecutor( + new java.util.concurrent.ThreadFactory() { + @Override + public Thread newThread(Runnable r) { + Thread t = new Thread(r, "rsx-mod-timer"); + t.setDaemon(true); + return t; + } + }); + + /** 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()); + try { + if (entryName == null || entryName.isEmpty()) { + Log.warn("mod " + modName + ": no entry point"); + return; + } + Class api = cl.loadClass("net.rustexremake.api.RsxMod"); + Object impl = cl.loadClass(entryName).newInstance(); + if (!api.isInstance(impl)) { + Log.warn("mod " + modName + ": " + entryName + + " does not implement RsxMod"); + return; + } + api.getMethod("onLoad").invoke(impl); + LOADED.add(modName); + Log.info("mod loaded: " + modName + " (" + entryName + ")"); + } catch (Throwable t) { + Log.error("mod " + modName + " failed to start", t); + } + } + + /** Invoked by the injected special.Main, on the game thread. */ + public static List drain(Class entry) { + final ClassLoader cl = entry.getClassLoader(); + Runnable r = DRAIN.get(cl); + if (r == null) { + r = new Runnable() { + @Override + public void run() { + runPending(cl); + } + }; + DRAIN.put(cl, r); + } + List one = new ArrayList(1); + one.add(r); + return one; + } + + /** Queue a task to run on the game thread; callable from any thread. */ + public static void post(Runnable task) { + for (List q : QUEUE.values()) { + synchronized (q) { + // coalesce: a slow tick must not let a repeating task pile up + for (Runnable r : q) { + if (r == task) { + return; + } + } + q.add(task); + } + return; + } + } + + /** + * Repeating task on the game thread. A timer thread only ever *posts*, so + * the callback itself always runs where touching game state is legal. + */ + public static void schedule(long millis, final Runnable task) { + try { + TIMER.scheduleAtFixedRate(new Runnable() { + @Override + public void run() { + post(task); + } + }, millis, millis, java.util.concurrent.TimeUnit.MILLISECONDS); + } catch (Throwable t) { + Log.error("cannot schedule repeating task", t); + } + } + + public static List loaded() { + return LOADED; + } + + private static void runPending(ClassLoader cl) { + List q = QUEUE.get(cl); + if (q == null) { + return; + } + List copy; + synchronized (q) { + if (q.isEmpty()) { + return; + } + copy = new ArrayList(q); + q.clear(); + } + for (Runnable r : copy) { + try { + r.run(); + } catch (Throwable t) { + Log.error("mod task failed in " + NAMES.get(cl), t); + } + } + } + + /** + * Build the jar to hand to the client's loader: the mod jar plus a generated + * special/Main.class that forwards into this bridge. + */ + public static byte[] prepare(File mod, byte[] specialMain, + String entryPoint) throws Exception { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ZipOutputStream zos = new ZipOutputStream(bos); + ZipFile zf = new ZipFile(mod); + try { + java.util.Enumeration en = zf.entries(); + while (en.hasMoreElements()) { + ZipEntry e = en.nextElement(); + if (e.isDirectory()) { + continue; + } + String n = e.getName(); + if ("META-INF/MANIFEST.MF".equals(n) || "META-INF/INDEX.LIST".equals(n)) { + continue; + } + // our generated entry point always wins + if ("special/Main.class".equals(n)) { + continue; + } + zos.putNextEntry(new ZipEntry(n)); + InputStream in = zf.getInputStream(e); + copy(in, zos); + zos.closeEntry(); + } + } finally { + zf.close(); + } + zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF")); + zos.write(("Manifest-Version: 1.0\r\n" + + "Rsx-Mod-Entrypoint: " + entryPoint + "\r\n\r\n") + .getBytes("UTF-8")); + zos.closeEntry(); + zos.putNextEntry(new ZipEntry("special/Main.class")); + zos.write(specialMain); + zos.closeEntry(); + zos.close(); + return bos.toByteArray(); + } + + /** Read the entry point a mod declares in its own manifest. */ + public static String entryPointOf(File mod) { + try { + ZipFile zf = new ZipFile(mod); + try { + ZipEntry me = zf.getEntry("META-INF/MANIFEST.MF"); + if (me == null) { + return null; + } + InputStream in = zf.getInputStream(me); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + bos.write(buf, 0, n); + } + in.close(); + // manifests are plain key/value lines; parsing them by hand + // avoids depending on java.util.jar, which is not reachable + // from every class loader the client may install + for (String line : new String(bos.toByteArray(), "UTF-8") + .split("\r?\n")) { + if (line.startsWith("Rsx-Mod-Entrypoint:")) { + return line.substring("Rsx-Mod-Entrypoint:".length()).trim(); + } + } + } finally { + zf.close(); + } + } catch (Throwable ignored) { + } + return null; + } + + private static void copy(InputStream in, java.io.OutputStream out) + throws Exception { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) { + out.write(buf, 0, n); + } + in.close(); + } + + public static byte[] readFile(File f) throws Exception { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + copy(new FileInputStream(f), bos); + return bos.toByteArray(); + } +} \ No newline at end of file diff --git a/bridge/src/main/java/rsx/Profile.java b/bridge/src/main/java/rsx/Profile.java new file mode 100644 index 0000000..30252f2 --- /dev/null +++ b/bridge/src/main/java/rsx/Profile.java @@ -0,0 +1,110 @@ +package rsx; + +/** + * Minimal reader for the compact .rsxp profile the pipeline generates. + * + * Format (tab or single-space separated, one record per line): + * rsxprofile 1 + * build + * loader + * patch.a2 + * patch.a6 + * class + * field + * method + */ +public final class Profile { + + public String buildId = ""; + public String gameJarSha256 = ""; + public long jarSize; + public long jarMtime; + public int namedClasses; + public int totalClasses; + + public String loaderClass; + public String loaderMethod; + public String loaderEntry; + + public String a2Class; + public String a2Property; + public String a6Class; +public String a6FieldName; + public String a6FieldObf; + public String serverOverrideField; + + /** logical name -> obfuscated class name */ + public final java.util.Map classes = + new java.util.HashMap(); + /** "class#member" -> [obfMember, descriptor, kind] */ + public final java.util.Map members = + new java.util.HashMap(); + + public static Profile load(String path) throws java.io.IOException { + Profile p = new Profile(); + java.io.BufferedReader r = new java.io.BufferedReader( + new java.io.InputStreamReader( + new java.io.FileInputStream(path), "UTF-8")); + try { + String line; + while ((line = r.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) { + continue; + } + String[] f = line.split("\\s+"); + String kind = f[0]; + if ("rsxprofile".equals(kind)) { + continue; + } else if ("build".equals(kind) && f.length >= 5) { + p.buildId = f[1]; + p.gameJarSha256 = f[2]; + p.namedClasses = Integer.parseInt(f[3]); + p.totalClasses = Integer.parseInt(f[4]); + if (f.length >= 7) { + // optional fast path: skip hashing a 56 MB jar on every + // launch when size and mtime already identify it + p.jarSize = Long.parseLong(f[5]); + p.jarMtime = Long.parseLong(f[6]); + } + } else if ("loader".equals(kind) && f.length >= 4) { + p.loaderClass = f[1]; + p.loaderMethod = f[2]; + p.loaderEntry = f[3]; + } else if ("patch.a2".equals(kind) && f.length >= 3) { + p.a2Class = f[1]; + p.a2Property = f[2]; + } else if ("patch.a6".equals(kind) && f.length >= 4) { + p.a6Class = f[1]; + p.a6FieldName = f[2]; + p.a6FieldObf = f[3]; + } else if ("class".equals(kind) && f.length >= 3) { + p.classes.put(f[1], f[2]); + } else if ("override.server".equals(kind) && f.length >= 2) { + p.serverOverrideField = f[1]; + } else if (("field".equals(kind) || "method".equals(kind)) + && f.length >= 5) { + p.members.put(f[1] + "#" + f[2], + new String[] {f[3], f[4], "field".equals(kind) ? "f" : "m"}); + } + } + } finally { + r.close(); + } + return p; + } + + public String obf(String logical) { + return classes.get(logical); + } + + public String[] member(String logical, String member) { + return members.get(logical + "#" + member); + } + + /** logical name -> readable name, for logging and mod diagnostics */ + public String describe(String logical) { + String o = classes.get(logical); + return logical + (o == null ? " (unmapped)" : " -> " + o); + } +} \ No newline at end of file diff --git a/bridge/src/main/java/rsx/Resolver.java b/bridge/src/main/java/rsx/Resolver.java new file mode 100644 index 0000000..a10b17e --- /dev/null +++ b/bridge/src/main/java/rsx/Resolver.java @@ -0,0 +1,370 @@ +package rsx; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Turns stable logical names into the real, obfuscated members of whatever + * build is running. + * + * The vendor re-obfuscates every release, so nothing here may hard-code a name. + * The profile supplies the translation, and the class loader is discovered at + * runtime because the game may load its own classes either from the system + * class path or from a private loader it creates itself. + */ +public final class Resolver { + + private static Profile profile; + private static ClassLoader loader; + private static String loaderSource = "none"; + private static final Map> CLASSES = new HashMap>(); + private static final Map FIELDS = new HashMap(); + private static final Map METHODS = new HashMap(); + private static java.lang.instrument.Instrumentation inst; + private static Map> loaded; + private static long lastScan; + + public static void init(Profile p) { + profile = p; + } + + public static void setInstrumentation(java.lang.instrument.Instrumentation i) { + inst = i; + } + + /** + * Index of every class the VM has already loaded, by binary name. + * + * The client encrypts its classes, so they cannot simply be loaded from the + * system class path: the game builds its own loader and defines them + * through Unsafe, bypassing transformers. Instrumentation is the one view + * that sees those classes, so it is what we index - and it is exact, with + * no guessing about which loader the vendor installed. + */ + private static Map> loadedIndex(boolean force) { + long now = System.currentTimeMillis(); + if (loaded != null && !force && now - lastScan < 1000L) { + return loaded; + } + if (loaded != null && now - lastScan < 1000L) { + return loaded; + } + Map> next = new HashMap>(); + if (inst != null) { + try { + for (Class c : inst.getAllLoadedClasses()) { + if (c != null) { + next.put(c.getName(), c); + } + } + } catch (Throwable t) { + return loaded == null ? next : loaded; + } + } else { + return loaded == null ? next : loaded; + } + loaded = next; + lastScan = now; + return loaded; + } + + public static Class findLoaded(String binaryName) { + return loadedIndex(true).get(binaryName); + } + + /** every class currently in the VM, for instance hunting */ + public static Class[] allLoadedClasses() { + Map> idx = loadedIndex(true); + return idx.values().toArray(new Class[0]); + } + + public static Profile profile() { + return profile; + } + + public static String loaderSource() { + return loaderSource; + } + + /** + * Find a loader that can see the game's own classes. Candidates are tried in + * order and the first one that can resolve the Minecraft class wins. + */ + public static synchronized ClassLoader loader() { + if (loader != null) { + return loader; + } + if (profile == null) { + return null; + } + String mc = profile.obf("net.minecraft.client.Minecraft"); + if (mc == null) { + return null; + } + if (loader != null) { + return loader; + } + List candidates = new ArrayList(); + try { + ClassLoader t = Thread.currentThread().getContextClassLoader(); + if (t != null) { + candidates.add(t); + } + } catch (Throwable ignored) { + } + try { + candidates.add(ClassLoader.getSystemClassLoader()); + } catch (Throwable ignored) { + } + try { + // the game entry point lives in game.jar under its real name + Class entry = Class.forName("net.minecraft.client.main.RustexMain", + false, ClassLoader.getSystemClassLoader()); + if (entry != null) { + candidates.add(entry.getClassLoader()); + } + } catch (Throwable ignored) { + } + for (ClassLoader cl : candidates) { + try { + Class c = Class.forName(mc, false, cl); + if (c != null) { + loader = cl; + loaderSource = cl.getClass().getName() + "@" + + System.identityHashCode(cl); + Log.info("game class loader: " + loaderSource); + return loader; + } + } catch (Throwable ignored) { + } + } + return null; + } + + public static Class cls(String logical) { + Class c = CLASSES.get(logical); + if (c != null) { + return c; + } + if (profile == null) { + return null; + } + String obf = profile.obf(logical); + if (obf == null) { + return null; + } + // 1) the class is already in the VM (the game defines its own classes + // through Unsafe, so this is the only way that always works) + // the game loads its classes over time, so a miss must force a rescan; + // otherwise we would keep looking at the snapshot from before the game + // had even started and never notice the class arriving + Class known = loadedIndex(true).get(obf); + if (known != null) { + CLASSES.put(logical, known); + loader = known.getClassLoader(); + loaderSource = "instrumentation:" + describeLoader(loader); + return known; + } + // 2) not loaded yet: try the loaders we can see + ClassLoader cl = loader(); + if (cl == null) { + return null; + } + try { + c = Class.forName(obf, false, cl); + CLASSES.put(logical, c); + return c; + } catch (Throwable t) { + return null; + } + } + + private static String describeLoader(ClassLoader cl) { + if (cl == null) { + return "bootstrap"; + } + return cl.getClass().getName() + "@" + + System.identityHashCode(cl); + } + + private static Field field(String logical, String member, Class owner) { + String key = logical + "#" + member; + Field f = FIELDS.get(key); + if (f != null) { + return f; + } + String[] m = profile == null ? null : profile.member(logical, member); + if (m == null || owner == null) { + return null; + } + try { + f = owner.getDeclaredField(m[0]); + f.setAccessible(true); + } catch (Throwable t) { + Log.warn("field " + key + " -> " + m[0] + " not found on " + owner); + return null; + } + FIELDS.put(key, f); + return f; + } + + private static Method method(String logical, String member, Class owner) { + String key = logical + "#" + member; + Method cached = METHODS.get(key); + if (cached != null) { + return cached; + } + String[] m = profile == null ? null : profile.member(logical, member); + if (m == null || owner == null) { + return null; + } + try { + Method found = null; + for (Method cand : owner.getDeclaredMethods()) { + if (cand.getName().equals(m[0]) + && cand.getReturnType().getName() + .equals(typeOf(m[1]))) { + found = cand; + break; + } + } + if (found == null) { + for (Method cand : owner.getDeclaredMethods()) { + if (cand.getName().equals(m[0])) { + found = cand; + break; + } + } + } + if (found == null) { + return null; + } + found.setAccessible(true); + METHODS.put(key, found); + return found; + } catch (Throwable t) { + Log.warn("method " + key + " -> " + m[0] + " not found: " + t); + return null; + } + } + + private static String typeOf(String descriptor) { + String d = descriptor; + if (d.startsWith("(")) { + int i = d.lastIndexOf(')'); + d = i >= 0 ? d.substring(i + 1) : d; + } + while (d.startsWith("[")) { + d = d.substring(1); + } + if (d.startsWith("L") && d.endsWith(";")) { + return d.substring(1, d.length() - 1).replace('/', '.'); + } + switch (d) { + case "V": return "void"; + case "Z": return "boolean"; + case "B": return "byte"; + case "C": return "char"; + case "S": return "short"; + case "I": return "int"; + case "J": return "long"; + case "F": return "float"; + case "D": return "double"; + default: return d; + } + } + + // ------------------------------------------------------------- public API + public static Object get(String logical, String member) { + Class owner = cls(logical); + Field f = field(logical, member, owner); + if (f == null) { + return null; + } + try { + return f.get(null); + } catch (Throwable t) { + Log.warn("static read " + logical + "#" + member + " failed: " + t); + return null; + } + } + + public static Object get(String logical, String member, Object instance) { + Class owner = cls(logical); + Field f = field(logical, member, owner); + if (f == null) { + return null; + } + try { + return f.get(instance); + } catch (Throwable t) { + Log.warn("read " + logical + "#" + member + " failed: " + t); + return null; + } + } + + public static void set(String logical, String member, Object instance, + Object value) { + Class owner = cls(logical); + Field f = field(logical, member, owner); + if (f == null) { + return; + } + try { + f.set(instance, value); + } catch (Throwable t) { + Log.warn("write " + logical + "#" + member + " failed: " + t); + } + } + + /** Write a field addressed by its obfuscated name. */ + public static boolean setRaw(String logicalClass, String obfName, + Object instance, Object value) { + Class owner = cls(logicalClass); + if (owner == null) { + return false; + } + try { + Field f = owner.getDeclaredField(obfName); + f.setAccessible(true); + f.set(instance, value); + return true; + } catch (Throwable t) { + Log.warn("write " + logicalClass + "#" + obfName + " failed: " + t); + return false; + } + } + + public static Object call(String logical, String member, Object... args) { + Class owner = cls(logical); + Method mm = method(logical, member, owner); + if (mm == null) { + return null; + } + try { + return mm.invoke(null, args); + } catch (Throwable t) { + Log.warn("call " + logical + "#" + member + " failed: " + t); + return null; + } + } + + public static Object call(String logical, String member, Object instance, + Object... args) { + Class owner = cls(logical); + Method mm = method(logical, member, owner); + if (mm == null) { + return null; + } + try { + return mm.invoke(instance, args); + } catch (Throwable t) { + Log.warn("call " + logical + "#" + member + " failed: " + t); + return null; + } + } +} \ No newline at end of file diff --git a/bridge/src/main/java/rsx/RsxAgent.java b/bridge/src/main/java/rsx/RsxAgent.java new file mode 100644 index 0000000..9433c1e --- /dev/null +++ b/bridge/src/main/java/rsx/RsxAgent.java @@ -0,0 +1,570 @@ +package rsx; + +import java.io.File; +import java.io.InputStream; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.List; +import java.util.zip.ZipFile; + +/** + * The agent. It is attached through the launcher setting `env`, so the vendor's + * jar and launcher stay untouched: no repack, no manifest hash breaks, and an + * update from the vendor cannot remove us. + * + * Jobs, in order: + * 1. read the profile for the build that is installed and verify it by hash + * 2. apply patch A2 (the client silently exits without -Djoml.fastmath) + * 3. wait for the game to come up, then apply patch A6 (Snooper telemetry) + * 4. load every jar from mods/ through the client's own code loader + */ +public final class RsxAgent { + + private static Profile profile; + private static File home; + private static byte[] specialMain; + + public static void premain(String args, java.lang.instrument.Instrumentation inst) { + try { + home = new File(RsxAgent.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()).getParentFile(); + } catch (Throwable t) { + home = new File("."); + } + + File target = null; + try { + String dir = System.getProperty("minecraft.applet.TargetDirectory"); + if (dir != null) { + target = new File(dir); + } + } catch (Throwable ignored) { + } + File logFile = new File(target != null ? target : home, + target != null ? "rsx/bridge.log" : "rsx-bridge.log"); + Log.open(logFile); + + Resolver.setInstrumentation(inst); + Log.info("rsx bridge starting (home=" + home + ")"); + + String mode = readMode(); + if ("vanilla".equals(mode)) { + Log.info("mode is vanilla: no mods, no patches, agent does nothing"); + return; + } + if (mode != null) { + Log.info("mode: " + mode); + } + + File prof = pick(new File(home, "profiles/latest.rsxp"), + new File(home, "profile.rsxp")); + if (prof == null) { + fail("no profile found: expected profiles/latest.rsxp next to the " + + "bridge jar, run prepare-build.cmd first"); + return; + } + try { + profile = Profile.load(prof.getAbsolutePath()); + Resolver.init(profile); + } catch (Throwable t) { + fail("cannot read profile " + prof + ": " + t); + return; + } + Log.info("profile " + profile.buildId + " from " + prof.getName() + + " (named " + profile.namedClasses + "/" + profile.totalClasses + ")"); + Log.info("loader anchor: " + profile.describe( + "net.rustexremake.loader.RemoteClassLoader")); + + File game = findGameJar(); + if (game == null) { + fail("cannot locate the client jar on java.class.path"); + return; + } + String sha; + boolean fastPath = profile.jarSize > 0 + && game.length() == profile.jarSize + && game.lastModified() / 1000L == profile.jarMtime; + if (fastPath) { + Log.info("build signature matches (size+mtime), skipping hash"); + sha = profile.gameJarSha256; + } else { + try { + sha = sha256(game); + } catch (Throwable t) { + fail("cannot hash " + game + ": " + t); + return; + } + } + if (!sha.equals(profile.gameJarSha256)) { + fail("profile is for build " + profile.buildId + " but the installed " + + "jar hashes to " + sha.substring(0, 16) + + "; run prepare-build.cmd again"); + return; + } + Log.info("build verified: " + game.getName() + " sha256 ok"); + + applyA2(); + + try { + specialMain = readResource("special/Main.class"); + } catch (Throwable t) { + fail("cannot read the generated special.Main: " + t); + return; + } + + Thread boot = new Thread(new Runnable() { + @Override + public void run() { + bootSequence(); + } + }, "rsx-boot"); + boot.setDaemon(true); + boot.start(); + } + + // ------------------------------------------------------------------- boot + private static void bootSequence() { + // the game creates its own loader, so wait until the client is really up + Object mc = null; + for (int i = 0; i < 600; i++) { + try { + mc = Resolver.get("net.minecraft.client.Minecraft", "instance"); + if (mc != null) { + break; + } + } catch (Throwable ignored) { + } + sleep(500); + mc = null; + } + if (mc == null) { + Log.warn("the game did not come up within 5 minutes; mods not loaded"); + return; + } + Log.info("client is up, loader: " + Resolver.loaderSource()); + + applyServerOverride(); + applyA6(mc); + // the telemetry object is created later in the client's own start-up, + // so the patch is retried for a while instead of being given up on + Thread snooper = new Thread(new Runnable() { + @Override + public void run() { + for (int i = 0; i < 24; i++) { + sleep(5000); + Object c = Resolver.get("net.minecraft.client.Minecraft", + "instance"); + if (c == null) { + continue; + } + if (applyA6(c)) { + return; + } + } + Log.warn("A6: telemetry object never appeared within 2 minutes"); + } + }, "rsx-snooper-patch"); + snooper.setDaemon(true); + snooper.start(); + + loadMods(mc); + + Log.info("ready: " + ModBridge.loaded().size() + " mod(s) loaded"); + } + + // --------------------------------------------------------------- patches + private static void applyA2() { + String prop = profile.a2Property; + if (prop == null || prop.isEmpty()) { + Log.warn("A2: no property in the profile"); + return; + } + // the client reads this flag in a static initialiser and exits silently + // when it is absent; setting it before that class loads is enough + System.setProperty(prop, "true"); + Log.info("A2 applied: -D" + prop + "=true (class " + profile.a2Class + ")"); + } + + private static final java.util.Set patched = + java.util.Collections.newSetFromMap( + new java.util.WeakHashMap()); + + /** + * Read the mode switch that play.cmd writes next to the bridge jar. + * + * "vanilla" keeps the agent attached but does nothing at all, which makes it + * easy to switch between modded and clean runs without editing the launcher. + */ + private static String readMode() { + File f = new File(home, "mode.txt"); + if (!f.isFile()) { + return null; + } + java.io.BufferedReader r = null; + try { + r = new java.io.BufferedReader(new java.io.InputStreamReader( + new java.io.FileInputStream(f), "UTF-8")); + String line = r.readLine(); + return line == null ? null : line.trim().toLowerCase(); + } catch (Throwable t) { + return null; + } finally { + if (r != null) { + try { + r.close(); + } catch (Throwable ignored) { + } + } + } + } + + /** + * Repoint the client's server list. + * + * The vendor main accepts a --srvs option and then overwrites it with a + * hardcoded list of production addresses, so the only way to run against + * another server (a local one, a friend, a LAN) is to set the field + * ourselves before the client connects. + */ + private static void applyServerOverride() { + String servers = System.getProperty("rsx.server"); + if (servers == null || servers.isEmpty()) { + return; + } + String field = profile.serverOverrideField; + if (field == null) { + Log.warn("rsx.server set but the profile has no override.server"); + return; + } + if (Resolver.setRaw("net.minecraft.client.Minecraft", field, null, + servers)) { + Log.info("server list overridden: " + servers); + } else { + Log.warn("could not override the server list with: " + servers); + } + } + + private static boolean applyA6(Object mc) { + Class sc = resolveClass(profile.a6Class); + if (sc == null) { + Log.warn("A6: snooper class not resolvable"); + return false; + } + // The profile's field hint turned out to be unreliable: the declared + // field stays null in practice, and the object may live in a static + // somewhere else entirely. So look for live instances instead of + // trusting one name. + java.util.List live = findInstances(mc, sc); + if (live.isEmpty()) { + return false; + } + int stopped = 0; + for (Object o : live) { + if (patched.contains(o)) { + continue; + } + if (killTimers(o)) { + stopped++; + patched.add(o); + } + } + if (stopped > 0) { + Log.info("A6 applied: telemetry timers replaced in " + + stopped + " object(s), " + live.size() + " found"); + } + return true; + } + + private static Class resolveClass(String obf) { + if (obf == null) { + return null; + } + try { + Class known = Resolver.findLoaded(obf); + if (known != null) { + return known; + } + ClassLoader cl = Resolver.loader(); + return cl == null ? null : Class.forName(obf, false, cl); + } catch (Throwable t) { + return null; + } + } + + /** Live instances of a class, in the client instance and in static fields. */ + private static java.util.List findInstances(Object mc, Class type) { + java.util.List found = new java.util.ArrayList(); + try { + for (java.lang.reflect.Field f : mc.getClass().getDeclaredFields()) { + f.setAccessible(true); + Object v = f.get(mc); + if (v != null && type.isInstance(v)) { + found.add(v); + } + } + } catch (Throwable ignored) { + } + try { + Class[] all = Resolver.allLoadedClasses(); + for (Class c : all) { + java.lang.reflect.Field[] fs; + try { + fs = c.getDeclaredFields(); + } catch (Throwable ignored) { + continue; + } + for (java.lang.reflect.Field f : fs) { + if (!java.lang.reflect.Modifier.isStatic(f.getModifiers())) { + continue; + } + if (!type.isAssignableFrom(f.getType())) { + continue; + } + try { + f.setAccessible(true); + Object v = f.get(null); + if (v != null && !found.contains(v)) { + found.add(v); + } + } catch (Throwable ignored) { + } + } + } + } catch (Throwable ignored) { + } + return found; + } + + /** + * Stops the telemetry object's timers without breaking the client. + * + * Two earlier attempts were caught by the real client: + * - nulling the field crashed it with a NullPointerException, because the + * telemetry code dereferences it; + * - cancelling the Timer crashed it with "Timer already cancelled", + * because the client reschedules that Timer later during startup. + * + * So the Timer is kept valid but replaced by one that accepts every + * reschedule and silently drops the work, and the tasks already queued in + * the old Timer are cancelled individually. + */ + private static boolean killTimers(Object o) { + boolean stopped = false; + for (java.lang.reflect.Field f : o.getClass().getDeclaredFields()) { + Object v; + try { + f.setAccessible(true); + v = f.get(o); + } catch (Throwable ignored) { + continue; + } + if (v instanceof java.util.Timer) { + java.util.Timer old = (java.util.Timer) v; + try { + // Swap first: the field must never be null, and the client + // reschedules through whatever object is in there. + f.set(o, new SilentTimer()); + // Only now is the old timer unreachable, so cancelling it is + // safe and drops whatever it had already queued. + old.cancel(); + stopped = true; + } catch (Throwable t) { + Log.warn("A6: could not swap the telemetry timer: " + t); + } + } else if (v instanceof java.util.TimerTask) { + ((java.util.TimerTask) v).cancel(); + stopped = true; + } else if (v instanceof java.util.concurrent.ScheduledExecutorService) { + ((java.util.concurrent.ScheduledExecutorService) v).shutdownNow(); + stopped = true; + } + } + return stopped; + } + + /** A Timer that swallows every schedule request instead of running it. */ + private static final class SilentTimer extends java.util.Timer { + SilentTimer() { + super(true); + } + + private static void drop(java.util.TimerTask task) { + if (task != null) { + task.cancel(); + } + } + + @Override + public void schedule(java.util.TimerTask task, long delay) { + drop(task); + } + + @Override + public void schedule(java.util.TimerTask task, java.util.Date when) { + drop(task); + } + + @Override + public void schedule(java.util.TimerTask task, long delay, long period) { + drop(task); + } + + @Override + public void schedule(java.util.TimerTask task, java.util.Date first, + long period) { + drop(task); + } + + @Override + public void scheduleAtFixedRate(java.util.TimerTask task, long delay, + long period) { + drop(task); + } + + @Override + public void scheduleAtFixedRate(java.util.TimerTask task, + java.util.Date first, long period) { + drop(task); + } + } + + // ------------------------------------------------------------------ mods + private static void loadMods(Object mc) { + File modsDir = null; + try { + String dir = System.getProperty("minecraft.applet.TargetDirectory"); + if (dir != null) { + modsDir = new File(dir, "mods"); + } + } catch (Throwable ignored) { + } + if (modsDir == null) { + modsDir = new File(home, "mods"); + } + if (!modsDir.isDirectory()) { + Log.info("no mods directory at " + modsDir); + return; + } + File[] files = modsDir.listFiles(); + if (files == null || files.length == 0) { + Log.info("mods directory is empty: " + modsDir); + return; + } + List jars = new ArrayList(); + for (File f : files) { + if (f.isFile() && f.getName().toLowerCase().endsWith(".jar")) { + jars.add(f); + } + } + Log.info("found " + jars.size() + " mod jar(s) in " + modsDir); + + Class loaderCls; + java.lang.reflect.Method load; + try { + loaderCls = Class.forName(profile.loaderClass, false, Resolver.loader()); + load = loaderCls.getDeclaredMethod(profile.loaderMethod, + byte[].class, String[].class); + load.setAccessible(true); + } catch (Throwable t) { + fail("cannot bind the client loader (" + profile.loaderClass + "." + + profile.loaderMethod + "): " + t); + return; + } + + for (File jar : jars) { + String name = jar.getName(); + try { + String entry = ModBridge.entryPointOf(jar); + if (entry == null) { + Log.warn(name + ": no Rsx-Mod-Entrypoint in the manifest, skipped"); + continue; + } + byte[] bytes = ModBridge.prepare(jar, specialMain, entry); + load.invoke(null, bytes, new String[] {name, entry}); + } catch (Throwable t) { + Log.error("mod " + name + " failed to load", t); + } + } + } + + // ----------------------------------------------------------------- utils + private static File findGameJar() { + String loader = profile.loaderClass; + if (loader == null) { + return null; + } + String cp = System.getProperty("java.class.path", ""); + for (String part : cp.split(File.pathSeparator)) { + if (!part.toLowerCase().endsWith(".jar")) { + continue; + } + try { + ZipFile z = new ZipFile(part); + try { + if (z.getEntry(loader + ".class") != null) { + return new File(part); + } + } finally { + z.close(); + } + } catch (Throwable ignored) { + } + } + return null; + } + + private static String sha256(File f) throws Exception { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + java.io.FileInputStream in = new java.io.FileInputStream(f); + try { + byte[] buf = new byte[1 << 20]; + int n; + while ((n = in.read(buf)) > 0) { + md.update(buf, 0, n); + } + } finally { + in.close(); + } + StringBuilder sb = new StringBuilder(); + for (byte b : md.digest()) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + private static byte[] readResource(String name) throws Exception { + InputStream in = RsxAgent.class.getClassLoader().getResourceAsStream(name); + if (in == null) { + throw new IllegalStateException("resource not found: " + name); + } + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[4096]; + int n; + while ((n = in.read(buf)) > 0) { + bos.write(buf, 0, n); + } + in.close(); + return bos.toByteArray(); + } + + private static File pick(File... files) { + for (File f : files) { + if (f.isFile()) { + return f; + } + } + return null; + } + + private static void fail(String msg) { + Log.error(msg, null); + System.err.println("[rsx] " + msg); + } + + private static void sleep(long ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException ignored) { + } + } +} \ No newline at end of file diff --git a/bridge/src/main/java/special/Main.java b/bridge/src/main/java/special/Main.java new file mode 100644 index 0000000..48241a3 --- /dev/null +++ b/bridge/src/main/java/special/Main.java @@ -0,0 +1,25 @@ +package special; + +import java.util.List; + +/** + * Generated entry point injected into every mod jar. + * + * The client's loader hard-codes this class name: it calls the static main and + * later the static getRunnableList from the game tick. Both methods forward to + * the bridge, which is visible to the loader because a -javaagent jar is + * appended to the system class path. + */ +public final class Main { + + private Main() { + } + + public static void main(String[] args) { + rsx.ModBridge.launch(Main.class, args); + } + + public static List getRunnableList() { + return rsx.ModBridge.drain(Main.class); + } +} \ No newline at end of file diff --git a/bridge/src/test/fakeclient/FakeMain.java b/bridge/src/test/fakeclient/FakeMain.java new file mode 100644 index 0000000..a6c7886 --- /dev/null +++ b/bridge/src/test/fakeclient/FakeMain.java @@ -0,0 +1,28 @@ +// 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. + +public class FakeMain { + + public static void main(String[] args) throws Exception { + System.out.println("[fake] class loader: " + FakeMain.class.getClassLoader()); + System.out.println("[fake] joml.fastmath = " + System.getProperty("joml.fastmath")); + + wwk client = new wwk(); + wwk.WA = client; + client.zb = "PLAYER"; + client.ta = "WORLD"; + client.S = null; + client.Zb = "TIMER"; + client.jC = "RENDER"; + client.pC = new pvdafug("http://snoop.minecraft.net/report"); + 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++) { + client.tick(); + Thread.sleep(200); + } + System.out.println("[fake] done"); + } +} \ No newline at end of file diff --git a/bridge/src/test/fakeclient/ilxq.java b/bridge/src/test/fakeclient/ilxq.java new file mode 100644 index 0000000..62913c1 --- /dev/null +++ b/bridge/src/test/fakeclient/ilxq.java @@ -0,0 +1,79 @@ +// Test stand-in for the client's code loader. +// +// The real client exposes a static method that takes a jar and the arguments, +// 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. + +import java.lang.reflect.Method; + +public class ilxq { + + private static Method runnableList; + + /** mirrors the vendor signature: static, (byte[], String[]) -> boolean */ + 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); + Class main = loader.loadClass("special.Main"); + Method m = main.getDeclaredMethod("main", String[].class); + m.setAccessible(true); + m.invoke(null, (Object) args); + runnableList = main.getDeclaredMethod("getRunnableList"); + runnableList.setAccessible(true); + return true; + } + + /** mirrors the vendor tick hook */ + public static void tickDrain() { + if (runnableList == null) { + return; + } + try { + Object res = runnableList.invoke(null); + if (res instanceof java.util.List) { + for (Object r : (java.util.List) res) { + ((Runnable) r).run(); + } + } + } catch (Throwable t) { + t.printStackTrace(); + } + } + + static MemLoader loader = new MemLoader(ilxq.class.getClassLoader()); + + static void jarLoader(byte[] jar) throws Exception { + java.util.zip.ZipInputStream zin = + new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(jar)); + java.util.zip.ZipEntry e; + while ((e = zin.getNextEntry()) != null) { + if (e.isDirectory()) { + continue; + } + java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream(); + byte[] buf = new byte[8192]; + int n; + while ((n = zin.read(buf)) > 0) { + bos.write(buf, 0, n); + } + loader.define(e.getName(), bos.toByteArray()); + } + zin.close(); + } + + static class MemLoader extends ClassLoader { + MemLoader(ClassLoader parent) { + super(parent); + } + + void define(String name, byte[] bytes) { + if (name.endsWith(".class")) { + // defineClass wants the internal name: dots, not slashes + defineClass(name.substring(0, name.length() - 6) + .replace('/', '.'), bytes, 0, bytes.length); + } + } + } +} \ No newline at end of file diff --git a/bridge/src/test/fakeclient/pvdafug.java b/bridge/src/test/fakeclient/pvdafug.java new file mode 100644 index 0000000..898602b --- /dev/null +++ b/bridge/src/test/fakeclient/pvdafug.java @@ -0,0 +1,31 @@ +// Test stand-in for the telemetry class (patch A6 target): owns a Timer that, +// in the real client, posts the machine's OS, JVM arguments and GL limits. + +import java.util.Timer; +import java.util.TimerTask; + +public class pvdafug { + + private final Timer do_ = new Timer("Snooper Timer", true); + private final String url; + + public pvdafug(String url) { + this.url = url; + do_.schedule(new TimerTask0(), 1000L); + } + + public String url() { + return url; + } + + private void post() { + System.out.println("[fake-snooper] posting to " + url); + } + + private class TimerTask0 extends TimerTask { + @Override + public void run() { + post(); + } + } +} \ No newline at end of file diff --git a/bridge/src/test/fakeclient/wwk.java b/bridge/src/test/fakeclient/wwk.java new file mode 100644 index 0000000..b4e6fd6 --- /dev/null +++ b/bridge/src/test/fakeclient/wwk.java @@ -0,0 +1,26 @@ +// Test stand-in for the client entry point. +// +// Field and method names are deliberately identical to the ones in the real +// profile (player is "zb", world is "ta", timer is "Zb" - one capital apart), so +// the bridge exercises the same exact-match lookups it will do on the game. + +public class wwk { + + public static wwk WA; // field_71432_P, the client instance + + public Object zb; // field_71439_g player + public Object ta; // field_71441_e world + public Object S; // field_71462_r currentScreen + public Object Zb; // field_71428_T timer + public Object jC; // field_71438_f renderGlobal + public pvdafug pC; // vendor field holding the telemetry object + + public static wwk getMinecraftStatic() { // func_71410_x + return WA; + } + + /** the vendor tick: drains mod tasks on this thread */ + public void tick() { + ilxq.tickDrain(); + } +} \ No newline at end of file diff --git a/bridge/src/test/mod/ExampleMod.java b/bridge/src/test/mod/ExampleMod.java new file mode 100644 index 0000000..e50402e --- /dev/null +++ b/bridge/src/test/mod/ExampleMod.java @@ -0,0 +1,34 @@ +package example; + +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. + */ +public class ExampleMod implements RsxMod { + + private int ticks; + + @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.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()); + } + }); + } +} \ No newline at end of file diff --git a/bridge/src/test/python/run_test.py b/bridge/src/test/python/run_test.py new file mode 100644 index 0000000..532a6cc --- /dev/null +++ b/bridge/src/test/python/run_test.py @@ -0,0 +1,186 @@ +#!/usr/bin/env python +""" +rsx-run-test — end-to-end test of the bridge against a stand-in client. + +The real client cannot be launched from a test (it needs auth, a window and a +GPU), so this builds a tiny client with the *same shape*: a code loader that +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. + +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 + +Usage: python run_test.py [--keep] +""" +import os +import shutil +import subprocess +import sys +import zipfile + +HERE = os.path.dirname(os.path.abspath(__file__)) +BRIDGE_JAR = os.path.join(os.path.dirname(HERE), "build", "libs", "rsx-bridge.jar") +SRC = os.path.join(HERE, "fakeclient") +MOD_SRC = os.path.join(HERE, "mod") +RUN = os.path.join(HERE, "run") + + +def sh(*args, **kw): + return subprocess.run(args, capture_output=True, text=True, **kw) + + +# The client ships its own Java 8. Run the stand on that same JRE: patch A6 +# reaches into java.util.Timer's internals, which the module system blocks on +# modern JDKs but allows on 8 - the runtime the agent actually meets in game. +CLIENT_JRE = r"G:\rustexremake\rustexremake\gamefiles\java\bin\java.exe" + + +def java(): + override = os.environ.get("RSX_TEST_JAVA") + if override: + return override + if os.path.exists(CLIENT_JRE): + return CLIENT_JRE + return "java" + + +def javac(sources, out, cp=None): + os.makedirs(out, exist_ok=True) + # class files must stay loadable by the client's Java 8 + cmd = ["javac", "-nowarn", "--release", "8", "-d", out] + if cp: + cmd += ["-cp", cp] + r = subprocess.run(cmd + sources, capture_output=True, text=True) + if r.returncode != 0: + print(r.stdout) + print(r.stderr) + raise SystemExit("javac failed") + + +def zip_dir(src, jar): + if os.path.exists(jar): + os.remove(jar) + with zipfile.ZipFile(jar, "w", zipfile.ZIP_DEFLATED) as z: + for dp, _dn, fns in os.walk(src): + for fn in fns: + if fn.endswith(".class"): + full = os.path.join(dp, fn) + rel = os.path.relpath(full, src).replace("\\", "/") + z.write(full, rel) + + +def main(): + 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): + shutil.rmtree(d, ignore_errors=True) + os.makedirs(classes, exist_ok=True) + os.makedirs(modclasses, 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 ==") + javac([os.path.join(MOD_SRC, f) for f in os.listdir(MOD_SRC) + if f.endswith(".java")], modclasses, cp=BRIDGE_JAR) + + game = os.path.join(RUN, "game.jar") + zip_dir(classes, game) + + mods = os.path.join(RUN, "mods") + 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("\\", "/")) + + import hashlib + h = hashlib.sha256() + with open(game, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + sha = h.hexdigest() + + bridge = os.path.join(RUN, "bridge") + os.makedirs(os.path.join(bridge, "profiles"), exist_ok=True) + shutil.copy(BRIDGE_JAR, os.path.join(bridge, "rsx-bridge.jar")) + profile = "\n".join([ + "rsxprofile 1", + "build %s %s 1 1" % (sha[:16], sha), + "loader ilxq loadJar special.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 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;", + ]) + "\n" + with open(os.path.join(bridge, "profiles", "latest.rsxp"), "w", + encoding="utf-8") as fh: + fh.write(profile) + + log = os.path.join(RUN, "rsx", "bridge.log") + if os.path.exists(log): + os.remove(log) + + print("== running with the agent attached ==") + cmd = [java(), + "-javaagent:" + os.path.join(bridge, "rsx-bridge.jar"), + "-Dminecraft.applet.TargetDirectory=" + RUN, + "-cp", os.path.join(bridge, "rsx-bridge.jar") + ";" + game, + "FakeMain"] + r = subprocess.run(cmd, capture_output=True, text=True, timeout=180) + out = r.stdout + r.stderr + for line in out.splitlines(): + print(" " + line) + + print() + print("== verdict ==") + checks = [ + ("profile loaded", "profile " in out and "from latest.rsxp" in out), + ("build hash verified", "build verified" in out), + ("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), + ("telemetry never fired", "[fake-snooper] posting" not in out), + ] + bad = 0 + for label, ok in checks: + print(" %-38s %s" % (label, "OK" if ok else "FAIL")) + if not ok: + bad += 1 + if bad: + print(" %d check(s) failed" % bad) + return 1 + print(" all checks passed") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..bd0d941 --- /dev/null +++ b/build.cmd @@ -0,0 +1,3 @@ +@echo off +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0build.ps1" diff --git a/install.cmd b/install.cmd new file mode 100644 index 0000000..7a942ad --- /dev/null +++ b/install.cmd @@ -0,0 +1,6 @@ +@echo off +rem Installs the modloader into this machine. Run as administrator if the +rem launcher refuses to close or you want it to work from an elevated session. +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\install.ps1" %* +pause diff --git a/launcher-patch/src/LauncherPatch.java b/launcher-patch/src/LauncherPatch.java new file mode 100644 index 0000000..f49e574 --- /dev/null +++ b/launcher-patch/src/LauncherPatch.java @@ -0,0 +1,843 @@ +import java.io.File; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.jar.JarOutputStream; +import java.util.zip.ZipEntry; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassWriter; +import org.objectweb.asm.Opcodes; +import org.objectweb.asm.tree.AbstractInsnNode; +import org.objectweb.asm.tree.ClassNode; +import org.objectweb.asm.tree.FieldInsnNode; +import org.objectweb.asm.tree.InsnList; +import org.objectweb.asm.tree.JumpInsnNode; +import org.objectweb.asm.tree.LdcInsnNode; +import org.objectweb.asm.tree.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.VarInsnNode; + +/** + * Puts the launch choice inside the launcher: injects a call to + * rsx.Choice.pick() at the start of the Play handler and records which + * obfuscated config field carries the agent argument. + * + * usage: LauncherPatch inspect|patch [] + */ +public final class LauncherPatch { + + private static final String TARGET_CLASS = "fa"; +private static final String UPDATER_CLASS = "sb"; + private static final String SELF_UPDATE_CLASS = "ha"; + private static final String GUARD = "rsx/Patch"; + private static final String PLAY_ID = "clickPlay"; + private static final String CHOICE = "rsx/Choice"; + private static final String CHOICE_RESOURCE = "rsx/env-field.txt"; + private static final byte[] MARKER = + "rsx-choice-patch".getBytes(StandardCharsets.UTF_8); + +public static void main(String[] args) throws Exception { + String mode = args.length > 0 ? args[0] : "inspect"; + Path jar = Paths.get(args[1]); + if ("inspect".equals(mode)) { + inspect(jar); + return; + } + if ("verify".equals(mode)) { + verify(jar); + return; + } + if ("check".equals(mode)) { + System.out.println(check(jar) ? "all classes verify" + : "VERIFICATION FAILED"); + return; + } + if ("dump".equals(mode)) { + dump(jar); + return; + } + if ("showenv".equals(mode)) { + showEnv(jar); + return; + } + if ("getprop".equals(mode)) { + System.out.println(" " + getProp(jar, + args.length > 2 ? args[2] : "")); + return; + } + if ("setenv".equals(mode)) { + setEnv(jar, args.length > 2 ? args[2] : ""); + return; + } + if ("setprop".equals(mode)) { + setProp(jar, args[2], args[3]); + return; + } + // patch [envFieldSpec] + Path home = Paths.get(args[2]); + String envMeta = args.length > 3 ? args[3].replace(",", "\n") : "wa\nALLATORIxDEMO"; + patch(jar, home, envMeta); + } + + /** + * Write a launcher config value with java.util.Properties. + * + * launcher_config.properties is a properties file: a backslash is an escape + * character, so writing the line by hand turns G:\rsx-mod into G:... and a + * following \r into a line break. Loading and storing instead guarantees the + * round trip, and the value is read back to prove it. + */ + private static void setEnv(Path config, String value) throws Exception { + setProp(config, "env", value); + String readBack = getProp(config, "env"); + if (!value.equals(readBack)) { + System.out.println(" ERROR: value did not survive the write"); + System.exit(3); + } + if (!value.isEmpty()) { + String jar = value.replaceFirst("^-javaagent:", "").replace("\"", ""); + System.out.println(" agent jar: " + jar + + " exists=" + new File(jar).isFile()); + } + } + + /** Write any launcher config value through java.util.Properties. */ + private static void setProp(Path config, String key, String value) + throws Exception { + java.util.Properties props = new java.util.Properties(); + try (java.io.Reader in = new java.io.InputStreamReader( + java.nio.file.Files.newInputStream(config), "ISO-8859-1")) { + props.load(in); + } + if (value.isEmpty()) { + props.remove(key); + } else { + props.setProperty(key, value); + } + try (java.io.Writer out = new java.io.OutputStreamWriter( + java.nio.file.Files.newOutputStream(config), "ISO-8859-1")) { + props.store(out, "rsx modloader"); + } + String readBack = getProp(config, key); + System.out.println(" " + key + " now: " + + readBack.replace("\r", "").replace("\n", "")); + if (!value.equals(readBack)) { + System.out.println(" ERROR: " + key + + " did not survive the write"); + System.exit(3); + } + } + + private static String getProp(Path config, String key) throws Exception { + java.util.Properties props = new java.util.Properties(); + try (java.io.Reader in = new java.io.InputStreamReader( + java.nio.file.Files.newInputStream(config), "ISO-8859-1")) { + props.load(in); + } + return props.getProperty(key, ""); + } + + /** + * Print how the launcher itself will read a config value. + * + * Used by the installer to prove the path survived the properties escaping. + */ + private static void showEnv(Path config) throws Exception { + java.util.Properties props = new java.util.Properties(); + try (java.io.Reader in = new java.io.InputStreamReader( + java.nio.file.Files.newInputStream(config), "ISO-8859-1")) { + props.load(in); + } + String env = props.getProperty("env", ""); + System.out.println(" env as java reads it: " + env); + if (env.startsWith("-javaagent:")) { + String jar = env.substring("-javaagent:".length()).replace("\"", ""); + File file = new File(jar); + System.out.println(" agent jar: " + jar); + System.out.println(" exists: " + file.isFile()); + if (!file.isFile()) { + System.exit(2); + } + } + System.out.println(" gamePath1: " + props.getProperty("gamePath1", "")); + } + + private static ClassNode readClass(JarFile jar, String name) throws Exception { + JarEntry entry = jar.getJarEntry(name + ".class"); + if (entry == null) { + throw new IllegalStateException("no class " + name + " in the jar"); + } + InputStream in = jar.getInputStream(entry); + try { + ClassNode node = new ClassNode(); + new ClassReader(in).accept(node, 0); + return node; + } finally { + in.close(); + } + } + + private static void inspect(Path jarPath) throws Exception { + JarFile jar = new JarFile(jarPath.toFile()); + try { + ClassNode cls = readClass(jar, TARGET_CLASS); + System.out.println("class " + cls.name.replace('/', '.') + ", methods " + + cls.methods.size()); + for (MethodNode m : cls.methods) { + String play = findPlayBranch(m); + if (play != null) { + System.out.println(" clickPlay handler: " + m.name + + m.desc + " (" + play + ")"); + } + String env = findEnvField(m); + if (env != null) { + System.out.println(" env argument in: " + m.name + + m.desc + " -> " + env); + } + } + } finally { + jar.close(); + } + } + + /** Returns a short description when the method dispatches "clickPlay". */ + private static String findPlayBranch(MethodNode m) { + InsnList insns = m.instructions; + for (int i = 0; i < insns.size(); i++) { + AbstractInsnNode insn = insns.get(i); + if (!(insn instanceof LdcInsnNode)) { + continue; + } + if (!PLAY_ID.equals(((LdcInsnNode) insn).cst)) { + continue; + } + for (int j = i + 1; j < Math.min(i + 4, insns.size()); j++) { + AbstractInsnNode next = insns.get(j); + if (next instanceof MethodInsnNode + && "equals".equals(((MethodInsnNode) next).name)) { + AbstractInsnNode after = insns.get(j + 1); + if (after instanceof JumpInsnNode) { + return "ldc+equals+" + after.getOpcode(); + } + } + } + } + return null; + } + + /** + * The agent argument reaches the command line as the value of a config + * field, guarded by an isEmpty() check right before List.add. + */ + private static String findEnvField(MethodNode m) { + AbstractInsnNode[] insns = m.instructions.toArray(); + for (int i = 0; i < insns.length; i++) { + AbstractInsnNode insn = insns[i]; + if (!(insn instanceof MethodInsnNode)) { + continue; + } + MethodInsnNode call = (MethodInsnNode) insn; + if (!"add".equals(call.name)) { + continue; + } + if (!call.owner.replace('/', '.').startsWith("java.util")) { + continue; + } + for (int j = i - 1; j >= 0 && i - j < 14; j--) { + AbstractInsnNode prev = insns[j]; + if (!(prev instanceof FieldInsnNode)) { + continue; + } + FieldInsnNode f = (FieldInsnNode) prev; + if (f.getOpcode() == Opcodes.GETFIELD + && f.desc.startsWith("Ljava/lang/String;") + && !TARGET_CLASS.equals(f.owner)) { + return f.owner.replace('/', '.') + "#" + f.name + ":" + f.desc; + } + } + } + return null; + } + +/** Show the code around the injected call so its placement can be checked. */ + private static void verify(Path jarPath) throws Exception { + JarFile jar = new JarFile(jarPath.toFile()); + try { + ClassNode sb = readClass(jar, UPDATER_CLASS); + boolean guard = false; + for (MethodNode m : sb.methods) { + if (hasGuardCall(m)) { + guard = true; + AbstractInsnNode[] insns = m.instructions.toArray(); + for (int i = 0; i < insns.length; i++) { + AbstractInsnNode insn = insns[i]; + if (insn instanceof MethodInsnNode + && GUARD.equals(((MethodInsnNode) insn).owner)) { + System.out.println("updater guard in " + + UPDATER_CLASS + "." + m.name + m.desc + + " at instruction " + i + " of " + + insns.length); + for (int j = Math.max(0, i - 1); + j < Math.min(insns.length, i + 5); j++) { + System.out.println(" " + j + ": " + + describe(insns[j])); + } + } + } + } + } + System.out.println(guard ? "updater guard present" + : "updater guard MISSING"); + + ClassNode cls = readClass(jar, TARGET_CLASS); + boolean found = false; + for (MethodNode m : cls.methods) { + AbstractInsnNode[] insns = m.instructions.toArray(); + for (int i = 0; i < insns.length; i++) { + AbstractInsnNode insn = insns[i]; + if (!(insn instanceof MethodInsnNode)) { + continue; + } + MethodInsnNode call = (MethodInsnNode) insn; + if (!CHOICE.equals(call.owner) || !"pick".equals(call.name)) { + continue; + } + found = true; + System.out.println("call in " + m.name + m.desc + + " at instruction " + i + " of " + insns.length); + for (int j = Math.max(0, i - 7); + j < Math.min(insns.length, i + 4); j++) { + System.out.println(" " + j + ": " + describe(insns[j])); + } + } + } + System.out.println(found ? "patch present" : "no patch found"); + } finally { + jar.close(); + } + } + + /** Print the code around the "clickPlay" test so the branch shape is clear. */ + private static void dump(Path jarPath) throws Exception { + JarFile jar = new JarFile(jarPath.toFile()); + try { + ClassNode cls = readClass(jar, TARGET_CLASS); + for (MethodNode m : cls.methods) { + AbstractInsnNode[] insns = m.instructions.toArray(); + for (int i = 0; i < insns.length; i++) { + if (!(insns[i] instanceof LdcInsnNode)) { + continue; + } + if (!PLAY_ID.equals(((LdcInsnNode) insns[i]).cst)) { + continue; + } + System.out.println("--- " + m.name + m.desc + + ", clickPlay at " + i); + for (int j = Math.max(0, i - 4); + j < Math.min(insns.length, i + 26); j++) { + System.out.println(" " + j + ": " + describe(insns[j])); + } + } + } + } finally { + jar.close(); + } + } + + /** + * Load the classes we touched so the JVM verifier has the last word. + * + * Repackaging alone does not prove anything: a missing stackmap frame only + * shows up when the class is linked, which is why a bad patch turns into an + * endless update check instead of an obvious failure. + */ +private static boolean check(Path jarPath) throws Exception { + ClassLoader loader = new java.net.URLClassLoader( + new java.net.URL[] {jarPath.toUri().toURL()}, + ClassLoader.getSystemClassLoader().getParent()); + ClassLoader base = javafxLoader(); + if (base != null) { + // JavaFX on the parent so its classes resolve for fa + loader = new java.net.URLClassLoader( + new java.net.URL[] {jarPath.toUri().toURL()}, base); + } +boolean ok = true; + for (String name : new String[] {TARGET_CLASS, UPDATER_CLASS, + SELF_UPDATE_CLASS, "rsx.Patch"}) { + try { + Class.forName(name, true, loader); + System.out.println(" " + name + " verifies"); + } catch (Throwable t) { + Throwable cause = t; + while (cause.getCause() != null) { + cause = cause.getCause(); + } + ok = false; + System.out.println(" " + name + " FAILED: " + cause); + } + } + return ok; + } + +/** + * The launcher runs with JavaFX on the module path; fa references javafx.stage + * directly, so verification of that class needs those modules present. + */ + private static ClassLoader javafxLoader() { + String home = System.getenv("APPDATA"); + if (home == null) { + return null; + } + java.nio.file.Path lib = java.nio.file.Paths.get(home, "kjstudio", + "launcher", "runtime", "javafx", "lib"); + if (!java.nio.file.Files.isDirectory(lib)) { + return null; + } + java.util.List urls = new java.util.ArrayList(); + try (java.util.stream.Stream files = + java.nio.file.Files.list(lib)) { + for (java.nio.file.Path p : (Iterable) files::iterator) { + if (p.toString().endsWith(".jar")) { + urls.add(p.toUri().toURL()); + } + } + } catch (Exception e) { + return null; + } + if (urls.isEmpty()) { + return null; + } + return new java.net.URLClassLoader(urls.toArray(new java.net.URL[0]), + ClassLoader.getSystemClassLoader().getParent()); + } + + private static String describe(AbstractInsnNode insn) { + if (insn instanceof org.objectweb.asm.tree.LabelNode) { + return "LABEL " + ((org.objectweb.asm.tree.LabelNode) insn).toString(); + } + String text = insn.getClass().getSimpleName() + .replace("InsnNode", "") + " op=" + insn.getOpcode(); + if (insn instanceof LdcInsnNode) { + text += " cst=\"" + ((LdcInsnNode) insn).cst + "\""; + } else if (insn instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) insn; + text += " " + call.owner.replace('/', '.') + "." + call.name; + } else if (insn instanceof JumpInsnNode) { + text += " -> " + ((JumpInsnNode) insn).label; + } else if (insn instanceof VarInsnNode) { + text += " var" + ((VarInsnNode) insn).var; + } else if (insn instanceof FieldInsnNode) { + text += " " + ((FieldInsnNode) insn).owner.replace('/', '.') + + "." + ((FieldInsnNode) insn).name; + } + return text; + } + +private static void patch(Path jarPath, Path home, String envMeta) + throws Exception { + JarFile jar = new JarFile(jarPath.toFile()); + ClassNode cls = readClass(jar, TARGET_CLASS); + + MethodNode play = null; + boolean alreadyPatched = false; + for (MethodNode m : cls.methods) { + if (hasChoiceCall(m)) { + alreadyPatched = true; + } + if (findPlayBranch(m) != null) { + play = m; + } + } + if (alreadyPatched) { + System.out.println("already patched, nothing to do"); + jar.close(); + return; + } + if (play == null) { + throw new IllegalStateException( + "no Play handler found in " + TARGET_CLASS); + } +injectPlayChoice(play); + + // fa.loadedWeb() sets the button state to ERROR, which hides every + // button, and it can run after ha.y() already picked PLAY. Restore it. + boolean startButtonPatched = false; + for (MethodNode m : cls.methods) { + if (!m.name.equals("loadedWeb") || !m.desc.equals("()V") + || hasStartButtonCall(m)) { + continue; + } + InsnList code = new InsnList(); + code.add(new VarInsnNode(Opcodes.ALOAD, 0)); + code.add(new MethodInsnNode(Opcodes.INVOKESTATIC, CHOICE, + "restoreStartButton", "(Ljava/lang/Object;)V", false)); + m.instructions.insert(code); + startButtonPatched = true; + System.out.println("injected start-button restore into " + + TARGET_CLASS + ".loadedWeb()"); + break; + } + if (!startButtonPatched) { + System.out.println("WARNING: " + TARGET_CLASS + + ".loadedWeb() not found or already guarded"); + } + + // Two separate checks look at app.jar. sb.q() compares size and hash + // against the manifest; ha.ALLATORIxDEMO()Z hashes the running jar and + // reports "update found, restart the launcher". Both must be covered, + // otherwise the launcher still nags about an update it would undo. + ClassNode sb = readClass(jar, UPDATER_CLASS); + for (MethodNode m : sb.methods) { + if (m.name.equals("q") && m.desc.startsWith("(Ljava/nio/file/Path;L") + && m.desc.endsWith(")Z") && !hasGuardCall(m)) { + injectGuard(m); + System.out.println("guarded " + UPDATER_CLASS + "." + m.name + + m.desc + " against re-download"); + break; + } + } + ClassWriter sbWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS); + sb.accept(sbWriter); + byte[] sbBytes = sbWriter.toByteArray(); + + ClassNode ha = readClass(jar, SELF_UPDATE_CLASS); + boolean selfGuarded = false; + for (MethodNode m : ha.methods) { + if (!hasAppJarCheck(m) || hasSelfGuard(m)) { + continue; + } + injectSelfGuard(m); + selfGuarded = true; + System.out.println("guarded " + SELF_UPDATE_CLASS + "." + m.name + + m.desc + " against the restart notice"); + break; + } + if (!selfGuarded) { + System.out.println("WARNING: " + SELF_UPDATE_CLASS + + " update check was not found or already guarded"); + } + ClassWriter haWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS); + ha.accept(haWriter); + byte[] haBytes = haWriter.toByteArray(); + +ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + cls.accept(writer); + writer.getClass(); + + byte[] envBytes = envMeta == null + ? "# not found\n#\n".getBytes(StandardCharsets.UTF_8) + : (envMeta + "\n" + "#\n").getBytes(StandardCharsets.UTF_8); + + Path backup = jarPath.resolveSibling(jarPath.getFileName() + ".orig"); + if (!Files.exists(backup)) { + Files.copy(jarPath, backup, StandardCopyOption.COPY_ATTRIBUTES); + System.out.println("backup: " + backup); + } + + Path tmp = jarPath.resolveSibling(jarPath.getFileName() + ".new"); + OutputStream out = Files.newOutputStream(tmp); + JarOutputStream outJar = new JarOutputStream(out); + try { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); +String name = entry.getName(); + if (name.equals(TARGET_CLASS + ".class") + || name.equals(UPDATER_CLASS + ".class") + || name.equals(SELF_UPDATE_CLASS + ".class")) { + continue; + } + if (name.startsWith("rsx/") || name.equals(CHOICE_RESOURCE)) { + continue; + } + JarEntry copy = new JarEntry(name); + outJar.putNextEntry(copy); + InputStream in = jar.getInputStream(entry); + try { + copyStream(in, outJar); + } finally { + in.close(); + } + outJar.closeEntry(); + } + outJar.putNextEntry(new JarEntry(TARGET_CLASS + ".class")); + outJar.write(writer.toByteArray()); + outJar.closeEntry(); + +outJar.putNextEntry(new JarEntry(UPDATER_CLASS + ".class")); + outJar.write(sbBytes); + outJar.closeEntry(); + + outJar.putNextEntry(new JarEntry(SELF_UPDATE_CLASS + ".class")); + outJar.write(haBytes); + outJar.closeEntry(); + +// rsx/ classes come from this patcher's own jar, which keeps the + // installer free of build directories: Choice$1 and Choice$2 have + // to travel along or loading Choice fails with NoClassDefFoundError + Path self = Paths.get(LauncherPatch.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + int copied = 0; + try (JarFile own = new JarFile(self.toFile())) { + Enumeration mine = own.entries(); + while (mine.hasMoreElements()) { + JarEntry e = mine.nextElement(); + if (!e.getName().startsWith("rsx/") || e.isDirectory()) { + continue; + } + outJar.putNextEntry(new JarEntry(e.getName())); + InputStream in = own.getInputStream(e); + try { + copyStream(in, outJar); + } finally { + in.close(); + } + outJar.closeEntry(); + copied++; + System.out.println(" + " + e.getName()); + } + } + if (copied == 0) { + throw new IllegalStateException( + "no rsx/ classes inside the patcher jar"); + } + + outJar.putNextEntry(new JarEntry(CHOICE_RESOURCE)); + outJar.write(envBytes); + outJar.closeEntry(); + + // the runtime location, so the launcher-side code finds the agent + // on whatever machine this gets installed on + outJar.putNextEntry(new JarEntry("rsx/home.txt")); + outJar.write((home.toAbsolutePath().toString() + "\n") + .getBytes(StandardCharsets.UTF_8)); + outJar.closeEntry(); + System.out.println(" modloader home: " + home.toAbsolutePath()); + + outJar.putNextEntry(new JarEntry("rsx/patch.marker")); + outJar.write(MARKER); + outJar.closeEntry(); + } finally { + outJar.close(); + jar.close(); + } + + replace(tmp, jarPath); + System.out.println("patched " + jarPath + " (" + + Files.size(jarPath) + " bytes)"); + + String hash = sha256(jarPath); + Path json = jarPath.resolveSibling("app.jar.json"); + if (Files.isRegularFile(json)) { + Files.write(json, ("{\"hash\": \"" + hash + "\"}") + .getBytes(StandardCharsets.UTF_8)); + System.out.println("app.jar.json updated with " + hash); + } + } + +private static boolean hasChoiceCall(MethodNode m) { + for (AbstractInsnNode insn = m.instructions.getFirst(); insn != null; + insn = insn.getNext()) { + if (insn instanceof MethodInsnNode + && CHOICE.equals(((MethodInsnNode) insn).owner)) { + return true; + } + } + return false; + } + + private static boolean hasGuardCall(MethodNode m) { + for (AbstractInsnNode insn = m.instructions.getFirst(); insn != null; + insn = insn.getNext()) { + if (insn instanceof MethodInsnNode + && GUARD.equals(((MethodInsnNode) insn).owner)) { + return true; + } + } + return false; + } + + /** + * Put the patched jar in place. + * + * Antivirus and the shell indexer routinely hold app.jar for a moment, so this + * retries and falls back through delete+move and plain copy instead of failing + * on a transient lock. + */ +private static void replace(Path tmp, Path target) throws Exception { + Exception last = null; + for (int attempt = 0; attempt < 12; attempt++) { + try { + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + return; + } catch (Exception e) { + last = e; + Thread.sleep(750L); + } + } + try { + Files.deleteIfExists(target); + Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING); + return; + } catch (Exception e) { + last = e; + } + Files.copy(tmp, target, StandardCopyOption.REPLACE_EXISTING); + Files.deleteIfExists(tmp); + System.out.println(" (replaced by copy, move stayed locked)"); + if (last != null) { + System.out.println(" lock detail: " + last.getMessage()); + } + } + + private static boolean hasStartButtonCall(MethodNode m) { + for (AbstractInsnNode insn = m.instructions.getFirst(); insn != null; + insn = insn.getNext()) { + if (insn instanceof MethodInsnNode + && "restoreStartButton".equals(((MethodInsnNode) insn).name)) { + return true; + } + } + return false; + } + + private static boolean hasSelfGuard(MethodNode m) { + for (AbstractInsnNode insn = m.instructions.getFirst(); insn != null; + insn = insn.getNext()) { + if (insn instanceof MethodInsnNode) { + MethodInsnNode call = (MethodInsnNode) insn; + if (GUARD.equals(call.owner) && "skipSelfUpdate".equals(call.name)) { + return true; + } + } + } + return false; + } + + /** The method that hashes the running jar mentions the "app.jar" literal. */ + private static boolean hasAppJarCheck(MethodNode m) { + if (!m.desc.endsWith(")Z")) { + return false; + } + for (AbstractInsnNode insn = m.instructions.getFirst(); insn != null; + insn = insn.getNext()) { + if (insn instanceof LdcInsnNode + && "app.jar".equals(((LdcInsnNode) insn).cst)) { + return true; + } + } + return false; + } + + /** + * Guard the check that hashes the jar in use: report "no update" so the + * launcher stops showing the restart notice. + * + * The method is an instance method whose slot 0 is the launcher itself, not a + * directory, so the guard is handed the working directory as a String: the same + * directory the check compares its jar against. + */ + private static void injectSelfGuard(MethodNode m) { + org.objectweb.asm.tree.LabelNode body = new org.objectweb.asm.tree.LabelNode(); + InsnList code = new InsnList(); + code.add(new org.objectweb.asm.tree.LdcInsnNode("user.dir")); + code.add(new MethodInsnNode(Opcodes.INVOKESTATIC, GUARD, + "skipSelfUpdate", "(Ljava/lang/String;)Z", false)); + code.add(new JumpInsnNode(Opcodes.IFNE, body)); + code.add(new org.objectweb.asm.tree.InsnNode(Opcodes.ICONST_0)); + code.add(new org.objectweb.asm.tree.InsnNode(Opcodes.IRETURN)); + code.add(new org.objectweb.asm.tree.FrameNode(Opcodes.F_SAME, 0, null, 0, null)); + code.add(body); + m.instructions.insert(code); + System.out.println("injected self-update guard into " + m.name + m.desc); + } + + /** Put rsx.Choice.pick() at the first instruction of the clickPlay block. */ + private static void injectPlayChoice(MethodNode play) { + MethodInsnNode call = new MethodInsnNode(Opcodes.INVOKESTATIC, + CHOICE, "pick", "()V", false); + InsnList insns = play.instructions; + for (AbstractInsnNode insn = insns.getFirst(); insn != null; ) { + AbstractInsnNode next = insn.getNext(); + if (insn instanceof LdcInsnNode + && PLAY_ID.equals(((LdcInsnNode) insn).cst)) { + AbstractInsnNode eq = insn.getNext(); + AbstractInsnNode jump = eq == null ? null : eq.getNext(); + if (eq instanceof MethodInsnNode && jump instanceof JumpInsnNode + && "equals".equals(((MethodInsnNode) eq).name)) { + // javac emits "IFNE end" here, so the block that handles + // clickPlay starts on the fall-through, right after the jump + insns.insert(jump, call); + System.out.println("injected rsx.Choice.pick() into " + + play.name + play.desc); + return; + } + } + insn = next; + } + throw new IllegalStateException("could not place the Play dialog call"); + } + + /** + * Guard the "needs downloading" test: if rsx.Patch claims the file, report + * it as current so the launcher does not overwrite its own patched jar. + */ + private static void injectGuard(MethodNode m) { + org.objectweb.asm.tree.LabelNode skip = + new org.objectweb.asm.tree.LabelNode(); + InsnList code = new InsnList(); + code.add(new VarInsnNode(Opcodes.ALOAD, 0)); + code.add(new MethodInsnNode(Opcodes.INVOKESTATIC, GUARD, + "skipDownload", "(Ljava/nio/file/Path;)Z", false)); + code.add(new JumpInsnNode(Opcodes.IFEQ, skip)); + code.add(new org.objectweb.asm.tree.InsnNode(Opcodes.ICONST_0)); + code.add(new org.objectweb.asm.tree.InsnNode(Opcodes.IRETURN)); + // the branch introduces a new frame: at this point the operand stack is + // empty again and the locals are untouched, which is a SAME frame. + // COMPUTE_MAXS does not rebuild frames, so without it the JVM refuses + // the class with "Expecting a stackmap frame at branch target". + code.add(new org.objectweb.asm.tree.FrameNode(Opcodes.F_SAME, 0, null, 0, null)); + code.add(skip); + m.instructions.insert(code); + System.out.println("injected guard into " + m.name + m.desc); + } + + private static void copyStream(InputStream in, OutputStream out) + throws Exception { + byte[] buffer = new byte[1 << 16]; + int read; + while ((read = in.read(buffer)) > 0) { + out.write(buffer, 0, read); + } + } + + private static String sha256(Path file) throws Exception { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + InputStream in = Files.newInputStream(file); + try { + byte[] buffer = new byte[1 << 20]; + int read; + while ((read = in.read(buffer)) > 0) { + digest.update(buffer, 0, read); + } + } finally { + in.close(); + } + StringBuilder sb = new StringBuilder(); + for (byte b : digest.digest()) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/launcher-patch/src/ScanCalls.java b/launcher-patch/src/ScanCalls.java new file mode 100644 index 0000000..bc9f62f --- /dev/null +++ b/launcher-patch/src/ScanCalls.java @@ -0,0 +1,150 @@ +import java.io.InputStream; +import java.util.Enumeration; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +import org.objectweb.asm.ClassReader; +import org.objectweb.asm.ClassVisitor; +import org.objectweb.asm.MethodVisitor; +import org.objectweb.asm.Opcodes; + +/** + * Finds who calls a given method inside the launcher jar. + * + * usage: java -Dscan.owner=ha -Dscan.name=y -Dscan.desc=()V ScanCalls app.jar + * + * The launcher is only partially decompiled, so the caller of the start-up + * routine is looked up in the bytecode directly. + */ +public final class ScanCalls { + + private static int hits; + private static String owner; + private static String name; + private static String desc; + + public static void main(String[] args) throws Exception { + String jarPath = args.length > 0 ? args[0] + : System.getProperty("scan.jar", "app.jar"); + owner = System.getProperty("scan.owner", ""); + name = System.getProperty("scan.name", ""); + desc = System.getProperty("scan.desc", ""); + String dump = System.getProperty("scan.dump", ""); + + JarFile jar = new JarFile(jarPath); + try { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (!entry.getName().endsWith(".class")) { + continue; + } + InputStream in = jar.getInputStream(entry); + try { + new ClassReader(in).accept( + dump.isEmpty() ? new CallerVisitor() : new Dumper(dump), 0); + } finally { + in.close(); + } + } + } finally { + jar.close(); + } + if (dump.isEmpty()) { + System.out.println(" calls of " + owner + "." + name + desc + ": " + hits); + } + } + + /** Prints the instructions of one method, e.g. scan.dump=xa.run()V */ + private static final class Dumper extends ClassVisitor { + + private final String target; + private String cls; + private String method; + + Dumper(String target) { + super(Opcodes.ASM9); + this.target = target; + } + + @Override + public void visit(int version, int access, String className, + String signature, String superName, String[] interfaces) { + this.cls = className.replace('/', '.'); + } + + @Override + public MethodVisitor visitMethod(int access, String methodName, + String descriptor, String signature, String[] exceptions) { + this.method = methodName + descriptor; + boolean wanted = (cls + "." + method).equals(target); + if (!wanted) { + return null; + } + System.out.println("--- " + cls + "." + method); + return new MethodVisitor(Opcodes.ASM9) { + private int index; + + @Override + public void visitMethodInsn(int opcode, String o, String n, + String d, boolean itf) { + System.out.println(" " + (index++) + ": invoke " + + o.replace('/', '.') + "." + n + d); + } + + @Override + public void visitFieldInsn(int opcode, String o, String n, String d) { + System.out.println(" " + (index++) + ": field " + + o.replace('/', '.') + "." + n); + } + + @Override + public void visitLdcInsn(Object value) { + System.out.println(" " + (index++) + ": ldc \"" + value + "\""); + } + + @Override + public void visitInsn(int opcode) { + if (opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) { + System.out.println(" " + (index++) + ": return"); + } + } + }; + } + } + + private static final class CallerVisitor extends ClassVisitor { + + private String cls; + private String method; + + CallerVisitor() { + super(Opcodes.ASM9); + } + + @Override + public void visit(int version, int access, String className, + String signature, String superName, String[] interfaces) { + this.cls = className.replace('/', '.'); + } + + @Override + public MethodVisitor visitMethod(int access, String methodName, + String descriptor, String signature, String[] exceptions) { + this.method = methodName + descriptor; + return new MethodVisitor(Opcodes.ASM9) { + @Override + public void visitMethodInsn(int opcode, String calledOwner, + String calledName, String calledDesc, boolean itf) { + if (calledOwner.equals(owner) + && calledName.equals(name) + && calledDesc.equals(desc)) { + hits++; + System.out.println(" " + cls + "." + method + + " -> " + owner + "." + name + desc); + } + } + }; + } + } +} \ No newline at end of file diff --git a/launcher-patch/src/rsx/Choice.java b/launcher-patch/src/rsx/Choice.java new file mode 100644 index 0000000..944d530 --- /dev/null +++ b/launcher-patch/src/rsx/Choice.java @@ -0,0 +1,418 @@ +package rsx; + +import java.io.File; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; + +import javafx.application.Platform; +import javafx.scene.control.Alert; +import javafx.scene.control.ButtonBar.ButtonData; +import javafx.scene.control.ButtonType; + +/** + * Asks which way to launch, right when Play is pressed inside the launcher. + * + * Injected into the launcher's Play handler, so the answer steers this very + * launch: the dialog runs before the game process is built, and the env + * argument the launcher passes to the JVM is adjusted in memory (the launcher + * keeps its config in memory, so editing the file would not be enough). + * + * The dialog is a JavaFX Alert on purpose. A Swing modal opened from the + * JavaFX application thread deadlocks the launcher - it showed a frozen window + * and never returned. + */ +public final class Choice { + + private static final String TITLE = "RustexRemake"; + private static final String MODS = "mods"; + private static final String VANILLA = "vanilla"; + + private Choice() { + } + + /** + * Puts the start button back after the page has loaded. + * + * The launcher has a race of its own: xa.run() calls ha.y(), which sets the + * state to PLAY and then blocks, while fa.loadedWeb() later sets it to ERROR + * when the WebView finishes - and the ERROR state hides every button. With a + * fast page load loadedWeb wins the race backwards and the Play button is + * simply not there. Re-applying the state once, after loadedWeb has run, keeps + * the button on screen no matter which order the two happen in. + */ + public static void restoreStartButton(final Object app) { + try { + javafx.application.Platform.runLater(new Runnable() { + @Override + public void run() { + try { + applyStartState(app); + } catch (Throwable t) { + log("could not restore the start button: " + t); + } + } + }); + } catch (Throwable t) { + log("could not schedule the start button fix: " + t); + } + } + + private static void applyStartState(Object app) throws Exception { + Class states = Class.forName("z"); + Object[] values = (Object[]) states.getMethod("values").invoke(null); + Field func = app.getClass().getDeclaredField("func"); + func.setAccessible(true); + Object ui = func.get(app); + Method setter = func.getType().getMethod("setStartButton", states); + int play = 0; + int download = 2; + boolean installed = gameJar(); + setter.invoke(ui, values[installed ? play : download]); + log("start button restored: " + + (installed ? "PLAY" : "DOWNLOAD")); + } + +/** + * Whether the game looks installed, which decides between the PLAY and the + * DOWNLOAD button. ha.u is only filled in when a launch starts, so at start-up + * the install path comes from the launcher config instead. + */ + private static boolean gameJar() { + if (jarUnder(System.getProperty("rsx.gamefiles"))) { + return true; + } + try { + Class ha = Class.forName("ha"); + if (jarUnder((String) ha.getField("u").get(null))) { + return true; + } + } catch (Throwable ignored) { + } + try { + java.util.Properties props = new java.util.Properties(); + Path config = launcherConfig(); + try (java.io.Reader in = new java.io.InputStreamReader( + Files.newInputStream(config), Charset.forName("ISO-8859-1"))) { + props.load(in); + } + String base = props.getProperty("gamePath1", ""); + if (!base.isEmpty() + && jarUnder(new File(base, "rustexremake/gamefiles").getPath())) { + return true; + } + } catch (Throwable ignored) { + } + return false; + } + + private static boolean jarUnder(String dir) { + return dir != null && !dir.isEmpty() + && new File(dir, "game.jar").isFile(); + } + + public static void pick() { + try { + String current = readMode(); + String chosen = ask(current); + if (chosen == null) { + log("kept " + describe(current)); + return; + } + writeMode(chosen); + updateConfigFile(chosen); + applyToRunningConfig(chosen); + log("mode: " + chosen); + } catch (Throwable t) { + log("choice failed, keeping the current mode: " + t); + } + } + + private static String ask(final String current) { + String scripted = System.getProperty("rsx.choice"); + if (scripted != null && !scripted.isEmpty()) { + return scripted; + } + try { + Class.forName("javafx.application.Platform"); + } catch (Throwable t) { + log("JavaFX not reachable, keeping " + describe(current)); + return null; + } + if (!Platform.isFxApplicationThread()) { + final String[] result = new String[1]; + final java.util.concurrent.CountDownLatch done = + new java.util.concurrent.CountDownLatch(1); + Platform.runLater(new Runnable() { + @Override + public void run() { + try { + result[0] = askOnFxThread(current); + } finally { + done.countDown(); + } + } + }); + try { + done.await(2, java.util.concurrent.TimeUnit.MINUTES); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return result[0]; + } + return askOnFxThread(current); + } + + private static String askOnFxThread(String current) { + Alert alert = new Alert(Alert.AlertType.NONE); + alert.setTitle(TITLE); + alert.setHeaderText("Как запустить игру?"); + alert.setContentText( + "С модами — обычный режим. Включится модлоадер, загрузятся моды " + + "из папки mods, а встроенная телеметрия игры будет отключена. " + + "Выбирайте, если играете с модами или пишете свой мод.\n\n" + + "Ванилла — игра запустится вообще без модлоадера: ни модов, ни " + + "патчей, ровно как в оригинале. Выбирайте, чтобы проверить, " + + "что игра сама по себе работает нормально.\n\n" + + "Оставить как есть — больше не спрашивать и запустить в том же " + + "режиме, что и в прошлый раз (" + describe(current) + ").\n\n" + + "Режим можно сменить в любой момент: снова нажмите «Играть»."); + + ButtonType mods = new ButtonType("С модами", ButtonData.YES); + ButtonType vanilla = new ButtonType("Ванилла", ButtonData.NO); + ButtonType keep = new ButtonType("Оставить как есть", ButtonData.CANCEL_CLOSE); + alert.getButtonTypes().setAll(mods, vanilla, keep); + + ButtonType answer = alert.showAndWait().orElse(keep); + if (answer == mods) { + return MODS; + } + if (answer == vanilla) { + return VANILLA; + } + return null; + } + + private static String describe(String mode) { + return VANILLA.equals(mode) ? "ванилла" : "с модами"; + } + +/** + * Where the modloader runtime lives. + * + * The patcher writes this path into the launcher jar at install time, so the + * same build works on any machine: nothing here is tied to one install dir. + */ + private static File home() { + String override = System.getProperty("rsx.home"); + if (override != null && !override.isEmpty()) { + return new File(override); + } + try (java.util.Scanner s = new java.util.Scanner( + Choice.class.getResourceAsStream("home.txt"), "UTF-8")) { + if (s.hasNextLine()) { + String path = s.nextLine().trim(); + if (!path.isEmpty()) { + return new File(path); + } + } + } catch (Throwable ignored) { + // fall through to the old default + } + return new File("G:\\rsx-mod\\bridge"); + } + + private static String agentArg() { + String override = System.getProperty("rsx.agent"); + if (override != null && !override.isEmpty()) { + return override; + } + return "-javaagent:\"" + new File(home(), "rsx-bridge.jar").getPath() + "\""; + } + + private static String readMode() { + File f = new File(home(), "mode.txt"); + if (!f.isFile()) { + return MODS; + } + try { + String text = new String(Files.readAllBytes(f.toPath()), + Charset.forName("UTF-8")).trim(); + return VANILLA.equalsIgnoreCase(text) ? VANILLA : MODS; + } catch (Throwable t) { + return MODS; + } + } + + private static void writeMode(String mode) { + try { + Files.write(new File(home(), "mode.txt").toPath(), + mode.getBytes(Charset.forName("UTF-8"))); + } catch (Throwable t) { + log("cannot write mode.txt: " + t); + } + } + + /** + * Keep launcher_config.properties in step so a fresh launcher agrees. + * + * Written through java.util.Properties on purpose: this file is a properties + * file, and writing lines by hand breaks as soon as a value contains + * backslashes or a colon - it escaped them as \ and \: and the launcher then + * handed the JVM a mangled -javaagent path, which stopped the game from + * starting at all. + */ + private static void updateConfigFile(String mode) { + Path config = launcherConfig(); + if (config == null || !Files.isRegularFile(config)) { + return; + } + java.util.Properties props = new java.util.Properties(); + try (java.io.Reader in = new java.io.InputStreamReader( + Files.newInputStream(config), Charset.forName("ISO-8859-1"))) { + props.load(in); + } catch (Throwable t) { + log("cannot read the launcher config: " + t); + return; + } + try { + Files.copy(config, config.resolveSibling( + config.getFileName().toString() + ".rsxbak"), + StandardCopyOption.REPLACE_EXISTING); + if (MODS.equals(mode)) { + props.setProperty("env", agentArg()); + } else { + props.remove("env"); + } + try (java.io.Writer out = new java.io.OutputStreamWriter( + Files.newOutputStream(config), + Charset.forName("ISO-8859-1"))) { + props.store(out, "rsx modloader"); + } + log("launcher config file updated"); + } catch (Throwable t) { + log("cannot update the launcher config: " + t); + } + } + + /** + * The launcher keeps its config in memory, so the file is not enough for + * the launch that is about to start. The patcher records which obfuscated + * config field carries the env argument; here it is set on the live object, + * which is reached through the launcher's own singleton rather than by + * calling arbitrary statics. + */ + private static void applyToRunningConfig(String mode) { + String[] meta = envField(); + if (meta == null) { + return; + } + try { + Class configClass = Class.forName(meta[0]); + Object config = liveConfig(configClass); + if (config == null) { + log("launcher config object not found, file only"); + return; + } + Field f = configClass.getDeclaredField(meta[1]); + f.setAccessible(true); + f.set(config, MODS.equals(mode) ? agentArg() : ""); + log("launcher env argument set in memory"); + } catch (Throwable t) { + log("cannot set the env argument in memory: " + t); + } + } + +/** + * Reach the launcher's live config object. + * + * jb.ALLATORIxDEMO() is an instance method in this build, so invoking it with a + * null receiver throws. The singleton itself sits in a static field of its own + * class, which is what gets read here. + */ + private static Object liveConfig(Class configClass) throws Exception { + Class launcher = Class.forName("jb"); + Object app = null; + for (Field f : launcher.getDeclaredFields()) { + if (!java.lang.reflect.Modifier.isStatic(f.getModifiers()) + || !launcher.isAssignableFrom(f.getType())) { + continue; + } + f.setAccessible(true); + Object value = f.get(null); + if (value != null) { + app = value; + break; + } + } + if (app == null) { + java.lang.reflect.Method accessor = + launcher.getMethod("ALLATORIxDEMO"); + if (!java.lang.reflect.Modifier.isStatic( + accessor.getModifiers())) { + log("jb.ALLATORIxDEMO is not static, cannot reach the launcher"); + return null; + } + app = accessor.invoke(null); + } + if (app == null) { + return null; + } + for (Field f : launcher.getDeclaredFields()) { + f.setAccessible(true); + Object candidate; + try { + candidate = f.get(app); + } catch (Throwable t) { + continue; + } + if (configClass.isInstance(candidate)) { + return candidate; + } + } + return null; + } + + private static String[] envField() { + try (java.util.Scanner s = new java.util.Scanner( + Choice.class.getResourceAsStream("env-field.txt"), "UTF-8")) { + if (!s.hasNextLine()) { + return null; + } + String owner = s.nextLine().trim(); + String field = s.nextLine().trim(); + return owner.isEmpty() ? null : new String[] {owner, field}; + } catch (Throwable t) { + return null; + } + } + + private static Path launcherConfig() { + String appdata = System.getenv("APPDATA"); + if (appdata == null) { + return null; + } + return Paths.get(appdata, "kjstudio", "launcher", + "launcher_config.properties"); + } + + private static void log(String message) { + try { + String appdata = System.getenv("APPDATA"); + Path dir = Paths.get(appdata == null ? "." : appdata, "kjstudio", + "rsx-launcher"); + Files.createDirectories(dir); + Files.write(dir.resolve("choice.log"), + (message + System.lineSeparator()).getBytes( + Charset.forName("UTF-8")), + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (Throwable ignored) { + // logging is best effort + } + } +} \ No newline at end of file diff --git a/launcher-patch/src/rsx/Patch.java b/launcher-patch/src/rsx/Patch.java new file mode 100644 index 0000000..10dcbe4 --- /dev/null +++ b/launcher-patch/src/rsx/Patch.java @@ -0,0 +1,84 @@ +package rsx; + +import java.io.File; + +/** + * Lets the patched launcher survive its own updater. + * + * The download engine compares every manifest entry with the file on disk and + * flags app.jar as outdated because the patch changes its size, so the launcher + * keeps offering to re-download itself and would overwrite the patch. The guard + * is skipped for every other file, so game updates keep working. + * + * Removing the marker file .rsx-patched next to app.jar, or starting the + * launcher with -Drsx.allowLauncherUpdate=true, restores the normal behaviour. + */ +public final class Patch { + + private Patch() { + } + + /** True when this file must not be treated as outdated. */ + public static boolean skipDownload(java.nio.file.Path path) { + try { + if (path == null) { + return false; + } + java.nio.file.Path name = path.getFileName(); + if (name == null || !"app.jar".equalsIgnoreCase(name.toString())) { + return false; + } + log("skip: " + path); + if ("true".equalsIgnoreCase( + System.getProperty("rsx.allowLauncherUpdate"))) { + return false; + } + File dir = path.toAbsolutePath().getParent() == null + ? new File(".") + : path.toAbsolutePath().getParent().toFile(); + boolean on = new File(dir, ".rsx-patched").isFile(); + log("skipDownload(" + path + ") -> " + on); + return on; + } catch (Throwable t) { + log("guard error: " + t); + return false; + } + } + + /** + * Same question for the second check, the one that hashes the running jar + * itself and then reports "update found, restart the launcher". + */ + public static boolean skipSelfUpdate(String launcherDir) { + try { + if (launcherDir == null || launcherDir.isEmpty()) { + return false; + } + if ("true".equalsIgnoreCase( + System.getProperty("rsx.allowLauncherUpdate"))) { + return false; + } + boolean on = new File(launcherDir, ".rsx-patched").isFile(); + log("skipSelfUpdate(" + launcherDir + ") -> " + on); + return on; + } catch (Throwable t) { + log("guard error: " + t); + return false; + } + } + + private static void log(String message) { + try { + String appdata = System.getenv("APPDATA"); + java.nio.file.Path dir = java.nio.file.Paths.get( + appdata == null ? "." : appdata, "kjstudio", "rsx-launcher"); + java.nio.file.Files.createDirectories(dir); + java.nio.file.Files.write(dir.resolve("updater-guard.log"), + (message + System.lineSeparator()).getBytes("UTF-8"), + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.APPEND); + } catch (Throwable ignored) { + // best effort + } + } +} \ No newline at end of file diff --git a/mod-template/build.gradle b/mod-template/build.gradle new file mode 100644 index 0000000..7601ee5 --- /dev/null +++ b/mod-template/build.gradle @@ -0,0 +1,42 @@ +// Build settings for a RustexRemake mod. +// +// A mod is compiled ONCE against the stable API and never rebuilt: the bridge +// resolves every logical name against whatever build is installed, so a vendor +// re-obfuscation does not invalidate anything that was already built. + +plugins { + id 'java' +} + +group = 'com.example' +version = '1.0.0' + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + // the only thing a mod ever compiles against + compileOnly files('libs/rsx-api.jar') +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release = 8 +} + +jar { + archiveFileName = "${project.name}.jar" + manifest { + // the bridge reads this to find the class to instantiate + attributes('Rsx-Mod-Entrypoint': 'com.example.ExampleMod') + } +} + +// convenience: drop the built mod straight into the client's mods folder +tasks.register('installMod', Copy) { + dependsOn 'jar' + from jar + into "${projectDir}/../../rsx-mod/mods" +} \ No newline at end of file diff --git a/mod-template/settings.gradle b/mod-template/settings.gradle new file mode 100644 index 0000000..2fecac1 --- /dev/null +++ b/mod-template/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'example-mod' diff --git a/mod-template/src/main/java/example/ExampleMod.java b/mod-template/src/main/java/example/ExampleMod.java new file mode 100644 index 0000000..2e6301c --- /dev/null +++ b/mod-template/src/main/java/example/ExampleMod.java @@ -0,0 +1,39 @@ +package com.example; + +import net.rustexremake.api.Rsx; +import net.rustexremake.api.RsxMod; + +/** + * Example mod. + * + * Note what is NOT here: no obfuscated class name, no field name, no reflection. + * The mod asks for logical names, and the bridge maps them onto the build that + * is installed. Build this once - it keeps working across vendor updates. + */ +public class ExampleMod implements RsxMod { + + private int ticks; + + @Override + public void onLoad() { + Rsx.info("ExampleMod загружен, билд " + Rsx.buildId()); + + // Repeating task. It is queued by a timer thread but always executed by + // the game's own tick, so touching game state here is safe. + Rsx.every(1000L, new Runnable() { + @Override + public void run() { + ticks++; + Rsx.info("tick " + ticks + + " | игрок: " + Rsx.player() + + " | мир: " + Rsx.world() + + " | экран: " + Rsx.currentScreen()); + } + }); + } + + @Override + public void onUnload() { + Rsx.info("ExampleMod выгружен"); + } +} \ No newline at end of file diff --git a/scripts/build.ps1 b/scripts/build.ps1 new file mode 100644 index 0000000..ff046ab --- /dev/null +++ b/scripts/build.ps1 @@ -0,0 +1,141 @@ +# Builds everything the installer needs, into dist\: +# +# dist\rsx-bridge.jar the agent (-javaagent), Java 8 +# dist\rsx-api.jar API a mod compiles against +# dist\rsx-launcher-patch.jar launcher patcher with ASM inside +# dist\mod-template\ ready-to-edit mod project +# +# Only a JDK is required: ASM is vendored under third_party\asm so the build +# works offline and the patcher jar is self-contained. + +$ErrorActionPreference = 'Stop' +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } +$Dist = Join-Path $Repo 'dist' +$Out = Join-Path $Repo 'build' +$Asm = @(Get-ChildItem (Join-Path $Repo 'third_party\asm') -Filter '*.jar' | + ForEach-Object { $_.FullName }) + +function Say($text) { Write-Host " $text" } + +if (-not (Get-Command javac -ErrorAction SilentlyContinue)) { + Write-Host ' Нужен JDK (javac) в PATH.' -ForegroundColor Red + exit 1 +} +if ($Asm.Count -lt 2) { + Write-Host ' Не найдены jar-ки ASM в third_party\asm.' -ForegroundColor Red + exit 1 +} + +New-Item -ItemType Directory -Force -Path $Dist, $Out | Out-Null + +# ---------------------------------------------------------------- agent +$agentSrc = Join-Path $Repo 'bridge\src\main\java' +$agentOut = Join-Path $Out 'agent' +Remove-Item $agentOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $agentOut | Out-Null +$agentFiles = Get-ChildItem $agentSrc -Recurse -Filter '*.java' | ForEach-Object { $_.FullName } +& javac -encoding UTF-8 --release 8 -nowarn -d $agentOut $agentFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Агент не собрался.' -ForegroundColor Red; exit 1 } +Say "агент скомпилирован ($($agentFiles.Count) файлов)" + +$manifest = Join-Path $Out 'agent-manifest.txt' +@" +Manifest-Version: 1.0 +Premain-Class: rsx.RsxAgent +Can-Redefine-Classes: false +Can-Retransform-Classes: true + +"@ | Set-Content -LiteralPath $manifest -Encoding ASCII + +Push-Location $agentOut +& jar cfm (Join-Path $Dist 'rsx-bridge.jar') $manifest -C $agentOut . +Pop-Location +Say "rsx-bridge.jar ($((Get-Item (Join-Path $Dist 'rsx-bridge.jar')).Length) байт)" + +# ---------------------------------------------------------------- api +$apiOut = Join-Path $Out 'api' +Remove-Item $apiOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $apiOut | Out-Null +$apiFiles = Get-ChildItem (Join-Path $Repo 'bridge\src\main\java\net') -Recurse -Filter '*.java' | + ForEach-Object { $_.FullName } +# the API delegates to the agent internals, so they have to be on the +# classpath; only net/ classes end up in the published jar +& javac -encoding UTF-8 --release 8 -nowarn -cp $agentOut -d $apiOut $apiFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' API не собрался.' -ForegroundColor Red; exit 1 } +Push-Location $apiOut +& jar cf (Join-Path $Dist 'rsx-api.jar') . +Pop-Location +Say "rsx-api.jar ($((Get-Item (Join-Path $Dist 'rsx-api.jar')).Length) байт)" + +# ---------------------------------------------------------------- patcher +$patchOut = Join-Path $Out 'patcher' +Remove-Item $patchOut -Recurse -Force -ErrorAction SilentlyContinue +New-Item -ItemType Directory -Force -Path $patchOut | Out-Null +$patchSrc = Join-Path $Repo 'launcher-patch\src' +# Choice needs JavaFX, which only the launcher ships; it is compiled separately +# below with those jars on the classpath +$patchFiles = Get-ChildItem $patchSrc -Recurse -Filter '*.java' | + Where-Object { $_.Name -ne 'Choice.java' } | + ForEach-Object { $_.FullName } +& javac -encoding UTF-8 --release 8 -nowarn -cp ($Asm -join ';') -d $patchOut $patchFiles +if ($LASTEXITCODE -ne 0) { Write-Host ' Патчер не собрался.' -ForegroundColor Red; exit 1 } + +# Choice talks to JavaFX, so the launcher's own runtime provides those classes +$javafx = Join-Path $env:APPDATA 'kjstudio\launcher\runtime\javafx\lib' +if (Test-Path $javafx) { + $fx = @(Get-ChildItem $javafx -Filter '*.jar' | ForEach-Object { $_.FullName }) + & javac -encoding UTF-8 --release 8 -nowarn -cp ($fx -join ';') -d $patchOut ` + (Join-Path $patchSrc 'rsx\Choice.java') + if ($LASTEXITCODE -ne 0) { + Write-Host ' Choice не собрался: нужны JavaFX-jar-ки лаунчера.' -ForegroundColor Red + exit 1 + } +} else { + Say 'ВНИМАНИЕ: JavaFX лаунчера не найден, Choice собран вслепую' +} + +# shade ASM so the jar runs on any machine +$shade = $patchOut +foreach ($jar in $Asm) { + Push-Location $shade + & jar xf $jar + Pop-Location +} +Remove-Item (Join-Path $shade 'META-INF\MANIFEST.MF') -Force -ErrorAction SilentlyContinue +Remove-Item (Join-Path $shade 'META-INF\versions') -Recurse -Force -ErrorAction SilentlyContinue + +$patchManifest = Join-Path $Out 'patcher-manifest.txt' +@" +Manifest-Version: 1.0 +Main-Class: LauncherPatch + +"@ | Set-Content -LiteralPath $patchManifest -Encoding ASCII +Push-Location $shade +& jar cfm (Join-Path $Dist 'rsx-launcher-patch.jar') $patchManifest -C $shade . +Pop-Location +Say "rsx-launcher-patch.jar ($((Get-Item (Join-Path $Dist 'rsx-launcher-patch.jar')).Length) байт)" + +# ---------------------------------------------------------------- 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 +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 $Dist 'rsx-api.jar') "$tpl\libs\" -Force +@' +@echo off +rem Builds this mod and drops the jar into the client's mods folder. +cd /d "%~dp0" +call gradle jar +if errorlevel 1 exit /b 1 +echo. +echo Скопируй build\libs\example-mod.jar в working_dir\mods +'@ | Set-Content -LiteralPath (Join-Path $tpl 'build.cmd') -Encoding ASCII +Say 'mod-template готов' + +Write-Host '' +Write-Host ' Готово:' -ForegroundColor Green +Get-ChildItem $Dist -File | ForEach-Object { Write-Host " $($_.Name)" } \ No newline at end of file diff --git a/scripts/install-hosts.ps1 b/scripts/install-hosts.ps1 new file mode 100644 index 0000000..ebc66e9 --- /dev/null +++ b/scripts/install-hosts.ps1 @@ -0,0 +1,28 @@ +# Points the vendor's hardcoded server address at this machine so the client +# reaches the local stand-in server instead of the real one. +# +# The client keeps 185.207.214.85 inside its own code (both the /server-ping +# URL and the connect target), so there is no in-game switch for it - the only +# way is a hosts entry. Run this once from an elevated prompt. + +$ErrorActionPreference = 'Stop' +$hosts = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts' +$marker = '# rsx modloader test server' +$line = '127.0.0.1 185.207.214.85' + +$id = [Security.Principal.WindowsIdentity]::GetCurrent() +$isAdmin = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Host ' Нужны права администратора: запусти PowerShell от администратора.' -ForegroundColor Red + exit 1 +} + +$content = @(Get-Content -LiteralPath $hosts | + Where-Object { $_ -notmatch [regex]::Escape($marker) -and + $_ -notmatch '^\s*127\.0\.0\.1\s+185\.207\.214\.85' }) + +Set-Content -LiteralPath $hosts -Encoding ASCII -Value ($content + "$line $marker") +Write-Host ' Готово: 185.207.214.85 теперь указывает на 127.0.0.1.' -ForegroundColor Green +Write-Host ' Запусти testserver\start.cmd, потом play.cmd -> 1 (с модами).' +Write-Host ' Обратно: uninstall-hosts.ps1 (или netsh int ip reset).' \ No newline at end of file diff --git a/scripts/install.ps1 b/scripts/install.ps1 new file mode 100644 index 0000000..195d3da --- /dev/null +++ b/scripts/install.ps1 @@ -0,0 +1,188 @@ +# Installs the modloader: copies the runtime files, patches the launcher and +# wires the agent into the launcher config. +# +# install.cmd normal install +# install.cmd --uninstall put everything back +# install.cmd --home install the runtime somewhere else +# install.cmd --game point at a specific game installation + +$ErrorActionPreference = 'Stop' +$Repo = Split-Path -Parent $PSScriptRoot +if (-not $Repo) { $Repo = $PSCommandPath | Split-Path -Parent | Split-Path -Parent } + +$LauncherHome = Join-Path $env:APPDATA 'kjstudio\launcher' +$Config = Join-Path $LauncherHome 'launcher_config.properties' +$AppJar = Join-Path $LauncherHome 'app.jar' +$Marker = Join-Path $LauncherHome '.rsx-patched' +$Dist = Join-Path $Repo 'dist' +$ModHome = Join-Path $env:LOCALAPPDATA 'rsx-mod' +$GamePath = $null +$Uninstall = $false + +for ($i = 0; $i -lt $args.Count; $i++) { + switch ($args[$i]) { + '--uninstall' { $Uninstall = $true } + '--home' { $ModHome = $args[++$i] } + '--game' { $GamePath = $args[++$i] } + default { } + } +} + +function Say($text, $color) { + if ($color) { Write-Host " $text" -ForegroundColor $color } else { Write-Host " $text" } +} +function Die($text) { Say $text Red; exit 1 } + +# --------------------------------------------------------------- launcher must be closed +function Get-LauncherRunning { + Get-Process -ErrorAction SilentlyContinue | Where-Object { + $_.MainWindowTitle -like '*Koptojop*' -or + ($_.Path -and $_.Path -like "$LauncherHome*") + } +} + +$running = Get-LauncherRunning +if ($running) { + Die 'Лаунчер запущен - закрой его (крестик) и запусти установщик снова.' +} + +# --------------------------------------------------------------- java and patcher +$FindPatcher = Join-Path $Dist 'rsx-launcher-patch.jar' + +function Find-Java { + $candidates = @( + (Join-Path $LauncherHome 'runtime\java\bin\java.exe') + ) + foreach ($c in $candidates) { + if (Test-Path $c) { return $c } + } + $cmd = Get-Command java -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + return $null +} + +# --------------------------------------------------------------- read the config safely +# Handing the config to java.util.Properties is the only safe way to read it: it +# is a properties file, where a backslash escapes the next character. +function Read-GamePath($java, $path, $patcher) { + if (-not (Test-Path $path)) { return $null } + $prev = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + $raw = & $java -jar $patcher getprop $path gamePath1 2>$null + $ErrorActionPreference = $prev + if ($raw) { + $value = ($raw | Select-Object -First 1) -replace '^\s+', '' + if ($value) { return $value } + } + return $null +} + +if (-not (Test-Path $Config)) { + Die "Не найден конфиг лаунчера: $Config" +} + +$game = Read-GamePath (Find-Java) $Config $FindPatcher +if ($GamePath) { $game = $GamePath } + +# --------------------------------------------------------------- uninstall +if ($Uninstall) { + Say 'Удаляю модлоадер...' + $backup = "$AppJar.orig" + if (Test-Path $backup) { + Copy-Item $backup $AppJar -Force + Say 'app.jar восстановлен из резервной копии' + } else { + Say 'резервной копии нет, лаунчер не трогаю' + } + $ErrorActionPreference = 'Continue' + & $java -jar $FindPatcher setenv $Config '' 2>&1 | + ForEach-Object { Say $_ } + $ErrorActionPreference = 'Stop' + Say 'env убран из конфига лаунчера' + Remove-Item $Marker -Force -ErrorAction SilentlyContinue + Say 'маркер удалён' + Say 'Готово. Лаунчер снова запускается как обычно.' Green + exit 0 +} + +# --------------------------------------------------------------- checks +if (-not (Test-Path $Dist)) { Die "Нет каталога dist: $Dist (собери через build.cmd)" } +foreach ($f in 'rsx-bridge.jar', 'rsx-launcher-patch.jar', 'rsx-api.jar') { + if (-not (Test-Path (Join-Path $Dist $f))) { Die "Нет dist\$f - запусти build.cmd" } +} +$java = Find-Java +if (-not $java) { Die 'Не найден java (нужен лаунчерный runtime или java в PATH)' } + +# a clean base to patch: re-patching an already patched jar is a no-op +if (Test-Path "$AppJar.orig") { + Copy-Item "$AppJar.orig" $AppJar -Force + Say 'восстановлен оригинальный app.jar перед патчем' +} + +Say "Лаунчер: $LauncherHome" +Say "Игра: $(if ($game) { $game } else { 'не указан в gamePath1' })" +Say "Модлоадер: $ModHome" +Say '' + +# --------------------------------------------------------------- copy the runtime +New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'bridge\profiles') | Out-Null +New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'mods') | Out-Null +Copy-Item (Join-Path $Dist 'rsx-bridge.jar') (Join-Path $ModHome 'bridge') -Force +Say 'скопирован rsx-bridge.jar' + +$existingProfile = Join-Path $ModHome 'bridge\profiles\latest.rsxp' +$oldProfile = 'G:\rsx-mod\bridge\profiles\latest.rsxp' +if (-not (Test-Path $existingProfile) -and (Test-Path $oldProfile)) { + Copy-Item $oldProfile (Split-Path $existingProfile) -Force + Say 'перенесён профиль сборки игры (latest.rsxp)' +} elseif (Test-Path $existingProfile) { + Say 'профиль уже на месте, оставляю как есть' +} else { + Say 'профиля нет - запусти prepare-build.cmd после первой установки', Yellow +} + +# --------------------------------------------------------------- patch the launcher +Say '' +Say 'Патчу лаунчер...' +$patcher = Join-Path $Dist 'rsx-launcher-patch.jar' +$meta = "wa`nALLATORIxDEMO" +# native stderr must not be treated as a PowerShell error here +$prev = $ErrorActionPreference +$ErrorActionPreference = 'Continue' +$out = & $java -jar $patcher patch $AppJar $ModHome $meta 2>&1 +$code = $LASTEXITCODE +$ErrorActionPreference = $prev +$out | ForEach-Object { Say $_ } +if ($code -ne 0) { + Die "Патч не удался (код $code). Если лаунчер запущен с правами администратора - закрой его и повтори." +} + +Set-Content -LiteralPath $Marker -Value 'patched app.jar: rsx modloader' -Encoding ASCII +Say 'маркер .rsx-patched создан' + +# --------------------------------------------------------------- wire the agent +$agentArg = "-javaagent:`"$ModHome\bridge\rsx-bridge.jar`"" +$ErrorActionPreference = 'Continue' +$out = & $java -jar $patcher setenv $Config $agentArg 2>&1 +$code = $LASTEXITCODE +$ErrorActionPreference = $prev +$out | ForEach-Object { Say $_ } +if ($code -ne 0) { + Die "Не удалось записать env в конфиг лаунчера (код $code)" +} + +Say '' +Say 'Проверяю, что путь читается как надо...' +$ErrorActionPreference = 'Continue' +$check = & $java -jar $patcher showenv $Config 2>&1 +$ErrorActionPreference = $prev +$check | ForEach-Object { Say $_ } + +Say '' +Say 'Готово.' Green +Say '' +Say 'Дальше:' +Say ' 1. запускай лаунчер через start-launcher.cmd, а не ярлыком -' +Say ' updater.exe перекачает app.jar и сотрёт патч' +Say ' 2. жми «Играть» - появится выбор «С модами / Ванилла»' +Say " 3. моды клади в $ModHome\mods" \ No newline at end of file diff --git a/scripts/start-launcher.ps1 b/scripts/start-launcher.ps1 new file mode 100644 index 0000000..14680e7 --- /dev/null +++ b/scripts/start-launcher.ps1 @@ -0,0 +1,41 @@ +# Starts the patched launcher directly, without updater.exe. +# +# updater.exe re-downloads app.jar whenever its hash differs from the +# manifest, which is exactly what a patched jar looks like - so launching +# through the desktop shortcut silently undoes the patch. This runs the +# launcher itself, with the JavaFX runtime that ships next to it. + +$ErrorActionPreference = 'Stop' +$LauncherHome = Join-Path $env:APPDATA 'kjstudio\launcher' +$Java = Join-Path $LauncherHome 'runtime\java\bin\javaw.exe' +$Jar = Join-Path $LauncherHome 'app.jar' +$FxLib = Join-Path $LauncherHome 'runtime\javafx\lib' +$FxBin = Join-Path $LauncherHome 'runtime\javafx\bin' + +if (-not (Test-Path $Java) -or -not (Test-Path $Jar)) { + Write-Host " Нет лаунчера: $Jar" -ForegroundColor Red + exit 1 +} + +Remove-Item (Join-Path $LauncherHome 'app.lock') -Force -ErrorAction SilentlyContinue + +$env:JAVA_TOOL_OPTIONS = '' +Write-Host ' Запускаю лаунчер напрямую (без updater.exe)...' +Start-Process -FilePath $Java -ArgumentList @( + '--module-path', "`"$FxLib`"", + '--add-modules', 'ALL-MODULE-PATH', + "-Djava.library.path=`"$FxBin`"", + '-Dprism.order=sw', + '-cp', "`"$Jar`"", + 'application.Main' +) -WorkingDirectory $LauncherHome | Out-Null + +Start-Sleep -Seconds 12 +$up = Get-Process -ErrorAction SilentlyContinue | + Where-Object { $_.MainWindowTitle -like '*Koptojop*' } +if ($up) { + Write-Host ' Лаунчер запущен, окно должно появиться.' -ForegroundColor Green +} else { + Write-Host ' Лаунчер не поднялся, смотри logs в папке лаунчера.' -ForegroundColor Yellow + exit 1 +} \ No newline at end of file diff --git a/scripts/uninstall-hosts.ps1 b/scripts/uninstall-hosts.ps1 new file mode 100644 index 0000000..cf61e00 --- /dev/null +++ b/scripts/uninstall-hosts.ps1 @@ -0,0 +1,20 @@ +# Removes the hosts entry added by install-hosts.ps1. + +$ErrorActionPreference = 'Stop' +$hosts = Join-Path $env:SystemRoot 'System32\drivers\etc\hosts' +$marker = '# rsx modloader test server' + +$id = [Security.Principal.WindowsIdentity]::GetCurrent() +$isAdmin = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +if (-not $isAdmin) { + Write-Host ' Нужны права администратора: запусти PowerShell от администратора.' -ForegroundColor Red + exit 1 +} + +$content = @(Get-Content -LiteralPath $hosts | + Where-Object { $_ -notmatch [regex]::Escape($marker) -and + $_ -notmatch '^\s*127\.0\.0\.1\s+185\.207\.214\.85' }) + +Set-Content -LiteralPath $hosts -Encoding ASCII -Value $content +Write-Host ' Запись удалена, клиент снова ходит на официальный сервер.' -ForegroundColor Green \ No newline at end of file diff --git a/start-launcher.cmd b/start-launcher.cmd new file mode 100644 index 0000000..de3a35f --- /dev/null +++ b/start-launcher.cmd @@ -0,0 +1,7 @@ +@echo off +rem Starts the patched launcher directly. +rem updater.exe re-downloads app.jar whenever its hash differs from the +rem manifest, which is exactly what a patched jar looks like. +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\start-launcher.ps1" +pause diff --git a/testserver/selftest.py b/testserver/selftest.py new file mode 100644 index 0000000..9a34d5b --- /dev/null +++ b/testserver/selftest.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Self-test for server.py: runs the stand-in server on a spare port and talks +to it with a hand-rolled 1.12.2 client, checking handshake, login and the +play-state packets the game needs to reach a world.""" + +import socket +import struct +import sys +import threading +import time +import uuid + +sys.path.insert(0, r"G:\rsx-mod\testserver") + +import server as srv # noqa: E402 + + +def read_varint(sock): + value = 0 + for shift in range(0, 35, 7): + chunk = sock.recv(1) + if not chunk: + raise AssertionError("closed") + byte = chunk[0] + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value + raise AssertionError("varint too long") + + +def read_packet(sock): + length = read_varint(sock) + data = b"" + while len(data) < length: + chunk = sock.recv(length - len(data)) + if not chunk: + raise AssertionError("closed mid packet") + data += chunk + pid_val = 0 + shift = 0 + pos = 0 + while True: + byte = data[pos] + pos += 1 + pid_val |= (byte & 0x7F) << shift + if not byte & 0x80: + break + shift += 7 + return pid_val, data[pos:] + + +def send_packet(sock, pid, payload=b""): + body = srv.varint(pid) + payload + sock.sendall(srv.varint(len(body)) + body) + + +def drain(sock, wanted, budget=8.0): + seen = {} + deadline = time.time() + budget + while time.time() < deadline and not wanted.issubset(seen): + try: + pid, payload = read_packet(sock) + except (socket.timeout, TimeoutError, AssertionError, OSError): + break + seen.setdefault(pid, payload) + return seen + + +def main(): + port = 25599 + listener = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + listener.bind(("127.0.0.1", port)) + listener.listen(4) + + def accept_loop(): + try: + sock, peer = listener.accept() + srv.handle(srv.Conn(sock, "%s:%s" % peer)) + except Exception: + pass + + threading.Thread(target=accept_loop, daemon=True).start() + + sock = socket.create_connection(("127.0.0.1", port), timeout=10) + sock.settimeout(10) + results = [] + + handshake = (srv.varint(srv.PROTOCOL) + srv.mc_string("127.0.0.1") + + struct.pack(">H", port) + srv.varint(2)) + send_packet(sock, 0x00, handshake) + results.append(("handshake sent", True)) + + send_packet(sock, 0x00, + srv.mc_string("TestPilot") + + srv.mc_string("00000000-0000-0000-0000-000000000000")) + pid, payload = read_packet(sock) + cursor = srv.Cursor(payload) + uuid_txt = cursor.string() + nick = cursor.string() + results.append(("login success", pid == 0x02 and nick == "TestPilot")) + uuid_ok = len(uuid_txt) in (32, 36) + try: + uuid.UUID(uuid_txt) + except (ValueError, AttributeError): + uuid_ok = False + results.append(("uuid parseable (%d chars)" % len(uuid_txt), uuid_ok)) + + send_packet(sock, 0x03, b"") + seen = drain(sock, {0x01, 0x08, 0x23}) + results.append(("join game received", 0x01 in seen)) + results.append(("spawn position received", 0x08 in seen)) + results.append(("brand received", 0x23 in seen)) + + if 0x01 in seen: + cur = srv.Cursor(seen[0x01]) + entity = struct.unpack(">i", cur.take(4))[0] + gamemode = cur.ubyte() + dimension = struct.unpack(">i", cur.take(4))[0] + cur.ubyte() + cur.ubyte() + level = cur.string() + results.append(("join game parses (entity=%d gm=%d dim=%d %s)" + % (entity, gamemode, dimension, level), + level == srv.WORLD_NAME)) + + if 0x08 in seen: + cur = srv.Cursor(seen[0x08]) + x = struct.unpack(">d", cur.take(8))[0] + y = struct.unpack(">d", cur.take(8))[0] + z = struct.unpack(">d", cur.take(8))[0] + cur.take(8) + cur.ubyte() + tid = cur.varint() + results.append(("spawn parses (%.1f/%.1f/%.1f tid=%d)" + % (x, y, z, tid), y == 64.0 and tid == 0)) + + send_packet(sock, 0x00, srv.varint(0x1234)) + send_packet(sock, 0x03, srv.mc_string("ru_ru") + bytes([8]) + + srv.varint(0) + b"\x01" + bytes([2]) + bytes([127]) + + srv.varint(1)) + send_packet(sock, 0x07, + struct.pack(">dddff?", 0.5, 64.0, 0.5, 0.0, 0.0, True)) + alive = drain(sock, {0x00}, budget=18.0) + results.append(("keep alive flow", 0x00 in alive)) + + sock.close() + listener.close() + + print("== self-test ==") + failed = 0 + for label, ok in results: + print(" %-40s %s" % (label, "OK" if ok else "FAIL")) + failed += 0 if ok else 1 + print(" %d check(s) failed" % failed) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/testserver/server.py b/testserver/server.py new file mode 100644 index 0000000..3fe436e --- /dev/null +++ b/testserver/server.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +"""Offline stand-in for the RustexRemake server: accepts any nickname and +token and drops the client into an empty world, so the modloader can be +verified end to end without the live server. + +Protocol: Minecraft 1.12.2 (340), no compression, no encryption. +Run: python server.py [--port 25566] [--host 0.0.0.0] +""" + +import argparse +import hashlib +import select +import socket +import struct +import sys +import threading +import time +import uuid as uuidlib + +PROTOCOL = 340 +GAMEMODE_SURVIVAL = 0 +DIMENSION_OVERWORLD = 0 +DIFFICULTY_NORMAL = 1 +WORLD_NAME = "flat" + +CLIENTBOUND_PLAY = { + 0x00: "KEEP_ALIVE", + 0x01: "JOIN_GAME", + 0x06: "SPAWN_POSITION", + 0x08: "PLAYER_POS_LOOK", + 0x23: "PLUGIN_MESSAGE", +} +SERVERBOUND_PLAY = { + 0x00: "KEEP_ALIVE", + 0x01: "CHAT", + 0x02: "CLIENT_STATUS", + 0x03: "CLIENT_SETTINGS", + 0x04: "PLUGIN_MESSAGE", + 0x06: "POSITION", + 0x07: "POSITION_LOOK", + 0x1A: "TELEPORT_CONFIRM", +} + + +class ProtocolError(Exception): + pass + + +class Conn: + def __init__(self, sock, peer): + self.sock = sock + self.peer = peer + self.buf = b"" + + def close(self): + try: + self.sock.close() + except OSError: + pass + + def _fill(self, n): + while len(self.buf) < n: + chunk = self.sock.recv(65536) + if not chunk: + raise ProtocolError("closed by peer") + self.buf += chunk + + def read_exact(self, n): + self._fill(n) + out, self.buf = self.buf[:n], self.buf[n:] + return out + + def read_varint(self): + value = 0 + for shift in range(0, 35, 7): + byte = self.read_exact(1)[0] + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value + raise ProtocolError("varint too long") + + def read_string(self): + return self.read_exact(self.read_varint()).decode("utf-8", "replace") + + def read_ushort(self): + return struct.unpack(">H", self.read_exact(2))[0] + + def read_packet(self): + length = self.read_varint() + if length <= 0 or length > 2_000_000: + raise ProtocolError("bad packet length %d" % length) + pid = self.read_varint() + payload = self.read_exact(length - varint_size(pid)) + return pid, payload + + def send(self, pid, payload=b""): + body = varint(pid) + payload + self.sock.sendall(varint(len(body)) + body) + + +def varint(value): + out = b"" + while True: + byte = value & 0x7F + value >>= 7 + out += bytes([byte | (0x80 if value else 0)]) + if not value: + return out + + +def varint_size(value): + size = 1 + while value >= 0x80: + value >>= 7 + size += 1 + return size + + +def mc_string(text): + raw = text.encode("utf-8") + return varint(len(raw)) + raw + + +def mc_bool(value): + return b"\x01" if value else b"\x00" + + +def offline_uuid(name): + return uuidlib.uuid3(uuidlib.NAMESPACE_DNS, "OfflinePlayer." + name) + + +def log(name, fmt, *args): + stamp = time.strftime("%H:%M:%S") + sys.stdout.write("[%s] %-12s %s\n" % (stamp, name, fmt % args if args else fmt)) + sys.stdout.flush() + + +def send_status(conn): + payload = ( + mc_string(json_status()) + ) + conn.send(0x00, payload) + + +def json_status(): + return ( + '{"version":{"name":"RustexRemake","protocol":%d},' + '"players":{"max":20,"online":1},' + '"description":{"text":"rsx modloader test server"}}' % PROTOCOL + ) + + +def send_keep_alive(conn, ident): + conn.send(0x00, varint(ident & 0x7FFFFFFF)) + + +def send_join_game(conn, entity_id): + payload = b"".join([ + struct.pack(">i", entity_id), + bytes([GAMEMODE_SURVIVAL]), + struct.pack(">i", DIMENSION_OVERWORLD), + bytes([DIFFICULTY_NORMAL]), + bytes([20]), + mc_string(WORLD_NAME), + mc_bool(False), + ]) + conn.send(0x01, payload) + + +def send_spawn_position(conn, x=0.5, y=64.0, z=0.5): + payload = struct.pack(">ddd", x, y, z) + conn.send(0x06, payload) + + +def send_pos_look(conn, x, y, z, yaw, pitch, teleport_id): + payload = b"".join([ + struct.pack(">ddd", x, y, z), + struct.pack(">ff", yaw, pitch), + bytes([0]), + varint(teleport_id), + ]) + conn.send(0x08, payload) + + +def send_brand(conn, brand): + inner = mc_string(brand) + conn.send(0x23, mc_string("MC|Brand") + varint(len(inner)) + inner) + + +def handle(conn): + name = None + entity_id = None + teleport_id = 0 + keepalive_id = 0x1234 + last_keepalive = 0.0 + try: + pid, payload = conn.read_packet() + if pid != 0x00: + raise ProtocolError("expected handshake, got %d" % pid) + cursor = Cursor(payload) + protocol = cursor.varint() + host = cursor.string() + port = cursor.ushort() + next_state = cursor.varint() + log("handshake", "protocol=%d host=%s port=%d next_state=%d", + protocol, host, port, next_state) + + if next_state == 1: + pid, _ = conn.read_packet() + send_status(conn) + log("status", "ping answered") + return + if next_state != 2: + raise ProtocolError("unsupported next_state %d" % next_state) + + pid, payload = conn.read_packet() + if pid != 0x00: + raise ProtocolError("expected login start, got %d" % pid) + cursor = Cursor(payload) + name = cursor.string() + profile_id = None + if cursor.remaining() > 0: + try: + profile_id = cursor.string() + except ProtocolError: + pass + if name is None: + raise ProtocolError("no nickname") + profile_id = profile_id or str(offline_uuid(name)).replace("-", "") + log("login", "nick=%r token-ignored uuid=%s", name, profile_id) + + conn.send(0x02, mc_string(profile_id) + mc_string(name)) + log("login", "login success sent, any token accepted") + + deadline = time.time() + 20 + while time.time() < deadline: + pid, payload = conn.read_packet() + if pid == 0x03: + log("login", "client acknowledged") + break + if pid == 0x00: + continue + log("login", "unexpected packet 0x%02X before play" % pid) + else: + raise ProtocolError("no login acknowledgement") + + entity_id = int.from_bytes(hashlib.sha256(name.encode()).digest()[:4], + "big") & 0x7FFFFFFF + send_brand(conn, "rsx-testserver") + send_join_game(conn, entity_id) + send_pos_look(conn, 0.5, 64.0, 0.5, 0.0, 0.0, teleport_id) + log("play", "join game sent, entity=%d, spawn sent" % entity_id) + + while True: + if time.time() - last_keepalive > 15: + last_keepalive = time.time() + keepalive_id = (keepalive_id + 1) & 0x7FFFFFFF + send_keep_alive(conn, keepalive_id) + + pid, payload = conn.read_packet() + label = SERVERBOUND_PLAY.get(pid, "0x%02X" % pid) + if pid == 0x00: + send_keep_alive(conn, keepalive_id) + log("play", "keep alive") + elif pid == 0x1A: + log("play", "teleport confirmed") + teleport_id += 1 + send_pos_look(conn, 0.5, 64.0, 0.5, 0.0, 0.0, teleport_id) + log("play", "position sent after teleport confirm") + elif pid == 0x02: + log("play", "client status (respawn?): %s", payload.hex()) + elif pid == 0x07: + log("play", "player position+look received") + else: + log("play", "%s (%d bytes)", label, len(payload)) + + except ProtocolError as exc: + log("close", "%s: %s", conn.peer, exc) + except OSError as exc: + log("close", "%s: %s", conn.peer, exc) + except Exception as exc: # noqa: BLE001 - stand-in server, log and move on + log("error", "%s: %r", conn.peer, exc) + finally: + if name: + log("bye", "nick=%r entity=%s", name, entity_id) + conn.close() + + +class Cursor: + def __init__(self, data): + self.data = data + self.pos = 0 + + def remaining(self): + return len(self.data) - self.pos + + def take(self, n): + if self.pos + n > len(self.data): + raise ProtocolError("packet truncated") + out = self.data[self.pos:self.pos + n] + self.pos += n + return out + + def byte(self): + return self.take(1)[0] + + def ubyte(self): + return self.byte() + + def ushort(self): + return struct.unpack(">H", self.take(2))[0] + + def varint(self): + value = 0 + for shift in range(0, 35, 7): + byte = self.byte() + value |= (byte & 0x7F) << shift + if not byte & 0x80: + return value + raise ProtocolError("varint too long") + + def string(self): + return self.take(self.varint()).decode("utf-8", "replace") + + +def parse_ports(spec): + ports = [] + for chunk in spec.split(","): + chunk = chunk.strip() + if "-" in chunk: + first, last = chunk.split("-", 1) + ports.extend(range(int(first), int(last) + 1)) + elif chunk: + ports.append(int(chunk)) + return ports + + +def serve_ping(ports, host): + """The client asks http:///server-ping for its server list and reads + lines of port~mode~x~y. 'Offline' skips the per-server ping, which is what + we want: the stand-in has nothing to measure.""" + body = "".join("%d~Offline~0~0\n" % port for port in ports) + try: + from http.server import BaseHTTPRequestHandler, HTTPServer + except ImportError: + log("ping", "http.server unavailable, ping endpoint disabled") + return None + + class Handler(BaseHTTPRequestHandler): + def do_GET(self): # noqa: N802 - required by BaseHTTPRequestHandler + payload = body.encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(payload))) + self.end_headers() + self.wfile.write(payload) + + def log_message(self, fmt, *args): + log("ping", self.path) + + try: + httpd = HTTPServer((host, 80), Handler) + except OSError as exc: + log("ping", "cannot listen on port 80 (%s); list will stay empty" % exc) + return None + threading.Thread(target=httpd.serve_forever, daemon=True).start() + log("ping", "serving /server-ping on %s:80 with %d server(s)" + % (host, len(ports))) + return httpd + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--host", default="0.0.0.0") + parser.add_argument("--ports", default="25566", + help="ports or ranges, e.g. 25566-25573") + parser.add_argument("--no-http", action="store_true", + help="do not answer the client server-ping request") + args = parser.parse_args() + + ports = parse_ports(args.ports) + listeners = [] + for port in ports: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + try: + sock.bind((args.host, port)) + except OSError as exc: + log("server", "cannot bind %s:%d (%s)" % (args.host, port, exc)) + sock.close() + continue + sock.listen(8) + listeners.append(sock) + log("server", "listening on %s:%d, any nick/token accepted", + args.host, port) + + if not args.no_http: + serve_ping(ports, args.host) + + try: + while True: + ready, _, _ = select.select(listeners, [], [], 1.0) + for listener in ready: + sock, peer = listener.accept() + conn = Conn(sock, "%s:%s" % peer) + threading.Thread(target=handle, args=(conn,), + daemon=True).start() + except KeyboardInterrupt: + log("server", "interrupted") + finally: + for listener in listeners: + listener.close() + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/third_party/asm/asm-9.7.jar b/third_party/asm/asm-9.7.jar new file mode 100644 index 0000000..fee9b02 Binary files /dev/null and b/third_party/asm/asm-9.7.jar differ diff --git a/third_party/asm/asm-commons-9.7.jar b/third_party/asm/asm-commons-9.7.jar new file mode 100644 index 0000000..6a7d3a1 Binary files /dev/null and b/third_party/asm/asm-commons-9.7.jar differ diff --git a/third_party/asm/asm-tree-9.7.jar b/third_party/asm/asm-tree-9.7.jar new file mode 100644 index 0000000..bbb3ca3 Binary files /dev/null and b/third_party/asm/asm-tree-9.7.jar differ diff --git a/uninstall.cmd b/uninstall.cmd new file mode 100644 index 0000000..be0f52f --- /dev/null +++ b/uninstall.cmd @@ -0,0 +1,6 @@ +@echo off +rem Puts everything back: restores the original launcher jar and removes the +rem agent argument from the launcher config. +chcp 65001 >nul +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\install.ps1" --uninstall %* +pause