diff --git a/bridge/src/main/java/net/rustexremake/api/Rsx.java b/bridge/src/main/java/net/rustexremake/api/Rsx.java index 5b7b573..3b36090 100644 --- a/bridge/src/main/java/net/rustexremake/api/Rsx.java +++ b/bridge/src/main/java/net/rustexremake/api/Rsx.java @@ -296,6 +296,34 @@ 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 /** diff --git a/bridge/src/main/java/rsx/EchoPacket.java b/bridge/src/main/java/rsx/EchoPacket.java new file mode 100644 index 0000000..9babc9c --- /dev/null +++ b/bridge/src/main/java/rsx/EchoPacket.java @@ -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; + } +} diff --git a/bridge/src/main/java/rsx/HookTransformer.java b/bridge/src/main/java/rsx/HookTransformer.java index 64875e3..540c694 100644 --- a/bridge/src/main/java/rsx/HookTransformer.java +++ b/bridge/src/main/java/rsx/HookTransformer.java @@ -9,60 +9,175 @@ 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 a draw call to the game's own HUD element, so mods can draw without - * touching OpenGL themselves. + * Appends calls of our own to two places in the running game. * - * 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. + * 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. * - * 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. + * 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 targetClass; - private final String targetMethod; - private final String targetDesc; + private final String hudClass; + private final String hudMethod; + private final String hudDesc; + private final String pingClass; + private final String pingMethod; - private boolean applied; - private boolean sawTarget; + private boolean hudApplied; + private boolean pingApplied; + private boolean sawHud; - public HookTransformer(String targetClass, String targetMethod, - String targetDesc) { - this.targetClass = targetClass; - this.targetMethod = targetMethod; - this.targetDesc = targetDesc; + 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 applied() { - return applied; + public boolean hudApplied() { + return hudApplied; } - /** True when the class was seen but the method inside it was not found. */ + public boolean pingApplied() { + return pingApplied; + } + + /** The class was seen but the method inside it was not. */ public boolean incomplete() { - return sawTarget && !applied; + 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 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. + * 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 org.objectweb.asm.tree.InsnNode) { - int op = ((org.objectweb.asm.tree.InsnNode) last).getOpcode(); + if (last instanceof InsnNode) { + int op = ((InsnNode) last).getOpcode(); if (op >= Opcodes.IRETURN && op <= Opcodes.RETURN) { m.instructions.insertBefore(last, call); return; @@ -70,52 +185,4 @@ public final class HookTransformer implements ClassFileTransformer { } 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/HudHook.java b/bridge/src/main/java/rsx/HudHook.java index 85002c9..874347a 100644 --- a/bridge/src/main/java/rsx/HudHook.java +++ b/bridge/src/main/java/rsx/HudHook.java @@ -97,6 +97,7 @@ public final class HudHook { gateTried = true; Profile p = Resolver.profile(); if (p == null) { + Log.warn("HUD: no profile to read the game HUD flag from"); return true; } try { @@ -104,10 +105,14 @@ public final class HudHook { 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 = diff --git a/bridge/src/main/java/rsx/Ping.java b/bridge/src/main/java/rsx/Ping.java new file mode 100644 index 0000000..2896c06 --- /dev/null +++ b/bridge/src/main/java/rsx/Ping.java @@ -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()) + "%"; + } +} diff --git a/bridge/src/main/java/rsx/Resolver.java b/bridge/src/main/java/rsx/Resolver.java index 4b5032f..910e09f 100644 --- a/bridge/src/main/java/rsx/Resolver.java +++ b/bridge/src/main/java/rsx/Resolver.java @@ -47,10 +47,12 @@ public final class Resolver { */ private static Map> 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> next = new HashMap>(); diff --git a/bridge/src/main/java/rsx/RsxAgent.java b/bridge/src/main/java/rsx/RsxAgent.java index 8a51b04..49f52b9 100644 --- a/bridge/src/main/java/rsx/RsxAgent.java +++ b/bridge/src/main/java/rsx/RsxAgent.java @@ -508,11 +508,18 @@ public final class RsxAgent { return; } try { - HookTransformer t = new HookTransformer(p.hudHookClass, - p.hudHookMethod, p.hudHookDesc); + 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 " + p.hudHookClass + "." - + p.hudHookMethod); + Log.info("HUD hook armed on " + p2.hudHookClass + "." + + p2.hudHookMethod); } catch (Throwable e) { Log.error("cannot arm the HUD hook", e); } diff --git a/bridge/src/test/fakeclient/FakeMain.java b/bridge/src/test/fakeclient/FakeMain.java index f0f368b..7a93efd 100644 --- a/bridge/src/test/fakeclient/FakeMain.java +++ b/bridge/src/test/fakeclient/FakeMain.java @@ -33,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++) { @@ -76,6 +89,8 @@ public class FakeMain { 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/cxaddrz.java b/bridge/src/test/fakeclient/cxaddrz.java new file mode 100644 index 0000000..7f7b1dd --- /dev/null +++ b/bridge/src/test/fakeclient/cxaddrz.java @@ -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 + } +} \ No newline at end of file diff --git a/bridge/src/test/mod/HudMod.java b/bridge/src/test/mod/HudMod.java index b10e777..da0672b 100644 --- a/bridge/src/test/mod/HudMod.java +++ b/bridge/src/test/mod/HudMod.java @@ -41,5 +41,16 @@ public class HudMod implements RsxMod { 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()); + } + } + }); } } diff --git a/bridge/src/test/python/run_test.py b/bridge/src/test/python/run_test.py index ad73f6c..dd7c01f 100644 --- a/bridge/src/test/python/run_test.py +++ b/bridge/src/test/python/run_test.py @@ -221,6 +221,10 @@ def main(): "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: @@ -233,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"] @@ -285,6 +291,9 @@ def main(): ("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 diff --git a/profiles/latest.json b/profiles/latest.json index 6f3b3fa..04ec1fe 100644 --- a/profiles/latest.json +++ b/profiles/latest.json @@ -219,6 +219,7 @@ }, "missing": [ "net.minecraft.client.Minecraft#snooper", + "net.minecraft.client.network.NetHandlerPlayClient#handleCustomPayload", "net.minecraft.network.NetworkManager" ], "build": { @@ -228,6 +229,6 @@ "jarMtime": 1792011090, "classes": 3759, "totalClasses": 7695, - "generated": "2026-10-17 03:11" + "generated": "2026-10-17 03:30" } } \ No newline at end of file diff --git a/profiles/latest.rsxp b/profiles/latest.rsxp index e983091..247900b 100644 --- a/profiles/latest.rsxp +++ b/profiles/latest.rsxp @@ -58,3 +58,6 @@ 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 (Ljava/lang/String;Ldcuglw;)V +class rsx.net.echoBuffer dcuglw