Патчить апдейтер, а не только лаунчер

Вендорный 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 файлов.
This commit is contained in:
SashegDev
2026-10-17 01:48:24 +03:00
parent ba6cfe50fc
commit 9f43d35f44
14 changed files with 1374 additions and 535 deletions
+3 -3
View File
@@ -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
+17
View File
@@ -0,0 +1,17 @@
# Authors
* **SashegDev** — автор и мейнтейнер (`gdsasheg@gmail.com`)
## Third-party components
* **ASM** (`third_party/asm`) — BSD 3-Clause, <https://asm.ow2.io/license.html>.
Вендорится, чтобы патчер запускался без Gradle и без сети.
* **org.json** — используется внутри `updater.jar` вендора, в наш код не входит.
## What this project is not
RustexRemake, KJST Launcher и его апдейтер — сторонние продукты. Здесь нет ни
их кода, ни их файлов, кроме готовых патчей, которые инструмент применяет на
машине пользователя. Правки лаунчера и апдейтера обратимы: оригиналы
сохраняются как `app.jar.orig` и `updater.exe.orig`, а `uninstall.cmd`
возвращает всё на место.
+1 -1
View File
@@ -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
+33 -1
View File
@@ -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-файл: слэш экранирует следующий
Binary file not shown.
Binary file not shown.
Binary file not shown.
-19
View File
@@ -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
+2 -1
View File
@@ -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):
+436 -34
View File
@@ -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,9 +40,19 @@ 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 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";
@@ -84,6 +96,10 @@ public static void main(String[] args) throws Exception {
setProp(jar, args[2], args[3]);
return;
}
if ("patchupdater".equals(mode)) {
patchUpdater(jar, Paths.get(args[2]));
return;
}
// patch <app.jar> <modloaderHome> [envFieldSpec]
Path home = Paths.get(args[2]);
String envMeta = args.length > 3 ? args[3].replace(",", "\n") : "wa\nALLATORIxDEMO";
@@ -374,9 +390,9 @@ private static boolean check(Path jarPath) throws Exception {
}
boolean ok = true;
for (String name : new String[] {TARGET_CLASS, UPDATER_CLASS,
SELF_UPDATE_CLASS, "rsx.Patch"}) {
SELF_UPDATE_CLASS, UPDATER_REPAIR_CLASS, "rsx.Patch"}) {
try {
Class.forName(name, true, loader);
Class.forName(name.replace('/', '.'), true, loader);
System.out.println(" " + name + " verifies");
} catch (Throwable t) {
Throwable cause = t;
@@ -462,6 +478,7 @@ 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) {
@@ -531,6 +548,38 @@ injectPlayChoice(play);
ha.accept(haWriter);
byte[] haBytes = haWriter.toByteArray();
// 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();
@@ -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);
@@ -555,7 +607,8 @@ ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
String name = entry.getName();
if (name.equals(TARGET_CLASS + ".class")
|| name.equals(UPDATER_CLASS + ".class")
|| name.equals(SELF_UPDATE_CLASS + ".class")) {
|| name.equals(SELF_UPDATE_CLASS + ".class")
|| name.equals(UPDATER_REPAIR_CLASS + ".class")) {
continue;
}
if (name.startsWith("rsx/") || name.equals(CHOICE_RESOURCE)) {
@@ -583,35 +636,13 @@ outJar.putNextEntry(new JarEntry(UPDATER_CLASS + ".class"));
outJar.write(haBytes);
outJar.closeEntry();
// rsx/ classes come from this patcher's own jar, which keeps the
// installer free of build directories: Choice$1 and Choice$2 have
// to travel along or loading Choice fails with NoClassDefFoundError
Path self = Paths.get(LauncherPatch.class.getProtectionDomain()
.getCodeSource().getLocation().toURI());
int copied = 0;
try (JarFile own = new JarFile(self.toFile())) {
Enumeration<JarEntry> 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(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);
@@ -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 + "\"}")
@@ -668,8 +700,332 @@ private static boolean hasChoiceCall(MethodNode m) {
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 <itself>" and Java tolerates a zip that
* has a prefix - so the tail can simply be replaced with a new jar.
*
* usage: patchupdater <updater.exe> <modloaderHome>
*/
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<JarEntry> 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. */
/**
* Put the patched jar in place.
* 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<java.nio.file.Path> files =
java.nio.file.Files.list(rsync())) {
for (java.nio.file.Path p
: (Iterable<java.nio.file.Path>) 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<JarEntry> 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
@@ -712,6 +1068,21 @@ private static void replace(Path tmp, Path target) throws Exception {
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()) {
@@ -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);
+25
View File
@@ -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");
+314
View File
@@ -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<Object> kept = new ArrayList<Object>();
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<String> cmd = new ArrayList<String>();
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
}
}
}
+27
View File
@@ -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
+44 -4
View File
@@ -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"
Say ' 4. если лаунчер всё же предложит обновление - согласись, патч применится сам'