diff --git a/bridge/src/main/java/net/rustexremake/api/Rsx.java b/bridge/src/main/java/net/rustexremake/api/Rsx.java index dc86d90..5b7b573 100644 --- a/bridge/src/main/java/net/rustexremake/api/Rsx.java +++ b/bridge/src/main/java/net/rustexremake/api/Rsx.java @@ -296,6 +296,94 @@ public final class Rsx { return ModBridge.tickCount(); } + // ------------------------------------------------------------------- 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(); + } + + /** 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); diff --git a/bridge/src/main/java/rsx/HookTransformer.java b/bridge/src/main/java/rsx/HookTransformer.java new file mode 100644 index 0000000..64875e3 --- /dev/null +++ b/bridge/src/main/java/rsx/HookTransformer.java @@ -0,0 +1,121 @@ +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.MethodInsnNode; +import org.objectweb.asm.tree.MethodNode; +import org.objectweb.asm.tree.VarInsnNode; + +/** + * Appends a draw call to the game's own HUD element, so mods can draw without + * touching OpenGL themselves. + * + * The target is the compass widget's render method. That choice is deliberate: + * 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 happens in the same pass, with the same scale, as the widget we + * are drawing next to. + * + * Nothing here knows an obfuscated name: the profile carries the target, and a + * game that no longer matches the profile simply gets no hook, which the agent + * reports once instead of failing to start. + */ +public final class HookTransformer implements ClassFileTransformer { + + private final String targetClass; + private final String targetMethod; + private final String targetDesc; + + private boolean applied; + private boolean sawTarget; + + public HookTransformer(String targetClass, String targetMethod, + String targetDesc) { + this.targetClass = targetClass; + this.targetMethod = targetMethod; + this.targetDesc = targetDesc; + } + + public boolean applied() { + return applied; + } + + /** True when the class was seen but the method inside it was not found. */ + public boolean incomplete() { + return sawTarget && !applied; + } + + /** + * Puts the call where it will actually run. + * + * A method always ends with a RETURN, so appending to the instruction list + * would put the call after it: dead 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 org.objectweb.asm.tree.InsnNode) { + int op = ((org.objectweb.asm.tree.InsnNode) last).getOpcode(); + if (op >= Opcodes.IRETURN && op <= Opcodes.RETURN) { + m.instructions.insertBefore(last, call); + return; + } + } + m.instructions.add(call); + } + + @Override + public byte[] transform(ClassLoader loader, String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, byte[] bytes) { + if (className == null || bytes == null + || !className.equals(targetClass)) { + return null; + } + sawTarget = true; + try { + ClassNode cls = new ClassNode(); + new ClassReader(bytes).accept(cls, 0); + for (MethodNode m : cls.methods) { + if (!m.name.equals(targetMethod) + || !m.desc.equals(targetDesc)) { + continue; + } + if (m.instructions == null) { + continue; + } + InsnList call = new InsnList(); + // push the context, which is the method's first argument + call.add(new VarInsnNode(Opcodes.ALOAD, + (m.access & Opcodes.ACC_STATIC) != 0 ? 0 : 1)); + // The context is taken as Object on purpose: whatever class 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 name that changes every update. + call.add(new MethodInsnNode(Opcodes.INVOKESTATIC, HudHook.NAME, + "render", "(Ljava/lang/Object;)V", false)); + insertAtEnd(m, call); + ClassWriter out = new ClassWriter( + ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES); + cls.accept(out); + applied = true; + Log.info("HUD hook applied to " + targetClass + "." + targetMethod); + return out.toByteArray(); + } + Log.warn("HUD hook: " + targetClass + " has no method " + + targetMethod + targetDesc + ", the panel will not draw"); + } catch (Throwable t) { + // A broken hook must never take the game down with it: returning + // null leaves the original class untouched. + Log.error("HUD hook failed, the game runs without the panel", t); + } + return null; + } +} diff --git a/bridge/src/main/java/rsx/Hud.java b/bridge/src/main/java/rsx/Hud.java new file mode 100644 index 0000000..112d3f0 --- /dev/null +++ b/bridge/src/main/java/rsx/Hud.java @@ -0,0 +1,129 @@ +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 size in HUD units, or 0 when it cannot be read. + * + * The context does not expose it by name, so this stays 0 until a build + * gives us a way; callers fall back to the game's own scale factor. + */ + public int width() { + return 0; + } + + public int height() { + return 0; + } + + /** 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}; + } +} diff --git a/bridge/src/main/java/rsx/HudHook.java b/bridge/src/main/java/rsx/HudHook.java new file mode 100644 index 0000000..85002c9 --- /dev/null +++ b/bridge/src/main/java/rsx/HudHook.java @@ -0,0 +1,199 @@ +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 LISTENERS = new ArrayList(); + 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 copy; + synchronized (LISTENERS) { + if (LISTENERS.isEmpty()) { + return; + } + copy = new ArrayList(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) { + 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) { + return true; + } + Class owner = Resolver.findLoaded(obf); + if (owner == null) { + 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; + } + } + + /** 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; + } +} diff --git a/bridge/src/main/java/rsx/Profile.java b/bridge/src/main/java/rsx/Profile.java index 30252f2..88b31a8 100644 --- a/bridge/src/main/java/rsx/Profile.java +++ b/bridge/src/main/java/rsx/Profile.java @@ -12,6 +12,7 @@ package rsx; * class * field * method + * hook.hud */ 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 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)) diff --git a/bridge/src/main/java/rsx/RsxAgent.java b/bridge/src/main/java/rsx/RsxAgent.java index 20a64e6..8a51b04 100644 --- a/bridge/src/main/java/rsx/RsxAgent.java +++ b/bridge/src/main/java/rsx/RsxAgent.java @@ -77,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"); @@ -489,6 +492,32 @@ 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 { + HookTransformer t = new HookTransformer(p.hudHookClass, + p.hudHookMethod, p.hudHookDesc); + inst.addTransformer(t, false); + Log.info("HUD hook armed on " + p.hudHookClass + "." + + p.hudHookMethod); + } catch (Throwable e) { + Log.error("cannot arm the HUD hook", e); + } + } + // ----------------------------------------------------------------- utils /** diff --git a/bridge/src/main/java/rsx/Ui.java b/bridge/src/main/java/rsx/Ui.java new file mode 100644 index 0000000..d7a3c91 --- /dev/null +++ b/bridge/src/main/java/rsx/Ui.java @@ -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 BUILDER = + new java.util.HashMap(); + 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> out = new java.util.ArrayList>(); + 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); + } +} diff --git a/bridge/src/test/fakeclient/FakeMain.java b/bridge/src/test/fakeclient/FakeMain.java index 6542906..f0f368b 100644 --- a/bridge/src/test/fakeclient/FakeMain.java +++ b/bridge/src/test/fakeclient/FakeMain.java @@ -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 { @@ -43,6 +47,35 @@ 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"); } } diff --git a/bridge/src/test/fakeclient/fakebatch.java b/bridge/src/test/fakeclient/fakebatch.java new file mode 100644 index 0000000..dc6bbea --- /dev/null +++ b/bridge/src/test/fakeclient/fakebatch.java @@ -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 DRAWN = + new java.util.ArrayList(); + + 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(); + } +} diff --git a/bridge/src/test/fakeclient/fakecmd.java b/bridge/src/test/fakeclient/fakecmd.java new file mode 100644 index 0000000..4f3a87f --- /dev/null +++ b/bridge/src/test/fakeclient/fakecmd.java @@ -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 + ")"; + } +} diff --git a/bridge/src/test/fakeclient/fakecmdbuilder.java b/bridge/src/test/fakeclient/fakecmdbuilder.java new file mode 100644 index 0000000..0755d96 --- /dev/null +++ b/bridge/src/test/fakeclient/fakecmdbuilder.java @@ -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; + } +} diff --git a/bridge/src/test/fakeclient/fakecompass.java b/bridge/src/test/fakeclient/fakecompass.java new file mode 100644 index 0000000..a5b3e6e --- /dev/null +++ b/bridge/src/test/fakeclient/fakecompass.java @@ -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 + } +} diff --git a/bridge/src/test/fakeclient/fakectx.java b/bridge/src/test/fakeclient/fakectx.java new file mode 100644 index 0000000..76cbfa7 --- /dev/null +++ b/bridge/src/test/fakeclient/fakectx.java @@ -0,0 +1,11 @@ +// Test stand-in for the game's HUD render context. +// +// One method takes a draw command. The bridge finds it by shape rather than by +// name, which is what makes the binding survive the next re-obfuscation. + +public class fakectx { + + public void accept(fakecmd command) { + fakebatch.submit(command); + } +} diff --git a/bridge/src/test/fakeclient/fakereg.java b/bridge/src/test/fakeclient/fakereg.java new file mode 100644 index 0000000..82157b0 --- /dev/null +++ b/bridge/src/test/fakeclient/fakereg.java @@ -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); + } +} diff --git a/bridge/src/test/fakeclient/fakescale.java b/bridge/src/test/fakeclient/fakescale.java new file mode 100644 index 0000000..593ba1f --- /dev/null +++ b/bridge/src/test/fakeclient/fakescale.java @@ -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() { + } +} diff --git a/bridge/src/test/fakeclient/fakescreen.java b/bridge/src/test/fakeclient/fakescreen.java new file mode 100644 index 0000000..c46d9dd --- /dev/null +++ b/bridge/src/test/fakeclient/fakescreen.java @@ -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; + } +} diff --git a/bridge/src/test/fakeclient/faketex.java b/bridge/src/test/fakeclient/faketex.java new file mode 100644 index 0000000..b27c143 --- /dev/null +++ b/bridge/src/test/fakeclient/faketex.java @@ -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; + } +} diff --git a/bridge/src/test/mod/HudMod.java b/bridge/src/test/mod/HudMod.java new file mode 100644 index 0000000..b10e777 --- /dev/null +++ b/bridge/src/test/mod/HudMod.java @@ -0,0 +1,45 @@ +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() { + @Override + public void run() { + frames++; + Rsx.Hud hud = Rsx.hud(); + if (hud == null || !hud.available()) { + return; + } + 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(); + } + }); + } +} diff --git a/bridge/src/test/python/run_test.py b/bridge/src/test/python/run_test.py index 137264a..ad73f6c 100644 --- a/bridge/src/test/python/run_test.py +++ b/bridge/src/test/python/run_test.py @@ -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"), ] @@ -200,6 +201,26 @@ 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", ]) + "\n" with open(os.path.join(bridge, "profiles", "latest.rsxp"), "w", encoding="utf-8") as fh: @@ -251,6 +272,19 @@ 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), + ("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), ("telemetry never fired", "[fake-snooper] posting" not in out), ] bad = 0 diff --git a/profiles/latest.json b/profiles/latest.json index d1fecbd..6f3b3fa 100644 --- a/profiles/latest.json +++ b/profiles/latest.json @@ -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" }, @@ -211,6 +228,6 @@ "jarMtime": 1792011090, "classes": 3759, "totalClasses": 7695, - "generated": "2026-10-17 02:15" + "generated": "2026-10-17 03:11" } } \ No newline at end of file diff --git a/profiles/latest.rsxp b/profiles/latest.rsxp index 7974800..e983091 100644 --- a/profiles/latest.rsxp +++ b/profiles/latest.rsxp @@ -33,7 +33,28 @@ 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 diff --git a/scripts/build.ps1 b/scripts/build.ps1 index 004c8e0..a3291d1 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -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