Пинг и потери: считаем сами, потому что считать нечем
Вендор заменил сетевой слой на свой Netty-обработчик, поля задержки в нём нет, а сам класс в маппингах не восстановлен. Ванильный getLag() не достать, и игра пинг нигде не показывает. Остаётся то, что есть всегда: сервер шлёт keep-alive раз в 15 секунд по своему собственному расписанию. Отклонение интервала прихода - это и есть сеть, и скользящее среднее по нему даёт оценку на любом сервере. Грубые интервалы (потерянный пакет удваивает интервал) в среднее не попадают. Число помечается как оценка: Rsx.pingExact() false, в строке "ping~". Эхо со своим nonce в имени канала осталось в коде, но в этой сборке обработчик кастомного payload у клиента не найден, поэтому профиль его не объявляет и эхо просто недоступно - точность пока такая, какая есть. Период keep-alive читается из -Drsx.keepalivePeriod: прокси иногда шлют иначе, и стенду нужен короткий, чтобы проверить математику за секунды. Заодно: Resolver игнорировал "принудительный" скан и отдавал устаревший индекс замет позапрошенных классов. HUD-гейт читался в первый кадр и не находил экран. Стенд кормитkeep-alive с интервалом 240 мс при периоде 200 и ждёт оценку около 50 мс - ровно то отклонение, которое он и создал.
This commit is contained in:
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()) + "%";
|
||||
}
|
||||
}
|
||||
@@ -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<?>>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 <init> (Ljava/lang/String;Ldcuglw;)V
|
||||
class rsx.net.echoBuffer dcuglw
|
||||
|
||||
Reference in New Issue
Block a user