Патчить апдейтер, а не только лаунчер
Вендорный 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:
+3
-3
@@ -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
@@ -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,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
|
||||
|
||||
@@ -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.
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
@@ -41,6 +43,16 @@ public final class LauncherPatch {
|
||||
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.putNextEntry(new JarEntry(UPDATER_REPAIR_CLASS + ".class"));
|
||||
outJar.write(jbBytes);
|
||||
outJar.closeEntry();
|
||||
copied++;
|
||||
System.out.println(" + " + e.getName());
|
||||
}
|
||||
}
|
||||
if (copied == 0) {
|
||||
throw new IllegalStateException(
|
||||
"no rsx/ classes inside the patcher jar");
|
||||
}
|
||||
|
||||
// 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,6 +700,330 @@ 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. */
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
+43
-3
@@ -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
|
||||
# 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. если лаунчер всё же предложит обновление - согласись, патч применится сам'
|
||||
Reference in New Issue
Block a user