mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
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:
4 files changed
+261
-20
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user