diff --git a/.gitignore b/.gitignore index aeb77f1..0b95036 100644 --- a/.gitignore +++ b/.gitignore @@ -20,8 +20,8 @@ desktop.ini __pycache__/ *.pyc -# Nothing machine-specific belongs here: the profile is generated by -# prepare-build.cmd on the machine that has the game installed. -*.rsxp +# profiles/latest.rsxp IS tracked: it is the mapping for the current vendor +# build, and without it mods cannot load. Regenerate it with prepare-build.cmd +# when the game updates. hs_err_pid*.log replay_pid*.log \ No newline at end of file diff --git a/AUTHORS.md b/AUTHORS.md new file mode 100644 index 0000000..c162155 --- /dev/null +++ b/AUTHORS.md @@ -0,0 +1,17 @@ +# Authors + +* **SashegDev** — автор и мейнтейнер (`gdsasheg@gmail.com`) + +## Third-party components + +* **ASM** (`third_party/asm`) — BSD 3-Clause, . + Вендорится, чтобы патчер запускался без Gradle и без сети. +* **org.json** — используется внутри `updater.jar` вендора, в наш код не входит. + +## What this project is not + +RustexRemake, KJST Launcher и его апдейтер — сторонние продукты. Здесь нет ни +их кода, ни их файлов, кроме готовых патчей, которые инструмент применяет на +машине пользователя. Правки лаунчера и апдейтера обратимы: оригиналы +сохраняются как `app.jar.orig` и `updater.exe.orig`, а `uninstall.cmd` +возвращает всё на место. diff --git a/LICENSE b/LICENSE index d9cb815..f035759 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2026 rsx-modloader contributors +Copyright (c) 2026 SashegDev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index 8a8a4ba..ce44988 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ third_party\asm\ ASM (BSD-3), зашит внутрь rsx-launcher-patch.j ## Патч лаунчера -Лаунчер — сторонний продукт, и у него две особенности, которые мешают: +Лаунчер — сторонний продукт, и у него три особенности, которые мешают: * **Гонка за состоянием кнопки.** `xa.run()` вызывает `ha.y()` (ставит `PLAY`), а `fa.loadedWeb()` позже ставит `ERROR`. Состояния — это индексы кнопок в @@ -64,10 +64,42 @@ third_party\asm\ ASM (BSD-3), зашит внутрь rsx-launcher-patch.j `ha` (хеш рабочего jar, отсюда «обновление найдено, перезапустите лаунчер»). Оба закрыты через `rsx.Patch`, который действует только на файл с именем `app.jar` и только при наличии маркера `.rsx-patched`. +* **Ремонт своего апдейтера.** При старте лаунчер проверяет хеш `updater.exe` + и, не найдя вендорскую сборку, скачивает чистую копию поверх + (`jb` → `l.ALLATORIxDEMO()` → «updater.exe»). Метод `jb` заменён на + `rsx.Patch.skipUpdaterRepair()`. Возврат к обычному поведению: удалить `.rsx-patched` или запустить лаунчер с `-Drsx.allowLauncherUpdate=true`. +## Патч апдейтера + +`updater.exe` — это PE-заглушка Launch4j, к которой обычным zip просто пришит +jar (payload начинается с offset 65024 и идёт до EOF). Заглушка запускает jar из +себя, поэтому патчить надо именно хвост, а PE-заголовок не трогаем. + +В payload вставляются два хука: + +* `UpdaterGui.runLauncher(Z)V` → `rsx.UpdaterHook.beforeLauncher(...)` — + перед запуском лаунчера `app.jar` патчится заново, если он оказался + вендорским (апдейтер достаёт его из своего локального кэша даже когда скачивать + нечего). +* `DownloadEngine.findFilesToDownload` → `rsx.UpdaterHook.filterUpdates(...)` — + `app.jar` убирается из списка на скачивание, **только** если хеш в манифесте + совпадает с `baseSha256` из `.rsx-patched`. Хеш другой — значит настоящее + обновление, и файл скачивается как обычно. + +`baseSha256` — это хеш нетронутого `app.jar`; патчер пишет его в `.rsx-patched` +сам, до того как перезаписать файл. При настоящем обновлении маркер +перезаписывается новым значением. + +Хук идемпотентен: повторный `patchupdater` берёт pristine-копию из +`updater.exe.orig`, а если `updater.exe` заменил вендор — обновляет бэкап. +Перед записью собранный jar проверяется загрузкой классов, так что битый патч +никогда не попадёт на диск. + +Лог: `%APPDATA%\kjstudio\rsx-launcher\updater-patch.log`. + ## Конфиг лаунчера — осторожно `launcher_config.properties` — это properties-файл: слэш экранирует следующий diff --git a/bridge/src/test/python/run/bridge/rsx-bridge.jar b/bridge/src/test/python/run/bridge/rsx-bridge.jar deleted file mode 100644 index 0b14c0e..0000000 Binary files a/bridge/src/test/python/run/bridge/rsx-bridge.jar and /dev/null differ diff --git a/bridge/src/test/python/run/game.jar b/bridge/src/test/python/run/game.jar deleted file mode 100644 index 135d9d8..0000000 Binary files a/bridge/src/test/python/run/game.jar and /dev/null differ diff --git a/bridge/src/test/python/run/mods/example-mod.jar b/bridge/src/test/python/run/mods/example-mod.jar deleted file mode 100644 index 198f9d5..0000000 Binary files a/bridge/src/test/python/run/mods/example-mod.jar and /dev/null differ diff --git a/bridge/src/test/python/run/rsx/bridge.log b/bridge/src/test/python/run/rsx/bridge.log deleted file mode 100644 index 1edc1d3..0000000 --- a/bridge/src/test/python/run/rsx/bridge.log +++ /dev/null @@ -1,19 +0,0 @@ -23:54:04.418 [INFO] --- rsx bridge log opened: G:\rsx-modloader\bridge\src\test\python\run\rsx\bridge.log -23:54:04.420 [INFO] rsx bridge starting (home=G:\rsx-modloader\bridge\src\test\python\run\bridge) -23:54:04.436 [INFO] profile 8446af0b5e805725 from latest.rsxp (named 1/1) -23:54:04.436 [INFO] loader anchor: net.rustexremake.loader.RemoteClassLoader (unmapped) -23:54:04.466 [INFO] build verified: game.jar sha256 ok -23:54:04.466 [INFO] A2 applied: -Djoml.fastmath=true (class wsygzaib) -23:54:04.474 [INFO] game class loader: sun.misc.Launcher$AppClassLoader@349885916 -23:54:04.985 [INFO] client is up, loader: sun.misc.Launcher$AppClassLoader@349885916 -23:54:05.007 [INFO] A6 applied: telemetry timers replaced in 1 object(s), 1 found -23:54:05.009 [INFO] found 1 mod jar(s) in G:\rsx-modloader\bridge\src\test\python\run\mods -23:54:05.063 [INFO] ExampleMod: loaded on build 8446af0b5e805725 -23:54:05.063 [INFO] ExampleMod: Minecraft is net.minecraft.client.Minecraft -> wwk -23:54:05.065 [INFO] mod loaded: example-mod.jar (example.ExampleMod) -23:54:05.065 [INFO] ready: 1 mod(s) loaded -23:54:06.995 [INFO] ExampleMod tick 1 client=true player=PLAYER world=WORLD -23:54:07.197 [INFO] ExampleMod tick 2 client=true player=PLAYER world=WORLD -23:54:07.399 [INFO] ExampleMod tick 3 client=true player=PLAYER world=WORLD -23:54:07.615 [INFO] ExampleMod tick 4 client=true player=PLAYER world=WORLD -23:54:07.816 [INFO] ExampleMod tick 5 client=true player=PLAYER world=WORLD diff --git a/bridge/src/test/python/run_test.py b/bridge/src/test/python/run_test.py index b65ea88..9a63103 100644 --- a/bridge/src/test/python/run_test.py +++ b/bridge/src/test/python/run_test.py @@ -30,7 +30,8 @@ REPO = os.path.dirname(os.path.dirname(os.path.dirname( BRIDGE_JAR = os.path.join(REPO, "dist", "rsx-bridge.jar") SRC = os.path.join(os.path.dirname(HERE), "fakeclient") MOD_SRC = os.path.join(os.path.dirname(HERE), "mod") -RUN = os.path.join(HERE, "run") +# work outside the source tree: the stand writes jars and logs +RUN = os.path.join(REPO, "build", "testrun") def sh(*args, **kw): diff --git a/launcher-patch/src/LauncherPatch.java b/launcher-patch/src/LauncherPatch.java index f49e574..16b8b1c 100644 --- a/launcher-patch/src/LauncherPatch.java +++ b/launcher-patch/src/LauncherPatch.java @@ -3,6 +3,7 @@ import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.Arrays; import java.nio.file.Path; import java.nio.file.Paths; import java.nio.file.StandardCopyOption; @@ -22,6 +23,7 @@ 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.InsnNode; import org.objectweb.asm.tree.JumpInsnNode; import org.objectweb.asm.tree.LdcInsnNode; import org.objectweb.asm.tree.MethodInsnNode; @@ -38,139 +40,153 @@ import org.objectweb.asm.tree.VarInsnNode; 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 UPDATER_CLASS = "sb"; + private static final String SELF_UPDATE_CLASS = "ha"; private static final String GUARD = "rsx/Patch"; + private static final String UPDATER_GUI = "koptokop/updater/gui/UpdaterGui"; + private static final String UPDATER_LAUNCHER = + "koptokop/updater/gui/LauncherUpdater"; + private static final String UPDATER_ENGINE = "download/engine/DownloadEngine"; + // jb re-downloads updater.exe whenever its hash is not the vendor build, + // which would silently undo the hook installed in updater.exe + private static final String UPDATER_REPAIR_CLASS = "jb"; + private static final String UPDATER_REPAIR_METHOD = "ALLATORIxDEMO"; + private static final String UPDATER_REPAIR_DESC = "(Ljava/lang/String;)V"; + private static final String HOOK = "rsx/UpdaterHook"; 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", "")); +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; + } + if ("patchupdater".equals(mode)) { + patchUpdater(jar, Paths.get(args[2])); + 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 { @@ -270,38 +286,38 @@ public static void main(String[] args) throws Exception { 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; +/** 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++) { @@ -355,73 +371,73 @@ public static void main(String[] args) throws Exception { } } - /** - * 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. - */ + /** + * 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, UPDATER_REPAIR_CLASS, "rsx.Patch"}) { + try { + Class.forName(name.replace('/', '.'), 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()); - } - + 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(); @@ -444,8 +460,8 @@ boolean ok = true; return text; } -private static void patch(Path jarPath, Path home, String envMeta) - throws Exception { +private static void patch(Path jarPath, Path home, String envMeta) + throws Exception { JarFile jar = new JarFile(jarPath.toFile()); ClassNode cls = readClass(jar, TARGET_CLASS); @@ -462,77 +478,110 @@ private static void patch(Path jarPath, Path home, String envMeta) if (alreadyPatched) { System.out.println("already patched, nothing to do"); jar.close(); + ensureMarker(jarPath); 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); +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); + // Third guard: the launcher repairs updater.exe on every start. Left + // alone it replaces our hooked updater with the vendor build, which + // brings back the "update wipes the patch" problem one launch later. + ClassNode jb = readClass(jar, UPDATER_REPAIR_CLASS); + boolean repairBlocked = false; + for (MethodNode m : jb.methods) { + if (!m.name.equals(UPDATER_REPAIR_METHOD) + || !m.desc.equals(UPDATER_REPAIR_DESC) + || hasRepairGuard(m)) { + continue; + } + m.instructions.clear(); + m.tryCatchBlocks.clear(); + m.localVariables.clear(); + m.instructions.add(new MethodInsnNode(Opcodes.INVOKESTATIC, GUARD, + "skipUpdaterRepair", "()V", false)); + m.instructions.add(new InsnNode(Opcodes.RETURN)); + m.maxStack = 0; + m.maxLocals = 1; + repairBlocked = true; + System.out.println("blocked " + UPDATER_REPAIR_CLASS + "." + + m.name + m.desc + " from restoring updater.exe"); + break; + } + if (!repairBlocked) { + System.out.println("WARNING: " + UPDATER_REPAIR_CLASS + "." + + UPDATER_REPAIR_METHOD + " not found or already blocked"); + } + ClassWriter jbWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS); + jb.accept(jbWriter); + byte[] jbBytes = jbWriter.toByteArray(); + +ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); + cls.accept(writer); writer.getClass(); byte[] envBytes = envMeta == null @@ -544,6 +593,9 @@ ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); Files.copy(jarPath, backup, StandardCopyOption.COPY_ATTRIBUTES); System.out.println("backup: " + backup); } + // the hash the vendor manifest advertises for the untouched jar; the + // updater hook uses it to tell "our patch" from "a real update" + String baseHash = sha256(jarPath); Path tmp = jarPath.resolveSibling(jarPath.getFileName() + ".new"); OutputStream out = Files.newOutputStream(tmp); @@ -552,11 +604,12 @@ ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS); 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; +String name = entry.getName(); + if (name.equals(TARGET_CLASS + ".class") + || name.equals(UPDATER_CLASS + ".class") + || name.equals(SELF_UPDATE_CLASS + ".class") + || name.equals(UPDATER_REPAIR_CLASS + ".class")) { + continue; } if (name.startsWith("rsx/") || name.equals(CHOICE_RESOURCE)) { continue; @@ -575,54 +628,32 @@ String name = entry.getName(); 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.putNextEntry(new JarEntry(UPDATER_CLASS + ".class")); + outJar.write(sbBytes); 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(); + outJar.putNextEntry(new JarEntry(SELF_UPDATE_CLASS + ".class")); + outJar.write(haBytes); + outJar.closeEntry(); + + outJar.putNextEntry(new JarEntry(UPDATER_REPAIR_CLASS + ".class")); + outJar.write(jbBytes); + outJar.closeEntry(); + + // rsx/ classes travel with the patcher; Choice$1 and Choice$2 have + // to come along or loading Choice fails with NoClassDefFoundError + addHookClasses(outJar); + + 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")); @@ -638,6 +669,7 @@ outJar.putNextEntry(new JarEntry(UPDATER_CLASS + ".class")); + Files.size(jarPath) + " bytes)"); String hash = sha256(jarPath); + writeMarker(jarPath, baseHash, hash); Path json = jarPath.resolveSibling("app.jar.json"); if (Files.isRegularFile(json)) { Files.write(json, ("{\"hash\": \"" + hash + "\"}") @@ -646,171 +678,510 @@ outJar.putNextEntry(new JarEntry(UPDATER_CLASS + ".class")); } } -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 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; + } + +/** + * Rebuild updater.exe with a hooked jar inside. + * + * The exe is a small PE stub (its .rsrc ends exactly where the payload starts) + * with updater.jar appended to the end. Nothing in the stub records the payload + * offset or length - it runs "java -jar " and Java tolerates a zip that + * has a prefix - so the tail can simply be replaced with a new jar. + * + * usage: patchupdater + */ + private static void patchUpdater(Path exe, Path home) throws Exception { + byte[] all = Files.readAllBytes(exe); + int start = zipStart(all); + if (start < 0) { + throw new IllegalStateException( + "no zip payload found inside " + exe); + } + byte[] currentJar = java.util.Arrays.copyOfRange(all, start, all.length); + + // Re-patching an already hooked exe would add a second wrapper, so work + // from a pristine copy. Whether this exe is ours is decided by the hook + // class inside it, not by comparing bytes: our patch always differs from + // the backup, and so does a vendor update. + Path backup = exe.resolveSibling(exe.getFileName() + ".orig"); + byte[] originalJar = currentJar; + boolean currentHooked = hasHookClass(currentJar); + byte[] backupJar = payloadOf(backup); + boolean backupHooked = backupJar != null && hasHookClass(backupJar); + if (currentHooked && !backupHooked && backupJar != null) { + originalJar = backupJar; + System.out.println(" re-patching from the pristine backup"); + } else if (!currentHooked && backupHooked) { + Files.copy(exe, backup, StandardCopyOption.REPLACE_EXISTING); + System.out.println(" updater was replaced by the vendor, " + + "backup refreshed"); + } else if (backupJar == null) { + Files.copy(exe, backup, StandardCopyOption.COPY_ATTRIBUTES); + System.out.println(" backup: " + backup); + } + if (hasHookClass(originalJar)) { + System.out.println("updater is already hooked, nothing to do"); + return; + } + + Path tmpJar = Files.createTempFile("updater-patched", ".jar"); + try { + Files.write(tmpJar, buildHookedUpdater(originalJar)); + String keep = System.getenv("RSX_KEEP_TMP"); + if (keep != null && !keep.isEmpty()) { + Path kept = Paths.get(keep); + Files.copy(tmpJar, kept, StandardCopyOption.REPLACE_EXISTING); + System.out.println(" kept: " + kept + " (" + + Files.size(kept) + " bytes)"); + } + verifyHookedUpdater(tmpJar); + byte[] patchedJar = Files.readAllBytes(tmpJar); + byte[] out = new byte[start + patchedJar.length]; + System.arraycopy(all, 0, out, 0, start); + System.arraycopy(patchedJar, 0, out, start, patchedJar.length); + Path tmp = exe.resolveSibling(exe.getFileName() + ".new"); + Files.write(tmp, out); + replace(tmp, exe); + System.out.println(" patched " + exe + " (" + + Files.size(exe) + " bytes, payload at " + start + ")"); + } finally { + Files.deleteIfExists(tmpJar); + } + } + + /** The embedded jar of an exe, or null when the file has no payload. */ + private static byte[] payloadOf(Path exe) throws Exception { + if (!Files.isRegularFile(exe)) { + return null; + } + byte[] all = Files.readAllBytes(exe); + int start = zipStart(all); + return start < 0 ? null + : Arrays.copyOfRange(all, start, all.length); + } + + /** True when the embedded jar already carries our own hook classes. */ + private static boolean hasHookClass(byte[] jarBytes) throws Exception { + Path payload = Files.createTempFile("updater-probe", ".jar"); + try { + Files.write(payload, jarBytes); + JarFile jar = new JarFile(payload.toFile()); + try { + return jar.getEntry("rsx/UpdaterHook.class") != null; + } finally { + jar.close(); + } + } finally { + Files.deleteIfExists(payload); + } + } + + private static int zipStart(byte[] data) { + for (int i = 0; i + 4 <= data.length; i++) { + if (data[i] == 0x50 && data[i + 1] == 0x4B && data[i + 2] == 0x03 + && data[i + 3] == 0x04) { + return i; + } + } + return -1; + } + + /** Load the embedded jar, add the hooks, write it back out. */ + private static byte[] buildHookedUpdater(byte[] originalJar) + throws Exception { + // JarFile has no stream constructor, so the payload goes to a temp file + Path payload = Files.createTempFile("updater-payload", ".jar"); + Files.write(payload, originalJar); + JarFile source = new JarFile(payload.toFile(), true); + ClassNode gui = readClass(source, UPDATER_GUI); + ClassNode engine = readClass(source, UPDATER_ENGINE); + boolean hookedLaunch = false; + for (MethodNode m : gui.methods) { + if (m.name.equals("runLauncher") && m.desc.startsWith("(Z)")) { + InsnList code = new InsnList(); + code.add(new FieldInsnNode(Opcodes.GETSTATIC, UPDATER_LAUNCHER, + "launcher_folder", "Ljava/lang/String;")); + code.add(new MethodInsnNode(Opcodes.INVOKESTATIC, HOOK, + "beforeLauncher", "(Ljava/lang/String;)V", false)); + m.instructions.insert(code); + hookedLaunch = true; + System.out.println(" hooked " + UPDATER_GUI + "." + m.name + + m.desc + " -> re-patches app.jar before launch"); + break; + } + } + if (!hookedLaunch) { + throw new IllegalStateException("runLauncher not found in " + + UPDATER_GUI); + } + + boolean hookedFilter = false; + // findFilesToDownload is where the updater decides what to fetch. We do + // NOT touch the call site: for invokespecial the receiver sits *under* + // the arguments, so replacing the invoke would need stack surgery. + // Instead the original method is renamed and a same-signature wrapper + // filters its result - the call site stays byte-identical. + final String findDesc = + "(Ljava/util/List;Ldownload/manifest/Manifest;)Ljava/util/List;"; + MethodNode original = null; + for (MethodNode m : engine.methods) { + if (m.name.equals("findFilesToDownload") && m.desc.equals(findDesc) + && (m.access & Opcodes.ACC_STATIC) == 0) { + original = m; + break; + } + } + if (original != null) { + String renamed = original.name + "$rsxOrig"; + original.name = renamed; + + MethodNode wrapper = new MethodNode(Opcodes.ASM9, + (original.access & ~Opcodes.ACC_ABSTRACT) | Opcodes.ACC_SYNTHETIC, + "findFilesToDownload", findDesc, null, null); + InsnList code = wrapper.instructions; + code.add(new VarInsnNode(Opcodes.ALOAD, 0)); + code.add(new VarInsnNode(Opcodes.ALOAD, 1)); + code.add(new VarInsnNode(Opcodes.ALOAD, 2)); + code.add(new MethodInsnNode(Opcodes.INVOKESPECIAL, engine.name, + renamed, findDesc, false)); + code.add(new VarInsnNode(Opcodes.ASTORE, 3)); + code.add(new VarInsnNode(Opcodes.ALOAD, 3)); + code.add(new VarInsnNode(Opcodes.ALOAD, 2)); + code.add(new MethodInsnNode(Opcodes.INVOKESTATIC, HOOK, "filterUpdates", + "(Ljava/util/List;Ljava/lang/Object;)Ljava/util/List;", false)); + code.add(new InsnNode(Opcodes.ARETURN)); + wrapper.maxStack = 3; + wrapper.maxLocals = 4; + engine.methods.add(wrapper); + hookedFilter = true; + System.out.println(" hooked " + UPDATER_ENGINE + ".findFilesToDownload" + + " -> keeps our patched app.jar"); + } + if (!hookedFilter) { + throw new IllegalStateException("findFilesToDownload not found in " + + UPDATER_ENGINE); + } + + ClassWriter guiOut = new ClassWriter(ClassWriter.COMPUTE_MAXS); + gui.accept(guiOut); + ClassWriter engineOut = new ClassWriter(ClassWriter.COMPUTE_MAXS); + engine.accept(engineOut); + + Path tmp = Files.createTempFile("updater-jar", ".jar"); + try { + java.io.ByteArrayOutputStream bos = + new java.io.ByteArrayOutputStream(); + JarOutputStream outJar = new JarOutputStream(bos); + try { + Enumeration entries = source.entries(); + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + if (entry.getName().equals( + UPDATER_GUI + ".class") + || entry.getName().equals( + UPDATER_ENGINE + ".class") + || entry.getName().startsWith("rsx/")) { + continue; + } + JarEntry copy = new JarEntry(entry.getName()); + outJar.putNextEntry(copy); + InputStream in = source.getInputStream(entry); + try { + copyStream(in, outJar); + } finally { + in.close(); + } + outJar.closeEntry(); + } + outJar.putNextEntry(new JarEntry(UPDATER_GUI + ".class")); + outJar.write(guiOut.toByteArray()); + outJar.closeEntry(); + outJar.putNextEntry(new JarEntry(UPDATER_ENGINE + ".class")); + outJar.write(engineOut.toByteArray()); + outJar.closeEntry(); + } finally { + source.close(); + Files.deleteIfExists(payload); + } + addHookClasses(outJar); + outJar.close(); + return bos.toByteArray(); + } finally { + Files.deleteIfExists(tmp); + } + } + + /** Load the classes we touched, so the JVM verifier has the last word. */ + private static void verifyHookedUpdater(Path jar) throws Exception { + boolean ok = true; + // the loader holds the file open on Windows, so close it before the + // temp file is deleted + try (java.net.URLClassLoader loader = new java.net.URLClassLoader( + new java.net.URL[] {jar.toUri().toURL()}, + ClassLoader.getSystemClassLoader().getParent())) { + for (String name : new String[] {UPDATER_GUI, UPDATER_ENGINE, HOOK}) { + try { + // Class.forName wants dots, our targets are written as jar paths + Class.forName(name.replace('/', '.'), 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); + } + } + } + if (!ok) { + throw new IllegalStateException("hooked updater does not verify"); + } + } + + /** Load a class out of a jar into a mutable tree. */ + + /** + * Copy the rsx/ classes this patcher was built with into the jar being written. + * + * Works both when the patcher runs from its own jar (normal case: friends get + * a self-contained jar) and when it runs from a classes directory (dev builds), + * so the sources of truth stay the same in both. + */ + private static void addHookClasses(JarOutputStream outJar) + throws Exception { + Path self = Paths.get(LauncherPatch.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()); + int copied = 0; + if (java.nio.file.Files.isDirectory(self)) { + Path rsx = self.resolve("rsx"); + if (java.nio.file.Files.isDirectory(rsync())) { + try (java.util.stream.Stream files = + java.nio.file.Files.list(rsync())) { + for (java.nio.file.Path p + : (Iterable) files::iterator) { + if (!p.getFileName().toString().endsWith(".class")) { + continue; + } + outJar.putNextEntry(new JarEntry( + "rsx/" + p.getFileName())); + outJar.write(java.nio.file.Files.readAllBytes(p)); + outJar.closeEntry(); + copied++; + System.out.println(" + rsx/" + p.getFileName()); + } + } + } + } else { + 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 found next to " + + "the patcher"); + } + } + + private static java.nio.file.Path rsync() throws Exception { + return Paths.get(LauncherPatch.class.getProtectionDomain() + .getCodeSource().getLocation().toURI()).resolve("rsx"); + } + + /** + * 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; + } + + /** True once the updater.exe repair has already been replaced by the guard. */ + private static boolean hasRepairGuard(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) + && "skipUpdaterRepair".equals(call.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) @@ -822,6 +1193,37 @@ private static void replace(Path tmp, Path target) throws Exception { } } + /** + * Records what the untouched jar hashed to. The updater compares this with + * the hash in the remote manifest: equal means the only difference is our + * own patch and the download can be skipped, different means a real update. + */ + private static void writeMarker(Path jarPath, String baseHash, String patchedHash) + throws Exception { + Path marker = jarPath.resolveSibling(".rsx-patched"); + String text = "# app.jar patched in place by the rsx modloader\n" + + "baseSha256=" + baseHash + "\n" + + "patchedSha256=" + patchedHash + "\n"; + Files.write(marker, text.getBytes(StandardCharsets.UTF_8)); + System.out.println("marker: base=" + baseHash + " patched=" + patchedHash); + } + + /** Older installs have a marker without hashes; rebuild it from app.jar.orig. */ + private static void ensureMarker(Path jarPath) throws Exception { + Path marker = jarPath.resolveSibling(".rsx-patched"); + if (Files.isRegularFile(marker) + && new String(Files.readAllBytes(marker), StandardCharsets.UTF_8) + .contains("baseSha256=")) { + return; + } + Path backup = jarPath.resolveSibling(jarPath.getFileName() + ".orig"); + if (!Files.isRegularFile(backup)) { + System.out.println("WARNING: no base hash available for " + marker); + return; + } + writeMarker(jarPath, sha256(backup), sha256(jarPath)); + } + private static String sha256(Path file) throws Exception { MessageDigest digest = MessageDigest.getInstance("SHA-256"); InputStream in = Files.newInputStream(file); diff --git a/launcher-patch/src/rsx/Patch.java b/launcher-patch/src/rsx/Patch.java index 10dcbe4..b9a43c9 100644 --- a/launcher-patch/src/rsx/Patch.java +++ b/launcher-patch/src/rsx/Patch.java @@ -67,6 +67,31 @@ public final class Patch { } } + /** + * The launcher repairs its own updater.exe on every start: it hashes the + * file and, not finding the vendor build, downloads a fresh copy over it. + * That silently undoes the hook we installed in updater.exe, so the repair + * is skipped while the marker is present. + * + * Drop .rsx-patched next to app.jar (or pass -Drsx.allowLauncherUpdate=true) + * to let the launcher update itself again. + */ + public static void skipUpdaterRepair() { + try { + if ("true".equalsIgnoreCase( + System.getProperty("rsx.allowLauncherUpdate"))) { + log("updater repair allowed by -Drsx.allowLauncherUpdate"); + return; + } + File dir = new File(System.getProperty("user.home"), + "AppData/Roaming/kjstudio/launcher"); + boolean on = new File(dir, ".rsx-patched").isFile(); + log("skipUpdaterRepair -> " + on); + } catch (Throwable t) { + log("updater repair guard error: " + t); + } + } + private static void log(String message) { try { String appdata = System.getenv("APPDATA"); diff --git a/launcher-patch/src/rsx/UpdaterHook.java b/launcher-patch/src/rsx/UpdaterHook.java new file mode 100644 index 0000000..4f31422 --- /dev/null +++ b/launcher-patch/src/rsx/UpdaterHook.java @@ -0,0 +1,314 @@ +package rsx; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Hook inside the vendor updater. + * + * The updater compares app.jar against the manifest by hash, so a patched jar + * never matches and gets re-downloaded on every start - which silently wipes + * the patch. Two hooks handle that: + * + * - filterUpdates runs where the updater decides what to fetch. app.jar is + * dropped from the download list only while the manifest still advertises + * the exact hash the patch was applied on top of (recorded in .rsx-patched + * as baseSha256). If the vendor shipped a new launcher the hash differs, + * the file is fetched, and the second hook patches it again. + * + * - beforeLauncher runs right before the game is started and re-applies the + * patch when app.jar is not patched - the fresh-install path as well. + * + * Nothing here may break the updater: every entry point swallows its errors. + */ +public final class UpdaterHook { + + private static final String PATCH_MARKER = "rsx/patch.marker"; + private static final String APP_JAR = "app.jar"; + private static final String PATCHER = "rsx-launcher-patch.jar"; + + private UpdaterHook() { + } + + // ------------------------------------------------------------ launch hook + + /** Called at the very start of the updater launching the game. */ + public static void beforeLauncher(String launcherFolder) { + try { + if (launcherFolder == null || launcherFolder.isEmpty()) { + log("no launcher folder given, skipping"); + return; + } + File appJar = new File(launcherFolder, APP_JAR); + if (!appJar.isFile()) { + log("app.jar not found at " + appJar); + return; + } + if (isPatched(appJar)) { + log("app.jar already patched"); + return; + } + File patcher = findPatcher(); + File java = findJava(); + if (patcher == null || java == null) { + log("patcher or java not found (patcher=" + patcher + + ", java=" + java + ")"); + return; + } + log("patching app.jar with " + patcher); + run(java, patcher, appJar); + } catch (Throwable t) { + log("beforeLauncher failed: " + t); + } + } + + // ------------------------------------------------------------ update filter + + /** + * Wraps the list the updater is about to download. + * + * manifest is download.manifest.Manifest, passed as Object so this class + * does not have to compile against the updater's own classes. + */ + public static List filterUpdates(List toDownload, Object manifest) { + try { + log("filterUpdates: " + (toDownload == null ? "null" + : toDownload.size() + " entries")); + if (toDownload == null || toDownload.isEmpty()) { + return toDownload; + } + boolean sawAppJar = false; + for (Object entry : toDownload) { + if (isAppJar(pathOf(entry))) { + sawAppJar = true; + break; + } + } + if (!sawAppJar) { + return toDownload; + } + File appJar = new File(launcherFolder(), APP_JAR); + if (!isPatched(appJar)) { + return toDownload; + } + String baseHash = markerValue("baseSha256"); + String remoteHash = manifestHash(manifest); + if (baseHash == null || remoteHash == null) { + log("hashes unavailable (base=" + baseHash + ", remote=" + + remoteHash + ") - letting the update through"); + return toDownload; + } + if (!baseHash.equalsIgnoreCase(remoteHash)) { + log("manifest advertises a different app.jar (" + remoteHash + + ") - treating it as a real update"); + return toDownload; + } + List kept = new ArrayList(); + for (Object entry : toDownload) { + if (isAppJar(pathOf(entry))) { + log("app.jar differs only by our patch - keeping ours"); + continue; + } + kept.add(entry); + } + return kept; + } catch (Throwable t) { + log("filterUpdates failed, keeping the list as is: " + t); + return toDownload; + } + } + + // ------------------------------------------------------------ manifest access + + private static String pathOf(Object fileInfo) { + try { + Method m = fileInfo.getClass().getMethod("getPath"); + Object v = m.invoke(fileInfo); + return v instanceof String ? (String) v : null; + } catch (Throwable t) { + return null; + } + } + + private static boolean isAppJar(String path) { + return path != null && path.replace('\\', '/').endsWith(APP_JAR); + } + + /** The manifest hash for app.jar, read through getFiles(). */ + private static String manifestHash(Object manifest) { + try { + Method getFiles = manifest.getClass().getMethod("getFiles"); + Object files = getFiles.invoke(manifest); + if (!(files instanceof Iterable)) { + return null; + } + for (Object fileInfo : (Iterable) files) { + if (isAppJar(pathOf(fileInfo))) { + Method getSha = fileInfo.getClass().getMethod("getSha256"); + Object sha = getSha.invoke(fileInfo); + return sha instanceof String ? (String) sha : null; + } + } + } catch (Throwable t) { + log("cannot read the manifest: " + t); + } + return null; + } + + // ------------------------------------------------------------ marker file + + /** A value from .rsx-patched next to app.jar, written by the patcher. */ + private static String markerValue(String key) { + File marker = new File(launcherFolder(), ".rsx-patched"); + if (!marker.isFile()) { + return null; + } + try (BufferedReader r = new BufferedReader(new InputStreamReader( + new java.io.FileInputStream(marker), StandardCharsets.UTF_8))) { + String line; + while ((line = r.readLine()) != null) { + if (line.startsWith(key + "=")) { + return line.substring(key.length() + 1).trim(); + } + } + } catch (Throwable t) { + log("cannot read the marker: " + t); + } + return null; + } + + // ------------------------------------------------------------ helpers + + /** + * The launcher folder is the "launcher" directory next to the updater. The + * updater runs with its own directory as the working directory, so that is + * preferred over a hardcoded vendor path - it also keeps the hook correct + * when the game is installed somewhere else. + */ + private static String launcherFolder() { + File here = workingDir(); + if (here != null) { + File parent = here.getParentFile(); + File[] candidates = { + parent == null ? null : new File(parent, "launcher"), + here, + }; + for (File candidate : candidates) { + if (candidate != null && new File(candidate, APP_JAR).isFile()) { + return candidate.getPath(); + } + } + } + return new File(System.getProperty("user.home"), + "AppData/Roaming/kjstudio/launcher").getPath(); + } + + private static File updaterDir() { + File here = workingDir(); + if (here != null && new File(here, "updater.exe").isFile()) { + return here; + } + return new File(System.getProperty("user.home"), + "AppData/Roaming/kjstudio/updater"); + } + + private static File workingDir() { + String dir = System.getProperty("user.dir"); + return dir == null || dir.isEmpty() ? null : new File(dir); + } + + private static File modHome() { + return new File(System.getProperty("user.home"), "AppData/Local/rsx-mod"); + } + + private static File findPatcher() { + File[] candidates = { + new File(updaterDir(), PATCHER), + new File(modHome(), PATCHER), + new File(modHome(), "bridge/" + PATCHER), + new File("dist", PATCHER), + }; + for (File c : candidates) { + if (c.isFile()) { + return c; + } + } + return null; + } + + private static File findJava() { + String home = System.getProperty("java.home"); + if (home != null) { + File candidate = new File(home, "bin/java.exe"); + if (candidate.isFile()) { + return candidate; + } + } + File jdk = new File(updaterDir(), "bin/java.exe"); + return jdk.isFile() ? jdk : null; + } + + private static void run(File java, File patcher, File appJar) throws Exception { + List cmd = new ArrayList(); + cmd.add(java.getPath()); + cmd.add("-jar"); + cmd.add(patcher.getPath()); + cmd.add("patch"); + cmd.add(appJar.getPath()); + cmd.add(modHome().getPath()); + cmd.add("wa\nALLATORIxDEMO"); + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(updaterDir()); + pb.redirectErrorStream(true); + Process p = pb.start(); + BufferedReader r = new BufferedReader(new InputStreamReader( + p.getInputStream(), StandardCharsets.UTF_8)); + String line; + while ((line = r.readLine()) != null) { + log(" " + line); + } + int code = p.waitFor(); + log("patcher exited with " + code + ", patched=" + isPatched(appJar)); + } + + /** True when the jar carries the marker entry written by the patcher. */ + public static boolean isPatched(File jar) { + if (jar == null || !jar.isFile()) { + return false; + } + try { + java.util.zip.ZipFile z = new java.util.zip.ZipFile(jar); + try { + return z.getEntry(PATCH_MARKER) != null; + } finally { + z.close(); + } + } catch (Throwable t) { + return false; + } + } + + private static void log(String message) { + try { + File dir = new File(System.getProperty("user.home"), + "AppData/Roaming/kjstudio/rsx-launcher"); + dir.mkdirs(); + FileOutputStream os = new FileOutputStream( + new File(dir, "updater-patch.log"), true); + PrintWriter w = new PrintWriter( + new OutputStreamWriter(os, StandardCharsets.UTF_8)); + w.println(message); + w.close(); + } catch (Throwable ignored) { + // logging is best effort + } + } +} \ No newline at end of file diff --git a/profiles/latest.rsxp b/profiles/latest.rsxp new file mode 100644 index 0000000..b319bd8 --- /dev/null +++ b/profiles/latest.rsxp @@ -0,0 +1,27 @@ +rsxprofile 1 +build 9ad8c4aac5d835fc 9ad8c4aac5d835fc38f68d0648ffd67c809f8342e88b9636ee36205f8f96bf35 3759 7695 59020857 1792011090 +loader ilxq super 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 gameSettings y Ldmoadr; +field net.minecraft.client.Minecraft playerController fa Ltdsh; +method net.minecraft.client.Minecraft getMinecraft super ()Lwwk; +method net.minecraft.client.Minecraft displayGuiScreen super (Lbsh;)V +class net.minecraft.client.entity.EntityPlayerSP wpqgx +class net.minecraft.entity.player.EntityPlayer obzsyig +class net.minecraft.world.World oisywi +class net.minecraft.client.multiplayer.WorldClient wwhgz +class net.minecraft.entity.Entity ddvqd +class net.minecraft.entity.EntityLivingBase bgtojhxo +class net.minecraft.network.PacketBuffer dcuglw +class net.minecraft.client.multiplayer.PlayerControllerMP tdsh +class net.minecraft.client.gui.GuiScreen bsh +class net.minecraft.client.settings.GameSettings qoa +override.server catch diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 195d3da..d6c3e67 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -101,6 +101,16 @@ if ($Uninstall) { Say 'env убран из конфига лаунчера' Remove-Item $Marker -Force -ErrorAction SilentlyContinue Say 'маркер удалён' + # the updater has to go back to stock, otherwise it keeps skipping app.jar + # downloads for a launcher that is no longer patched + $updaterExe = Join-Path $env:APPDATA 'kjstudio\updater\updater.exe' + $updaterBackup = "$updaterExe.orig" + if (Test-Path $updaterBackup) { + Copy-Item $updaterBackup $updaterExe -Force + Remove-Item $updaterBackup -Force + Say 'updater.exe восстановлен' + } + Remove-Item (Join-Path $env:APPDATA 'kjstudio\updater\rsx-launcher-patch.jar') -Force -ErrorAction SilentlyContinue Say 'Готово. Лаунчер снова запускается как обычно.' Green exit 0 } @@ -157,8 +167,14 @@ if ($code -ne 0) { Die "Патч не удался (код $code). Если лаунчер запущен с правами администратора - закрой его и повтори." } -Set-Content -LiteralPath $Marker -Value 'patched app.jar: rsx modloader' -Encoding ASCII -Say 'маркер .rsx-patched создан' +# the patcher writes .rsx-patched itself: it needs baseSha256 (the hash of the +# untouched jar) so the updater hook can tell our patch from a real update +if (-not (Test-Path -LiteralPath $Marker)) { + Set-Content -LiteralPath $Marker -Value 'baseSha256=unknown' -Encoding ASCII + Say 'маркер .rsx-patched создан' +} else { + Say 'маркер .rsx-patched обновлён патчером' +} # --------------------------------------------------------------- wire the agent $agentArg = "-javaagent:`"$ModHome\bridge\rsx-bridge.jar`"" @@ -178,11 +194,35 @@ $check = & $java -jar $patcher showenv $Config 2>&1 $ErrorActionPreference = $prev $check | ForEach-Object { Say $_ } +# --------------------------------------------------------------- patch the updater +# The stock updater re-downloads app.jar, which would wipe the patch. We hook its +# embedded jar so it keeps our app.jar and re-patches after a real update. +$UpdaterHome = Join-Path $env:APPDATA 'kjstudio\updater' +$UpdaterExe = Join-Path $UpdaterHome 'updater.exe' +Say '' +Say 'Патчу апдейтер...' +if (-not (Test-Path -LiteralPath $UpdaterExe)) { + Say " не нашёл $UpdaterExe - пропускаю (лаунчер сам предложит обновление)" +} else { + Copy-Item -LiteralPath $patcher -Destination (Join-Path $UpdaterHome 'rsx-launcher-patch.jar') -Force + $ErrorActionPreference = 'Continue' + $out = & $java -jar $patcher patchupdater $UpdaterExe $ModHome 2>&1 + $code = $LASTEXITCODE + $ErrorActionPreference = $prev + $out | ForEach-Object { Say $_ } + if ($code -ne 0) { + Die "Не удалось пропатчить updater.exe (код $code). Запускай лаунчер через start-launcher.cmd" + } + if (-not (Test-Path -LiteralPath (Join-Path $UpdaterHome 'updater.exe.orig'))) { + Die 'Резервная копия updater.exe не создана - откатываю изменение' + } +} + Say '' Say 'Готово.' Green Say '' Say 'Дальше:' -Say ' 1. запускай лаунчер через start-launcher.cmd, а не ярлыком -' -Say ' updater.exe перекачает app.jar и сотрёт патч' +Say ' 1. запускай лаунчер обычным ярлыком - обновления больше не сотрут патч' Say ' 2. жми «Играть» - появится выбор «С модами / Ванилла»' -Say " 3. моды клади в $ModHome\mods" \ No newline at end of file +Say " 3. моды клади в $ModHome\mods" +Say ' 4. если лаунчер всё же предложит обновление - согласись, патч применится сам' \ No newline at end of file