Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 88b90802a7 | |||
| 108c001af6 | |||
| b5ffe0e9d7 | |||
| 419b2d0a6b | |||
| 04872b21b6 | |||
| 13ddc0d0f5 |
@@ -118,7 +118,23 @@ jar (payload начинается с offset 65024 и идёт до EOF). Заг
|
||||
|
||||
и классом, реализующим `RsxMod`. Собирается один раз против `rsx-api.jar` и
|
||||
дальше не пересобирается: все обфусцированные имена мод не знает, бриф
|
||||
разрешает их через профиль сборки. Готовый шаблон — в `mod-template\`.
|
||||
разрешает их через профиль сборки.
|
||||
|
||||
Общая библиотека кладётся в `%LOCALAPPDATA%\rsx-mod\mods\..\libs\` — то есть
|
||||
`%LOCALAPPDATA%\rsx-mod\libs\`: агент подмешивает эти jar в системный class
|
||||
path при старте, и моды видят библиотеку, не вшивая её в себя.
|
||||
|
||||
Репозитории:
|
||||
|
||||
| что | где |
|
||||
|---|---|
|
||||
| модлоадер | [sasheg/rsx-modloader](https://git.swe.zernmc.ru/sasheg/rsx-modloader) |
|
||||
| общая библиотека | [sasheg/rsxm-lib](https://git.swe.zernmc.ru/sasheg/rsxm-lib) |
|
||||
| эталонный мод | [sasheg/rsxm-example](https://git.swe.zernmc.ru/sasheg/rsxm-example) |
|
||||
| HUD | [sasheg/rsxm-hud](https://git.swe.zernmc.ru/sasheg/rsxm-hud) |
|
||||
|
||||
Готовый шаблон простого мода — в `mod-template\`, пример на библиотеке — в
|
||||
[rsxm-example](https://git.swe.zernmc.ru/sasheg/rsxm-example).
|
||||
|
||||
Rsx.everyTicks(20, this::onSecond); // раз в 20 игровых тиков
|
||||
Rsx.every(5000L, this::poll); // раз в 5 секунд реального времени
|
||||
|
||||
@@ -296,6 +296,132 @@ public final class Rsx {
|
||||
return ModBridge.tickCount();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- ping
|
||||
|
||||
/**
|
||||
* Latency to the server, in milliseconds, or -1 while it is still unknown.
|
||||
*
|
||||
* The client measures nothing itself on this build, so this is derived from
|
||||
* the server's keep-alive cadence; it settles after a few samples. Use
|
||||
* pingExact() to know whether the number is a real round trip.
|
||||
*/
|
||||
public static int ping() {
|
||||
return rsx.Ping.rttMillis();
|
||||
}
|
||||
|
||||
/** True when the number came from a measured round trip, not an estimate. */
|
||||
public static boolean pingExact() {
|
||||
return rsx.Ping.exact();
|
||||
}
|
||||
|
||||
/** Packet loss in percent, measured the same way. */
|
||||
public static float packetLoss() {
|
||||
return rsx.Ping.lossPercent();
|
||||
}
|
||||
|
||||
/** A ready-to-print line, e.g. "ping~ 42 ms, loss 0.4%". */
|
||||
public static String pingLine() {
|
||||
return rsx.Ping.describe();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- hud
|
||||
|
||||
/**
|
||||
* The game's HUD, valid only inside a draw callback.
|
||||
*
|
||||
* Drawing here uses the game's own command pipeline, so a panel sits in the
|
||||
* same layer and the same interface scale as the compass and the health
|
||||
* bars, and follows the game's own show/hide state - including the key that
|
||||
* hides the whole HUD.
|
||||
*/
|
||||
public static Hud hud() {
|
||||
rsx.Hud live = rsx.Hud.current();
|
||||
return live == null ? null : new Hud(live);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw something on the game's HUD, every frame, while it is visible.
|
||||
*
|
||||
* Rsx.onHud(new Rsx.HudDraw() {
|
||||
* public void draw(Hud hud) {
|
||||
* hud.text(8, 8, "42 ms", 0xFFFFFF, 1f);
|
||||
* }
|
||||
* });
|
||||
*
|
||||
* The callback runs on the render thread. Do the cheap thing here: the whole
|
||||
* HUD is drawn inside one frame, and work done per frame is work done 60
|
||||
* times a second.
|
||||
*/
|
||||
public static void onHud(HudDraw drawer) {
|
||||
rsx.HudHook.add(new rsx.HudHook.Listener() {
|
||||
@Override
|
||||
public void onHud(rsx.Hud h) {
|
||||
drawer.draw(new Hud(h));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** A HUD draw callback. */
|
||||
public interface HudDraw {
|
||||
void draw(Hud hud);
|
||||
}
|
||||
|
||||
/** The game's HUD, as a mod sees it. */
|
||||
public static final class Hud {
|
||||
|
||||
private final rsx.Hud h;
|
||||
|
||||
Hud(rsx.Hud h) {
|
||||
this.h = h;
|
||||
}
|
||||
|
||||
/** False while the game is not drawing its HUD. */
|
||||
public boolean available() {
|
||||
return h != null && h.available();
|
||||
}
|
||||
|
||||
/** The interface scale the game draws its own widgets with. */
|
||||
public float scale() {
|
||||
return h.scale();
|
||||
}
|
||||
|
||||
/** Screen width in pixels, 0 when the game did not say. */
|
||||
public int width() {
|
||||
return h == null ? 0 : h.width();
|
||||
}
|
||||
|
||||
/** Screen height in pixels, 0 when the game did not say. */
|
||||
public int height() {
|
||||
return h == null ? 0 : h.height();
|
||||
}
|
||||
|
||||
/** A solid rectangle; alpha 0..1. */
|
||||
public void rect(float x, float y, float w, float height, int rgb,
|
||||
float alpha) {
|
||||
if (h == null) {
|
||||
return;
|
||||
}
|
||||
h.rect(x, y, w, height, rgb, alpha);
|
||||
}
|
||||
|
||||
/**
|
||||
* Text from the game's own ASCII atlas: digits and latin letters come
|
||||
* out in the same glyphs the game uses. Characters the atlas has no
|
||||
* glyph for are skipped.
|
||||
*/
|
||||
public void text(float x, float y, String value, int rgb, float alpha) {
|
||||
if (h == null) {
|
||||
return;
|
||||
}
|
||||
h.text(x, y, value, rgb, alpha);
|
||||
}
|
||||
|
||||
/** Width of a label in HUD units. */
|
||||
public float textWidth(String value) {
|
||||
return h == null ? 0f : h.textWidth(value);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ logs
|
||||
public static void info(String msg) {
|
||||
rsx.Log.info(msg);
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package rsx;
|
||||
|
||||
/**
|
||||
* Builds the echo packet.
|
||||
*
|
||||
* The nonce travels in the packet's channel name, so no payload buffer has to
|
||||
* be built or parsed: a server that supports the mod echoes the channel back
|
||||
* and the client recognises its own echo by the name.
|
||||
*
|
||||
* The packet class is vendor code with no stable name, so the profile has to
|
||||
* carry it. When it does not, the echo is simply unavailable and the modloader
|
||||
* falls back to the keep-alive estimate - which is why nothing here fails loudly
|
||||
* on a build it does not recognise.
|
||||
*/
|
||||
public final class EchoPacket {
|
||||
|
||||
public static final String CHANNEL = "rsx_echo";
|
||||
|
||||
private EchoPacket() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return a packet carrying the nonce, or null when this build has no echo
|
||||
*/
|
||||
public static Object create(Object netHandlerPlayClient, int nonce) {
|
||||
Profile p = Resolver.profile();
|
||||
if (p == null) {
|
||||
return null;
|
||||
}
|
||||
String[] ctor = p.member("rsx.net.echoPacket", "ctor");
|
||||
if (ctor == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Class<?> cls = Resolver.findLoaded(p.obf("rsx.net.echoPacket"));
|
||||
if (cls == null) {
|
||||
return null;
|
||||
}
|
||||
Class<?> buffer = Resolver.findLoaded(
|
||||
p.obf("rsx.net.echoBuffer"));
|
||||
java.lang.reflect.Constructor<?> c = cls.getDeclaredConstructor(
|
||||
String.class, buffer);
|
||||
c.setAccessible(true);
|
||||
return c.newInstance(CHANNEL + ":" + nonce, null);
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The nonce from a channel name, or -1 when it is not ours. */
|
||||
public static int nonceOf(String channel) {
|
||||
if (channel == null || !channel.startsWith(CHANNEL + ":")) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
return Integer.parseInt(channel.substring(CHANNEL.length() + 1));
|
||||
} catch (Throwable t) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when this build can send an echo at all. */
|
||||
public static boolean available() {
|
||||
Profile p = Resolver.profile();
|
||||
return p != null && p.member("rsx.net.echoPacket", "ctor") != null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package rsx;
|
||||
|
||||
import java.lang.instrument.ClassFileTransformer;
|
||||
import java.security.ProtectionDomain;
|
||||
|
||||
import org.objectweb.asm.ClassReader;
|
||||
import org.objectweb.asm.ClassWriter;
|
||||
import org.objectweb.asm.Opcodes;
|
||||
import org.objectweb.asm.tree.AbstractInsnNode;
|
||||
import org.objectweb.asm.tree.ClassNode;
|
||||
import org.objectweb.asm.tree.InsnList;
|
||||
import org.objectweb.asm.tree.InsnNode;
|
||||
import org.objectweb.asm.tree.MethodInsnNode;
|
||||
import org.objectweb.asm.tree.MethodNode;
|
||||
import org.objectweb.asm.tree.VarInsnNode;
|
||||
|
||||
/**
|
||||
* Appends calls of our own to two places in the running game.
|
||||
*
|
||||
* The HUD hook goes into the compass widget's render. That target is chosen, not
|
||||
* arbitrary: the widget is drawn only while the game's HUD layer is visible, so
|
||||
* a hook there inherits the game's own show/hide behaviour instead of
|
||||
* duplicating it, and the draw lands in the same pass, at the same scale, as the
|
||||
* widget we are drawing next to.
|
||||
*
|
||||
* The keep-alive hook goes into the client's packet handler, which is where the
|
||||
* server's 15 second cadence arrives - the only timing signal the client exposes
|
||||
* on a build whose network layer the vendor rewrote.
|
||||
*
|
||||
* Nothing here knows an obfuscated name: the profile carries both targets. A
|
||||
* build that no longer matches simply gets no hook, which is reported once
|
||||
* rather than being fatal.
|
||||
*/
|
||||
public final class HookTransformer implements ClassFileTransformer {
|
||||
|
||||
private final String hudClass;
|
||||
private final String hudMethod;
|
||||
private final String hudDesc;
|
||||
private final String pingClass;
|
||||
private final String pingMethod;
|
||||
|
||||
private boolean hudApplied;
|
||||
private boolean pingApplied;
|
||||
private boolean sawHud;
|
||||
|
||||
public HookTransformer(String hudClass, String hudMethod, String hudDesc,
|
||||
String pingClass, String pingMethod) {
|
||||
this.hudClass = hudClass;
|
||||
this.hudMethod = hudMethod;
|
||||
this.hudDesc = hudDesc;
|
||||
this.pingClass = pingClass;
|
||||
this.pingMethod = pingMethod;
|
||||
}
|
||||
|
||||
public boolean hudApplied() {
|
||||
return hudApplied;
|
||||
}
|
||||
|
||||
public boolean pingApplied() {
|
||||
return pingApplied;
|
||||
}
|
||||
|
||||
/** The class was seen but the method inside it was not. */
|
||||
public boolean incomplete() {
|
||||
return sawHud && !hudApplied;
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] transform(ClassLoader loader, String className,
|
||||
Class<?> classBeingRedefined,
|
||||
ProtectionDomain protectionDomain, byte[] bytes) {
|
||||
if (className == null || bytes == null) {
|
||||
return null;
|
||||
}
|
||||
boolean isHud = className.equals(hudClass);
|
||||
boolean isPing = pingClass != null && className.equals(pingClass);
|
||||
if (!isHud && !isPing) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
ClassNode cls = new ClassNode();
|
||||
new ClassReader(bytes).accept(cls, 0);
|
||||
boolean changed = false;
|
||||
if (isHud) {
|
||||
sawHud = true;
|
||||
changed = hookHud(cls);
|
||||
hudApplied = hudApplied || changed;
|
||||
}
|
||||
if (isPing) {
|
||||
changed = hookKeepAlive(cls);
|
||||
pingApplied = pingApplied || changed;
|
||||
}
|
||||
if (!changed) {
|
||||
return null;
|
||||
}
|
||||
ClassWriter out = new ClassWriter(
|
||||
ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
|
||||
cls.accept(out);
|
||||
return out.toByteArray();
|
||||
} catch (Throwable t) {
|
||||
// A broken hook must never take the game down: returning null leaves
|
||||
// the original class untouched.
|
||||
Log.error("hook failed, the game runs without it", t);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean hookHud(ClassNode cls) {
|
||||
if (hudMethod == null || hudDesc == null) {
|
||||
return false;
|
||||
}
|
||||
for (MethodNode m : cls.methods) {
|
||||
if (!m.name.equals(hudMethod) || !m.desc.equals(hudDesc)
|
||||
|| m.instructions == null) {
|
||||
continue;
|
||||
}
|
||||
InsnList call = new InsnList();
|
||||
// the context is the method's first argument
|
||||
call.add(new VarInsnNode(Opcodes.ALOAD,
|
||||
(m.access & Opcodes.ACC_STATIC) != 0 ? 0 : 1));
|
||||
// Taken as Object on purpose: whatever the game calls its widget
|
||||
// context, it is assignable to Object, so one descriptor works on
|
||||
// every build. Spelling out the real type would tie the call to a
|
||||
// class name that changes with every update.
|
||||
call.add(new MethodInsnNode(Opcodes.INVOKESTATIC, HudHook.NAME,
|
||||
"render", "(Ljava/lang/Object;)V", false));
|
||||
insertAtEnd(m, call);
|
||||
Log.info("HUD hook applied to " + cls.name.replace('/', '.')
|
||||
+ "." + hudMethod);
|
||||
return true;
|
||||
}
|
||||
Log.warn("HUD hook: " + cls.name + " has no method " + hudMethod
|
||||
+ hudDesc + ", the panel will not draw");
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean hookKeepAlive(ClassNode cls) {
|
||||
String desc = keepAliveDesc();
|
||||
if (pingMethod == null || desc == null) {
|
||||
return false;
|
||||
}
|
||||
for (MethodNode m : cls.methods) {
|
||||
if (!m.name.equals(pingMethod) || !m.desc.equals(desc)
|
||||
|| m.instructions == null) {
|
||||
continue;
|
||||
}
|
||||
InsnList call = new InsnList();
|
||||
call.add(new MethodInsnNode(Opcodes.INVOKESTATIC, Ping.NAME,
|
||||
"onKeepAlive", "()V", false));
|
||||
insertAtEnd(m, call);
|
||||
Log.info("keep-alive hook applied to " + cls.name.replace('/', '.')
|
||||
+ "." + pingMethod);
|
||||
return true;
|
||||
}
|
||||
Log.warn("keep-alive hook: " + cls.name + " has no method "
|
||||
+ pingMethod + desc + ", ping stays unavailable");
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String keepAliveDesc() {
|
||||
Profile p = Resolver.profile();
|
||||
if (p == null) {
|
||||
return null;
|
||||
}
|
||||
String[] m = p.member("net.minecraft.client.network.NetHandlerPlayClient",
|
||||
"handleKeepAlive");
|
||||
return m == null ? null : m[1];
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the call where it will actually run.
|
||||
*
|
||||
* A method ends with a RETURN, so appending to the instruction list would put
|
||||
* the call after it: code that verifies fine and never fires. The last
|
||||
* return is the only place a "do this before leaving" call belongs.
|
||||
*/
|
||||
private static void insertAtEnd(MethodNode m, InsnList call) {
|
||||
AbstractInsnNode last = m.instructions.getLast();
|
||||
if (last instanceof InsnNode) {
|
||||
int op = ((InsnNode) last).getOpcode();
|
||||
if (op >= Opcodes.IRETURN && op <= Opcodes.RETURN) {
|
||||
m.instructions.insertBefore(last, call);
|
||||
return;
|
||||
}
|
||||
}
|
||||
m.instructions.add(call);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package rsx;
|
||||
|
||||
/**
|
||||
* The game's HUD, as a mod sees it.
|
||||
*
|
||||
* Coordinates are in the game's own HUD scale, so a panel drawn here sits in
|
||||
* the same layer as the compass, the health bars and everything else, and
|
||||
* follows the player's interface scale setting.
|
||||
*
|
||||
* Every method is safe to call when the game is not drawing: they quietly do
|
||||
* nothing, because a mod that forgets to check should not be able to break the
|
||||
* frame.
|
||||
*/
|
||||
public final class Hud {
|
||||
|
||||
/**
|
||||
* The vanilla ASCII atlas is a 16x16 grid of 8x8 glyphs, which is what makes
|
||||
* the game's own font possible for us without touching the TTF pipeline.
|
||||
*/
|
||||
private static final int GLYPHS = 16;
|
||||
private static final int GLYPH = 8;
|
||||
|
||||
private final Object ctx;
|
||||
|
||||
Hud(Object ctx) {
|
||||
this.ctx = ctx;
|
||||
}
|
||||
|
||||
/** False while the game is not drawing its HUD. */
|
||||
public boolean available() {
|
||||
return ctx != null && Ui.ready();
|
||||
}
|
||||
|
||||
/** The live context, for the duration of one frame's callback only. */
|
||||
public static Hud current() {
|
||||
Object c = HudHook.context();
|
||||
return c == null ? null : new Hud(c);
|
||||
}
|
||||
|
||||
public float scale() {
|
||||
return Ui.scale();
|
||||
}
|
||||
|
||||
/** Screen width, 0 when the game did not say. */
|
||||
public int width() {
|
||||
return HudHook.width();
|
||||
}
|
||||
|
||||
/** Screen height, 0 when the game did not say. */
|
||||
public int height() {
|
||||
return HudHook.height();
|
||||
}
|
||||
|
||||
/** A solid rectangle. Colour is 0xRRGGBB, alpha 0..1. */
|
||||
public void rect(float x, float y, float w, float h, int argb,
|
||||
float alpha) {
|
||||
float[] box = box(x, y, w, h);
|
||||
Object b = Ui.rect(box[0], box[1], box[2], box[3]);
|
||||
if (b == null) {
|
||||
return;
|
||||
}
|
||||
Ui.color(b, argb);
|
||||
Ui.alpha(b, alpha);
|
||||
HudHook.submit(Ui.build(b));
|
||||
}
|
||||
|
||||
/**
|
||||
* Text from the game's ASCII atlas.
|
||||
*
|
||||
* Only characters in the atlas are drawn; anything else is skipped rather
|
||||
* than drawn as noise, which keeps a mod's label from turning into garbage
|
||||
* when it uses a language the atlas has no glyphs for.
|
||||
*/
|
||||
public void text(float x, float y, String value, int argb, float alpha) {
|
||||
if (value == null || value.isEmpty() || Ui.atlas() == null) {
|
||||
return;
|
||||
}
|
||||
float s = Ui.scale();
|
||||
float size = GLYPH * s;
|
||||
float cx = x;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char c = value.charAt(i);
|
||||
if (c == ' ') {
|
||||
cx += size * 0.5f;
|
||||
continue;
|
||||
}
|
||||
int index = c;
|
||||
if (index >= 32 && index < 32 + GLYPHS * GLYPHS) {
|
||||
index -= 32;
|
||||
float u0 = (index % GLYPHS) / (float) GLYPHS;
|
||||
float v0 = (index / GLYPHS) / (float) GLYPHS;
|
||||
float u1 = u0 + 1f / GLYPHS;
|
||||
float v1 = v0 + 1f / GLYPHS;
|
||||
float[] box = box(cx, y, size, size);
|
||||
Object b = Ui.textured(Ui.atlas(), box[0], box[1], box[2], box[3]);
|
||||
if (b == null) {
|
||||
return;
|
||||
}
|
||||
Ui.color(b, argb);
|
||||
Ui.alpha(b, alpha);
|
||||
Ui.uv(b, u0, v0, u1, v1);
|
||||
HudHook.submit(Ui.build(b));
|
||||
}
|
||||
cx += size;
|
||||
}
|
||||
}
|
||||
|
||||
/** Width of a string in HUD units, so a panel can be sized to its label. */
|
||||
public float textWidth(String value) {
|
||||
if (value == null || value.isEmpty()) {
|
||||
return 0f;
|
||||
}
|
||||
float size = GLYPH * Ui.scale();
|
||||
float w = 0f;
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
w += value.charAt(i) == ' ' ? size * 0.5f : size;
|
||||
}
|
||||
return w;
|
||||
}
|
||||
|
||||
private float[] box(float x, float y, float w, float h) {
|
||||
float s = Ui.scale();
|
||||
return new float[] {x * s, y * s, w * s, h * s};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package rsx;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Receives the live HUD render context every frame and hands it to mods.
|
||||
*
|
||||
* The injected call lands in the game's compass widget, which means this runs
|
||||
* only while the game's HUD is visible: F3 hides the widget, the widget's
|
||||
* render is never called, and neither is ours. The explicit gate below is a
|
||||
* second line of defence for the case where a build hides part of the HUD
|
||||
* differently.
|
||||
*/
|
||||
public final class HudHook {
|
||||
|
||||
/** The internal name the transformer calls, see HookTransformer. */
|
||||
public static final String NAME = "rsx/HudHook";
|
||||
|
||||
/** Something that wants to draw while the game draws its HUD. */
|
||||
public interface Listener {
|
||||
void onHud(Hud hud);
|
||||
}
|
||||
|
||||
private static final List<Listener> LISTENERS = new ArrayList<Listener>();
|
||||
private static volatile Object context;
|
||||
private static Method submit;
|
||||
private static boolean submitTried;
|
||||
private static boolean gateTried;
|
||||
private static Method gate;
|
||||
private static Object screen;
|
||||
private static boolean screenTried;
|
||||
private static boolean warned;
|
||||
|
||||
private HudHook() {
|
||||
}
|
||||
|
||||
public static void add(Listener listener) {
|
||||
if (listener == null) {
|
||||
return;
|
||||
}
|
||||
synchronized (LISTENERS) {
|
||||
LISTENERS.add(listener);
|
||||
}
|
||||
Log.info("HUD: listener registered, " + LISTENERS.size() + " total");
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from inside the game's HUD render, on the render thread.
|
||||
*
|
||||
* Never throws: a mod that cannot draw must not be able to take the frame,
|
||||
* and with it the game, down.
|
||||
*/
|
||||
public static void render(Object ctx) {
|
||||
try {
|
||||
if (ctx == null || !visible()) {
|
||||
return;
|
||||
}
|
||||
context = ctx;
|
||||
if (!warned && !Ui.ready()) {
|
||||
warned = true;
|
||||
return;
|
||||
}
|
||||
List<Listener> copy;
|
||||
synchronized (LISTENERS) {
|
||||
if (LISTENERS.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
copy = new ArrayList<Listener>(LISTENERS);
|
||||
}
|
||||
Hud hud = new Hud(ctx);
|
||||
for (Listener l : copy) {
|
||||
try {
|
||||
l.onHud(hud);
|
||||
} catch (Throwable t) {
|
||||
Log.error("HUD listener failed", t);
|
||||
}
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
Log.error("HUD hook failed", t);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors the game's own HUD visibility.
|
||||
*
|
||||
* Read off the HUD screen singleton: it carries the flag that decides
|
||||
* whether widgets are drawn at all, which is the same flag the game toggles
|
||||
* with F3. If it cannot be read we draw anyway - the hook already keeps us
|
||||
* in step in practice, this is only the backup.
|
||||
*/
|
||||
private static boolean visible() {
|
||||
if (gateTried) {
|
||||
return screen == null || gate == null || truth(screen);
|
||||
}
|
||||
gateTried = true;
|
||||
Profile p = Resolver.profile();
|
||||
if (p == null) {
|
||||
Log.warn("HUD: no profile to read the game HUD flag from");
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
String obf = p.obf("hud.screen");
|
||||
String[] single = p.member("hud.screen", "singleton");
|
||||
String[] visible = p.member("hud.screen", "visible");
|
||||
if (obf == null || single == null || visible == null) {
|
||||
Log.warn("HUD: this profile has no HUD screen anchor, the panel "
|
||||
+ "follows the game on its own");
|
||||
return true;
|
||||
}
|
||||
Class<?> owner = Resolver.findLoaded(obf);
|
||||
if (owner == null) {
|
||||
Log.warn("HUD: the HUD screen class " + obf
|
||||
+ " is not loaded yet, the panel follows the game");
|
||||
return true;
|
||||
}
|
||||
java.lang.reflect.Field f =
|
||||
owner.getDeclaredField(single[0]);
|
||||
f.setAccessible(true);
|
||||
screen = f.get(null);
|
||||
if (screen == null) {
|
||||
return true;
|
||||
}
|
||||
gate = owner.getDeclaredMethod(visible[0]);
|
||||
gate.setAccessible(true);
|
||||
Log.info("HUD: following the game HUD visibility");
|
||||
return truth(screen);
|
||||
} catch (Throwable t) {
|
||||
Log.warn("HUD: cannot read the game HUD flag (" + t
|
||||
+ "), the panel follows the game on its own");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** The flag is inverted: the widgets are drawn while it is false. */
|
||||
private static boolean truth(Object instance) {
|
||||
try {
|
||||
return !((Boolean) gate.invoke(instance)).booleanValue();
|
||||
} catch (Throwable t) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Screen size, read off the context.
|
||||
*
|
||||
* The context offers two no-argument int getters and nothing names them, so
|
||||
* they are told apart by what they can only be: on any window the larger one
|
||||
* is the width. Getting this wrong only mirrors the panel, it cannot break
|
||||
* the frame.
|
||||
*/
|
||||
static int width() {
|
||||
int[] s = size();
|
||||
return s == null ? 0 : Math.max(s[0], s[1]);
|
||||
}
|
||||
|
||||
static int height() {
|
||||
int[] s = size();
|
||||
return s == null ? 0 : Math.min(s[0], s[1]);
|
||||
}
|
||||
|
||||
private static int[] size;
|
||||
|
||||
private static synchronized int[] size() {
|
||||
if (size != null || context == null) {
|
||||
return size;
|
||||
}
|
||||
java.util.List<Integer> found = new ArrayList<Integer>();
|
||||
for (Class<?> c = context.getClass(); c != null; c = c.getSuperclass()) {
|
||||
for (Method m : c.getDeclaredMethods()) {
|
||||
if (m.getParameterTypes().length != 0
|
||||
|| m.getReturnType() != int.class) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
m.setAccessible(true);
|
||||
found.add(Integer.valueOf(((Integer) m.invoke(context))
|
||||
.intValue()));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found.size() >= 2) {
|
||||
size = new int[] {found.get(0).intValue(), found.get(1).intValue()};
|
||||
Log.info("HUD: screen " + width() + "x" + height());
|
||||
}
|
||||
return size;
|
||||
}
|
||||
|
||||
/** True while a mod callback is running, i.e. Rsx.hud() is meaningful. */
|
||||
public static boolean inside() {
|
||||
return context != null;
|
||||
}
|
||||
|
||||
static Object context() {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands a built command to the game's context.
|
||||
*
|
||||
* The submit method is looked up on the live context object rather than by
|
||||
* name: it is the single method taking one draw-command argument, and that
|
||||
* survives a rename where a hard-coded name would not.
|
||||
*/
|
||||
static void submit(Object command) {
|
||||
if (command == null || context == null) {
|
||||
return;
|
||||
}
|
||||
if (!submitTried) {
|
||||
submitTried = true;
|
||||
submit = findSubmit(context.getClass());
|
||||
}
|
||||
if (submit == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
submit.invoke(context, command);
|
||||
} catch (Throwable t) {
|
||||
Log.error("HUD: cannot submit a draw command", t);
|
||||
}
|
||||
}
|
||||
|
||||
private static Method findSubmit(Class<?> ctxClass) {
|
||||
Class<?> target = Ui.targetClass();
|
||||
for (Class<?> c = ctxClass; c != null; c = c.getSuperclass()) {
|
||||
for (Method m : c.getDeclaredMethods()) {
|
||||
if (m.getParameterTypes().length != 1) {
|
||||
continue;
|
||||
}
|
||||
Class<?> p = m.getParameterTypes()[0];
|
||||
if (target != null) {
|
||||
if (!target.isAssignableFrom(p)) {
|
||||
continue;
|
||||
}
|
||||
} else if (!Ui.looksLikeCommand(p)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
m.setAccessible(true);
|
||||
return m;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.warn("HUD: the game context has no way to accept a draw command");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package rsx;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Latency and loss, measured from the packets the game already sends.
|
||||
*
|
||||
* The vendor replaced the network layer with their own Netty handler and left
|
||||
* no latency field anywhere, so there is nothing to read: it has to be measured.
|
||||
* Two sources, because no single one works everywhere:
|
||||
*
|
||||
* echo the mod sends a packet with a nonce and the server sends it back.
|
||||
* Exact to a millisecond or two, and the only way to know which packets
|
||||
* were actually lost. It needs a server that answers, and an anti-cheat
|
||||
* may object to unknown packets, so it is off unless asked for.
|
||||
*
|
||||
* keep-alive the server sends one every 15 seconds and the client answers
|
||||
* at once. Those arrivals carry no round trip, but the *spacing* between
|
||||
* them does: the server keeps a 15 s cadence measured on its own clock,
|
||||
* so the jitter of the arrivals is the network's. Averaging many
|
||||
* samples gives a usable number on any server, slowly.
|
||||
*/
|
||||
public final class Ping {
|
||||
|
||||
/** The internal name the transformer calls, see HookTransformer. */
|
||||
public static final String NAME = "rsx/Ping";
|
||||
|
||||
/**
|
||||
* The server's keep-alive cadence.
|
||||
*
|
||||
* 15 seconds is what the game sends. Proxies and some servers use another
|
||||
* value, and the test needs a short one, so it is settable; a wrong value
|
||||
* only makes the estimate wrong, never wrong in a dangerous way.
|
||||
*/
|
||||
private static final long KEEPALIVE_PERIOD = Long.parseLong(
|
||||
System.getProperty("rsx.keepalivePeriod", "15000"));
|
||||
|
||||
private static final int WINDOW = 50;
|
||||
|
||||
private static long lastKeepAlive;
|
||||
private static int echoSent;
|
||||
private static int echoAnswered;
|
||||
private static int keepAliveSeen;
|
||||
private static int keepAliveGap;
|
||||
|
||||
// moving average of the keep-alive derived latency, in tenths of a ms so a
|
||||
// float is not needed and nothing drifts
|
||||
private static long echoSum;
|
||||
private static long echoCount;
|
||||
private static long keepSum;
|
||||
private static int keepSamples;
|
||||
private static int lastRttTenths = -1;
|
||||
|
||||
private static Method sendPacket;
|
||||
|
||||
private Ping() {
|
||||
}
|
||||
|
||||
/** True when the echo measurement is available and running. */
|
||||
public static boolean echoReady() {
|
||||
return sendPacket != null;
|
||||
}
|
||||
|
||||
public static void bindSendPacket(Method m) {
|
||||
sendPacket = m;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- keep-alive
|
||||
|
||||
/** A server keep-alive arrived. */
|
||||
public static void onKeepAlive() {
|
||||
long now = System.currentTimeMillis();
|
||||
int gap = lastKeepAlive == 0L ? -1 : (int) (now - lastKeepAlive);
|
||||
lastKeepAlive = now;
|
||||
keepAliveSeen++;
|
||||
if (gap > 0) {
|
||||
keepAliveGap = gap;
|
||||
// the deviation from the server's own cadence is the network's
|
||||
int off = gap - (int) KEEPALIVE_PERIOD;
|
||||
// one lost packet shows up as a double interval: skip those samples
|
||||
// rather than counting a 15 s "latency" into the average
|
||||
if (off > -KEEPALIVE_PERIOD / 4 && off < KEEPALIVE_PERIOD / 2) {
|
||||
if (off < 0) {
|
||||
off = -off;
|
||||
}
|
||||
// tenths of a millisecond, the same unit the echo uses, so the
|
||||
// two sources can be averaged together
|
||||
long tenths = off * 10L;
|
||||
keepSum += tenths;
|
||||
keepSamples++;
|
||||
if (keepSamples > WINDOW) {
|
||||
keepSum -= tenths;
|
||||
keepSamples--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ echo
|
||||
|
||||
private static int nextNonce = 1;
|
||||
private static long sentAt;
|
||||
private static int pendingNonce;
|
||||
private static boolean pending;
|
||||
|
||||
/**
|
||||
* Sends one echo. Returns false when the game has no such packet or the
|
||||
* previous echo is still unanswered, which is itself a loss signal.
|
||||
*/
|
||||
public static boolean sendEcho(Object netHandlerPlayClient) {
|
||||
if (sendPacket == null) {
|
||||
return false;
|
||||
}
|
||||
if (pending) {
|
||||
loss();
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
int nonce = nextNonce++;
|
||||
pendingNonce = nonce;
|
||||
pending = true;
|
||||
sentAt = System.currentTimeMillis();
|
||||
echoSent++;
|
||||
Object packet = EchoPacket.create(netHandlerPlayClient, nonce);
|
||||
if (packet == null) {
|
||||
pending = false;
|
||||
return false;
|
||||
}
|
||||
sendPacket.invoke(netHandlerPlayClient, packet);
|
||||
return true;
|
||||
} catch (Throwable t) {
|
||||
pending = false;
|
||||
Log.warn("echo failed: " + t);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The server sent our echo back. */
|
||||
public static void onEcho(int nonce) {
|
||||
if (!pending || nonce != pendingNonce) {
|
||||
return;
|
||||
}
|
||||
pending = false;
|
||||
echoAnswered++;
|
||||
long rtt = System.currentTimeMillis() - sentAt;
|
||||
lastRttTenths = (int) (rtt * 10L);
|
||||
echoSum += lastRttTenths;
|
||||
echoCount++;
|
||||
if (echoCount > WINDOW) {
|
||||
echoSum -= lastRttTenths;
|
||||
echoCount--;
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when an echo is known to be lost. */
|
||||
public static void loss() {
|
||||
if (pending) {
|
||||
pending = false;
|
||||
// counted by the missing answer: sent minus answered
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- readouts
|
||||
|
||||
/** Round trip in milliseconds, or -1 when it is not known yet. */
|
||||
public static int rttMillis() {
|
||||
if (echoCount > 0) {
|
||||
return (int) (echoSum / echoCount / 10L);
|
||||
}
|
||||
if (keepSamples > 0) {
|
||||
return (int) (keepSum / keepSamples / 10L);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** True when the number came from the exact measurement. */
|
||||
public static boolean exact() {
|
||||
return echoCount > 0;
|
||||
}
|
||||
|
||||
/** Packet loss in percent, 0 when nothing was lost. */
|
||||
public static float lossPercent() {
|
||||
int total = echoSent + keepAliveSeen;
|
||||
int lost = (echoSent - echoAnswered) + Math.max(0, keepAliveGap == 0
|
||||
? 0 : (keepAliveGap > (int) (KEEPALIVE_PERIOD * 1.5) ? 1 : 0));
|
||||
if (total <= 0) {
|
||||
return 0f;
|
||||
}
|
||||
return 100f * lost / total;
|
||||
}
|
||||
|
||||
public static String describe() {
|
||||
int rtt = rttMillis();
|
||||
if (rtt < 0) {
|
||||
return "ping: measuring";
|
||||
}
|
||||
return (exact() ? "ping" : "ping~") + " " + rtt + " ms"
|
||||
+ ", loss " + String.format(java.util.Locale.US, "%.1f", lossPercent()) + "%";
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ package rsx;
|
||||
* class <logicalName> <obfClass>
|
||||
* field <logicalName> <member> <obfMember> <descriptor>
|
||||
* method <logicalName> <member> <obfMember> <descriptor>
|
||||
* hook.hud <obfClass> <obfMethod> <descriptor>
|
||||
*/
|
||||
public final class Profile {
|
||||
|
||||
@@ -29,9 +30,18 @@ public final class Profile {
|
||||
public String a2Class;
|
||||
public String a2Property;
|
||||
public String a6Class;
|
||||
public String a6FieldName;
|
||||
public String a6FieldObf;
|
||||
public String serverOverrideField;
|
||||
public String a6FieldName;
|
||||
public String a6FieldObf;
|
||||
public String serverOverrideField;
|
||||
|
||||
/**
|
||||
* Where the bridge appends its own draw call. The method is the compass
|
||||
* widget's render, so the panel is only ever drawn while the game draws the
|
||||
* compass, which is how it follows the game's own HUD visibility.
|
||||
*/
|
||||
public String hudHookClass;
|
||||
public String hudHookMethod;
|
||||
public String hudHookDesc;
|
||||
|
||||
/** logical name -> obfuscated class name */
|
||||
public final java.util.Map<String, String> classes =
|
||||
@@ -80,6 +90,10 @@ public String a6FieldName;
|
||||
p.a6FieldObf = f[3];
|
||||
} else if ("class".equals(kind) && f.length >= 3) {
|
||||
p.classes.put(f[1], f[2]);
|
||||
} else if ("hook.hud".equals(kind) && f.length >= 4) {
|
||||
p.hudHookClass = f[1];
|
||||
p.hudHookMethod = f[2];
|
||||
p.hudHookDesc = f[3];
|
||||
} else if ("override.server".equals(kind) && f.length >= 2) {
|
||||
p.serverOverrideField = f[1];
|
||||
} else if (("field".equals(kind) || "method".equals(kind))
|
||||
|
||||
@@ -47,10 +47,12 @@ public final class Resolver {
|
||||
*/
|
||||
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) {
|
||||
// A forced lookup has to actually see classes that arrived since the
|
||||
// last scan - the HUD gate is read on the first frame, long after the
|
||||
// index was built - but a full scan is tens of thousands of classes, so
|
||||
// even a forced call is rate limited rather than free.
|
||||
long minGap = force ? 50L : 1000L;
|
||||
if (loaded != null && now - lastScan < minGap) {
|
||||
return loaded;
|
||||
}
|
||||
Map<String, Class<?>> next = new HashMap<String, Class<?>>();
|
||||
|
||||
@@ -26,8 +26,8 @@ public final class RsxAgent {
|
||||
|
||||
public static void premain(String args, java.lang.instrument.Instrumentation inst) {
|
||||
try {
|
||||
home = new File(RsxAgent.class.getProtectionDomain()
|
||||
.getCodeSource().getLocation().toURI()).getParentFile();
|
||||
home = findHome(new File(RsxAgent.class.getProtectionDomain()
|
||||
.getCodeSource().getLocation().toURI()));
|
||||
} catch (Throwable t) {
|
||||
home = new File(".");
|
||||
}
|
||||
@@ -47,6 +47,8 @@ public final class RsxAgent {
|
||||
Resolver.setInstrumentation(inst);
|
||||
Log.info("rsx bridge starting (home=" + home + ")");
|
||||
|
||||
appendLibraries(inst, new File(home, "libs"));
|
||||
|
||||
String mode = readMode();
|
||||
if ("vanilla".equals(mode)) {
|
||||
Log.info("mode is vanilla: no mods, no patches, agent does nothing");
|
||||
@@ -75,6 +77,9 @@ public final class RsxAgent {
|
||||
Log.info("loader anchor: " + profile.describe(
|
||||
"net.rustexremake.loader.RemoteClassLoader"));
|
||||
|
||||
// now that the profile says where the HUD hook goes
|
||||
installHudHook(inst);
|
||||
|
||||
File game = findGameJar();
|
||||
if (game == null) {
|
||||
fail("cannot locate the client jar on java.class.path");
|
||||
@@ -487,7 +492,95 @@ public final class RsxAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Let mods draw on the game's HUD.
|
||||
*
|
||||
* The hook goes into the compass widget, so a panel drawn through it lives in
|
||||
* the same pass, the same scale, and the same show/hide state as the widgets
|
||||
* around it. A build that no longer has the anchor in its profile simply
|
||||
* runs without a HUD - reported once, never fatal.
|
||||
*/
|
||||
private static void installHudHook(
|
||||
java.lang.instrument.Instrumentation inst) {
|
||||
Profile p = profile;
|
||||
if (inst == null || p == null || p.hudHookClass == null) {
|
||||
Log.info("no HUD hook in this profile, the panel is unavailable");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
Profile p2 = profile;
|
||||
String[] ka = p2.member(
|
||||
"net.minecraft.client.network.NetHandlerPlayClient",
|
||||
"handleKeepAlive");
|
||||
HookTransformer t = new HookTransformer(p2.hudHookClass,
|
||||
p2.hudHookMethod, p2.hudHookDesc,
|
||||
ka == null ? null : p2.obf(
|
||||
"net.minecraft.client.network.NetHandlerPlayClient"),
|
||||
ka == null ? null : ka[0]);
|
||||
inst.addTransformer(t, false);
|
||||
Log.info("HUD hook armed on " + p2.hudHookClass + "."
|
||||
+ p2.hudHookMethod);
|
||||
} catch (Throwable e) {
|
||||
Log.error("cannot arm the HUD hook", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- utils
|
||||
|
||||
/**
|
||||
* Where the modloader lives.
|
||||
*
|
||||
* The agent jar may sit in the home itself or in its bridge\ folder, and
|
||||
* nothing here may assume a fixed depth: walk up until a directory looks
|
||||
* like a modloader home, so libs\, mods\ and config\ resolve the same way
|
||||
* whoever installed it.
|
||||
*/
|
||||
private static File findHome(File jarOrDir) throws Exception {
|
||||
File at = jarOrDir.isDirectory() ? jarOrDir : jarOrDir.getParentFile();
|
||||
for (int up = 0; at != null && up < 4; up++) {
|
||||
if (new File(at, "mods").isDirectory()
|
||||
|| new File(at, "libs").isDirectory()
|
||||
|| new File(at, "bridge").isDirectory()) {
|
||||
return at;
|
||||
}
|
||||
at = at.getParentFile();
|
||||
}
|
||||
return jarOrDir.isDirectory() ? jarOrDir : jarOrDir.getParentFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Put every jar from libs\ on the system class path.
|
||||
*
|
||||
* The client gives each mod its own class loader, so mods cannot see each
|
||||
* other - but every one of those loaders has the system loader as its
|
||||
* parent. Appending here therefore makes a shared library visible to all
|
||||
* mods, in one copy, and mods never have to bundle it.
|
||||
*/
|
||||
private static void appendLibraries(
|
||||
java.lang.instrument.Instrumentation inst, File dir) {
|
||||
if (inst == null || dir == null || !dir.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
File[] jars = dir.listFiles();
|
||||
if (jars == null || jars.length == 0) {
|
||||
Log.info("no libraries in " + dir);
|
||||
return;
|
||||
}
|
||||
java.util.Arrays.sort(jars);
|
||||
for (File jar : jars) {
|
||||
if (!jar.isFile()
|
||||
|| !jar.getName().toLowerCase().endsWith(".jar")) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
inst.appendToSystemClassLoaderSearch(new java.util.jar.JarFile(jar));
|
||||
Log.info("library on the class path: " + jar.getName());
|
||||
} catch (Throwable t) {
|
||||
Log.error("cannot load library " + jar.getName(), t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static File findGameJar() {
|
||||
String loader = profile.loaderClass;
|
||||
if (loader == null) {
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package rsx;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
/**
|
||||
* Binds to the game's own drawing API.
|
||||
*
|
||||
* Everything here is resolved from the profile by descriptor, never by name, and
|
||||
* every resolution can fail on its own: a build that changed one factory takes
|
||||
* that one capability away, logs it once, and leaves the rest working. That is
|
||||
* deliberate - a vendor update should cost a panel, not the modloader.
|
||||
*/
|
||||
public final class Ui {
|
||||
|
||||
/** Vanilla ASCII font atlas: the same glyphs the game uses for numbers. */
|
||||
public static final String ASCII_ATLAS = "textures/font/ascii.png";
|
||||
|
||||
private static boolean checked;
|
||||
|
||||
private static Class<?> batch;
|
||||
private static Method rectFactory;
|
||||
private static Method texturedFactory;
|
||||
private static final java.util.Map<String, Method> BUILDER =
|
||||
new java.util.HashMap<String, Method>();
|
||||
private static Class<?> targetClass;
|
||||
|
||||
private static Method byPath;
|
||||
private static Field scaleField;
|
||||
|
||||
private static Object atlas;
|
||||
private static boolean atlasTried;
|
||||
|
||||
private Ui() {
|
||||
}
|
||||
|
||||
public static synchronized boolean ready() {
|
||||
if (!checked) {
|
||||
checked = true;
|
||||
bind();
|
||||
}
|
||||
return rectFactory != null;
|
||||
}
|
||||
|
||||
private static void bind() {
|
||||
Profile p = Resolver.profile();
|
||||
if (p == null) {
|
||||
Log.warn("HUD: no profile, the panel will not draw");
|
||||
return;
|
||||
}
|
||||
batch = resolve("rsx.ui.batch", null);
|
||||
targetClass = resolve("rsx.ui.target", null);
|
||||
rectFactory = method(p, "rsx.ui.batch", "rect");
|
||||
texturedFactory = method(p, "rsx.ui.batch", "textured");
|
||||
byPath = method(p, "rsx.ui.textures", "byPath");
|
||||
scaleField = field(p, "rsx.ui.scale", "factor");
|
||||
|
||||
if (!ready()) {
|
||||
Log.warn("HUD: the draw API is not in this build, "
|
||||
+ "the panel will not draw");
|
||||
} else {
|
||||
Log.info("HUD: draw API bound, scale=" + scale());
|
||||
}
|
||||
BUILDER.clear();
|
||||
}
|
||||
|
||||
private static Class<?> resolve(String logical, String fallback) {
|
||||
String obf = Resolver.profile() == null ? null
|
||||
: Resolver.profile().obf(logical);
|
||||
if (obf == null) {
|
||||
return null;
|
||||
}
|
||||
Class<?> known = Resolver.findLoaded(obf);
|
||||
if (known != null) {
|
||||
return known;
|
||||
}
|
||||
// not loaded yet: the game may still be able to find it
|
||||
ClassLoader cl = Resolver.loader();
|
||||
if (cl == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Class.forName(obf, false, cl);
|
||||
} catch (Throwable ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static Method method(Profile p, String logical, String member) {
|
||||
String[] m = p.member(logical, member);
|
||||
Class<?> owner = resolve(logical, null);
|
||||
if (m == null || owner == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Method found = owner.getDeclaredMethod(m[0], typesOf(m[1]));
|
||||
found.setAccessible(true);
|
||||
return found;
|
||||
} catch (Throwable exactFailed) {
|
||||
// A descriptor names classes that may not be loaded yet - the
|
||||
// texture type, for one, only appears once something asks the
|
||||
// registry for a texture. Fall back to the method's own signature:
|
||||
// same name, same number of arguments.
|
||||
Method loose = byShape(owner, m[0], arityOf(m[1]));
|
||||
if (loose == null) {
|
||||
Log.warn("HUD: " + logical + "#" + member + " -> " + m[0] + m[1]
|
||||
+ " is not there: " + exactFailed);
|
||||
return null;
|
||||
}
|
||||
Log.info("HUD: " + logical + "#" + member
|
||||
+ " bound by shape, the exact type is not loaded yet");
|
||||
return loose;
|
||||
}
|
||||
}
|
||||
|
||||
private static Method byShape(Class<?> owner, String name, int arity) {
|
||||
for (Method m : owner.getDeclaredMethods()) {
|
||||
if (!m.getName().equals(name) || m.getParameterTypes().length != arity) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
m.setAccessible(true);
|
||||
return m;
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int arityOf(String descriptor) {
|
||||
int n = 0;
|
||||
if (descriptor == null || !descriptor.startsWith("(")) {
|
||||
return 0;
|
||||
}
|
||||
for (int i = 1; i < descriptor.length() && descriptor.charAt(i) != ')';) {
|
||||
if (descriptor.charAt(i) == 'L' || descriptor.charAt(i) == '[') {
|
||||
while (i < descriptor.length()
|
||||
&& descriptor.charAt(i) != ';') {
|
||||
i++;
|
||||
}
|
||||
i++;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
private static Field field(Profile p, String logical, String member) {
|
||||
String[] m = p.member(logical, member);
|
||||
Class<?> owner = resolve(logical, null);
|
||||
if (m == null || owner == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Field f = owner.getDeclaredField(m[0]);
|
||||
f.setAccessible(true);
|
||||
return f;
|
||||
} catch (Throwable t) {
|
||||
Log.warn("HUD: " + logical + "#" + member + " -> " + m[0]
|
||||
+ " is not there: " + t);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The game writes these by hand, so they need parsing rather than Class.forName. */
|
||||
private static Class<?>[] typesOf(String descriptor) {
|
||||
java.util.List<Class<?>> out = new java.util.ArrayList<Class<?>>();
|
||||
if (descriptor == null || !descriptor.startsWith("(")) {
|
||||
return out.toArray(new Class<?>[0]);
|
||||
}
|
||||
int i = 1;
|
||||
while (i < descriptor.length() && descriptor.charAt(i) != ')') {
|
||||
int end = i;
|
||||
// a reference type runs to its ';', a primitive is exactly one char
|
||||
if (descriptor.charAt(i) == 'L' || descriptor.charAt(i) == '[') {
|
||||
end = i;
|
||||
while (end < descriptor.length()
|
||||
&& descriptor.charAt(end) != ';') {
|
||||
end++;
|
||||
}
|
||||
out.add(Resolver.findLoaded(
|
||||
descriptor.substring(i + 1, end)));
|
||||
i = end + 1;
|
||||
} else {
|
||||
out.add(typeOf(descriptor.substring(i, i + 1)));
|
||||
i++;
|
||||
}
|
||||
}
|
||||
return out.toArray(new Class<?>[0]);
|
||||
}
|
||||
|
||||
private static Class<?> typeOf(String t) {
|
||||
if (t.length() == 1) {
|
||||
switch (t.charAt(0)) {
|
||||
case 'F': return float.class;
|
||||
case 'I': return int.class;
|
||||
case 'J': return long.class;
|
||||
case 'Z': return boolean.class;
|
||||
case 'B': return byte.class;
|
||||
case 'D': return double.class;
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
return Resolver.findLoaded(t);
|
||||
}
|
||||
|
||||
/** The multiplier the game's own widgets use; 1.0 when it cannot be read. */
|
||||
public static float scale() {
|
||||
if (scaleField == null) {
|
||||
return 1f;
|
||||
}
|
||||
try {
|
||||
return scaleField.getFloat(null);
|
||||
} catch (Throwable t) {
|
||||
return 1f;
|
||||
}
|
||||
}
|
||||
|
||||
public static Object atlas() {
|
||||
if (!atlasTried) {
|
||||
atlasTried = true;
|
||||
Method lookup = byPath;
|
||||
if (lookup == null) {
|
||||
// the registry is resolved before the game has loaded it, so
|
||||
// bind it now, when a texture is actually wanted
|
||||
lookup = method(Resolver.profile(), "rsx.ui.textures", "byPath");
|
||||
}
|
||||
if (lookup == null) {
|
||||
Log.warn("HUD: no texture registry, text will not draw");
|
||||
} else {
|
||||
byPath = lookup;
|
||||
try {
|
||||
atlas = lookup.invoke(null, ASCII_ATLAS);
|
||||
Log.info("HUD: font atlas " + ASCII_ATLAS + " -> "
|
||||
+ (atlas != null ? "ok" : "not found"));
|
||||
} catch (Throwable t) {
|
||||
Log.warn("HUD: cannot load " + ASCII_ATLAS + ": " + t);
|
||||
}
|
||||
}
|
||||
}
|
||||
return atlas;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fresh command builder for a solid rectangle.
|
||||
*
|
||||
* The box is passed to the factory, not set afterwards: the factory
|
||||
* signature is (x, y, width, height) and that is the only shape every
|
||||
* version of this API agrees on.
|
||||
*/
|
||||
public static Object rect(float x, float y, float w, float h) {
|
||||
if (!ready()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return rectFactory.invoke(null, Float.valueOf(x), Float.valueOf(y),
|
||||
Float.valueOf(w), Float.valueOf(h));
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static Object textured(Object tex, float x, float y, float w,
|
||||
float h) {
|
||||
if (!ready() || texturedFactory == null || tex == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return texturedFactory.invoke(null, tex, Float.valueOf(x),
|
||||
Float.valueOf(y), Float.valueOf(w), Float.valueOf(h));
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The builder's own methods are resolved on the class we actually got back
|
||||
* from a factory, not on the profile's class name.
|
||||
*
|
||||
* That class does not exist until something asks the game for a command, so
|
||||
* resolving it up front would always fail; holding the instance makes the
|
||||
* question moot and takes one more rename off the list of things that can
|
||||
* break.
|
||||
*/
|
||||
private static Method on(Object builder, Profile p, String member,
|
||||
int arity) {
|
||||
if (builder == null) {
|
||||
return null;
|
||||
}
|
||||
String key = builder.getClass().getName() + "#" + member;
|
||||
Method cached = BUILDER.get(key);
|
||||
if (cached != null) {
|
||||
return cached;
|
||||
}
|
||||
String[] m = p.member("rsx.ui.command", member);
|
||||
if (m == null) {
|
||||
return null;
|
||||
}
|
||||
for (Method f : builder.getClass().getDeclaredMethods()) {
|
||||
if (!f.getName().equals(m[0])
|
||||
|| f.getParameterTypes().length != arity) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
f.setAccessible(true);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
BUILDER.put(key, f);
|
||||
return f;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static void texture(Object builder, Object tex) {
|
||||
if (builder == null || tex == null) {
|
||||
return;
|
||||
}
|
||||
Method m = on(builder, Resolver.profile(), "texture", 1);
|
||||
if (m == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
m.invoke(builder, tex);
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public static void color(Object builder, int argb) {
|
||||
if (builder == null) {
|
||||
return;
|
||||
}
|
||||
Method m = on(builder, Resolver.profile(), "color", 1);
|
||||
if (m == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
m.invoke(builder, Integer.valueOf(argb));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public static void alpha(Object builder, float value) {
|
||||
if (builder == null) {
|
||||
return;
|
||||
}
|
||||
Method m = on(builder, Resolver.profile(), "alpha", 1);
|
||||
if (m == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
m.invoke(builder, Float.valueOf(value));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public static void uv(Object builder, float u0, float v0, float u1,
|
||||
float v1) {
|
||||
if (builder == null) {
|
||||
return;
|
||||
}
|
||||
Method m = on(builder, Resolver.profile(), "uv", 4);
|
||||
if (m == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
m.invoke(builder, Float.valueOf(u0), Float.valueOf(v0),
|
||||
Float.valueOf(u1), Float.valueOf(v1));
|
||||
} catch (Throwable ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
public static Object build(Object builder) {
|
||||
if (builder == null) {
|
||||
return null;
|
||||
}
|
||||
Method m = on(builder, Resolver.profile(), "build", 0);
|
||||
if (m == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Object built = m.invoke(builder);
|
||||
rememberCommand(built);
|
||||
return built;
|
||||
} catch (Throwable t) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static Class<?> targetClass() {
|
||||
return targetClass;
|
||||
}
|
||||
|
||||
/** The class of the last command we built: the one a context accepts. */
|
||||
private static volatile Class<?> lastCommand;
|
||||
|
||||
public static void rememberCommand(Object command) {
|
||||
if (command != null) {
|
||||
lastCommand = command.getClass();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stand-in test for "this class is a draw command" when the profile has
|
||||
* no anchor: it must be handed out by the game's own batch, and the batch's
|
||||
* factory return type is the command builder, whose build() gives one.
|
||||
*/
|
||||
public static boolean looksLikeCommand(Class<?> c) {
|
||||
if (c == null) {
|
||||
return false;
|
||||
}
|
||||
if (lastCommand != null && c.isAssignableFrom(lastCommand)) {
|
||||
return true;
|
||||
}
|
||||
Class<?> target = targetClass;
|
||||
return target != null && target.isAssignableFrom(c);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,10 @@
|
||||
// and feeds them to ilxq one by one, exactly as it does in the game. The last
|
||||
// one loaded is the only one whose getRunnableList() the vendor keeps, so the
|
||||
// first two only work if the bridge really has a single shared drain.
|
||||
//
|
||||
// The HUD part mimics the one path that matters: the compass widget renders, and
|
||||
// only while the HUD screen's gate is open. Toggling the gate is what the game's
|
||||
// own hide key does, so the test can check the panel follows it.
|
||||
|
||||
public class FakeMain {
|
||||
|
||||
@@ -29,6 +33,19 @@ public class FakeMain {
|
||||
client.pC = new pvdafug("http://snoop.minecraft.net/report");
|
||||
System.out.println("[fake] client up, telemetry scheduled");
|
||||
|
||||
// ---- the latency estimate, from the server's keep-alive cadence.
|
||||
//
|
||||
// The server keeps a 15 s cadence measured on its own clock, so the
|
||||
// deviation of the arrival interval is the network's. The stand compress
|
||||
// that into milliseconds; what matters is that a constant offset shows
|
||||
// up in the estimate.
|
||||
cxaddrz play = new cxaddrz();
|
||||
System.out.println("[fake] feeding keep-alive arrivals, 240 ms apart, period 200");
|
||||
for (int i = 0; i < 6; i++) {
|
||||
Thread.sleep(240);
|
||||
play.onKeepAlive(new Object());
|
||||
}
|
||||
System.out.println("[fake] keep-alives fed");
|
||||
Thread.sleep(2500);
|
||||
System.out.println("[fake] ticking 8 times");
|
||||
for (int i = 0; i < 8; i++) {
|
||||
@@ -43,6 +60,37 @@ public class FakeMain {
|
||||
client.tick();
|
||||
Thread.sleep(200);
|
||||
}
|
||||
|
||||
// ---- the HUD, drawn by the compass widget the way the game draws it
|
||||
fakecompass compass = new fakecompass();
|
||||
fakectx ctx = new fakectx();
|
||||
fakescreen screen = fakescreen.SINGLE;
|
||||
|
||||
System.out.println("[fake] HUD visible for 3 frames");
|
||||
for (int i = 0; i < 3; i++) {
|
||||
fakebatch.reset();
|
||||
if (!screen.isHidden()) {
|
||||
compass.render(ctx);
|
||||
}
|
||||
System.out.println("[fake] frame " + i + " commands=" + fakebatch.DRAWN.size()
|
||||
+ (fakebatch.DRAWN.isEmpty() ? ""
|
||||
: " first=" + fakebatch.DRAWN.get(0)
|
||||
+ " last=" + fakebatch.DRAWN.get(
|
||||
fakebatch.DRAWN.size() - 1)));
|
||||
}
|
||||
|
||||
System.out.println("[fake] HUD hidden (what F3 does) for 3 frames");
|
||||
screen.setHidden(true);
|
||||
for (int i = 0; i < 3; i++) {
|
||||
fakebatch.reset();
|
||||
if (!screen.isHidden()) {
|
||||
compass.render(ctx);
|
||||
}
|
||||
System.out.println("[fake] frame " + i + " commands=" + fakebatch.DRAWN.size());
|
||||
}
|
||||
screen.setHidden(false);
|
||||
|
||||
|
||||
System.out.println("[fake] done");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Test stand-in for the client's play packet handler.
|
||||
//
|
||||
// The real one has a keep-alive handler, which the bridge hooks to time the
|
||||
// server's 15 second cadence. Here the same shape lets the test feed arrivals
|
||||
// with a chosen delay and see what the estimator makes of them.
|
||||
|
||||
public class cxaddrz {
|
||||
|
||||
public void onKeepAlive(Object keepAlivePacket) {
|
||||
// the real one answers the server
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Test stand-in for the game's draw batch.
|
||||
//
|
||||
// Two static factories - one for a solid rectangle, one for a textured one -
|
||||
// each taking the box directly, and each returning a builder. This is the shape
|
||||
// the bridge binds to by descriptor.
|
||||
|
||||
public final class fakebatch {
|
||||
|
||||
private fakebatch() {
|
||||
}
|
||||
|
||||
public static final java.util.List<fakecmd> DRAWN =
|
||||
new java.util.ArrayList<fakecmd>();
|
||||
|
||||
public static fakecmdbuilder rect(float x, float y, float w, float h) {
|
||||
return new fakecmdbuilder(new fakecmd(x, y, w, h, null));
|
||||
}
|
||||
|
||||
public static fakecmdbuilder textured(Object tex, float x, float y,
|
||||
float w, float h) {
|
||||
return new fakecmdbuilder(new fakecmd(x, y, w, h, tex));
|
||||
}
|
||||
|
||||
public static void submit(fakecmd command) {
|
||||
DRAWN.add(command);
|
||||
}
|
||||
|
||||
public static void reset() {
|
||||
DRAWN.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// Test stand-in for the game's draw command.
|
||||
//
|
||||
// The real build calls it lthzuq$b; here it has a legal name because the real
|
||||
// one uses the Java keywords "class" and "super", which javac will not accept.
|
||||
// What matters for the bridge is the shape - public fields, built by a builder,
|
||||
// accepted by a context - and the test profile points at these names.
|
||||
|
||||
public class fakecmd {
|
||||
public final float x;
|
||||
public final float y;
|
||||
public final float w;
|
||||
public final float h;
|
||||
public final Object tex;
|
||||
public int color;
|
||||
public float alpha = 1f;
|
||||
public float u0;
|
||||
public float v0;
|
||||
public float u1 = 1f;
|
||||
public float v1 = 1f;
|
||||
|
||||
public fakecmd(float x, float y, float w, float h, Object tex) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.w = w;
|
||||
this.h = h;
|
||||
this.tex = tex;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "cmd(" + x + "," + y + " " + w + "x" + h
|
||||
+ " tex=" + (tex == null ? "-" : tex.toString())
|
||||
+ " color=" + Integer.toHexString(color)
|
||||
+ " a=" + alpha
|
||||
+ " uv=" + u0 + "," + v0 + ".." + u1 + "," + v1 + ")";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Test stand-in for the game's command builder: chainable setters, then build.
|
||||
|
||||
public final class fakecmdbuilder {
|
||||
|
||||
private final fakecmd cmd;
|
||||
|
||||
fakecmdbuilder(fakecmd cmd) {
|
||||
this.cmd = cmd;
|
||||
}
|
||||
|
||||
public fakecmdbuilder texture(Object tex) {
|
||||
return this;
|
||||
}
|
||||
|
||||
public fakecmdbuilder color(int argb) {
|
||||
cmd.color = argb;
|
||||
return this;
|
||||
}
|
||||
|
||||
public fakecmdbuilder alpha(float value) {
|
||||
cmd.alpha = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public fakecmdbuilder uv(float u0, float v0, float u1, float v1) {
|
||||
cmd.u0 = u0;
|
||||
cmd.v0 = v0;
|
||||
cmd.u1 = u1;
|
||||
cmd.v1 = v1;
|
||||
return this;
|
||||
}
|
||||
|
||||
public fakecmd build() {
|
||||
return cmd;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// Test stand-in for the compass widget: the hook target.
|
||||
//
|
||||
// Its render does nothing; the bridge appends its own draw right after. The
|
||||
// caller only invokes it while the HUD screen's gate is open, so hiding the HUD
|
||||
// means this method is never called and neither is the panel.
|
||||
|
||||
public class fakecompass {
|
||||
|
||||
public void render(fakectx ctx) {
|
||||
// the real one draws the compass here
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Test stand-in for the game's HUD render context.
|
||||
//
|
||||
// One method takes a draw command, and two no-argument int getters give the
|
||||
// screen size. The bridge finds the submit method by shape and tells the two
|
||||
// getters apart by which is larger, so the names here do not matter.
|
||||
|
||||
public class fakectx {
|
||||
|
||||
public int screenWidth() {
|
||||
return 1920;
|
||||
}
|
||||
|
||||
public int screenHeight() {
|
||||
return 1080;
|
||||
}
|
||||
|
||||
public void accept(fakecmd command) {
|
||||
fakebatch.submit(command);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
// Test stand-in for the game's texture registry: a static String -> texture.
|
||||
|
||||
public final class fakereg {
|
||||
|
||||
private fakereg() {
|
||||
}
|
||||
|
||||
public static faketex byPath(String path) {
|
||||
return new faketex(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
// Test stand-in for the HUD scale holder: one public static float that the
|
||||
// game's widgets multiply their geometry by.
|
||||
|
||||
public final class fakescale {
|
||||
|
||||
public static volatile float factor = 0.5f;
|
||||
|
||||
private fakescale() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Test stand-in for the HUD screen singleton.
|
||||
//
|
||||
// The gate lives here and nowhere else, exactly as in the real build: while it
|
||||
// is set, the game draws no widgets at all, which is what the hide key flips.
|
||||
|
||||
public class fakescreen {
|
||||
|
||||
public static final fakescreen SINGLE = new fakescreen();
|
||||
|
||||
private boolean hidden;
|
||||
|
||||
public boolean isHidden() {
|
||||
return hidden;
|
||||
}
|
||||
|
||||
public void setHidden(boolean hidden) {
|
||||
this.hidden = hidden;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Test stand-in for the game's texture handle and its registry.
|
||||
|
||||
public final class faketex {
|
||||
|
||||
public final String path;
|
||||
|
||||
public faketex(String path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "tex:" + path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package rsxtest;
|
||||
|
||||
// Test stand-in for a shared library that lives in libs\ and is appended to the
|
||||
// system class path. Every mod sees this class through its loader's parent, so
|
||||
// one copy serves all of them - which is the whole point of the libs\ folder.
|
||||
|
||||
public final class testlib {
|
||||
|
||||
private static int calls;
|
||||
|
||||
private testlib() {
|
||||
}
|
||||
|
||||
public static String greet(String who) {
|
||||
calls++;
|
||||
return "hello " + who;
|
||||
}
|
||||
|
||||
public static int calls() {
|
||||
return calls;
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,9 @@ public class pvdafug {
|
||||
|
||||
public pvdafug(String url) {
|
||||
this.url = url;
|
||||
do_.schedule(new TimerTask0(), 1000L);
|
||||
// wide enough that A6 always wins; in the real client the first post is
|
||||
// seconds away, and a stand that fired first would only test timing
|
||||
do_.schedule(new TimerTask0(), 5000L);
|
||||
}
|
||||
|
||||
public String url() {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package example;
|
||||
|
||||
import net.rustexremake.api.Rsx;
|
||||
import net.rustexremake.api.RsxMod;
|
||||
|
||||
/**
|
||||
* Checks that a mod can draw on the game's HUD.
|
||||
*
|
||||
* The bridge hooks the compass widget, so this only ever runs while the game is
|
||||
* drawing its HUD, and it must stop the moment the game says the HUD is hidden -
|
||||
* that is the same state the game's own hide key flips. The stand prints one line
|
||||
* per frame so the test can count them both ways.
|
||||
*/
|
||||
public class HudMod implements RsxMod {
|
||||
|
||||
public static int frames;
|
||||
public static int rects;
|
||||
public static int glyphs;
|
||||
|
||||
private final Runnable draw = new Runnable() {
|
||||
private int reported;
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
frames++;
|
||||
Rsx.Hud hud = Rsx.hud();
|
||||
if (hud == null || !hud.available()) {
|
||||
return;
|
||||
}
|
||||
if (reported == 0 && hud.width() > 0) {
|
||||
reported = 1;
|
||||
Rsx.info("HudMod screen: " + hud.width() + "x" + hud.height()
|
||||
+ " scale=" + hud.scale());
|
||||
}
|
||||
hud.rect(8, 8, 40, 12, 0x20201C, 0.85f);
|
||||
rects++;
|
||||
hud.text(8, 8, "42 ms", 0xECE3DB, 1f);
|
||||
glyphs++;
|
||||
}
|
||||
};
|
||||
|
||||
@Override
|
||||
public void onLoad() {
|
||||
Rsx.info("HudMod: registering a draw callback");
|
||||
Rsx.onHud(new Rsx.HudDraw() {
|
||||
@Override
|
||||
public void draw(Rsx.Hud hud) {
|
||||
draw.run();
|
||||
}
|
||||
});
|
||||
// the latency readout, printed once the estimates have something to say
|
||||
Rsx.everyTicks(20, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
frames++;
|
||||
if (Rsx.ping() >= 0) {
|
||||
Rsx.info("HudMod ping: " + Rsx.pingLine()
|
||||
+ " exact=" + Rsx.pingExact());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package example;
|
||||
|
||||
import net.rustexremake.api.Rsx;
|
||||
import net.rustexremake.api.RsxMod;
|
||||
import rsxtest.testlib;
|
||||
|
||||
/**
|
||||
* Third and last mod: the one whose getRunnableList() the vendor actually keeps,
|
||||
@@ -14,6 +15,9 @@ public class ThirdMod implements RsxMod {
|
||||
@Override
|
||||
public void onLoad() {
|
||||
Rsx.info("ThirdMod: loaded, this is the drain the vendor keeps");
|
||||
// testlib lives in libs\ and is appended to the system class path, so it
|
||||
// is visible here without being bundled into this mod's jar
|
||||
Rsx.info("ThirdMod sees the library: " + testlib.greet("third"));
|
||||
Rsx.everyTicks(1, new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
|
||||
@@ -44,6 +44,7 @@ MODS = [
|
||||
("example-mod.jar", "example.ExampleMod"),
|
||||
("second-mod.jar", "example.SecondMod"),
|
||||
("third-mod.jar", "example.ThirdMod"),
|
||||
("hud-mod.jar", "example.HudMod"),
|
||||
]
|
||||
|
||||
|
||||
@@ -79,6 +80,10 @@ def javac(sources, out, cp=None):
|
||||
raise SystemExit("javac failed")
|
||||
|
||||
|
||||
def bridge_dir():
|
||||
return os.path.join(RUN, "bridge")
|
||||
|
||||
|
||||
def zip_dir(src, jar):
|
||||
if os.path.exists(jar):
|
||||
os.remove(jar)
|
||||
@@ -132,9 +137,21 @@ def main():
|
||||
javac([os.path.join(SRC, f) for f in os.listdir(SRC) if f.endswith(".java")],
|
||||
classes)
|
||||
|
||||
# the shared library lives in libs\ and is appended to the system class path
|
||||
libdir = os.path.join(bridge_dir(), "libs")
|
||||
shutil.rmtree(libdir, ignore_errors=True)
|
||||
os.makedirs(libdir, exist_ok=True)
|
||||
libclasses = os.path.join(RUN, "libclasses")
|
||||
shutil.rmtree(libclasses, ignore_errors=True)
|
||||
javac([os.path.join(SRC, "lib", "rsxtest", "testlib.java")], libclasses)
|
||||
zip_dir(libclasses, os.path.join(libdir, "testlib.jar"))
|
||||
|
||||
print("== compiling the mods ==")
|
||||
# mods compile against the API and the shared library, but package neither:
|
||||
# the library is on the system class path at runtime
|
||||
javac([os.path.join(MOD_SRC, f) for f in os.listdir(MOD_SRC)
|
||||
if f.endswith(".java")], MOD_CLASSES, cp=BRIDGE_JAR)
|
||||
if f.endswith(".java")], MOD_CLASSES,
|
||||
cp=BRIDGE_JAR + os.pathsep + libclasses)
|
||||
|
||||
game = os.path.join(RUN, "game.jar")
|
||||
zip_dir(classes, game)
|
||||
@@ -184,6 +201,30 @@ def main():
|
||||
"class net.minecraft.world.World oisywi",
|
||||
"field net.minecraft.world.World isRemote isRemoteFlag Z",
|
||||
"method net.minecraft.world.World getWorldTime getWorldTime ()J",
|
||||
# the game's own HUD draw API and the widget the bridge hooks; the
|
||||
# stand-in uses legal names because the real ones are Java keywords
|
||||
"class hud.screen fakescreen",
|
||||
"field hud.screen singleton SINGLE Lfakescreen;",
|
||||
"method hud.screen visible isHidden ()Z",
|
||||
"hook.hud fakecompass render (Lfakectx;)V",
|
||||
"class rsx.ui.batch fakebatch",
|
||||
"method rsx.ui.batch rect rect (FFFF)Lfakecmdbuilder;",
|
||||
"method rsx.ui.batch textured textured (Lfaketex;FFFF)Lfakecmdbuilder;",
|
||||
"class rsx.ui.command fakecmdbuilder",
|
||||
"method rsx.ui.command build build ()Lfakecmd;",
|
||||
"method rsx.ui.command texture texture (Ljava/lang/Object;)Lfakecmdbuilder;",
|
||||
"method rsx.ui.command color color (I)Lfakecmdbuilder;",
|
||||
"method rsx.ui.command alpha alpha (F)Lfakecmdbuilder;",
|
||||
"method rsx.ui.command uv uv (FFFF)Lfakecmdbuilder;",
|
||||
"class rsx.ui.target fakecmd",
|
||||
"class rsx.ui.textures fakereg",
|
||||
"method rsx.ui.textures byPath byPath (Ljava/lang/String;)Lfaketex;",
|
||||
"class rsx.ui.scale fakescale",
|
||||
"field rsx.ui.scale factor factor F",
|
||||
# the client packet handler, for the latency estimate
|
||||
"class net.minecraft.client.network.NetHandlerPlayClient cxaddrz",
|
||||
"method net.minecraft.client.network.NetHandlerPlayClient handleKeepAlive onKeepAlive (Ljava/lang/Object;)V",
|
||||
"method net.minecraft.client.network.NetHandlerPlayClient sendPacket sendPacket (Ljava/lang/Object;)V",
|
||||
]) + "\n"
|
||||
with open(os.path.join(bridge, "profiles", "latest.rsxp"), "w",
|
||||
encoding="utf-8") as fh:
|
||||
@@ -196,6 +237,8 @@ def main():
|
||||
print("== running with the agent attached ==")
|
||||
cmd = [java(),
|
||||
"-javaagent:" + os.path.join(bridge, "rsx-bridge.jar"),
|
||||
# the stand compresses the 15 s cadence into milliseconds
|
||||
"-Drsx.keepalivePeriod=200",
|
||||
"-Dminecraft.applet.TargetDirectory=" + RUN,
|
||||
"-cp", os.path.join(bridge, "rsx-bridge.jar") + ";" + game,
|
||||
"FakeMain"]
|
||||
@@ -218,6 +261,10 @@ def main():
|
||||
("first mod loaded", "mod loaded: example-mod.jar" in out),
|
||||
("second mod loaded", "mod loaded: second-mod.jar" in out),
|
||||
("third mod loaded", "mod loaded: third-mod.jar" in out),
|
||||
("mod sees the shared library",
|
||||
"ThirdMod sees the library: hello third" in out),
|
||||
("library was appended to the class path",
|
||||
"library on the class path: testlib.jar" in out),
|
||||
("mod 1 work runs", "ExampleMod tick 1" in out),
|
||||
("mod 2 work runs (not the kept drain)",
|
||||
"SecondMod run 1" in out),
|
||||
@@ -231,6 +278,23 @@ def main():
|
||||
("failing task reported to onError", "ExampleMod onError" in out),
|
||||
("failing task eventually dropped",
|
||||
"times in a row, dropping it" in out),
|
||||
("HUD hook armed", "HUD hook armed on" in out),
|
||||
("HUD hook applied to the widget", "HUD hook applied to" in out),
|
||||
("HUD follows the game visibility",
|
||||
"HUD: following the game HUD visibility" in out),
|
||||
("panel drew while visible",
|
||||
"HUD visible for 3 frames" in out
|
||||
and out.count("commands=5") == 3),
|
||||
("panel drew a rectangle and a label",
|
||||
"color=20201c" in out and "textures/font/ascii.png" in out
|
||||
and "color=ece3db" in out),
|
||||
("screen size read from the context", "HudMod screen: 1920x1080" in out),
|
||||
("panel stops when the game hides the HUD",
|
||||
"HUD hidden (what F3 does)" in out
|
||||
and "frame 0 commands=0" in out and "frame 2 commands=0" in out),
|
||||
("keep-alive hook applied", "keep-alive hook applied to" in out),
|
||||
("latency estimated from the cadence", "HudMod ping: ping~" in out),
|
||||
("estimate is not claimed exact", "exact=false" in out),
|
||||
("telemetry never fired", "[fake-snooper] posting" not in out),
|
||||
]
|
||||
bad = 0
|
||||
|
||||
+19
-1
@@ -171,6 +171,23 @@
|
||||
"net.minecraft.network.PacketBuffer": {
|
||||
"obf": "dcuglw"
|
||||
},
|
||||
"net.minecraft.client.network.NetHandlerPlayClient": {
|
||||
"obf": "cxaddrz",
|
||||
"methods": {
|
||||
"handleKeepAlive": {
|
||||
"obf": "super",
|
||||
"desc": "(Lysfj;)V",
|
||||
"kind": "m",
|
||||
"srg": "func_147272_a"
|
||||
},
|
||||
"sendPacket": {
|
||||
"obf": "super",
|
||||
"desc": "(Lffoox;)V",
|
||||
"kind": "m",
|
||||
"srg": "func_147297_a"
|
||||
}
|
||||
}
|
||||
},
|
||||
"net.minecraft.client.multiplayer.PlayerControllerMP": {
|
||||
"obf": "tdsh"
|
||||
},
|
||||
@@ -202,6 +219,7 @@
|
||||
},
|
||||
"missing": [
|
||||
"net.minecraft.client.Minecraft#snooper",
|
||||
"net.minecraft.client.network.NetHandlerPlayClient#handleCustomPayload",
|
||||
"net.minecraft.network.NetworkManager"
|
||||
],
|
||||
"build": {
|
||||
@@ -211,6 +229,6 @@
|
||||
"jarMtime": 1792011090,
|
||||
"classes": 3759,
|
||||
"totalClasses": 7695,
|
||||
"generated": "2026-10-17 02:15"
|
||||
"generated": "2026-10-17 03:30"
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,31 @@ class net.minecraft.entity.EntityLivingBase bgtojhxo
|
||||
method net.minecraft.entity.EntityLivingBase getHealth byte ()F
|
||||
method net.minecraft.entity.EntityLivingBase getMaxHealth if ()F
|
||||
class net.minecraft.network.PacketBuffer dcuglw
|
||||
class net.minecraft.client.network.NetHandlerPlayClient cxaddrz
|
||||
method net.minecraft.client.network.NetHandlerPlayClient handleKeepAlive super (Lysfj;)V
|
||||
method net.minecraft.client.network.NetHandlerPlayClient sendPacket super (Lffoox;)V
|
||||
class net.minecraft.client.multiplayer.PlayerControllerMP tdsh
|
||||
class net.minecraft.client.gui.GuiScreen bsh
|
||||
class net.minecraft.client.settings.GameSettings qoa
|
||||
override.server catch
|
||||
class hud.screen aogu
|
||||
field hud.screen singleton try Laogu;
|
||||
method hud.screen visible do ()Z
|
||||
hook.hud jzojqxa super (Lzmtuealk$a;)V
|
||||
class rsx.ui.batch lthzuq
|
||||
method rsx.ui.batch rect super (FFFF)Llthzuq$b$b;
|
||||
method rsx.ui.batch textured super (Lwyw;FFFF)Llthzuq$b$b;
|
||||
class rsx.ui.command lthzuq$b$b
|
||||
method rsx.ui.command build super ()Llthzuq$b;
|
||||
method rsx.ui.command texture super (Lwyw;)Llthzuq$b$b;
|
||||
method rsx.ui.command color super (I)Llthzuq$b$b;
|
||||
method rsx.ui.command alpha super (F)Llthzuq$b$b;
|
||||
method rsx.ui.command uv super (FFFF)Llthzuq$b$b;
|
||||
class rsx.ui.target lthzuq$b
|
||||
class rsx.ui.textures mhemp
|
||||
method rsx.ui.textures byPath super (Ljava/lang/String;)Lwyw;
|
||||
class rsx.ui.scale avpajdfu
|
||||
field rsx.ui.scale factor break F
|
||||
class rsx.net.echoPacket fgoftjl
|
||||
method rsx.net.echoPacket ctor <init> (Ljava/lang/String;Ldcuglw;)V
|
||||
class rsx.net.echoBuffer dcuglw
|
||||
|
||||
+54
-4
@@ -35,10 +35,22 @@ $agentOut = Join-Path $Out 'agent'
|
||||
Remove-Item $agentOut -Recurse -Force -ErrorAction SilentlyContinue
|
||||
New-Item -ItemType Directory -Force -Path $agentOut | Out-Null
|
||||
$agentFiles = Get-ChildItem $agentSrc -Recurse -Filter '*.java' | ForEach-Object { $_.FullName }
|
||||
& javac -encoding UTF-8 --release 8 -nowarn -d $agentOut $agentFiles
|
||||
& javac -encoding UTF-8 --release 8 -nowarn -cp ($Asm -join ';') -d $agentOut $agentFiles
|
||||
if ($LASTEXITCODE -ne 0) { Write-Host ' Агент не собрался.' -ForegroundColor Red; exit 1 }
|
||||
Say "агент скомпилирован ($($agentFiles.Count) файлов)"
|
||||
|
||||
# The agent rewrites bytecode at runtime to give mods the game's own HUD, so it
|
||||
# carries ASM inside rather than making every install provide it.
|
||||
foreach ($jar in $Asm) {
|
||||
Push-Location $agentOut
|
||||
& jar xf $jar
|
||||
Pop-Location
|
||||
}
|
||||
Get-ChildItem (Join-Path $agentOut 'META-INF') -Recurse -ErrorAction SilentlyContinue |
|
||||
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
Remove-Item (Join-Path $agentOut 'module-info.class') -Force -ErrorAction SilentlyContinue
|
||||
Say 'ASM зашит в агент'
|
||||
|
||||
$manifest = Join-Path $Out 'agent-manifest.txt'
|
||||
@"
|
||||
Manifest-Version: 1.0
|
||||
@@ -127,13 +139,51 @@ Copy-Item (Join-Path $Repo 'mod-template\src\main\java\com\example\ExampleMod.ja
|
||||
Copy-Item (Join-Path $Dist 'rsx-api.jar') "$tpl\libs\" -Force
|
||||
@'
|
||||
@echo off
|
||||
rem Builds this mod and drops the jar into the client's mods folder.
|
||||
rem Builds the mod and drops the jar into the client's mods folder.
|
||||
rem Only a JDK is needed: no Gradle, no network, nothing but javac and jar.
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
call gradle jar
|
||||
|
||||
if not exist "build\classes" mkdir "build\classes"
|
||||
if not exist "build\mods" mkdir "build\mods"
|
||||
|
||||
set ENTRY=com.example.ExampleMod
|
||||
|
||||
rem where the modloader looks for mods; override with set MODS_DIR=...
|
||||
if "%MODS_DIR%"=="" set MODS_DIR=%LOCALAPPDATA%\rsx-mod\mods
|
||||
|
||||
echo [1/3] compiling...
|
||||
dir /s /b src\main\java\*.java > build\sources.txt
|
||||
javac -encoding UTF-8 --release 8 -nowarn -cp libs\rsx-api.jar -d build\classes @build\sources.txt
|
||||
if errorlevel 1 (
|
||||
echo.
|
||||
echo Сборка не удалась.
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo [2/3] packaging example-mod.jar...
|
||||
if exist "build\mods\example-mod.jar" del "build\mods\example-mod.jar"
|
||||
jar cfm "build\mods\example-mod.jar" manifest.txt -C build/classes .
|
||||
if errorlevel 1 exit /b 1
|
||||
|
||||
echo [3/3] installing...
|
||||
if not exist "%MODS_DIR%" (
|
||||
echo.
|
||||
echo Папка модов не найдена: %MODS_DIR%
|
||||
echo Скопируй build\mods\example-mod.jar в неё сам.
|
||||
exit /b 0
|
||||
)
|
||||
copy /y "build\mods\example-mod.jar" "%MODS_DIR%\" >nul
|
||||
echo.
|
||||
echo Скопируй build\libs\example-mod.jar в working_dir\mods
|
||||
echo Готово: build\mods\example-mod.jar скопирован в %MODS_DIR%
|
||||
'@ | Set-Content -LiteralPath (Join-Path $tpl 'build.cmd') -Encoding ASCII
|
||||
|
||||
# the manifest carries the entry point the bridge looks for
|
||||
$entry = (Select-String -LiteralPath "$tpl\src\main\java\com\example\ExampleMod.java" `
|
||||
-Pattern '^package\s+([\w.]+)' | ForEach-Object { $_.Matches[0].Groups[1].Value })
|
||||
"Manifest-Version: 1.0`r`nRsx-Mod-Entrypoint: $entry.ExampleMod`r`n`r`n" |
|
||||
Set-Content -LiteralPath (Join-Path $tpl 'manifest.txt') -Encoding ASCII
|
||||
|
||||
Say 'mod-template готов'
|
||||
|
||||
Write-Host ''
|
||||
|
||||
+11
-4
@@ -135,12 +135,15 @@ Say "Модлоадер: $ModHome"
|
||||
Say ''
|
||||
|
||||
# --------------------------------------------------------------- copy the runtime
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'bridge\profiles') | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'profiles') | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'mods') | Out-Null
|
||||
Copy-Item (Join-Path $Dist 'rsx-bridge.jar') (Join-Path $ModHome 'bridge') -Force
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $ModHome 'libs') | Out-Null
|
||||
# The agent sits in the home itself, not in a subfolder: then its own directory
|
||||
# IS the home, and mods\, libs\ and config\ resolve without any guessing.
|
||||
Copy-Item (Join-Path $Dist 'rsx-bridge.jar') $ModHome -Force
|
||||
Say 'скопирован rsx-bridge.jar'
|
||||
|
||||
$existingProfile = Join-Path $ModHome 'bridge\profiles\latest.rsxp'
|
||||
$existingProfile = Join-Path $ModHome 'profiles\latest.rsxp'
|
||||
$repoProfile = Join-Path $Repo 'profiles\latest.rsxp'
|
||||
$oldProfile = 'G:\rsx-mod\bridge\profiles\latest.rsxp'
|
||||
if (Test-Path $existingProfile) {
|
||||
@@ -182,7 +185,11 @@ if (-not (Test-Path -LiteralPath $Marker)) {
|
||||
}
|
||||
|
||||
# --------------------------------------------------------------- wire the agent
|
||||
$agentArg = "-javaagent:`"$ModHome\bridge\rsx-bridge.jar`""
|
||||
# Forward slashes on purpose. The launcher rewrites this config through a
|
||||
# properties writer that mangles Windows paths - it dropped a whole path segment
|
||||
# from ours once - and a forward slash has nothing to escape. The JVM takes this
|
||||
# form happily on Windows.
|
||||
$agentArg = '-javaagent:"' + ($ModHome -replace '\\', '/') + '/rsx-bridge.jar"'
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$out = & $java -jar $patcher setenv $Config $agentArg 2>&1
|
||||
$code = $LASTEXITCODE
|
||||
|
||||
Reference in New Issue
Block a user