mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Anticheat prediction: a client still slowed by an item use the server ended isn't flagged
For 10 ticks after the server's item use ends, the replay also tries the client still slowed by it: the client finishes a use on its own ticks and the server on its own (food eaten one after another with the key held), and the server stops one for its own reasons. Slower than the keys allow is no advantage; a use the server has and the client may not still counts at once. tools/anticheat_e2e.py: prediction-items (a shield, food and a bow used on the move) and prediction-water (a deep pool, a current, a bubble column, a ledge) with a real Horizon client. Fixes #5860. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
35f5e95f5d
commit
30dfdc3e1c
2 files changed
+169
-12
No files matched your search
@@ -39,6 +39,14 @@ pub const DRIFT: u8 = 40;
|
||||
/// The most a single tick weighs, in tolerances.
|
||||
const MOST_WEIGHT: f64 = 5.0;
|
||||
|
||||
/// Ticks after the server's item use ends during which the client may still be slowed by it: half a
|
||||
/// second. The client finishes a use on its own ticks and the server on its own (food eaten one after
|
||||
/// another with the key held), and the server stops one for its own reasons (a shield disabled):
|
||||
/// the client's ends a few ticks later, more with an uneven ping. Slower than its keys allow is no
|
||||
/// advantage; the other way round (a use the server has and the client may not) would let a no-slow
|
||||
/// walk, so a start counts at once, as the client's use packet comes before its tick's move.
|
||||
const USE_WINDOW: u8 = 10;
|
||||
|
||||
/// The ticks of a catch-up that landed farther than the tolerance, none of them excused.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Over {
|
||||
@@ -181,6 +189,10 @@ pub struct Replay {
|
||||
fresh: bool,
|
||||
/// The server's item-use slowdown at the last tick played.
|
||||
using: Option<f32>,
|
||||
/// The one it had before its last change, and the ticks left in which the client may still be
|
||||
/// slowed by it after it ended ([`USE_WINDOW`]).
|
||||
used: Option<f32>,
|
||||
use_window: u8,
|
||||
/// 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,
|
||||
@@ -215,6 +227,8 @@ impl Replay {
|
||||
applied: restart.changed,
|
||||
fresh: true,
|
||||
using: server.item_use_speed,
|
||||
used: server.item_use_speed,
|
||||
use_window: 0,
|
||||
jump_unsure: 0,
|
||||
steady: STEADY,
|
||||
missed: Vec3::ZERO,
|
||||
@@ -430,6 +444,14 @@ impl Replay {
|
||||
self.body.ground.on_ground = tick.flags.is_some_and(|f| f & 1 != 0);
|
||||
}
|
||||
let using = std::mem::replace(&mut self.using, server.item_use_speed);
|
||||
if using != server.item_use_speed {
|
||||
(self.used, self.use_window) = (using, USE_WINDOW);
|
||||
} else {
|
||||
self.use_window = self.use_window.saturating_sub(1);
|
||||
}
|
||||
let used = self.used;
|
||||
let use_unsure = using != server.item_use_speed
|
||||
|| self.use_window > 0 && server.item_use_speed.is_none() && used.is_some();
|
||||
let said = tick.position.map(|p| Vec3::new(p[0], p[1], p[2]));
|
||||
let keys = keys(tick.input);
|
||||
let last = self.said;
|
||||
@@ -443,7 +465,7 @@ impl Replay {
|
||||
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 & 2 != 0 && use_unsure
|
||||
|| candidate & 4 != 0 && jump_unsure
|
||||
|| candidate & 8 != 0 && meeting.is_some()
|
||||
};
|
||||
@@ -461,7 +483,7 @@ impl Replay {
|
||||
body.ground.on_ground = !body.ground.on_ground;
|
||||
}
|
||||
if candidate & 2 != 0 {
|
||||
body.item_use_speed = using;
|
||||
body.item_use_speed = used;
|
||||
}
|
||||
if candidate & 4 != 0 {
|
||||
body.no_jump_delay = i32::from(body.no_jump_delay == 0);
|
||||
@@ -677,6 +699,8 @@ mod tests {
|
||||
/// The first ticks the client is on its loading screen: it ends them without moving or
|
||||
/// sending a move.
|
||||
frozen: usize,
|
||||
/// The ticks the client uses an item (slowed to 0.2), and those the server thinks it does.
|
||||
item_use: Option<(std::ops::Range<usize>, std::ops::Range<usize>)>,
|
||||
}
|
||||
|
||||
impl Run {
|
||||
@@ -692,6 +716,7 @@ mod tests {
|
||||
blocks: Vec::new(),
|
||||
attributes: None,
|
||||
frozen: 0,
|
||||
item_use: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -771,6 +796,7 @@ mod tests {
|
||||
let run_burst = run.burst;
|
||||
let run_attributes = run.attributes;
|
||||
let run_frozen = run.frozen;
|
||||
let run_item_use = run.item_use;
|
||||
let mut back = start;
|
||||
for (i, (input, yaw)) in run.script.into_iter().enumerate() {
|
||||
let tick = i as u64;
|
||||
@@ -795,6 +821,11 @@ mod tests {
|
||||
back = before;
|
||||
}
|
||||
client.yaw = yaw;
|
||||
if let Some((ref on_client, ref on_server)) = run_item_use {
|
||||
let speed = |r: &std::ops::Range<usize>| r.contains(&i).then_some(0.2);
|
||||
(client.item_use_speed, server.item_use_speed) =
|
||||
(speed(on_client), speed(on_server));
|
||||
}
|
||||
if i >= run_frozen {
|
||||
world(&mut client, &mut |w| {
|
||||
w.controlled_simulation_tick(keys(input)).unwrap()
|
||||
@@ -859,6 +890,50 @@ mod tests {
|
||||
assert!(after.iter().all(|s| s.over.ticks == 0 || s.over.moved));
|
||||
}
|
||||
|
||||
/// Walking forward the whole script.
|
||||
fn forward() -> Vec<(u8, f32)> {
|
||||
vec![(1, 0.0); 70]
|
||||
}
|
||||
|
||||
/// Food eaten one after another with the key held, walking (#5860): the client finishes each a
|
||||
/// few ticks after the server does. Nothing counts.
|
||||
#[test]
|
||||
fn item_uses_a_few_ticks_apart_from_the_servers_are_played() {
|
||||
let run = Run {
|
||||
script: forward(),
|
||||
item_use: Some((5..40, 5..37)),
|
||||
..Run::walk()
|
||||
};
|
||||
let Some(steps) = steps_in(run, |_, _, honest| honest, None) else {
|
||||
return;
|
||||
};
|
||||
assert!(steps.iter().all(|s| s.over.ticks == 0));
|
||||
let run = Run {
|
||||
script: forward(),
|
||||
item_use: Some((10..42, 10..33)),
|
||||
..Run::walk()
|
||||
};
|
||||
let steps = steps_in(run, |_, _, honest| honest, None).unwrap();
|
||||
assert!(steps.iter().all(|s| s.over.ticks == 0));
|
||||
}
|
||||
|
||||
/// A client that keeps walking at full speed through a use the server started (a no-slow) is
|
||||
/// caught once the window has passed.
|
||||
#[test]
|
||||
fn a_use_without_its_slowdown_is_caught() {
|
||||
let run = Run {
|
||||
script: forward(),
|
||||
item_use: Some((0..0, 5..65)),
|
||||
..Run::walk()
|
||||
};
|
||||
let Some(steps) = steps_in(run, |_, _, honest| honest, None) else {
|
||||
return;
|
||||
};
|
||||
let counted =
|
||||
|r: std::ops::Range<usize>| steps[r].iter().filter(|s| s.over.ticks > 0).count();
|
||||
assert!(counted(17..65) >= 40, "{}", counted(17..65));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_played_tick_tells_its_offset_and_state() {
|
||||
let played = Played {
|
||||
|
||||
+92
-10
@@ -349,13 +349,82 @@ def honest_play(game, console):
|
||||
game.wait(40)
|
||||
|
||||
|
||||
def check_prediction(bins, scratch, out, seconds):
|
||||
folder = scratch / 'prediction'
|
||||
def water_play(game, console):
|
||||
"""In water as players are on the official server: sprint-swimming down and up a deep pool, held at
|
||||
the surface by jump, sinking, sneaking down, climbing out onto a ledge; standing in, against and with
|
||||
a current; carried up a bubble column."""
|
||||
def tp(x, y, z, yaw=0, pitch=0):
|
||||
console(f'tp Walker {x} {y} {z} {yaw} {pitch}')
|
||||
game.wait(20)
|
||||
# Above the flat ground (its top is at -61): a pool 10 deep, with a ledge one block above the water at its far end.
|
||||
console('fill 40 -60 0 50 -50 31 minecraft:stone')
|
||||
console('fill 41 -59 1 49 -50 29 minecraft:water')
|
||||
console('fill 41 -49 30 49 -49 31 minecraft:stone')
|
||||
# A current: a source at one end of a channel one block deep.
|
||||
console('fill 59 -50 -1 63 -49 13 minecraft:stone')
|
||||
console('fill 61 -49 0 61 -49 12 minecraft:air')
|
||||
console('setblock 61 -49 0 minecraft:water')
|
||||
# A bubble column over soul sand.
|
||||
console('fill 70 -60 0 72 -50 2 minecraft:stone')
|
||||
console('fill 71 -59 1 71 -50 1 minecraft:water')
|
||||
console('setblock 71 -60 1 minecraft:soul_sand')
|
||||
game.wait(40)
|
||||
tp(45.5, -51, 3.5)
|
||||
game.raw('look', 0, 40)
|
||||
game.raw('move', 'forward', 2, '--sprint')
|
||||
game.raw('look', 0, -40)
|
||||
game.raw('move', 'forward', 2, '--sprint')
|
||||
game.raw('look', 0, 0)
|
||||
game.script(['key space down', 'wait 60', 'key space up', 'wait 40', 'key shift down', 'wait 30',
|
||||
'key shift up', 'wait 20'])
|
||||
tp(45.5, -50.5, 25.5)
|
||||
game.raw('move', 'forward', 3, '--jump')
|
||||
game.raw('move', 'forward', 1)
|
||||
tp(61.5, -49, 6.5)
|
||||
game.wait(60)
|
||||
game.raw('look', 180, 0)
|
||||
game.raw('move', 'forward', 2)
|
||||
game.raw('look', 0, 0)
|
||||
game.raw('move', 'forward', 2, '--sprint')
|
||||
tp(71.5, -58, 1.5)
|
||||
game.wait(80)
|
||||
tp(45.5, -48, 33.5)
|
||||
game.wait(20)
|
||||
|
||||
|
||||
def items_play(game, console):
|
||||
"""Items used on the move, as on the official server: a shield raised from the off hand while
|
||||
walking, trying to sprint, switching hotbar slots and swinging at a cow; raised and lowered in
|
||||
quick taps; a golden apple eaten and a bow drawn while walking."""
|
||||
for slot, item in (('weapon.offhand', 'shield'), ('hotbar.0', 'diamond_sword'),
|
||||
('hotbar.1', 'golden_apple 8'), ('hotbar.2', 'bow'), ('hotbar.3', 'dirt 32'),
|
||||
('hotbar.4', 'arrow 32')):
|
||||
console(f'item replace entity Walker {slot} with minecraft:{item}')
|
||||
game.wait(20)
|
||||
game.script(['key 1 tap', 'mouse right down', 'key w down', 'wait 40', 'key ctrl down', 'wait 20',
|
||||
'key ctrl up', 'key 4 tap', 'wait 20', 'key 1 tap', 'wait 20', 'key a down', 'wait 20',
|
||||
'key a up', 'key space down', 'wait 20', 'key space up', 'key w up', 'mouse right up',
|
||||
'wait 20'])
|
||||
console('execute as Walker at Walker run summon minecraft:cow ^ ^ ^2')
|
||||
game.wait(10)
|
||||
game.script(['mouse right down', 'wait 10', 'mouse left click', 'wait 10', 'mouse left click',
|
||||
'wait 10', 'mouse right up', 'wait 10'])
|
||||
console('kill @e[type=minecraft:cow]')
|
||||
game.script(['key w down'] + ['mouse right down', 'wait 3', 'mouse right up', 'wait 3'] * 6
|
||||
+ ['key w up', 'wait 10'])
|
||||
game.script(['wait 20'])
|
||||
game.script(['key 2 tap', 'wait 10', 'key w down', 'mouse right down', 'wait 45', 'mouse right up',
|
||||
'wait 10', 'key 3 tap', 'wait 10', 'mouse right down', 'wait 30', 'mouse right up',
|
||||
'wait 10', 'key w up', 'wait 20'])
|
||||
|
||||
|
||||
def check_prediction(bins, scratch, out, seconds, name='prediction', play=None):
|
||||
folder = scratch / name
|
||||
# The other checks stay out of it: this one is about the prediction alone, with the thresholds it gets
|
||||
# once trusted (it logs only by default).
|
||||
toml = ('[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n[anticheat.ground]\nenabled = false\n'
|
||||
'[anticheat.prediction]\nsetback = 5\nalert = 15\nkick = 60\n')
|
||||
server, port = start(bins, folder, toml, out, 'prediction', gamemode='survival', difficulty='peaceful')
|
||||
server, port = start(bins, folder, toml, out, name, gamemode='survival', difficulty='peaceful')
|
||||
from lan_e2e import Game # noqa: E402 (only this check drives a client)
|
||||
game = Game('client', bins / f'horizon-ctl{EXE}', lab.RESOURCES.resolve())
|
||||
for key in ('XDG_DATA_HOME', 'XDG_CONFIG_HOME', 'HORIZON_LAUNCHER_HOME'):
|
||||
@@ -368,18 +437,21 @@ def check_prediction(bins, scratch, out, seconds):
|
||||
game.raw('connect', f'127.0.0.1:{relay.port}', 'Walker')
|
||||
game.raw('wait-for', 'loaded', '--timeout', 300)
|
||||
game.wait(60)
|
||||
honest_play(game, server.console)
|
||||
(play or honest_play)(game, server.console)
|
||||
except Exception as e:
|
||||
raise Failed(f'prediction: the honest client could not play: {e}') from e
|
||||
raise Failed(f'{name}: the honest client could not play: {e}') from e
|
||||
finally:
|
||||
(out / 'prediction-client.log').write_text(game.close())
|
||||
(out / f'{name}-client.log').write_text(game.close())
|
||||
relay.close()
|
||||
shutil.rmtree(game.work, ignore_errors=True)
|
||||
log = folder / 'logs/anticheat.log'
|
||||
if log.exists():
|
||||
shutil.copy(log, out / 'prediction-anticheat.log')
|
||||
shutil.copy(log, out / f'{name}-anticheat.log')
|
||||
if log.exists() and 'Walker' in log.read_text():
|
||||
raise Failed(f'prediction: the honest client was flagged:\n{log.read_text()}')
|
||||
raise Failed(f'{name}: the honest client was flagged:\n{log.read_text()}')
|
||||
if play:
|
||||
print(f'{name}: the honest client (20-120 ms jitter) was never flagged')
|
||||
return
|
||||
cheat = bots(bins, port, out, 'Cheat', 1, 'walk', seconds)
|
||||
finally:
|
||||
server.stop()
|
||||
@@ -610,10 +682,20 @@ def check_combat(bins, scratch, out, seconds):
|
||||
f'flagged; health left: {health}')
|
||||
|
||||
|
||||
def check_prediction_water(bins, scratch, out, seconds):
|
||||
check_prediction(bins, scratch, out, seconds, 'prediction-water', water_play)
|
||||
|
||||
|
||||
def check_prediction_items(bins, scratch, out, seconds):
|
||||
check_prediction(bins, scratch, out, seconds, 'prediction-items', items_play)
|
||||
|
||||
|
||||
CHECKS = {'off': check_off, 'timer': check_timer, 'ground': check_ground, 'prediction': check_prediction,
|
||||
'prediction-vanilla': check_prediction_vanilla, 'combat': check_combat}
|
||||
'prediction-water': check_prediction_water, 'prediction-items': check_prediction_items,
|
||||
'prediction-vanilla': check_prediction_vanilla,
|
||||
'combat': check_combat}
|
||||
# Run when no --check is given: the vanilla client needs a screen session, so it runs only when asked.
|
||||
DEFAULT = ['combat', 'ground', 'off', 'prediction', 'timer']
|
||||
DEFAULT = ['combat', 'ground', 'off', 'prediction', 'prediction-items', 'prediction-water', 'timer']
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
Reference in new issue
Block a user