Модлоадер RustexRemake: агент, патч лаунчера, установщик и сборка

Моды грузятся через -javaagent, game.jar не патчится, переживают ре-обфускацию.

Что внутри:
- агент rsx-bridge.jar: профиль сборки игры, патчи A2/A6, загрузка модов
  через родной загрузчик игры (ilxq -> special.Main)
- патч лаунчера: окно выбора «С модами / Ванилла» по кнопке «Играть»
  и защита от автоапдейта лаунчера
- install/uninstall: всё ставится в %LOCALAPPDATA%\rsx-mod, пути не хардкодятся
- build: три готовых артефакта в dist, нужен только JDK
- testserver: локальный сервер, принимает любой ник и токен

Проверено на живом клиенте: мод грузится и выполняется в игровом потоке,
в логе видно игрока и мир.
This commit is contained in:
SashegDev
2026-10-17 00:32:10 +03:00
commit b8e5df778e
40 changed files with 4730 additions and 0 deletions
+47
View File
@@ -0,0 +1,47 @@
// rsx-bridge: the mod loader that is attached to the client through -javaagent.
//
// Compiled for Java 8 because the client ships its own 8u51 runtime and nothing
// newer is available on that JVM. No external dependencies on purpose: the jar
// is loaded into a process we do not control, so every dependency is a risk.
plugins {
id 'java'
}
group = 'net.rustexremake'
version = '1.0.0'
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
tasks.withType(JavaCompile).configureEach {
options.encoding = 'UTF-8'
options.release = 8
}
jar {
archiveFileName = 'rsx-bridge.jar'
manifest {
attributes(
'Premain-Class': 'rsx.RsxAgent',
'Agent-Class': 'rsx.RsxAgent',
'Can-Redefine-Classes': 'false',
'Can-Retransform-Classes': 'false',
'Implementation-Title': 'RustexRemake mod bridge',
'Implementation-Version': project.version
)
}
}
// a copy of the profile for the build that is installed, so the installer only
// has to drop two files next to the jar
tasks.register('installLayout', Copy) {
from jar
from "${rootDir}/../rsx-map/profiles" {
include 'latest.rsxp'
rename { 'profile.rsxp' }
}
into "${rootDir}/build/install"
}
+1
View File
@@ -0,0 +1 @@
rootProject.name = 'rsx-bridge'
@@ -0,0 +1,123 @@
package net.rustexremake.api;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import rsx.ModBridge;
import rsx.Resolver;
/**
* Stable API for mods.
*
* Nothing in this class mentions an obfuscated name. A mod asks for
* "net.minecraft.client.Minecraft" and the bridge looks up what that class is
* called in the build that is currently installed, so a mod compiled once keeps
* working after the vendor renames everything again.
*/
public final class Rsx {
private Rsx() {
}
// ------------------------------------------------------------- identity
public static String buildId() {
return Resolver.profile() == null ? "unknown"
: Resolver.profile().buildId;
}
public static String describe(String logical) {
return Resolver.profile() == null ? logical
: Resolver.profile().describe(logical);
}
public static boolean ready() {
return Resolver.cls("net.minecraft.client.Minecraft") != null;
}
// ------------------------------------------------------------- accessors
public static Object minecraft() {
return Resolver.get("net.minecraft.client.Minecraft", "instance");
}
public static Object player() {
return Resolver.get("net.minecraft.client.Minecraft", "player", minecraft());
}
public static Object world() {
return Resolver.get("net.minecraft.client.Minecraft", "world", minecraft());
}
public static Object currentScreen() {
return Resolver.get("net.minecraft.client.Minecraft", "currentScreen", minecraft());
}
public static Class<?> classOf(String logical) {
return Resolver.cls(logical);
}
public static Object get(String logicalClass, String member) {
return Resolver.get(logicalClass, member);
}
public static Object get(String logicalClass, String member, Object instance) {
return Resolver.get(logicalClass, member, instance);
}
public static void set(String logicalClass, String member, Object instance,
Object value) {
Resolver.set(logicalClass, member, instance, value);
}
public static Object call(String logicalClass, String member, Object... args) {
return Resolver.call(logicalClass, member, args);
}
public static Object call(String logicalClass, String member, Object instance,
Object... args) {
return Resolver.call(logicalClass, member, instance, args);
}
/**
* Reflective escape hatch for members the profile does not carry yet.
* Mods should prefer the named accessors above.
*/
public static Field field(Class<?> owner, String name) {
try {
Field f = owner.getDeclaredField(name);
f.setAccessible(true);
return f;
} catch (Throwable t) {
return null;
}
}
public static Method method(Class<?> owner, String name, Class<?>... args) {
try {
Method m = owner.getDeclaredMethod(name, args);
m.setAccessible(true);
return m;
} catch (Throwable t) {
return null;
}
}
// -------------------------------------------------------------- threading
/** Run a task on the game thread, on the next tick. */
public static void onGameThread(Runnable task) {
ModBridge.post(task);
}
/** Run a task repeatedly on the game thread. */
public static void every(long millis, final Runnable task) {
ModBridge.schedule(millis, task);
}
// ------------------------------------------------------------------ logs
public static void info(String msg) {
rsx.Log.info(msg);
}
public static void warn(String msg) {
rsx.Log.warn(msg);
}
}
@@ -0,0 +1,17 @@
package net.rustexremake.api;
/**
* The whole contract a mod has to implement.
*
* onLoad runs once, on the thread that loads the mod jar. Anything scheduled
* through {@link Rsx#every} or {@link Rsx#onGameThread} runs later on the game's
* own thread, which is the only thread where touching game state is safe.
*/
public interface RsxMod {
void onLoad();
/** optional */
default void onUnload() {
}
}
+62
View File
@@ -0,0 +1,62 @@
package rsx;
import java.io.File;
import java.io.FileOutputStream;
import java.io.PrintStream;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* Bridge log. The game runs windowed with javaw.exe, so stdout is not visible to
* anybody: everything goes to a file next to the bridge, and that file is the
* primary way to verify a mod actually ran.
*/
public final class Log {
private static PrintStream out;
private static final SimpleDateFormat FMT =
new SimpleDateFormat("HH:mm:ss.SSS");
public static synchronized void open(File file) {
try {
File dir = file.getParentFile();
if (dir != null) {
dir.mkdirs();
}
out = new PrintStream(new FileOutputStream(file, true), true, "UTF-8");
info("--- rsx bridge log opened: " + file.getAbsolutePath());
} catch (Throwable t) {
out = null;
System.err.println("[rsx] cannot open log: " + t);
}
}
public static synchronized void info(String msg) {
write("INFO", msg);
}
public static synchronized void warn(String msg) {
write("WARN", msg);
}
public static synchronized void error(String msg, Throwable t) {
Throwable root = t;
while (root != null && root.getCause() != null && root.getCause() != root) {
root = root.getCause();
}
write("ERROR", msg + (t == null ? "" : " :: " + root));
if (t != null && out != null) {
java.io.StringWriter sw = new java.io.StringWriter();
t.printStackTrace(new java.io.PrintWriter(sw));
out.println(sw.toString());
}
}
private static void write(String level, String msg) {
String line = FMT.format(new Date()) + " [" + level + "] " + msg;
System.out.println("[rsx] " + line);
if (out != null) {
out.println(line);
}
}
}
+250
View File
@@ -0,0 +1,250 @@
package rsx;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipOutputStream;
/**
* Loads mods through the client's own code loader.
*
* The client already ships a loader: it takes a jar, finds the entry class
* `special.Main`, calls its static main(String[]) and later invokes the static
* getRunnableList() from the game tick. That is the vendor's own supported way
* to run code, so reusing it means mods execute inside the game's class loader
* with full access - and nothing in the game jar has to change.
*
* A mod is a plain jar with a manifest entry
* Rsx-Mod-Entrypoint: com.example.MyMod
* whose class implements net.rustexremake.api.RsxMod. The bridge injects a
* generated `special.Main`, so mod authors never learn that contract: they just
* implement one interface. The entry point travels to the bridge through the
* argument array the loader passes through, so no manifest parsing is needed at
* runtime.
*/
public final class ModBridge {
private static final Map<ClassLoader, String> NAMES =
new ConcurrentHashMap<ClassLoader, String>();
private static final Map<ClassLoader, List<Runnable>> QUEUE =
new ConcurrentHashMap<ClassLoader, List<Runnable>>();
private static final Map<ClassLoader, Runnable> DRAIN =
new ConcurrentHashMap<ClassLoader, Runnable>();
private static final List<String> LOADED = new ArrayList<String>();
private static final java.util.concurrent.ScheduledExecutorService TIMER =
java.util.concurrent.Executors.newSingleThreadScheduledExecutor(
new java.util.concurrent.ThreadFactory() {
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "rsx-mod-timer");
t.setDaemon(true);
return t;
}
});
/** Invoked by the injected special.Main, during jar loading. */
public static void launch(Class<?> entry, String[] args) {
ClassLoader cl = entry.getClassLoader();
String modName = args != null && args.length > 0 ? args[0] : "unknown";
String entryName = args != null && args.length > 1 ? args[1] : null;
NAMES.put(cl, modName);
QUEUE.put(cl, new ArrayList<Runnable>());
try {
if (entryName == null || entryName.isEmpty()) {
Log.warn("mod " + modName + ": no entry point");
return;
}
Class<?> api = cl.loadClass("net.rustexremake.api.RsxMod");
Object impl = cl.loadClass(entryName).newInstance();
if (!api.isInstance(impl)) {
Log.warn("mod " + modName + ": " + entryName
+ " does not implement RsxMod");
return;
}
api.getMethod("onLoad").invoke(impl);
LOADED.add(modName);
Log.info("mod loaded: " + modName + " (" + entryName + ")");
} catch (Throwable t) {
Log.error("mod " + modName + " failed to start", t);
}
}
/** Invoked by the injected special.Main, on the game thread. */
public static List<Runnable> drain(Class<?> entry) {
final ClassLoader cl = entry.getClassLoader();
Runnable r = DRAIN.get(cl);
if (r == null) {
r = new Runnable() {
@Override
public void run() {
runPending(cl);
}
};
DRAIN.put(cl, r);
}
List<Runnable> one = new ArrayList<Runnable>(1);
one.add(r);
return one;
}
/** Queue a task to run on the game thread; callable from any thread. */
public static void post(Runnable task) {
for (List<Runnable> q : QUEUE.values()) {
synchronized (q) {
// coalesce: a slow tick must not let a repeating task pile up
for (Runnable r : q) {
if (r == task) {
return;
}
}
q.add(task);
}
return;
}
}
/**
* Repeating task on the game thread. A timer thread only ever *posts*, so
* the callback itself always runs where touching game state is legal.
*/
public static void schedule(long millis, final Runnable task) {
try {
TIMER.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
post(task);
}
}, millis, millis, java.util.concurrent.TimeUnit.MILLISECONDS);
} catch (Throwable t) {
Log.error("cannot schedule repeating task", t);
}
}
public static List<String> loaded() {
return LOADED;
}
private static void runPending(ClassLoader cl) {
List<Runnable> q = QUEUE.get(cl);
if (q == null) {
return;
}
List<Runnable> copy;
synchronized (q) {
if (q.isEmpty()) {
return;
}
copy = new ArrayList<Runnable>(q);
q.clear();
}
for (Runnable r : copy) {
try {
r.run();
} catch (Throwable t) {
Log.error("mod task failed in " + NAMES.get(cl), t);
}
}
}
/**
* Build the jar to hand to the client's loader: the mod jar plus a generated
* special/Main.class that forwards into this bridge.
*/
public static byte[] prepare(File mod, byte[] specialMain,
String entryPoint) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ZipOutputStream zos = new ZipOutputStream(bos);
ZipFile zf = new ZipFile(mod);
try {
java.util.Enumeration<? extends ZipEntry> en = zf.entries();
while (en.hasMoreElements()) {
ZipEntry e = en.nextElement();
if (e.isDirectory()) {
continue;
}
String n = e.getName();
if ("META-INF/MANIFEST.MF".equals(n) || "META-INF/INDEX.LIST".equals(n)) {
continue;
}
// our generated entry point always wins
if ("special/Main.class".equals(n)) {
continue;
}
zos.putNextEntry(new ZipEntry(n));
InputStream in = zf.getInputStream(e);
copy(in, zos);
zos.closeEntry();
}
} finally {
zf.close();
}
zos.putNextEntry(new ZipEntry("META-INF/MANIFEST.MF"));
zos.write(("Manifest-Version: 1.0\r\n"
+ "Rsx-Mod-Entrypoint: " + entryPoint + "\r\n\r\n")
.getBytes("UTF-8"));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("special/Main.class"));
zos.write(specialMain);
zos.closeEntry();
zos.close();
return bos.toByteArray();
}
/** Read the entry point a mod declares in its own manifest. */
public static String entryPointOf(File mod) {
try {
ZipFile zf = new ZipFile(mod);
try {
ZipEntry me = zf.getEntry("META-INF/MANIFEST.MF");
if (me == null) {
return null;
}
InputStream in = zf.getInputStream(me);
ByteArrayOutputStream bos = new ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) > 0) {
bos.write(buf, 0, n);
}
in.close();
// manifests are plain key/value lines; parsing them by hand
// avoids depending on java.util.jar, which is not reachable
// from every class loader the client may install
for (String line : new String(bos.toByteArray(), "UTF-8")
.split("\r?\n")) {
if (line.startsWith("Rsx-Mod-Entrypoint:")) {
return line.substring("Rsx-Mod-Entrypoint:".length()).trim();
}
}
} finally {
zf.close();
}
} catch (Throwable ignored) {
}
return null;
}
private static void copy(InputStream in, java.io.OutputStream out)
throws Exception {
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) > 0) {
out.write(buf, 0, n);
}
in.close();
}
public static byte[] readFile(File f) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
copy(new FileInputStream(f), bos);
return bos.toByteArray();
}
}
+110
View File
@@ -0,0 +1,110 @@
package rsx;
/**
* Minimal reader for the compact .rsxp profile the pipeline generates.
*
* Format (tab or single-space separated, one record per line):
* rsxprofile 1
* build <id> <sha256> <namedClasses> <totalClasses>
* loader <obfClass> <loadMethod> <entryClass>
* patch.a2 <obfClass> <property>
* patch.a6 <obfClass> <clientFieldName> <clientFieldObf>
* class <logicalName> <obfClass>
* field <logicalName> <member> <obfMember> <descriptor>
* method <logicalName> <member> <obfMember> <descriptor>
*/
public final class Profile {
public String buildId = "";
public String gameJarSha256 = "";
public long jarSize;
public long jarMtime;
public int namedClasses;
public int totalClasses;
public String loaderClass;
public String loaderMethod;
public String loaderEntry;
public String a2Class;
public String a2Property;
public String a6Class;
public String a6FieldName;
public String a6FieldObf;
public String serverOverrideField;
/** logical name -> obfuscated class name */
public final java.util.Map<String, String> classes =
new java.util.HashMap<String, String>();
/** "class#member" -> [obfMember, descriptor, kind] */
public final java.util.Map<String, String[]> members =
new java.util.HashMap<String, String[]>();
public static Profile load(String path) throws java.io.IOException {
Profile p = new Profile();
java.io.BufferedReader r = new java.io.BufferedReader(
new java.io.InputStreamReader(
new java.io.FileInputStream(path), "UTF-8"));
try {
String line;
while ((line = r.readLine()) != null) {
line = line.trim();
if (line.isEmpty() || line.startsWith("#")) {
continue;
}
String[] f = line.split("\\s+");
String kind = f[0];
if ("rsxprofile".equals(kind)) {
continue;
} else if ("build".equals(kind) && f.length >= 5) {
p.buildId = f[1];
p.gameJarSha256 = f[2];
p.namedClasses = Integer.parseInt(f[3]);
p.totalClasses = Integer.parseInt(f[4]);
if (f.length >= 7) {
// optional fast path: skip hashing a 56 MB jar on every
// launch when size and mtime already identify it
p.jarSize = Long.parseLong(f[5]);
p.jarMtime = Long.parseLong(f[6]);
}
} else if ("loader".equals(kind) && f.length >= 4) {
p.loaderClass = f[1];
p.loaderMethod = f[2];
p.loaderEntry = f[3];
} else if ("patch.a2".equals(kind) && f.length >= 3) {
p.a2Class = f[1];
p.a2Property = f[2];
} else if ("patch.a6".equals(kind) && f.length >= 4) {
p.a6Class = f[1];
p.a6FieldName = f[2];
p.a6FieldObf = f[3];
} else if ("class".equals(kind) && f.length >= 3) {
p.classes.put(f[1], f[2]);
} else if ("override.server".equals(kind) && f.length >= 2) {
p.serverOverrideField = f[1];
} else if (("field".equals(kind) || "method".equals(kind))
&& f.length >= 5) {
p.members.put(f[1] + "#" + f[2],
new String[] {f[3], f[4], "field".equals(kind) ? "f" : "m"});
}
}
} finally {
r.close();
}
return p;
}
public String obf(String logical) {
return classes.get(logical);
}
public String[] member(String logical, String member) {
return members.get(logical + "#" + member);
}
/** logical name -> readable name, for logging and mod diagnostics */
public String describe(String logical) {
String o = classes.get(logical);
return logical + (o == null ? " (unmapped)" : " -> " + o);
}
}
+370
View File
@@ -0,0 +1,370 @@
package rsx;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* Turns stable logical names into the real, obfuscated members of whatever
* build is running.
*
* The vendor re-obfuscates every release, so nothing here may hard-code a name.
* The profile supplies the translation, and the class loader is discovered at
* runtime because the game may load its own classes either from the system
* class path or from a private loader it creates itself.
*/
public final class Resolver {
private static Profile profile;
private static ClassLoader loader;
private static String loaderSource = "none";
private static final Map<String, Class<?>> CLASSES = new HashMap<String, Class<?>>();
private static final Map<String, Field> FIELDS = new HashMap<String, Field>();
private static final Map<String, Method> METHODS = new HashMap<String, Method>();
private static java.lang.instrument.Instrumentation inst;
private static Map<String, Class<?>> loaded;
private static long lastScan;
public static void init(Profile p) {
profile = p;
}
public static void setInstrumentation(java.lang.instrument.Instrumentation i) {
inst = i;
}
/**
* Index of every class the VM has already loaded, by binary name.
*
* The client encrypts its classes, so they cannot simply be loaded from the
* system class path: the game builds its own loader and defines them
* through Unsafe, bypassing transformers. Instrumentation is the one view
* that sees those classes, so it is what we index - and it is exact, with
* no guessing about which loader the vendor installed.
*/
private static Map<String, Class<?>> loadedIndex(boolean force) {
long now = System.currentTimeMillis();
if (loaded != null && !force && now - lastScan < 1000L) {
return loaded;
}
if (loaded != null && now - lastScan < 1000L) {
return loaded;
}
Map<String, Class<?>> next = new HashMap<String, Class<?>>();
if (inst != null) {
try {
for (Class<?> c : inst.getAllLoadedClasses()) {
if (c != null) {
next.put(c.getName(), c);
}
}
} catch (Throwable t) {
return loaded == null ? next : loaded;
}
} else {
return loaded == null ? next : loaded;
}
loaded = next;
lastScan = now;
return loaded;
}
public static Class<?> findLoaded(String binaryName) {
return loadedIndex(true).get(binaryName);
}
/** every class currently in the VM, for instance hunting */
public static Class<?>[] allLoadedClasses() {
Map<String, Class<?>> idx = loadedIndex(true);
return idx.values().toArray(new Class<?>[0]);
}
public static Profile profile() {
return profile;
}
public static String loaderSource() {
return loaderSource;
}
/**
* Find a loader that can see the game's own classes. Candidates are tried in
* order and the first one that can resolve the Minecraft class wins.
*/
public static synchronized ClassLoader loader() {
if (loader != null) {
return loader;
}
if (profile == null) {
return null;
}
String mc = profile.obf("net.minecraft.client.Minecraft");
if (mc == null) {
return null;
}
if (loader != null) {
return loader;
}
List<ClassLoader> candidates = new ArrayList<ClassLoader>();
try {
ClassLoader t = Thread.currentThread().getContextClassLoader();
if (t != null) {
candidates.add(t);
}
} catch (Throwable ignored) {
}
try {
candidates.add(ClassLoader.getSystemClassLoader());
} catch (Throwable ignored) {
}
try {
// the game entry point lives in game.jar under its real name
Class<?> entry = Class.forName("net.minecraft.client.main.RustexMain",
false, ClassLoader.getSystemClassLoader());
if (entry != null) {
candidates.add(entry.getClassLoader());
}
} catch (Throwable ignored) {
}
for (ClassLoader cl : candidates) {
try {
Class<?> c = Class.forName(mc, false, cl);
if (c != null) {
loader = cl;
loaderSource = cl.getClass().getName() + "@"
+ System.identityHashCode(cl);
Log.info("game class loader: " + loaderSource);
return loader;
}
} catch (Throwable ignored) {
}
}
return null;
}
public static Class<?> cls(String logical) {
Class<?> c = CLASSES.get(logical);
if (c != null) {
return c;
}
if (profile == null) {
return null;
}
String obf = profile.obf(logical);
if (obf == null) {
return null;
}
// 1) the class is already in the VM (the game defines its own classes
// through Unsafe, so this is the only way that always works)
// the game loads its classes over time, so a miss must force a rescan;
// otherwise we would keep looking at the snapshot from before the game
// had even started and never notice the class arriving
Class<?> known = loadedIndex(true).get(obf);
if (known != null) {
CLASSES.put(logical, known);
loader = known.getClassLoader();
loaderSource = "instrumentation:" + describeLoader(loader);
return known;
}
// 2) not loaded yet: try the loaders we can see
ClassLoader cl = loader();
if (cl == null) {
return null;
}
try {
c = Class.forName(obf, false, cl);
CLASSES.put(logical, c);
return c;
} catch (Throwable t) {
return null;
}
}
private static String describeLoader(ClassLoader cl) {
if (cl == null) {
return "bootstrap";
}
return cl.getClass().getName() + "@"
+ System.identityHashCode(cl);
}
private static Field field(String logical, String member, Class<?> owner) {
String key = logical + "#" + member;
Field f = FIELDS.get(key);
if (f != null) {
return f;
}
String[] m = profile == null ? null : profile.member(logical, member);
if (m == null || owner == null) {
return null;
}
try {
f = owner.getDeclaredField(m[0]);
f.setAccessible(true);
} catch (Throwable t) {
Log.warn("field " + key + " -> " + m[0] + " not found on " + owner);
return null;
}
FIELDS.put(key, f);
return f;
}
private static Method method(String logical, String member, Class<?> owner) {
String key = logical + "#" + member;
Method cached = METHODS.get(key);
if (cached != null) {
return cached;
}
String[] m = profile == null ? null : profile.member(logical, member);
if (m == null || owner == null) {
return null;
}
try {
Method found = null;
for (Method cand : owner.getDeclaredMethods()) {
if (cand.getName().equals(m[0])
&& cand.getReturnType().getName()
.equals(typeOf(m[1]))) {
found = cand;
break;
}
}
if (found == null) {
for (Method cand : owner.getDeclaredMethods()) {
if (cand.getName().equals(m[0])) {
found = cand;
break;
}
}
}
if (found == null) {
return null;
}
found.setAccessible(true);
METHODS.put(key, found);
return found;
} catch (Throwable t) {
Log.warn("method " + key + " -> " + m[0] + " not found: " + t);
return null;
}
}
private static String typeOf(String descriptor) {
String d = descriptor;
if (d.startsWith("(")) {
int i = d.lastIndexOf(')');
d = i >= 0 ? d.substring(i + 1) : d;
}
while (d.startsWith("[")) {
d = d.substring(1);
}
if (d.startsWith("L") && d.endsWith(";")) {
return d.substring(1, d.length() - 1).replace('/', '.');
}
switch (d) {
case "V": return "void";
case "Z": return "boolean";
case "B": return "byte";
case "C": return "char";
case "S": return "short";
case "I": return "int";
case "J": return "long";
case "F": return "float";
case "D": return "double";
default: return d;
}
}
// ------------------------------------------------------------- public API
public static Object get(String logical, String member) {
Class<?> owner = cls(logical);
Field f = field(logical, member, owner);
if (f == null) {
return null;
}
try {
return f.get(null);
} catch (Throwable t) {
Log.warn("static read " + logical + "#" + member + " failed: " + t);
return null;
}
}
public static Object get(String logical, String member, Object instance) {
Class<?> owner = cls(logical);
Field f = field(logical, member, owner);
if (f == null) {
return null;
}
try {
return f.get(instance);
} catch (Throwable t) {
Log.warn("read " + logical + "#" + member + " failed: " + t);
return null;
}
}
public static void set(String logical, String member, Object instance,
Object value) {
Class<?> owner = cls(logical);
Field f = field(logical, member, owner);
if (f == null) {
return;
}
try {
f.set(instance, value);
} catch (Throwable t) {
Log.warn("write " + logical + "#" + member + " failed: " + t);
}
}
/** Write a field addressed by its obfuscated name. */
public static boolean setRaw(String logicalClass, String obfName,
Object instance, Object value) {
Class<?> owner = cls(logicalClass);
if (owner == null) {
return false;
}
try {
Field f = owner.getDeclaredField(obfName);
f.setAccessible(true);
f.set(instance, value);
return true;
} catch (Throwable t) {
Log.warn("write " + logicalClass + "#" + obfName + " failed: " + t);
return false;
}
}
public static Object call(String logical, String member, Object... args) {
Class<?> owner = cls(logical);
Method mm = method(logical, member, owner);
if (mm == null) {
return null;
}
try {
return mm.invoke(null, args);
} catch (Throwable t) {
Log.warn("call " + logical + "#" + member + " failed: " + t);
return null;
}
}
public static Object call(String logical, String member, Object instance,
Object... args) {
Class<?> owner = cls(logical);
Method mm = method(logical, member, owner);
if (mm == null) {
return null;
}
try {
return mm.invoke(instance, args);
} catch (Throwable t) {
Log.warn("call " + logical + "#" + member + " failed: " + t);
return null;
}
}
}
+570
View File
@@ -0,0 +1,570 @@
package rsx;
import java.io.File;
import java.io.InputStream;
import java.security.MessageDigest;
import java.util.ArrayList;
import java.util.List;
import java.util.zip.ZipFile;
/**
* The agent. It is attached through the launcher setting `env`, so the vendor's
* jar and launcher stay untouched: no repack, no manifest hash breaks, and an
* update from the vendor cannot remove us.
*
* Jobs, in order:
* 1. read the profile for the build that is installed and verify it by hash
* 2. apply patch A2 (the client silently exits without -Djoml.fastmath)
* 3. wait for the game to come up, then apply patch A6 (Snooper telemetry)
* 4. load every jar from mods/ through the client's own code loader
*/
public final class RsxAgent {
private static Profile profile;
private static File home;
private static byte[] specialMain;
public static void premain(String args, java.lang.instrument.Instrumentation inst) {
try {
home = new File(RsxAgent.class.getProtectionDomain()
.getCodeSource().getLocation().toURI()).getParentFile();
} catch (Throwable t) {
home = new File(".");
}
File target = null;
try {
String dir = System.getProperty("minecraft.applet.TargetDirectory");
if (dir != null) {
target = new File(dir);
}
} catch (Throwable ignored) {
}
File logFile = new File(target != null ? target : home,
target != null ? "rsx/bridge.log" : "rsx-bridge.log");
Log.open(logFile);
Resolver.setInstrumentation(inst);
Log.info("rsx bridge starting (home=" + home + ")");
String mode = readMode();
if ("vanilla".equals(mode)) {
Log.info("mode is vanilla: no mods, no patches, agent does nothing");
return;
}
if (mode != null) {
Log.info("mode: " + mode);
}
File prof = pick(new File(home, "profiles/latest.rsxp"),
new File(home, "profile.rsxp"));
if (prof == null) {
fail("no profile found: expected profiles/latest.rsxp next to the "
+ "bridge jar, run prepare-build.cmd first");
return;
}
try {
profile = Profile.load(prof.getAbsolutePath());
Resolver.init(profile);
} catch (Throwable t) {
fail("cannot read profile " + prof + ": " + t);
return;
}
Log.info("profile " + profile.buildId + " from " + prof.getName()
+ " (named " + profile.namedClasses + "/" + profile.totalClasses + ")");
Log.info("loader anchor: " + profile.describe(
"net.rustexremake.loader.RemoteClassLoader"));
File game = findGameJar();
if (game == null) {
fail("cannot locate the client jar on java.class.path");
return;
}
String sha;
boolean fastPath = profile.jarSize > 0
&& game.length() == profile.jarSize
&& game.lastModified() / 1000L == profile.jarMtime;
if (fastPath) {
Log.info("build signature matches (size+mtime), skipping hash");
sha = profile.gameJarSha256;
} else {
try {
sha = sha256(game);
} catch (Throwable t) {
fail("cannot hash " + game + ": " + t);
return;
}
}
if (!sha.equals(profile.gameJarSha256)) {
fail("profile is for build " + profile.buildId + " but the installed "
+ "jar hashes to " + sha.substring(0, 16)
+ "; run prepare-build.cmd again");
return;
}
Log.info("build verified: " + game.getName() + " sha256 ok");
applyA2();
try {
specialMain = readResource("special/Main.class");
} catch (Throwable t) {
fail("cannot read the generated special.Main: " + t);
return;
}
Thread boot = new Thread(new Runnable() {
@Override
public void run() {
bootSequence();
}
}, "rsx-boot");
boot.setDaemon(true);
boot.start();
}
// ------------------------------------------------------------------- boot
private static void bootSequence() {
// the game creates its own loader, so wait until the client is really up
Object mc = null;
for (int i = 0; i < 600; i++) {
try {
mc = Resolver.get("net.minecraft.client.Minecraft", "instance");
if (mc != null) {
break;
}
} catch (Throwable ignored) {
}
sleep(500);
mc = null;
}
if (mc == null) {
Log.warn("the game did not come up within 5 minutes; mods not loaded");
return;
}
Log.info("client is up, loader: " + Resolver.loaderSource());
applyServerOverride();
applyA6(mc);
// the telemetry object is created later in the client's own start-up,
// so the patch is retried for a while instead of being given up on
Thread snooper = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 24; i++) {
sleep(5000);
Object c = Resolver.get("net.minecraft.client.Minecraft",
"instance");
if (c == null) {
continue;
}
if (applyA6(c)) {
return;
}
}
Log.warn("A6: telemetry object never appeared within 2 minutes");
}
}, "rsx-snooper-patch");
snooper.setDaemon(true);
snooper.start();
loadMods(mc);
Log.info("ready: " + ModBridge.loaded().size() + " mod(s) loaded");
}
// --------------------------------------------------------------- patches
private static void applyA2() {
String prop = profile.a2Property;
if (prop == null || prop.isEmpty()) {
Log.warn("A2: no property in the profile");
return;
}
// the client reads this flag in a static initialiser and exits silently
// when it is absent; setting it before that class loads is enough
System.setProperty(prop, "true");
Log.info("A2 applied: -D" + prop + "=true (class " + profile.a2Class + ")");
}
private static final java.util.Set<Object> patched =
java.util.Collections.newSetFromMap(
new java.util.WeakHashMap<Object, Boolean>());
/**
* Read the mode switch that play.cmd writes next to the bridge jar.
*
* "vanilla" keeps the agent attached but does nothing at all, which makes it
* easy to switch between modded and clean runs without editing the launcher.
*/
private static String readMode() {
File f = new File(home, "mode.txt");
if (!f.isFile()) {
return null;
}
java.io.BufferedReader r = null;
try {
r = new java.io.BufferedReader(new java.io.InputStreamReader(
new java.io.FileInputStream(f), "UTF-8"));
String line = r.readLine();
return line == null ? null : line.trim().toLowerCase();
} catch (Throwable t) {
return null;
} finally {
if (r != null) {
try {
r.close();
} catch (Throwable ignored) {
}
}
}
}
/**
* Repoint the client's server list.
*
* The vendor main accepts a --srvs option and then overwrites it with a
* hardcoded list of production addresses, so the only way to run against
* another server (a local one, a friend, a LAN) is to set the field
* ourselves before the client connects.
*/
private static void applyServerOverride() {
String servers = System.getProperty("rsx.server");
if (servers == null || servers.isEmpty()) {
return;
}
String field = profile.serverOverrideField;
if (field == null) {
Log.warn("rsx.server set but the profile has no override.server");
return;
}
if (Resolver.setRaw("net.minecraft.client.Minecraft", field, null,
servers)) {
Log.info("server list overridden: " + servers);
} else {
Log.warn("could not override the server list with: " + servers);
}
}
private static boolean applyA6(Object mc) {
Class<?> sc = resolveClass(profile.a6Class);
if (sc == null) {
Log.warn("A6: snooper class not resolvable");
return false;
}
// The profile's field hint turned out to be unreliable: the declared
// field stays null in practice, and the object may live in a static
// somewhere else entirely. So look for live instances instead of
// trusting one name.
java.util.List<Object> live = findInstances(mc, sc);
if (live.isEmpty()) {
return false;
}
int stopped = 0;
for (Object o : live) {
if (patched.contains(o)) {
continue;
}
if (killTimers(o)) {
stopped++;
patched.add(o);
}
}
if (stopped > 0) {
Log.info("A6 applied: telemetry timers replaced in "
+ stopped + " object(s), " + live.size() + " found");
}
return true;
}
private static Class<?> resolveClass(String obf) {
if (obf == null) {
return null;
}
try {
Class<?> known = Resolver.findLoaded(obf);
if (known != null) {
return known;
}
ClassLoader cl = Resolver.loader();
return cl == null ? null : Class.forName(obf, false, cl);
} catch (Throwable t) {
return null;
}
}
/** Live instances of a class, in the client instance and in static fields. */
private static java.util.List<Object> findInstances(Object mc, Class<?> type) {
java.util.List<Object> found = new java.util.ArrayList<Object>();
try {
for (java.lang.reflect.Field f : mc.getClass().getDeclaredFields()) {
f.setAccessible(true);
Object v = f.get(mc);
if (v != null && type.isInstance(v)) {
found.add(v);
}
}
} catch (Throwable ignored) {
}
try {
Class<?>[] all = Resolver.allLoadedClasses();
for (Class<?> c : all) {
java.lang.reflect.Field[] fs;
try {
fs = c.getDeclaredFields();
} catch (Throwable ignored) {
continue;
}
for (java.lang.reflect.Field f : fs) {
if (!java.lang.reflect.Modifier.isStatic(f.getModifiers())) {
continue;
}
if (!type.isAssignableFrom(f.getType())) {
continue;
}
try {
f.setAccessible(true);
Object v = f.get(null);
if (v != null && !found.contains(v)) {
found.add(v);
}
} catch (Throwable ignored) {
}
}
}
} catch (Throwable ignored) {
}
return found;
}
/**
* Stops the telemetry object's timers without breaking the client.
*
* Two earlier attempts were caught by the real client:
* - nulling the field crashed it with a NullPointerException, because the
* telemetry code dereferences it;
* - cancelling the Timer crashed it with "Timer already cancelled",
* because the client reschedules that Timer later during startup.
*
* So the Timer is kept valid but replaced by one that accepts every
* reschedule and silently drops the work, and the tasks already queued in
* the old Timer are cancelled individually.
*/
private static boolean killTimers(Object o) {
boolean stopped = false;
for (java.lang.reflect.Field f : o.getClass().getDeclaredFields()) {
Object v;
try {
f.setAccessible(true);
v = f.get(o);
} catch (Throwable ignored) {
continue;
}
if (v instanceof java.util.Timer) {
java.util.Timer old = (java.util.Timer) v;
try {
// Swap first: the field must never be null, and the client
// reschedules through whatever object is in there.
f.set(o, new SilentTimer());
// Only now is the old timer unreachable, so cancelling it is
// safe and drops whatever it had already queued.
old.cancel();
stopped = true;
} catch (Throwable t) {
Log.warn("A6: could not swap the telemetry timer: " + t);
}
} else if (v instanceof java.util.TimerTask) {
((java.util.TimerTask) v).cancel();
stopped = true;
} else if (v instanceof java.util.concurrent.ScheduledExecutorService) {
((java.util.concurrent.ScheduledExecutorService) v).shutdownNow();
stopped = true;
}
}
return stopped;
}
/** A Timer that swallows every schedule request instead of running it. */
private static final class SilentTimer extends java.util.Timer {
SilentTimer() {
super(true);
}
private static void drop(java.util.TimerTask task) {
if (task != null) {
task.cancel();
}
}
@Override
public void schedule(java.util.TimerTask task, long delay) {
drop(task);
}
@Override
public void schedule(java.util.TimerTask task, java.util.Date when) {
drop(task);
}
@Override
public void schedule(java.util.TimerTask task, long delay, long period) {
drop(task);
}
@Override
public void schedule(java.util.TimerTask task, java.util.Date first,
long period) {
drop(task);
}
@Override
public void scheduleAtFixedRate(java.util.TimerTask task, long delay,
long period) {
drop(task);
}
@Override
public void scheduleAtFixedRate(java.util.TimerTask task,
java.util.Date first, long period) {
drop(task);
}
}
// ------------------------------------------------------------------ mods
private static void loadMods(Object mc) {
File modsDir = null;
try {
String dir = System.getProperty("minecraft.applet.TargetDirectory");
if (dir != null) {
modsDir = new File(dir, "mods");
}
} catch (Throwable ignored) {
}
if (modsDir == null) {
modsDir = new File(home, "mods");
}
if (!modsDir.isDirectory()) {
Log.info("no mods directory at " + modsDir);
return;
}
File[] files = modsDir.listFiles();
if (files == null || files.length == 0) {
Log.info("mods directory is empty: " + modsDir);
return;
}
List<File> jars = new ArrayList<File>();
for (File f : files) {
if (f.isFile() && f.getName().toLowerCase().endsWith(".jar")) {
jars.add(f);
}
}
Log.info("found " + jars.size() + " mod jar(s) in " + modsDir);
Class<?> loaderCls;
java.lang.reflect.Method load;
try {
loaderCls = Class.forName(profile.loaderClass, false, Resolver.loader());
load = loaderCls.getDeclaredMethod(profile.loaderMethod,
byte[].class, String[].class);
load.setAccessible(true);
} catch (Throwable t) {
fail("cannot bind the client loader (" + profile.loaderClass + "."
+ profile.loaderMethod + "): " + t);
return;
}
for (File jar : jars) {
String name = jar.getName();
try {
String entry = ModBridge.entryPointOf(jar);
if (entry == null) {
Log.warn(name + ": no Rsx-Mod-Entrypoint in the manifest, skipped");
continue;
}
byte[] bytes = ModBridge.prepare(jar, specialMain, entry);
load.invoke(null, bytes, new String[] {name, entry});
} catch (Throwable t) {
Log.error("mod " + name + " failed to load", t);
}
}
}
// ----------------------------------------------------------------- utils
private static File findGameJar() {
String loader = profile.loaderClass;
if (loader == null) {
return null;
}
String cp = System.getProperty("java.class.path", "");
for (String part : cp.split(File.pathSeparator)) {
if (!part.toLowerCase().endsWith(".jar")) {
continue;
}
try {
ZipFile z = new ZipFile(part);
try {
if (z.getEntry(loader + ".class") != null) {
return new File(part);
}
} finally {
z.close();
}
} catch (Throwable ignored) {
}
}
return null;
}
private static String sha256(File f) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
java.io.FileInputStream in = new java.io.FileInputStream(f);
try {
byte[] buf = new byte[1 << 20];
int n;
while ((n = in.read(buf)) > 0) {
md.update(buf, 0, n);
}
} finally {
in.close();
}
StringBuilder sb = new StringBuilder();
for (byte b : md.digest()) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
private static byte[] readResource(String name) throws Exception {
InputStream in = RsxAgent.class.getClassLoader().getResourceAsStream(name);
if (in == null) {
throw new IllegalStateException("resource not found: " + name);
}
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[4096];
int n;
while ((n = in.read(buf)) > 0) {
bos.write(buf, 0, n);
}
in.close();
return bos.toByteArray();
}
private static File pick(File... files) {
for (File f : files) {
if (f.isFile()) {
return f;
}
}
return null;
}
private static void fail(String msg) {
Log.error(msg, null);
System.err.println("[rsx] " + msg);
}
private static void sleep(long ms) {
try {
Thread.sleep(ms);
} catch (InterruptedException ignored) {
}
}
}
+25
View File
@@ -0,0 +1,25 @@
package special;
import java.util.List;
/**
* Generated entry point injected into every mod jar.
*
* The client's loader hard-codes this class name: it calls the static main and
* later the static getRunnableList from the game tick. Both methods forward to
* the bridge, which is visible to the loader because a -javaagent jar is
* appended to the system class path.
*/
public final class Main {
private Main() {
}
public static void main(String[] args) {
rsx.ModBridge.launch(Main.class, args);
}
public static List<Runnable> getRunnableList() {
return rsx.ModBridge.drain(Main.class);
}
}
+28
View File
@@ -0,0 +1,28 @@
// Drives the fake client the way the game would, so the bridge can be tested
// end to end: bring the client up, hand the mods to the vendor loader, then tick.
public class FakeMain {
public static void main(String[] args) throws Exception {
System.out.println("[fake] class loader: " + FakeMain.class.getClassLoader());
System.out.println("[fake] joml.fastmath = " + System.getProperty("joml.fastmath"));
wwk client = new wwk();
wwk.WA = client;
client.zb = "PLAYER";
client.ta = "WORLD";
client.S = null;
client.Zb = "TIMER";
client.jC = "RENDER";
client.pC = new pvdafug("http://snoop.minecraft.net/report");
System.out.println("[fake] client up, telemetry scheduled");
Thread.sleep(2500);
System.out.println("[fake] ticking 5 times");
for (int i = 0; i < 5; i++) {
client.tick();
Thread.sleep(200);
}
System.out.println("[fake] done");
}
}
+79
View File
@@ -0,0 +1,79 @@
// Test stand-in for the client's code loader.
//
// The real client exposes a static method that takes a jar and the arguments,
// loads the entry class `special.Main`, calls its main(String[]) and later
// invokes its static getRunnableList() from the game tick. This stand-in has the
// same shape so the bridge can be exercised without launching the game.
import java.lang.reflect.Method;
public class ilxq {
private static Method runnableList;
/** mirrors the vendor signature: static, (byte[], String[]) -> boolean */
public static boolean loadJar(byte[] jar, String[] args) throws Exception {
System.out.println("[fake] loading mod jar, " + jar.length + " bytes, args="
+ java.util.Arrays.toString(args));
jarLoader(jar);
Class<?> main = loader.loadClass("special.Main");
Method m = main.getDeclaredMethod("main", String[].class);
m.setAccessible(true);
m.invoke(null, (Object) args);
runnableList = main.getDeclaredMethod("getRunnableList");
runnableList.setAccessible(true);
return true;
}
/** mirrors the vendor tick hook */
public static void tickDrain() {
if (runnableList == null) {
return;
}
try {
Object res = runnableList.invoke(null);
if (res instanceof java.util.List) {
for (Object r : (java.util.List) res) {
((Runnable) r).run();
}
}
} catch (Throwable t) {
t.printStackTrace();
}
}
static MemLoader loader = new MemLoader(ilxq.class.getClassLoader());
static void jarLoader(byte[] jar) throws Exception {
java.util.zip.ZipInputStream zin =
new java.util.zip.ZipInputStream(new java.io.ByteArrayInputStream(jar));
java.util.zip.ZipEntry e;
while ((e = zin.getNextEntry()) != null) {
if (e.isDirectory()) {
continue;
}
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
byte[] buf = new byte[8192];
int n;
while ((n = zin.read(buf)) > 0) {
bos.write(buf, 0, n);
}
loader.define(e.getName(), bos.toByteArray());
}
zin.close();
}
static class MemLoader extends ClassLoader {
MemLoader(ClassLoader parent) {
super(parent);
}
void define(String name, byte[] bytes) {
if (name.endsWith(".class")) {
// defineClass wants the internal name: dots, not slashes
defineClass(name.substring(0, name.length() - 6)
.replace('/', '.'), bytes, 0, bytes.length);
}
}
}
}
+31
View File
@@ -0,0 +1,31 @@
// Test stand-in for the telemetry class (patch A6 target): owns a Timer that,
// in the real client, posts the machine's OS, JVM arguments and GL limits.
import java.util.Timer;
import java.util.TimerTask;
public class pvdafug {
private final Timer do_ = new Timer("Snooper Timer", true);
private final String url;
public pvdafug(String url) {
this.url = url;
do_.schedule(new TimerTask0(), 1000L);
}
public String url() {
return url;
}
private void post() {
System.out.println("[fake-snooper] posting to " + url);
}
private class TimerTask0 extends TimerTask {
@Override
public void run() {
post();
}
}
}
+26
View File
@@ -0,0 +1,26 @@
// Test stand-in for the client entry point.
//
// Field and method names are deliberately identical to the ones in the real
// profile (player is "zb", world is "ta", timer is "Zb" - one capital apart), so
// the bridge exercises the same exact-match lookups it will do on the game.
public class wwk {
public static wwk WA; // field_71432_P, the client instance
public Object zb; // field_71439_g player
public Object ta; // field_71441_e world
public Object S; // field_71462_r currentScreen
public Object Zb; // field_71428_T timer
public Object jC; // field_71438_f renderGlobal
public pvdafug pC; // vendor field holding the telemetry object
public static wwk getMinecraftStatic() { // func_71410_x
return WA;
}
/** the vendor tick: drains mod tasks on this thread */
public void tick() {
ilxq.tickDrain();
}
}
+34
View File
@@ -0,0 +1,34 @@
package example;
import net.rustexremake.api.Rsx;
import net.rustexremake.api.RsxMod;
/**
* Example mod. It is compiled against the stable API only, so it does not have
* to be rebuilt when the vendor re-obfuscates the client.
*/
public class ExampleMod implements RsxMod {
private int ticks;
@Override
public void onLoad() {
Rsx.info("ExampleMod: loaded on build " + Rsx.buildId());
Rsx.info("ExampleMod: Minecraft is " + Rsx.describe("net.minecraft.client.Minecraft"));
// a repeating task runs on the game thread, driven by the client's own
// tick, so touching game state here is safe
Rsx.every(200L, new Runnable() {
@Override
public void run() {
ticks++;
Object mc = Rsx.minecraft();
Object player = Rsx.player();
Rsx.info("ExampleMod tick " + ticks
+ " client=" + (mc != null)
+ " player=" + player
+ " world=" + Rsx.world());
}
});
}
}
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env python
"""
rsx-run-test — end-to-end test of the bridge against a stand-in client.
The real client cannot be launched from a test (it needs auth, a window and a
GPU), so this builds a tiny client with the *same shape*: a code loader that
takes a jar, calls `special.Main.main(String[])`, and drains
`special.Main.getRunnableList()` from its tick - plus a client class whose
field names are the ones the real profile uses.
It verifies, in one run:
* the profile is found and the build hash matches
* patch A2 sets joml.fastmath before anything reads it
* patch A6 cancels the telemetry timer (the stand-in prints if it fires)
* a mod jar is handed to the client's own loader
* the mod's onLoad runs and its repeating task runs on the game thread
Usage: python run_test.py [--keep]
"""
import os
import shutil
import subprocess
import sys
import zipfile
HERE = os.path.dirname(os.path.abspath(__file__))
BRIDGE_JAR = os.path.join(os.path.dirname(HERE), "build", "libs", "rsx-bridge.jar")
SRC = os.path.join(HERE, "fakeclient")
MOD_SRC = os.path.join(HERE, "mod")
RUN = os.path.join(HERE, "run")
def sh(*args, **kw):
return subprocess.run(args, capture_output=True, text=True, **kw)
# The client ships its own Java 8. Run the stand on that same JRE: patch A6
# reaches into java.util.Timer's internals, which the module system blocks on
# modern JDKs but allows on 8 - the runtime the agent actually meets in game.
CLIENT_JRE = r"G:\rustexremake\rustexremake\gamefiles\java\bin\java.exe"
def java():
override = os.environ.get("RSX_TEST_JAVA")
if override:
return override
if os.path.exists(CLIENT_JRE):
return CLIENT_JRE
return "java"
def javac(sources, out, cp=None):
os.makedirs(out, exist_ok=True)
# class files must stay loadable by the client's Java 8
cmd = ["javac", "-nowarn", "--release", "8", "-d", out]
if cp:
cmd += ["-cp", cp]
r = subprocess.run(cmd + sources, capture_output=True, text=True)
if r.returncode != 0:
print(r.stdout)
print(r.stderr)
raise SystemExit("javac failed")
def zip_dir(src, jar):
if os.path.exists(jar):
os.remove(jar)
with zipfile.ZipFile(jar, "w", zipfile.ZIP_DEFLATED) as z:
for dp, _dn, fns in os.walk(src):
for fn in fns:
if fn.endswith(".class"):
full = os.path.join(dp, fn)
rel = os.path.relpath(full, src).replace("\\", "/")
z.write(full, rel)
def main():
if not os.path.exists(BRIDGE_JAR):
raise SystemExit("bridge jar not built: " + BRIDGE_JAR)
classes = os.path.join(RUN, "fakeclasses")
modclasses = os.path.join(RUN, "modclasses")
for d in (classes, modclasses):
shutil.rmtree(d, ignore_errors=True)
os.makedirs(classes, exist_ok=True)
os.makedirs(modclasses, exist_ok=True)
print("== compiling the stand-in client ==")
javac([os.path.join(SRC, f) for f in os.listdir(SRC) if f.endswith(".java")],
classes)
print("== compiling the example mod ==")
javac([os.path.join(MOD_SRC, f) for f in os.listdir(MOD_SRC)
if f.endswith(".java")], modclasses, cp=BRIDGE_JAR)
game = os.path.join(RUN, "game.jar")
zip_dir(classes, game)
mods = os.path.join(RUN, "mods")
os.makedirs(mods, exist_ok=True)
modjar = os.path.join(mods, "example-mod.jar")
if os.path.exists(modjar):
os.remove(modjar)
with zipfile.ZipFile(modjar, "w", zipfile.ZIP_DEFLATED) as z:
z.writestr("META-INF/MANIFEST.MF",
"Manifest-Version: 1.0\r\n"
"Rsx-Mod-Entrypoint: example.ExampleMod\r\n\r\n")
for dp, _dn, fns in os.walk(modclasses):
for fn in fns:
if fn.endswith(".class"):
full = os.path.join(dp, fn)
z.write(full, os.path.relpath(full, modclasses)
.replace("\\", "/"))
import hashlib
h = hashlib.sha256()
with open(game, "rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
sha = h.hexdigest()
bridge = os.path.join(RUN, "bridge")
os.makedirs(os.path.join(bridge, "profiles"), exist_ok=True)
shutil.copy(BRIDGE_JAR, os.path.join(bridge, "rsx-bridge.jar"))
profile = "\n".join([
"rsxprofile 1",
"build %s %s 1 1" % (sha[:16], sha),
"loader ilxq loadJar 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 snooper pC Lpvdafug;",
"method net.minecraft.client.Minecraft getMinecraft getMinecraftStatic ()Lwwk;",
]) + "\n"
with open(os.path.join(bridge, "profiles", "latest.rsxp"), "w",
encoding="utf-8") as fh:
fh.write(profile)
log = os.path.join(RUN, "rsx", "bridge.log")
if os.path.exists(log):
os.remove(log)
print("== running with the agent attached ==")
cmd = [java(),
"-javaagent:" + os.path.join(bridge, "rsx-bridge.jar"),
"-Dminecraft.applet.TargetDirectory=" + RUN,
"-cp", os.path.join(bridge, "rsx-bridge.jar") + ";" + game,
"FakeMain"]
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180)
out = r.stdout + r.stderr
for line in out.splitlines():
print(" " + line)
print()
print("== verdict ==")
checks = [
("profile loaded", "profile " in out and "from latest.rsxp" in out),
("build hash verified", "build verified" in out),
("A2 applied", "A2 applied" in out and "joml.fastmath = true" in out),
("A6 disabled the telemetry",
"A6 applied: telemetry timers replaced in 1 object(s)" in out),
("mod handed to the client loader", "loading mod jar" in out),
("mod loaded", "mod loaded: example-mod.jar" in out),
("mod task ran on the game thread", "ExampleMod tick 1" in out),
("telemetry never fired", "[fake-snooper] posting" not in out),
]
bad = 0
for label, ok in checks:
print(" %-38s %s" % (label, "OK" if ok else "FAIL"))
if not ok:
bad += 1
if bad:
print(" %d check(s) failed" % bad)
return 1
print(" all checks passed")
return 0
if __name__ == "__main__":
sys.exit(main())