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

Вендорный 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):
File diff suppressed because it is too large Load Diff
+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
+45 -5
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 " 3. моды клади в $ModHome\mods"
Say ' 4. если лаунчер всё же предложит обновление - согласись, патч применится сам'