Модлоадер 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:
@@ -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"
|
||||
}
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user