From 9f43d35f4448fd771ccf362f3793348f43a83b04 Mon Sep 17 00:00:00 2001 From: SashegDev Date: Sat, 17 Oct 2026 01:48:24 +0300 Subject: [PATCH] =?UTF-8?q?=EF=BB=BF=D0=9F=D0=B0=D1=82=D1=87=D0=B8=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B0=D0=BF=D0=B4=D0=B5=D0=B9=D1=82=D0=B5=D1=80,=20?= =?UTF-8?q?=D0=B0=20=D0=BD=D0=B5=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20?= =?UTF-8?q?=D0=BB=D0=B0=D1=83=D0=BD=D1=87=D0=B5=D1=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вендорный updater.exe перекачивает app.jar, и патч лаунчера после этого стирался. Теперь патчится и сам апдейтер: в его payload (jar, пришитый к PE-заглушке Launch4j после offset 65024) вставляются два хука. - UpdaterGui.runLauncher -> beforeLauncher: перепатчить app.jar перед стартом лаунчера, если апдейтер достал его из своего кэша. - DownloadEngine.findFilesToDownload -> filterUpdates: убрать app.jar из скачивания, но только если хеш манифеста равен baseSha256 из .rsx-patched. Другой хеш означает настоящее обновление, и файл качается как обычно. Хук сделан обёрткой вокруг findFilesToDownload, а не правкой call site: у invokespecial получатель лежит под аргументами, и замена вызова ломала стек. Ещё лаунчер при старте чинит свой updater.exe (jb -> l качает "updater.exe"), из-за чего хук жил ровно один запуск. Этот путь закрыт третьим guard. Ещё: - .rsx-patched теперь пишет сам патчер: baseSha256 нужен фильтру, чтобы отличать наш патч от настоящего обновления. - patchupdater идемпотентен и берёт pristine-копию из updater.exe.orig. - Перед записью payload проверяется загрузкой классов. - Установщик патчит апдейтер, а uninstall откатывает и его. - Маркер и log больше не прибиты к %APPDATA%\kjstudio: папки выводятся из расположения апдейтера. Проверено на живой установке два запуска подряд: хук выживает, app.jar остаётся пропатченным, updater качает 0 файлов. --- .gitignore | 6 +- AUTHORS.md | 17 + LICENSE | 2 +- README.md | 34 +- .../src/test/python/run/bridge/rsx-bridge.jar | Bin 26476 -> 0 bytes bridge/src/test/python/run/game.jar | Bin 5188 -> 0 bytes .../src/test/python/run/mods/example-mod.jar | Bin 1760 -> 0 bytes bridge/src/test/python/run/rsx/bridge.log | 19 - bridge/src/test/python/run_test.py | 3 +- launcher-patch/src/LauncherPatch.java | 1412 +++++++++++------ launcher-patch/src/rsx/Patch.java | 25 + launcher-patch/src/rsx/UpdaterHook.java | 314 ++++ profiles/latest.rsxp | 27 + scripts/install.ps1 | 50 +- 14 files changed, 1374 insertions(+), 535 deletions(-) create mode 100644 AUTHORS.md delete mode 100644 bridge/src/test/python/run/bridge/rsx-bridge.jar delete mode 100644 bridge/src/test/python/run/game.jar delete mode 100644 bridge/src/test/python/run/mods/example-mod.jar delete mode 100644 bridge/src/test/python/run/rsx/bridge.log create mode 100644 launcher-patch/src/rsx/UpdaterHook.java create mode 100644 profiles/latest.rsxp 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 0b14c0e0f4d966a0de8795a22a9cfaecd9fb61b6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 26476 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 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 198f9d580c257cd8db4458ccef67bbf812a8f074..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1760 zcmZ{ldpHwn9LE=i94mK|N@M0e_llYfld-Y3gl}i^FF`#_df6Y{r&U#eP5i9yn+@008j=zt-K?&EEq+rN6N zVt%J{R_NDtRA^yVYT4A$W>Jp!;(4`MHKAWH&UU)eLf|2A0Pd%9L!oylPviiACItXs zova+0NunQ)B3q+=MC_r1P+NFd6p6t&MeN6GBGnL@ZkvO2f{dC3%IdqghtX<8hgU!a z4lLmSX{5hQ?*Qrkr9P=?&HNS@LJ_2Bx68t-ohb-(Y3Ny>QagkQ@eVVGj$2;(I`v1b zusX1m1*fVmxO{68 z={$@RMfNY&O|IT?sPD$N3s;P?B>0%s2$fjSd37?ofN~I5MN&Rj(AH) zdWX#dxZ5qXt9e5iImJaDo^~e2j8sT&Q~tTNVTq9STPz9U<-{Z`Ixx7-Wv{}e+%nJk zqvG@=A-`xaXUwo*QPq2i`zBZbyg8P~O2+rLSNmv$JGOycr1w!tKE7G-nc_(eg0D=tj3+Q4rG;j9CbsXq2h=X^4`I(b$FlgqM}jL zE{Bxb)s*4=zOKl?ET9EDA2+;1w-=1g2sk?0)5HaJW3ifh0kIB#^qQJ@m_! zUaGT%+5g}}bUs8w{IaH_*4+Fm+YIL@NoY_Tv|o#T#9Fu$by3X?LbaYT_j!O!s<8 zCNCr-xE)m4&TcVp$nv+jGPDM!*PW=u>iHxe|9*;9)d9Ls)7M{y{MkKpTg}~W8~Co} z15D(FtX)^n^v|}uuVw{F!j$_%8_8b#j1O6)s@6Xf?CqgHwd#NTU9YCb50~1B}Dks9div*G6-S9f^SaIy00>NOfT{qPrD928(B2`VYhk+S<=i+GX z!4*!X4`eQBmUxr;L^+~ruVUK0^eE_QxwqTs;E+ydNla-4e6}!8&TC@S%#-hDbCGRe z)YU)_K4<%)LCW}c>JxNh#Z=jxamvnO(?=Js)@0Wr3 zeI%?}3BUUZepUF@&VRfI0QhGP$+rJ*?!W5%+8_VvP~?B7^P^jEK0w8loAR>RDT@oh GmF!=~GW{F? 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