Merge pull request #5074 from horizon-rust/feat/anticheat-prediction-vanilla

🤖 Agent: proven. tools/anticheat_e2e.py --check prediction-vanilla: the vanilla 26.3 client behind 20-120 ms jitter never flagged in 6/6 runs (before these fixes 5/6 flagged); --check prediction (Horizon client) 3/3 clean, bot flagged and kicked; pr_ready green (1340 tests); server_knockback, dedicated_server, server_entities pass; 60 bots MSPT 4.08 off / 4.39 on. CI green (lunge_motion failed once, known flake, passes locally on this head and on rerun).
This commit is contained in:
Ludovic Marie authored and GitHub committed 2026-10-31 11:53:04 +01:00
commit c3905131bf
4 files changed
+261 -20

No files matched your search

+8 -6
View File
@@ -59,19 +59,21 @@ impl Game<'_> {
};
if let Some(restart) = record.take_restart() {
// What the client keeps through a teleport (its jump delay, a glide...) is the last
// replay's, once it has played the ticks before it.
// replay's, once it has played the ticks before it. The client's motion through the
// teleport is its own (an End gateway's), and a replay that was excused when the teleport
// came may be off in what it can't see: either way the replay finds the client again.
let mut excused = restart.kept_motion;
let mut replay = g
.replay
.take()
.and_then(|mut replay| {
replay
.finish(record, server, &restart, &mut run)
.then_some(replay)
let finished = replay.finish(record, server, &restart, &mut run);
excused |= replay.excused();
finished.then_some(replay)
})
.unwrap_or_else(|| Box::new(Replay::new(server, &restart)));
replay.restart(&restart);
// The client's motion through the teleport is its own: the replay finds it again.
if restart.kept_motion {
if excused {
replay.excuse();
}
g.replay = Some(replay);
+44 -12
View File
@@ -73,7 +73,8 @@ pub struct Played {
/// How far the closest outcome ended from where the client said.
pub offset: f64,
/// Which of the unknowns it took: 1, the ground the other way (the first tick after a start); 2,
/// the item use before the server's change. 0 is the tick as the server heard it.
/// the item use before the server's change; 4, the jump delay the other way (a jump held soon
/// after an excuse). 0 is the tick as the server heard it.
pub candidate: u8,
/// The replay and the client agree about the ground (the move's flag 1).
pub ground: bool,
@@ -98,6 +99,9 @@ pub struct Replay {
fresh: bool,
/// The server's item-use slowdown at the last tick played.
using: Option<f32>,
/// Ticks the client's jump delay is unknown: under an excuse the replay may have jumped where the
/// client couldn't (on a block it hadn't received), or the other way round.
jump_unsure: u8,
/// The last ticks played, oldest first, at most [`KEPT`].
pub ticks: VecDeque<Played>,
}
@@ -120,6 +124,7 @@ impl Replay {
applied: restart.changed,
fresh: true,
using: server.item_use_speed,
jump_unsure: 0,
ticks: VecDeque::new(),
}
}
@@ -151,7 +156,9 @@ impl Replay {
/// Plays the ticks the client ended before it accepted a teleport, which the replay hasn't yet
/// (a burst of them came with the accept): what they did to the body (its jump delay) goes on
/// through the teleport. Nothing counts. False when the record no longer keeps them.
/// through the teleport. Then applies the changes the server sent before the teleport that those
/// ticks hadn't seen: the client had them all when it accepted it. Nothing counts. False when the
/// record no longer keeps them.
pub fn finish(
&mut self,
record: &Record,
@@ -159,8 +166,21 @@ impl Replay {
restart: &Restart,
run: impl FnMut(&mut PlayerMotion, Keys, bool) -> crate::Result<()>,
) -> bool {
self.play_to(record, server, restart.ended, f64::INFINITY, run)
.is_some()
if self
.play_to(record, server, restart.ended, f64::INFINITY, run)
.is_none()
{
return false;
}
let Some(changes) = record.changes_since(self.applied) else {
return false;
};
let left = usize::try_from(restart.changed.saturating_sub(self.applied)).unwrap_or(0);
for &(_, change) in changes.take(left) {
self.apply(change, server);
self.applied += 1;
}
true
}
fn play_to(
@@ -216,7 +236,12 @@ impl Replay {
/// The ticks just caught up landed off for a reason the replay can't play yet: they count for
/// nothing, and neither do the ones after until the moves agree again (at most [`DRIFT`]).
pub fn excuse(&mut self) {
(self.agreed, self.drift) = (self.said, DRIFT);
(self.agreed, self.drift, self.jump_unsure) = (self.said, DRIFT, DRIFT);
}
/// Whether an excuse still covers the ticks.
pub fn excused(&self) -> bool {
self.drift > 0
}
/// Where the client was when its moves last agreed.
@@ -232,10 +257,10 @@ impl Replay {
Change::Velocity(v) => body.velocity = vector(v),
Change::Knockback(k) => body.velocity = body.velocity.add_vector(vector(k)),
Change::State => {
// The attributes hold the server's sprint modifier: the body sprints as the server
// thinks until the replay sets the client's own.
// ClientPacketListener.handleUpdateAttributes: the client takes the server's
// modifiers, its sprint modifier included, and goes on sprinting or not as it was
// (a sprint modifier gone while it sprints stays gone until it stops).
body.attributes = server.attributes.clone();
body.sprinting = server.sprinting;
body.status = server.status.clone();
}
// `sync` takes them every tick.
@@ -272,17 +297,21 @@ impl Replay {
// No move: the client is within 2.0E-4 of where it last said.
None => (body.position.distance(last) - UNSENT).max(0.0),
};
let jump_unsure = self.jump_unsure > 0 && keys.jump;
self.jump_unsure = self.jump_unsure.saturating_sub(1);
let unknown = |candidate: u8| {
candidate & 1 != 0 && fresh || candidate & 2 != 0 && using != server.item_use_speed
candidate & 1 != 0 && fresh
|| candidate & 2 != 0 && using != server.item_use_speed
|| candidate & 4 != 0 && jump_unsure
};
let (offset, candidate) = if !unknown(1) && !unknown(2) {
let (offset, candidate) = if ![1, 2, 4].into_iter().any(unknown) {
// Nothing to try (most ticks): the body itself plays the tick.
run(&mut self.body, keys, tick.sprinting).ok()?;
(offset(&self.body), 0)
} else {
let mut best: Option<(f64, u8, Box<PlayerMotion>)> = None;
for candidate in
(0..4u8).filter(|&c| c == 0 || (1..=2).all(|b| c & b == 0 || unknown(b)))
for candidate in (0..8u8)
.filter(|&c| c == 0 || [1, 2, 4].into_iter().all(|b| c & b == 0 || unknown(b)))
{
let mut body = self.body.clone();
if candidate & 1 != 0 {
@@ -291,6 +320,9 @@ impl Replay {
if candidate & 2 != 0 {
body.item_use_speed = using;
}
if candidate & 4 != 0 {
body.no_jump_delay = i32::from(body.no_jump_delay == 0);
}
if run(&mut body, keys, tick.sprinting).is_err() {
continue;
}
@@ -0,0 +1,67 @@
package dev.horizonlab.parity;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import net.minecraft.client.KeyMapping;
import net.minecraft.client.Minecraft;
import net.minecraft.client.Options;
/**
* The vanilla client as an honest player for the anticheat's movement prediction
* (tools/anticheat_e2e.py --check prediction-vanilla): it runs net.minecraft.client.main.Main
* unchanged and presses its key mappings from lines on stdin, as a player's keyboard would:
* "down forward", "up jump", "look 90 10" (yaw, pitch). Everything the keys do is vanilla's own
* movement code. It prints "[VanillaMover] ready" once the player is in its level with no screen
* open, and "[VanillaMover] at x y z" after each "where".
*/
public final class VanillaMover {
public static void main(String[] args) throws Exception {
Thread reader = new Thread(VanillaMover::read, "horizon-vanilla-mover");
reader.setDaemon(true);
reader.start();
net.minecraft.client.main.Main.main(args);
}
private static void read() {
try {
Minecraft mc = waitForPlayer();
System.out.println("[VanillaMover] ready");
BufferedReader in = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8));
for (String line; (line = in.readLine()) != null; ) {
String[] words = line.trim().split("\\s+");
mc.execute(() -> apply(mc, words));
}
} catch (Throwable t) {
t.printStackTrace();
}
}
private static Minecraft waitForPlayer() throws InterruptedException {
while (true) {
Thread.sleep(250);
Minecraft mc = Minecraft.getInstance();
if (mc != null && mc.player != null && mc.level != null && mc.gui.screen() == null) {
return mc;
}
}
}
private static void apply(Minecraft mc, String[] words) {
Options o = mc.options;
Map<String, KeyMapping> keys = Map.of(
"forward", o.keyUp, "back", o.keyDown, "left", o.keyLeft, "right", o.keyRight,
"jump", o.keyJump, "sneak", o.keyShift, "sprint", o.keySprint, "use", o.keyUse);
switch (words[0]) {
case "down", "up" -> keys.get(words[1]).setDown(words[0].equals("down"));
case "look" -> {
mc.player.setYRot(Float.parseFloat(words[1]));
mc.player.setXRot(Float.parseFloat(words[2]));
}
case "where" -> System.out.println("[VanillaMover] at " + mc.player.getX() + " " + mc.player.getY()
+ " " + mc.player.getZ() + " screen " + mc.gui.screen());
default -> System.out.println("[VanillaMover] unknown: " + String.join(" ", words));
}
}
}
+142 -2
View File
@@ -20,6 +20,11 @@ Checks:
soul sand, swims, walks into cows, has blocks changed around it, flies in creative, glides with an
elytra and a firework, launches with riptide and rides a boat: it is never flagged. A bot, which
moves by its own rules and sends no keys, is flagged and sent back.
- prediction-vanilla (only with --check): the same with the real vanilla 26.3 client (tools/vanilla_client.py, from the owner's
own files; on macOS from the logged-in screen session, even a locked one), its keys pressed by
VanillaMover (mods/horizon/src/test/java): it walks, sprint-jumps, strafes and sneaks, with Speed
given and cleared, over packed ice, swims, walks into cows, jumps at a block set in front of it and
glides with an elytra and a firework, through the same relay: it is never flagged.
Each server runs in a fresh folder with fresh HORIZON_HOME and XDG folders (lab.game_env), on a free port,
in a flat world (creative unless the check says otherwise: the bots fly). Build first:
@@ -359,7 +364,142 @@ def check_prediction(bins, scratch, out, seconds):
f'times and sent back {cheater["pulled_back"]} times')
CHECKS = {'off': check_off, 'timer': check_timer, 'ground': check_ground, 'prediction': check_prediction}
class VanillaClient:
"""The vanilla client, joined to `address` as `name`, its keys pressed by VanillaMover from `send`."""
def __init__(self, address, name, work, log):
import vanilla_client # noqa: E402 (only this check runs it)
classes = work / 'classes'
classes.mkdir(parents=True)
jars = os.pathsep.join(map(str, vanilla_client.classpath()))
release = str(lab.config()[0]['minecraft']['java_major'])
mover = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMover.java'
subprocess.run(['javac', '--release', release, '-proc:none', '-nowarn', '-cp', jars, '-d', classes, mover],
check=True, capture_output=True)
args = argparse.Namespace(name=name, join=address, world=None, game_dir=work / 'client', assets=None,
heap='2G', width=854, height=480, lan_port=None, backend=None)
args.game_dir.mkdir()
vanilla_client.write_options(args.game_dir, {})
command = vanilla_client.command(args)
cp = command.index('-cp') + 1
command[cp] = os.pathsep.join([str(classes), command[cp]])
command[command.index('net.minecraft.client.main.Main')] = 'dev.horizonlab.parity.VanillaMover'
self.log = open(log, 'w')
self.ready = threading.Event()
self.positions = []
self.process = subprocess.Popen(command, cwd=args.game_dir, env=vanilla_client.environment(), text=True,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
threading.Thread(target=self.pump, daemon=True).start()
def pump(self):
for line in self.process.stdout:
self.log.write(line)
if '[VanillaMover] ready' in line:
self.ready.set()
elif '[VanillaMover] at ' in line:
self.positions.append([float(v) for v in line.split('[VanillaMover] at ')[1].split()[:3]])
def __call__(self, *lines, then=0.0):
"""Send `lines` ("down forward", "look 90 0"...), then wait `then` seconds."""
for line in lines:
self.process.stdin.write(line + '\n')
self.process.stdin.flush()
time.sleep(then)
def close(self):
self.process.terminate()
try:
self.process.wait(20)
except subprocess.TimeoutExpired:
self.process.kill()
self.log.close()
def vanilla_play(keys, console):
"""What the vanilla client does: a second or a few per step, as a player would."""
at = lambda command: console(f'execute as Vanilla at Vanilla run {command}') # noqa: E731
keys('look 0 0', 'down forward', then=3)
keys('down sprint', 'down jump', then=4)
keys('up jump', 'up sprint', 'down left', 'look 45 0', then=2)
keys('up left', 'up forward', 'down sneak', 'down back', then=2)
keys('up sneak', 'up back')
console('effect give Vanilla minecraft:speed 30 2 true')
keys('down forward', 'down jump', then=4)
keys('up jump', 'up forward')
console('effect clear Vanilla')
at('fill ~-3 ~-1 ~-3 ~3 ~-1 ~12 minecraft:packed_ice')
keys('down forward', 'down sprint', 'down jump', then=3)
keys('up jump', 'up sprint', 'up forward', then=1)
at('fill ~-3 ~-2 ~-3 ~3 ~-1 ~12 minecraft:water')
keys('down forward', then=4)
keys('down jump', then=2)
keys('up jump', 'up forward')
at('fill ~-4 ~-4 ~-4 ~4 ~-1 ~13 minecraft:stone')
time.sleep(2)
at('summon minecraft:cow ^ ^ ^2')
keys('down forward', then=2)
keys('up forward')
console('kill @e[type=minecraft:cow]')
at('setblock ^ ^ ^1 minecraft:stone')
keys('down forward', 'down jump', then=2)
keys('up forward', 'up jump', then=1)
# An elytra from 60 blocks up (Resistance V against the landing): a jump to open it, a firework.
console('effect give Vanilla minecraft:resistance 60 4 true')
console('item replace entity Vanilla armor.chest with minecraft:elytra')
console('item replace entity Vanilla weapon.mainhand with minecraft:firework_rocket')
at('tp @s ~ ~60 ~ 0 0')
time.sleep(1)
keys('down jump', then=0.2)
keys('up jump', then=0.5)
keys('down jump', then=0.2)
keys('up jump', then=0.5)
keys('down use', then=0.2)
keys('up use', then=4)
at('tp @s ~ -60 ~')
time.sleep(3)
def check_prediction_vanilla(bins, scratch, out, seconds):
folder = scratch / 'prediction-vanilla'
toml = '[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n[anticheat.ground]\nenabled = false\n'
server, port = start(bins, folder, toml, out, 'prediction-vanilla', gamemode='survival', difficulty='peaceful')
relay = Jitter(port)
client = None
try:
try:
client = VanillaClient(f'127.0.0.1:{relay.port}', 'Vanilla', folder / 'vanilla',
out / 'prediction-vanilla-client.log')
if not client.ready.wait(300):
raise Failed('prediction-vanilla: the vanilla client never got into the world')
time.sleep(5)
client('where')
vanilla_play(client, server.console)
client('where', then=1)
finally:
if client:
client.close()
relay.close()
finally:
server.stop()
log = folder / 'logs/anticheat.log'
if log.exists():
shutil.copy(log, out / 'prediction-vanilla-anticheat.log')
if 'Vanilla' in log.read_text():
raise Failed(f'prediction-vanilla: the vanilla client was flagged:\n{log.read_text()}')
if len(client.positions) < 2:
raise Failed('prediction-vanilla: the vanilla client did not say where it was')
(x0, _, z0), (x1, _, z1) = client.positions[0], client.positions[-1]
walked = ((x1 - x0) ** 2 + (z1 - z0) ** 2) ** 0.5
if walked < 20:
raise Failed(f'prediction-vanilla: the vanilla client only got {walked:.1f} blocks away: its keys did nothing')
print(f'prediction-vanilla: the vanilla client (20-120 ms jitter) was never flagged; it ended {walked:.0f} blocks '
'from where it joined')
CHECKS = {'off': check_off, 'timer': check_timer, 'ground': check_ground, 'prediction': check_prediction,
'prediction-vanilla': check_prediction_vanilla}
# Run when no --check is given: the vanilla client needs a screen session, so it runs only when asked.
DEFAULT = ['ground', 'off', 'prediction', 'timer']
def main():
@@ -376,7 +516,7 @@ def main():
os.environ.update(lab.game_env())
failed = []
with tempfile.TemporaryDirectory(prefix='anticheat-e2e-') as scratch:
for name in args.check or sorted(CHECKS):
for name in args.check or DEFAULT:
try:
CHECKS[name](bins, Path(scratch), out, args.seconds)
except (Failed, RuntimeError) as e: