mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Anticheat on by default: a combat check that acts, the other checks log only
Ludovic, 10-31: turn the anticheat on. Checks a vanilla client can't trip act by default; the others run but only log until real players show no false positive. `[anticheat] enabled = false`, or `enabled = false` in a check's table, turns them off. New `combat` check, acting by default: vanilla's client attacks only from Minecraft.startAttack, once a client tick, on the entity of the tick's hitResult, and swings (ServerboundPunchPacket) with each attack. A client tick with more attacks than swings, or a second entity attacked in the same tick, flags; from `setback` (2) on the attacks are dropped, `kick` 20. The swing counts before or after the attack (older clients behind a version proxy swing first). `timer`, `ground` and `prediction` default to setback, alert and kick 0 (log only); a log-only ground check keeps the claim. The thresholds once trusted are in docs/anticheat.md. horizon-bots swing after each attack as vanilla does; --no-swing and --attack A,B are the cheats for tools/anticheat_e2e.py --check combat, which runs with no server.toml (the defaults). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
125875b844
commit
fdfa4fbfab
12 files changed
+410
-78
No files matched your search
@@ -0,0 +1,99 @@
|
||||
//! Combat: attacks vanilla's client can't send (docs/anticheat.md).
|
||||
//!
|
||||
//! Vanilla's client sends ServerboundAttackPacket from one place only, `Minecraft.startAttack`, run from
|
||||
//! `handleKeybinds` once per client tick. It attacks the entity of the tick's `hitResult`, which
|
||||
//! `Minecraft.tick` picks once before the keys are handled, so every attack of one client tick is on the
|
||||
//! same entity. And each attack swings the arm (ServerboundPunchPacket) in the same call, so a client
|
||||
//! tick never has more attacks than swings: a tick that does comes from a kill aura hiding its swing.
|
||||
//! The swing is counted before or after the attack, as older clients behind a version proxy swing
|
||||
//! first; a swing without an attack (a miss, a block) is fine.
|
||||
|
||||
/// What breaks vanilla's way of attacking.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum Foul {
|
||||
/// A client tick with more attacks than swings: how many more.
|
||||
NoSwing(u32),
|
||||
/// A second entity attacked in the same client tick: the first one and this one.
|
||||
TwoTargets(i32, i32),
|
||||
}
|
||||
|
||||
/// The attacks of the client tick being heard.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq)]
|
||||
pub struct Attacks {
|
||||
/// The entity this tick attacked first.
|
||||
target: Option<i32>,
|
||||
attacks: u32,
|
||||
swings: u32,
|
||||
}
|
||||
|
||||
impl Attacks {
|
||||
/// ServerboundAttackPacket on entity `target`.
|
||||
pub fn attack(&mut self, target: i32) -> Option<Foul> {
|
||||
self.attacks = self.attacks.saturating_add(1);
|
||||
let first = *self.target.get_or_insert(target);
|
||||
(first != target).then_some(Foul::TwoTargets(first, target))
|
||||
}
|
||||
|
||||
/// ServerboundPunchPacket: the arm swung.
|
||||
pub fn swing(&mut self) {
|
||||
self.swings = self.swings.saturating_add(1);
|
||||
}
|
||||
|
||||
/// ServerboundClientTickEndPacket: the tick's attacks are counted, and the next tick picks its
|
||||
/// target again.
|
||||
pub fn tick_end(&mut self) -> Option<Foul> {
|
||||
let unswung = self.attacks.saturating_sub(self.swings);
|
||||
*self = Self::default();
|
||||
(unswung > 0).then_some(Foul::NoSwing(unswung))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_vanilla_tick_is_clean() {
|
||||
let mut a = Attacks::default();
|
||||
// Two clicks in one tick on the same entity, each swung for.
|
||||
for _ in 0..2 {
|
||||
assert_eq!(a.attack(7), None);
|
||||
a.swing();
|
||||
}
|
||||
assert_eq!(a.tick_end(), None);
|
||||
// The next tick may attack another entity, swinging first (an older client).
|
||||
a.swing();
|
||||
assert_eq!(a.attack(8), None);
|
||||
assert_eq!(a.tick_end(), None);
|
||||
// A swing on its own (a miss, a block) is fine.
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_attack_not_swung_for_is_caught() {
|
||||
let mut a = Attacks::default();
|
||||
a.attack(7);
|
||||
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
|
||||
// A swing in the tick before doesn't count for this one.
|
||||
a.swing();
|
||||
a.tick_end();
|
||||
a.attack(7);
|
||||
a.attack(7);
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), Some(Foul::NoSwing(1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_entities_in_one_tick_are_caught() {
|
||||
let mut a = Attacks::default();
|
||||
a.attack(7);
|
||||
a.swing();
|
||||
assert_eq!(a.attack(8), Some(Foul::TwoTargets(7, 8)));
|
||||
a.swing();
|
||||
// Still against the tick's first target.
|
||||
assert_eq!(a.attack(9), Some(Foul::TwoTargets(7, 9)));
|
||||
a.swing();
|
||||
assert_eq!(a.tick_end(), None);
|
||||
}
|
||||
}
|
||||
@@ -2,17 +2,21 @@
|
||||
//! `server.toml`, each player's violation levels, and the rules of the checks. The server's hub
|
||||
//! (`horizon_client::lan`) feeds it what players send and carries out what it decides.
|
||||
//!
|
||||
//! It is off unless the owner turns it on. A check that fails *flags* the player: its violation level
|
||||
//! (VL) rises by the flag's weight and falls back by `decay` a second. From `setback` on, the move or
|
||||
//! action is cancelled (the player is sent back where the server last had them); from `alert` on,
|
||||
//! online operators and the console are told; from `kick` on, the player is disconnected. Nothing bans.
|
||||
//! Any threshold at 0 turns that action off.
|
||||
//! It is on by default, and so is every check, but only the checks a vanilla client can't trip act on
|
||||
//! their own (`combat`); the others log only until they have shown no false positive with real
|
||||
//! players (their `setback`, `alert` and `kick` are 0). `enabled = false` turns the anticheat, or one
|
||||
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
|
||||
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
|
||||
//! player is sent back where the server last had them); from `alert` on, online operators and the
|
||||
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
|
||||
//! that action off.
|
||||
//!
|
||||
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
|
||||
//! do, never from another anticheat's code.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
pub mod prediction;
|
||||
pub mod timer;
|
||||
|
||||
@@ -72,7 +76,7 @@ macro_rules! checks {
|
||||
}
|
||||
)*
|
||||
|
||||
/// `server.toml`'s `[anticheat]`: off by default.
|
||||
/// `server.toml`'s `[anticheat]`: on by default.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct Config {
|
||||
@@ -89,7 +93,7 @@ macro_rules! checks {
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
enabled: true,
|
||||
alerts: true,
|
||||
alert_level: 2,
|
||||
log: "logs/anticheat.log".into(),
|
||||
@@ -120,7 +124,8 @@ checks! {
|
||||
/// Timer: more client ticks than the server's clock allows (a sped-up game). Vanilla's client ends
|
||||
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
|
||||
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
|
||||
timer: Timer => TimerConfig [2.0, 5.0, 20.0, 0.02] {
|
||||
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
|
||||
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
|
||||
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
|
||||
tolerance_ms: f64 = 300.0,
|
||||
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
|
||||
@@ -129,9 +134,10 @@ checks! {
|
||||
}
|
||||
|
||||
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
|
||||
/// the ground to skip fall damage) and walking on water. The claim is always dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back.
|
||||
ground: Ground => GroundConfig [5.0, 15.0, 60.0, 2.0] {}
|
||||
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
|
||||
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
|
||||
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
|
||||
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
|
||||
|
||||
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
|
||||
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
|
||||
@@ -141,11 +147,18 @@ checks! {
|
||||
/// setback puts the player back where its moves last agreed. A tick near what the replay can't
|
||||
/// play yet (another entity, a block change or abilities the client hadn't seen, a moving piston,
|
||||
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
|
||||
/// until the moves agree again.
|
||||
prediction: Prediction => PredictionConfig [5.0, 15.0, 60.0, 2.0] {
|
||||
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
|
||||
/// `kick = 60`.
|
||||
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
|
||||
/// How far, in blocks, a move may land from where the replay puts it.
|
||||
tolerance: f64 = 0.03,
|
||||
}
|
||||
|
||||
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
|
||||
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
|
||||
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
|
||||
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
|
||||
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
|
||||
}
|
||||
|
||||
/// How many checks there are.
|
||||
@@ -219,6 +232,8 @@ pub struct Player {
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
impl Player {
|
||||
@@ -262,12 +277,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_by_default_and_without_a_section() {
|
||||
assert!(!Config::default().enabled);
|
||||
fn on_by_default_but_only_combat_acts() {
|
||||
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
|
||||
assert_eq!(c, Config::default());
|
||||
assert!(!c.runs(Check::Timer));
|
||||
assert!(!c.runs(Check::Ground));
|
||||
for &check in Check::ALL {
|
||||
assert!(c.runs(check), "{}", check.name());
|
||||
let a = c.actions(check);
|
||||
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
|
||||
assert_eq!(acts, check == Check::Combat, "{}", check.name());
|
||||
}
|
||||
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
|
||||
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -63,9 +63,12 @@ horizon-bots: load-test bots for a Minecraft Java Edition 26.3 server (offline m
|
||||
into a writable book), flood-sign (four 384-character lines on the block
|
||||
below), flood-recipe (recipe book placements)
|
||||
--flood N packets a tick for the flood behaviours; default 100
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test);
|
||||
--attack NAME attack the player NAME twice a second, from wherever the bot is (a reach test),
|
||||
swinging after each attack as vanilla's client does. NAME,NAME...: each of them
|
||||
in the same tick (a multi-target kill aura, for the anticheat's tests);
|
||||
the report gives each bot's last health
|
||||
--attack-after S start attacking S seconds after joining; default 5
|
||||
--no-swing attack without swinging (a kill aura hiding its swing, for the anticheat's tests)
|
||||
--no-loaded never say the level is loaded (ServerboundPlayerLoadedPacket): a vanilla server
|
||||
ignores the player's moves and actions for its first 3 seconds
|
||||
--no-read stop reading from the server once spawned (a client that lets the
|
||||
@@ -142,6 +145,7 @@ struct Options {
|
||||
/// The player to attack, and how long after joining to start.
|
||||
attack: Option<String>,
|
||||
attack_after: f64,
|
||||
no_swing: bool,
|
||||
flood: usize,
|
||||
no_read: bool,
|
||||
no_loaded: bool,
|
||||
@@ -174,6 +178,7 @@ fn options() -> Result<Options, String> {
|
||||
explore_speed: 22.0,
|
||||
attack: None,
|
||||
attack_after: 5.0,
|
||||
no_swing: false,
|
||||
flood: 100,
|
||||
no_read: false,
|
||||
no_loaded: false,
|
||||
@@ -197,6 +202,10 @@ fn options() -> Result<Options, String> {
|
||||
o.no_read = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-swing" {
|
||||
o.no_swing = true;
|
||||
continue;
|
||||
}
|
||||
if arg == "--no-loaded" {
|
||||
o.no_loaded = true;
|
||||
continue;
|
||||
@@ -335,8 +344,8 @@ struct Bot {
|
||||
counters: OnceLock<Arc<Counters>>,
|
||||
/// The bot stopped reading (--no-read).
|
||||
deaf: AtomicBool,
|
||||
/// `--attack`: the target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<(Option<u128>, Option<i32>)>,
|
||||
/// `--attack`: each target's UUID and, once AddEntity showed it, its entity id.
|
||||
target: Mutex<Vec<(u128, Option<i32>)>>,
|
||||
attacks: AtomicU64,
|
||||
/// The last SetHealth.
|
||||
health: Mutex<Option<f32>>,
|
||||
@@ -701,9 +710,10 @@ fn read(
|
||||
}
|
||||
cb::ADD_ENTITY => {
|
||||
if let Ok(play::Clientbound::AddEntity(p)) = play::Clientbound::decode(id, &body) {
|
||||
let mut target = lock(&bot.target);
|
||||
if target.0 == Some(p.uuid) {
|
||||
target.1 = Some(p.id);
|
||||
for target in lock(&bot.target).iter_mut() {
|
||||
if target.0 == p.uuid {
|
||||
target.1 = Some(p.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -802,7 +812,12 @@ fn run_bot(
|
||||
let (reader, writer) = connection.split();
|
||||
let _ = bot.counters.set(reader.counters.clone());
|
||||
let writer = Arc::new(Mutex::new(writer));
|
||||
lock(&bot.target).0 = o.attack.as_deref().map(offline_uuid);
|
||||
*lock(&bot.target) = o
|
||||
.attack
|
||||
.iter()
|
||||
.flat_map(|names| names.split(','))
|
||||
.map(|name| (offline_uuid(name), None))
|
||||
.collect();
|
||||
bot.online.store(true, Ordering::SeqCst);
|
||||
{
|
||||
let (writer, bot) = (writer.clone(), bot.clone());
|
||||
@@ -1012,13 +1027,16 @@ fn run_bot(
|
||||
send(body);
|
||||
}
|
||||
}
|
||||
let target = lock(&bot.target).1;
|
||||
if let Some(id) = target
|
||||
&& joined.elapsed().as_secs_f64() >= o.attack_after
|
||||
&& tick % 10 == 0
|
||||
{
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
if joined.elapsed().as_secs_f64() >= o.attack_after && tick % 10 == 0 {
|
||||
let targets: Vec<i32> = lock(&bot.target).iter().filter_map(|t| t.1).collect();
|
||||
for id in targets {
|
||||
send(play::Serverbound::Attack(play::Attack { id }).encode());
|
||||
// Minecraft.startAttack swings right after the attack.
|
||||
if !o.no_swing {
|
||||
send(play::Serverbound::Punch(Empty).encode());
|
||||
}
|
||||
bot.attacks.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
tick += 1;
|
||||
let ok = send(
|
||||
|
||||
@@ -165,8 +165,8 @@ pub(super) fn player_joined(id: u128, name: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; off
|
||||
/// without the file or the section, or (with an error logged) when the section is wrong.
|
||||
/// `server.toml`'s `[anticheat]`, beside server.properties when horizon-server runs the game; the
|
||||
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
|
||||
fn load_anticheat() -> horizon_anticheat::Config {
|
||||
let Ok(text) = std::fs::read_to_string("server.toml") else {
|
||||
return Default::default();
|
||||
@@ -174,7 +174,10 @@ fn load_anticheat() -> horizon_anticheat::Config {
|
||||
match horizon_anticheat::Config::from_server_toml(&text) {
|
||||
Ok(config) => {
|
||||
if config.enabled {
|
||||
log("INFO", "Anticheat on (server.toml [anticheat])");
|
||||
log(
|
||||
"INFO",
|
||||
"Anticheat on (server.toml [anticheat]; enabled = false turns it off)",
|
||||
);
|
||||
}
|
||||
config
|
||||
}
|
||||
@@ -183,7 +186,10 @@ fn load_anticheat() -> horizon_anticheat::Config {
|
||||
"ERROR",
|
||||
&format!("server.toml: {e}; the anticheat stays off"),
|
||||
);
|
||||
Default::default()
|
||||
horizon_anticheat::Config {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! The anticheat on a dedicated server's hub (docs/anticheat.md): the checks run where the hub handles
|
||||
//! what players send, and a flag is carried out here (send back, alert, kick). The rules and the
|
||||
//! violation levels are `horizon_anticheat`'s. The hub holds none of this unless `server.toml` turns it
|
||||
//! on, so a server without it does exactly what vanilla's does.
|
||||
//! violation levels are `horizon_anticheat`'s. A dedicated server has it on by default; the hub holds
|
||||
//! none of this when `server.toml` turns it off (or for a LAN world), and then does exactly what vanilla's
|
||||
//! server does.
|
||||
use super::hub::{HostEvent, Hub, TickCtx};
|
||||
use crate::collisions::SceneCollisions;
|
||||
use horizon_anticheat::{Check, Config};
|
||||
@@ -106,6 +107,14 @@ impl Hub {
|
||||
|
||||
/// ServerboundClientTickEndPacket: one client tick spent.
|
||||
pub(super) fn anticheat_tick_end(&mut self, i: usize, events: &mut Vec<HostEvent>) {
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return;
|
||||
};
|
||||
if ac.config.runs(Check::Combat)
|
||||
&& let Some(foul) = self.guests[i].anticheat.attacks.tick_end()
|
||||
{
|
||||
self.combat_foul(i, foul, events);
|
||||
}
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return;
|
||||
};
|
||||
@@ -132,6 +141,61 @@ impl Hub {
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerboundAttackPacket on entity `target`: `combat`. Returns whether the attack is dropped:
|
||||
/// from `setback` on, every attack is, until the level decays below it.
|
||||
pub(super) fn anticheat_attack(
|
||||
&mut self,
|
||||
i: usize,
|
||||
target: i32,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
if !ac.config.runs(Check::Combat) {
|
||||
return false;
|
||||
}
|
||||
if let Some(foul) = self.guests[i].anticheat.attacks.attack(target)
|
||||
&& self.combat_foul(i, foul, events)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
let setback = ac.config.combat.setback;
|
||||
setback > 0.0
|
||||
&& self.guests[i]
|
||||
.anticheat
|
||||
.vl(&ac.config, Check::Combat, self.ticks)
|
||||
>= setback
|
||||
}
|
||||
|
||||
/// ServerboundPunchPacket: the arm swung, for `combat`.
|
||||
pub(super) fn anticheat_swing(&mut self, i: usize) {
|
||||
if self.anticheat.is_some() {
|
||||
self.guests[i].anticheat.attacks.swing();
|
||||
}
|
||||
}
|
||||
|
||||
fn combat_foul(
|
||||
&mut self,
|
||||
i: usize,
|
||||
foul: horizon_anticheat::combat::Foul,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
use horizon_anticheat::combat::Foul;
|
||||
let detail = match foul {
|
||||
Foul::NoSwing(n) => {
|
||||
format!("attacked without swinging ({n} more attacks than swings in a tick)")
|
||||
}
|
||||
Foul::TwoTargets(first, then) => {
|
||||
format!("attacked entities {first} and {then} in one tick")
|
||||
}
|
||||
};
|
||||
self.flag(i, Check::Combat, 1.0, &detail, events)
|
||||
}
|
||||
|
||||
/// Whether the prediction records the players' client ticks.
|
||||
pub fn recording(&self) -> bool {
|
||||
self.anticheat
|
||||
@@ -197,7 +261,8 @@ impl Hub {
|
||||
|
||||
/// `ground`, for a move from guest `i` that says it ends on the ground at `to` (`stands`: the move
|
||||
/// itself was stopped by something below): None when the claim stands (or isn't judged), else whether
|
||||
/// the player was sent back. A false claim is dropped either way. A claim at the height where the
|
||||
/// the player was sent back. A false claim is dropped either way, unless the check only logs
|
||||
/// (`setback = 0`). A claim at the height where the
|
||||
/// ground last held the player, within half a second, isn't judged: the block may have gone
|
||||
/// server-side (broken, pushed) before the client heard.
|
||||
pub(super) fn anticheat_ground(
|
||||
@@ -234,7 +299,9 @@ impl Hub {
|
||||
"claims the ground {below} at {:.1} {:.1} {:.1}",
|
||||
to[0], to[1], to[2]
|
||||
);
|
||||
Some(self.flag(i, Check::Ground, 1.0, &detail, events))
|
||||
let back = self.flag(i, Check::Ground, 1.0, &detail, events);
|
||||
let logs_only = self.anticheat.as_deref()?.config.ground.setback == 0.0;
|
||||
(!logs_only).then_some(back)
|
||||
}
|
||||
|
||||
/// Whether something at `to` can hold guest `i` up; None: not judged. Only blocks that can't hold
|
||||
@@ -350,6 +417,8 @@ impl Hub {
|
||||
let v = g.anticheat.flag(&ac.config, check, weight, ticks);
|
||||
let action = if v.kick {
|
||||
", kicked"
|
||||
} else if v.setback && check == Check::Combat {
|
||||
", attacks dropped"
|
||||
} else if v.setback {
|
||||
", sent back"
|
||||
} else {
|
||||
@@ -389,7 +458,9 @@ impl Hub {
|
||||
if v.kick {
|
||||
let conn = self.guests[i].conn;
|
||||
self.kick(conn, text("Kicked by the server's anticheat", "white"));
|
||||
} else if v.setback && self.guests[i].awaiting_teleport.is_none() {
|
||||
} else if v.setback && check != Check::Combat && self.guests[i].awaiting_teleport.is_none()
|
||||
{
|
||||
// A combat setback drops the attack (`anticheat_attack`); the others send the player back.
|
||||
self.resend_teleport(i, back);
|
||||
}
|
||||
v.setback || v.kick
|
||||
|
||||
@@ -2685,6 +2685,15 @@ impl Hub {
|
||||
g.max_unacknowledged = 10;
|
||||
}
|
||||
S::Pong(p) => self.anticheat_pong(i, p.id),
|
||||
S::Attack(p) => {
|
||||
if !self.anticheat_attack(i, p.id, events) {
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::Attack(p))));
|
||||
}
|
||||
}
|
||||
S::Punch(p) => {
|
||||
self.anticheat_swing(i);
|
||||
events.push(HostEvent::Packet(conn, Box::new(S::Punch(p))));
|
||||
}
|
||||
S::ClientTickEnd(_) => {
|
||||
// handleClientTickEnd: no move this tick, no known movement.
|
||||
let g = &mut self.guests[i];
|
||||
|
||||
@@ -62,12 +62,14 @@ bind = "" # the control API: "" a free port on 127.0.0.1; or
|
||||
token = "" # its token (16 characters or more); "" a new random one at every start
|
||||
allow_public = false # true lets bind be any address; the token is then all that guards the server
|
||||
|
||||
# Horizon's anticheat (docs/anticheat.md) is off: the server checks only what vanilla's checks. To turn it on:
|
||||
# Horizon's anticheat (docs/anticheat.md) is on: `combat` drops and kicks attacks no vanilla client sends;
|
||||
# timer, ground and prediction only write logs/anticheat.log until they're trusted. To change it:
|
||||
# [anticheat]
|
||||
# enabled = true
|
||||
# enabled = true # false: the server checks only what vanilla's checks
|
||||
# alerts = true # tell online operators (alert_level = 2) in their chat; the console always is
|
||||
# log = "logs/anticheat.log" # every flag, one line each
|
||||
# [anticheat.timer] # a sped-up game: more client ticks than the server's clock allows
|
||||
# enabled = true # false turns this check off
|
||||
# setback = 2 # the violation level from which the player is sent back; 0: never
|
||||
# alert = 5
|
||||
# kick = 20
|
||||
@@ -112,12 +114,12 @@ pub struct Config {
|
||||
pub panel: Panel,
|
||||
#[serde(default)]
|
||||
pub api: Api,
|
||||
/// The game reads this section itself (docs/anticheat.md); written only when it isn't the default (off).
|
||||
#[serde(default, skip_serializing_if = "anticheat_off")]
|
||||
/// The game reads this section itself (docs/anticheat.md); written only when it isn't the default.
|
||||
#[serde(default, skip_serializing_if = "anticheat_default")]
|
||||
pub anticheat: horizon_anticheat::Config,
|
||||
}
|
||||
|
||||
fn anticheat_off(c: &horizon_anticheat::Config) -> bool {
|
||||
fn anticheat_default(c: &horizon_anticheat::Config) -> bool {
|
||||
*c == horizon_anticheat::Config::default()
|
||||
}
|
||||
|
||||
@@ -621,14 +623,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anticheat_is_off_unless_its_section_turns_it_on() {
|
||||
assert!(!Config::default().anticheat.enabled);
|
||||
fn anticheat_is_on_unless_its_section_turns_it_off() {
|
||||
assert!(Config::default().anticheat.enabled);
|
||||
assert!(!TEMPLATE.lines().any(|l| l.trim() == "[anticheat]"));
|
||||
let text =
|
||||
format!("{TEMPLATE}\n[anticheat]\nenabled = true\n[anticheat.timer]\nkick = 0\n");
|
||||
format!("{TEMPLATE}\n[anticheat]\nenabled = true\n[anticheat.timer]\nkick = 20\n");
|
||||
let c = Config::parse(&text).unwrap();
|
||||
assert!(c.anticheat.enabled);
|
||||
assert_eq!(c.anticheat.timer.kick, 0.0);
|
||||
assert_eq!(c.anticheat.timer.kick, 20.0);
|
||||
let off = Config::parse(&format!("{TEMPLATE}\n[anticheat]\nenabled = false\n")).unwrap();
|
||||
assert!(!off.anticheat.enabled);
|
||||
assert_eq!(Config::parse(&render(&off)).unwrap(), off);
|
||||
// The game reads the same section from the same file.
|
||||
assert_eq!(
|
||||
horizon_anticheat::Config::from_server_toml(&text).unwrap(),
|
||||
|
||||
+23
-11
@@ -1,31 +1,40 @@
|
||||
# The dedicated server's anticheat
|
||||
|
||||
Horizon's dedicated server can check what players send beyond what vanilla checks. It is **off**: a
|
||||
server that doesn't turn it on runs vanilla's checks only ("moved too quickly", "moved wrongly"...)
|
||||
and costs nothing more. An owner turns it on in `server.toml`:
|
||||
Horizon's dedicated server checks what players send beyond what vanilla checks. It is **on** by default,
|
||||
every check with it, but only the checks a vanilla client can't trip act on their own: `combat` drops the
|
||||
attacks of a kill aura and kicks it. `timer`, `ground` and `prediction` only write `logs/anticheat.log`
|
||||
(their `setback`, `alert` and `kick` are 0) until they have shown no false positive with real players;
|
||||
then they act by default too. An owner changes it in `server.toml`:
|
||||
|
||||
```toml
|
||||
[anticheat]
|
||||
enabled = true
|
||||
enabled = true # false: vanilla's checks only ("moved too quickly", "moved wrongly"...)
|
||||
alerts = true # tell online operators (alert_level, default 2) in their chat
|
||||
alert_level = 2
|
||||
log = "logs/anticheat.log" # every flag, one line each; "" writes none
|
||||
|
||||
[anticheat.timer] # each check has a table like this one; every key is optional
|
||||
enabled = true
|
||||
setback = 2
|
||||
enabled = true # false turns this check off
|
||||
setback = 2 # the thresholds once trusted (0, log only, by default)
|
||||
alert = 5
|
||||
kick = 20
|
||||
decay = 0.02
|
||||
```
|
||||
|
||||
| Check | Acts by default | Thresholds once trusted (`setback`, `alert`, `kick`) |
|
||||
|---|---|---|
|
||||
| `combat` | yes: 2, 5, 20 | |
|
||||
| `timer` | no, logs only | 2, 5, 20 |
|
||||
| `ground` | no, logs only (a false claim stands) | 5, 15, 60 |
|
||||
| `prediction` | no, logs only | 5, 15, 60 |
|
||||
|
||||
It lives in the server for now, in its own crate (`horizon-anticheat`) plus one file of the hub
|
||||
(`lan/anticheat.rs`), because the native mod API ([native-mods.md](native-mods.md)) has no hooks yet for a
|
||||
player's moves (the position, the on-ground claim, cancelling a move) or for packet timing. It moves into a
|
||||
native mod once those hooks exist.
|
||||
|
||||
horizon-server checks the section before every start; the game reads it from `server.toml` when it
|
||||
starts (`horizon-client --dedicated` run by hand reads the same file from its folder, if there is one).
|
||||
horizon-server checks the section before every start (a wrong one turns the anticheat off, with an
|
||||
error in the log); the game reads it from `server.toml` when it starts (`horizon-client --dedicated` run by hand reads the same file from its folder, if there is one).
|
||||
|
||||
## How a check acts
|
||||
|
||||
@@ -47,10 +56,11 @@ Every flag goes to `log`, with the time, the player, the check, its level, what
|
||||
| Check | Catches | How |
|
||||
|---|---|---|
|
||||
| `timer` | a sped-up game (more ticks a second than the server's clock) | vanilla's client ends each tick with ServerboundClientTickEndPacket, at the server's tick rate. Each one spends one tick of a balance that wall-clock time fills. A client more than `tolerance_ms` (300) ahead is flagged; a quiet connection banks at most `max_lag_ms` (1000), so a lag spike's backlog isn't flagged and a pause can't be saved for a burst. A slow server tick adds its own time before its backlog is counted. |
|
||||
| `ground` | no-fall (claiming the ground to skip fall damage) and walking on water | a move that says it ends on the ground is judged only when every block around the feet (0.6 below them, 1/16 wider than the body) is air or a fluid, nothing collides a tenth of a block down, and no boat, minecart, shulker or happy ghast is near. Anything else (powder snow, scaffolding, a waterlogged slab...) isn't judged. A false claim is always dropped, so vanilla's fall damage follows; creative, spectators and gliders are skipped (no fall damage to dodge). |
|
||||
| `prediction` | speed, step, fly, jesus: any move vanilla's movement can't make from the player's own keys | each client tick (the packets between two ServerboundClientTickEndPackets: keys, sprinting, rotation, the move) is recorded, and every server tick ends with a ping so the server knows from which client tick on what it changed (a push, a teleport, an effect) was in effect. Each tick is then played again with the client's own movement code, from where the last one ended (it starts at a teleport the client accepted), and compared with where the client said it went; the replay then goes on from the client's position. What the server sent the player that changes its movement (a velocity, an explosion's knockback, attributes, effects) is applied on the first client tick that had seen it, so a client that ignores a push (anti-knockback) is off too. The keys and sprinting are the client's own, so they are never guessed; what the server can't know is tried (the ground on the first tick after a teleport, the item use on the tick the server's changed). Not played: riding, sleeping, dead, spectators. A move that lands farther than `tolerance` (0.03 blocks) from the replay flags, weighing its offset in tolerances (at most 5 a tick); the setback puts the player back where its moves last agreed. What the replay can't play yet excuses the tick, and the ticks after it until the moves agree again (2 seconds at most): another entity or player within 3 blocks (a push, a boat or shulker to stand on, a firework to fly with), an entity within 4 blocks that the server removed and the client hadn't heard of yet, a block change within 4 blocks the client hadn't seen or the server hasn't sent yet, abilities (a game mode, flight, a glider put on) the client hadn't seen, a moving piston, a chunk around the player the client doesn't have, a riptide launch, a teleport that keeps the player's motion (an End gateway's). Around a fast player (an elytra) these distances grow by two ticks of its speed, as what the server has around it is a tick or so behind. Under an excuse the replay takes the client's velocity, so it agrees again within a few ticks. With `[anticheat]` on it costs one ping and one replayed tick a tick per player. |
|
||||
| `ground` | no-fall (claiming the ground to skip fall damage) and walking on water | a move that says it ends on the ground is judged only when every block around the feet (0.6 below them, 1/16 wider than the body) is air or a fluid, nothing collides a tenth of a block down, and no boat, minecart, shulker or happy ghast is near. Anything else (powder snow, scaffolding, a waterlogged slab...) isn't judged. A false claim is dropped, so vanilla's fall damage follows (not while the check only logs); creative, spectators and gliders are skipped (no fall damage to dodge). |
|
||||
| `prediction` | speed, step, fly, jesus: any move vanilla's movement can't make from the player's own keys | each client tick (the packets between two ServerboundClientTickEndPackets: keys, sprinting, rotation, the move) is recorded, and every server tick ends with a ping so the server knows from which client tick on what it changed (a push, a teleport, an effect) was in effect. Each tick is then played again with the client's own movement code, from where the last one ended (it starts at a teleport the client accepted), and compared with where the client said it went; the replay then goes on from the client's position. What the server sent the player that changes its movement (a velocity, an explosion's knockback, attributes, effects) is applied on the first client tick that had seen it, so a client that ignores a push (anti-knockback) is off too. The keys and sprinting are the client's own, so they are never guessed; what the server can't know is tried (the ground on the first tick after a teleport, the item use on the tick the server's changed). Not played: riding, sleeping, dead, spectators. A move that lands farther than `tolerance` (0.03 blocks) from the replay flags, weighing its offset in tolerances (at most 5 a tick); the setback puts the player back where its moves last agreed. What the replay can't play yet excuses the tick, and the ticks after it until the moves agree again (2 seconds at most): another entity or player within 3 blocks (a push, a boat or shulker to stand on, a firework to fly with), an entity within 4 blocks that the server removed and the client hadn't heard of yet, a block change within 4 blocks the client hadn't seen or the server hasn't sent yet, abilities (a game mode, flight, a glider put on) the client hadn't seen, a moving piston, a chunk around the player the client doesn't have, a riptide launch, a teleport that keeps the player's motion (an End gateway's). Around a fast player (an elytra) these distances grow by two ticks of its speed, as what the server has around it is a tick or so behind. Under an excuse the replay takes the client's velocity, so it agrees again within a few ticks. It costs one ping and one replayed tick a tick per player. |
|
||||
| `combat` | a kill aura: attacks a vanilla client can't send | vanilla's client attacks from one place only (`Minecraft.startAttack`, once a client tick, with no screen open): the entity under the crosshair, picked once a tick, then it swings (ServerboundPunchPacket) at once. An attack with no swing before the next attack or the tick's end (a kill aura hiding its swing), or a second entity attacked in the same client tick (a multi-target aura), flags once. From `setback` (2) on the player's attacks are dropped until the level decays below it (`decay` 0.5 a second); `kick` is 20. It costs nothing until a player attacks. |
|
||||
|
||||
More checks (movement, combat, world, anti-xray) come in their own pull requests; this table lists the
|
||||
More checks (aim, world, anti-xray) come in their own pull requests; this table lists the
|
||||
ones in this version.
|
||||
|
||||
## Writing a check
|
||||
@@ -64,6 +74,8 @@ ones in this version.
|
||||
bubble columns, latency). A false positive is worse than a missed cheat: when unsure, don't flag.
|
||||
- A clean player must cost nothing: no work per tick unless the check needs it, and none at all while the
|
||||
anticheat is off (`Hub::anticheat` is `None`).
|
||||
- A new check that a vanilla client could trip ships logging only (thresholds at 0), until real players
|
||||
have shown it no false positive.
|
||||
- Original code only (CONTRIBUTING.md): written from vanilla 26.3 and public descriptions of what cheats
|
||||
do. Never read, copy or paraphrase another anticheat's source (Grim, NoCheatPlus, Vulcan, Matrix,
|
||||
Spartan, Paper's anti-xray...), its decompiled jars or docs quoting its code.
|
||||
|
||||
@@ -12,8 +12,8 @@ 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
|
||||
* "down forward", "up jump", "look 90 10" (yaw, pitch), "attack" (a click of the attack key:
|
||||
* Minecraft.startAttack, as handleKeybinds runs it). Everything the keys do is vanilla's own 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 {
|
||||
@@ -59,6 +59,16 @@ public final class VanillaMover {
|
||||
mc.player.setYRot(Float.parseFloat(words[1]));
|
||||
mc.player.setXRot(Float.parseFloat(words[2]));
|
||||
}
|
||||
case "attack" -> {
|
||||
try {
|
||||
var startAttack = Minecraft.class.getDeclaredMethod("startAttack");
|
||||
startAttack.setAccessible(true);
|
||||
startAttack.invoke(mc);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
throw new IllegalStateException(e);
|
||||
}
|
||||
System.out.println("[VanillaMover] attacked " + mc.hitResult.getType());
|
||||
}
|
||||
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));
|
||||
|
||||
+90
-15
@@ -6,9 +6,9 @@ and horizon-bots, legit bots beside cheating ones.
|
||||
python3 tools/anticheat_e2e.py --bin-dir target/fast --check timer
|
||||
|
||||
Checks:
|
||||
- off: no `[anticheat]` in server.toml: the anticheat says nothing, writes no log and kicks no one, the
|
||||
cheating bot included (vanilla's checks only).
|
||||
- timer: `[anticheat]` on: a bot ticking 1.5 times as fast as vanilla's client is flagged, sent back,
|
||||
- off: `[anticheat] enabled = false` in server.toml: the anticheat says nothing, writes no log and kicks
|
||||
no one, the cheating bot included (vanilla's checks only).
|
||||
- timer: `[anticheat]` on, with the timer's thresholds once trusted (it logs only by default): a bot ticking 1.5 times as fast as vanilla's client is flagged, sent back,
|
||||
reported to the console and kicked; the legit bots (walking and exploring at 20 Hz) are never
|
||||
flagged.
|
||||
- ground: `[anticheat]` on, survival (allow-flight on, so vanilla's floating kick stays out of it): a bot
|
||||
@@ -20,11 +20,16 @@ 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.
|
||||
- combat: no server.toml (the anticheat's defaults: `combat` acts), survival: a bot attacking a player
|
||||
twice a second and swinging each time, as vanilla's client does, is never flagged and its hits
|
||||
land; one that doesn't swing, and one that attacks two players in the same tick, are flagged,
|
||||
their attacks dropped, and kicked.
|
||||
- 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.
|
||||
glides with an elytra and a firework, through the same relay: it is never flagged. It hits the
|
||||
cow too, and `combat` never flags it.
|
||||
|
||||
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:
|
||||
@@ -63,13 +68,23 @@ PROPERTIES = {
|
||||
'simulation-distance': '4',
|
||||
'max-players': '100',
|
||||
}
|
||||
# The bots move by their own rules, not vanilla's: the prediction, which plays each move again with
|
||||
# vanilla's movement code, would flag every one of them. Its own check runs real clients.
|
||||
# The timer and the ground with the thresholds they get once trusted (they log only by default). The bots
|
||||
# move by their own rules, not vanilla's: the prediction, which plays each move again with vanilla's
|
||||
# movement code, would flag every one of them. Its own check runs real clients.
|
||||
ON = '''[anticheat]
|
||||
enabled = true
|
||||
[anticheat.timer]
|
||||
setback = 2
|
||||
alert = 5
|
||||
kick = 20
|
||||
[anticheat.ground]
|
||||
setback = 5
|
||||
alert = 15
|
||||
kick = 60
|
||||
[anticheat.prediction]
|
||||
enabled = false
|
||||
'''
|
||||
OFF = '[anticheat]\nenabled = false\n'
|
||||
|
||||
|
||||
class Failed(Exception):
|
||||
@@ -115,7 +130,7 @@ def run_pair(bins, folder, out, name, anticheat, seconds):
|
||||
|
||||
|
||||
def check_off(bins, scratch, out, seconds):
|
||||
lines, legit, cheat, log = run_pair(bins, scratch / 'off', out, 'Off', None, seconds)
|
||||
lines, legit, cheat, log = run_pair(bins, scratch / 'off', out, 'Off', OFF, seconds)
|
||||
if log is not None:
|
||||
raise Failed(f'off: an anticheat log was written:\n{log}')
|
||||
noise = [l for l in lines if 'Anticheat' in l]
|
||||
@@ -325,8 +340,10 @@ def honest_play(game, console):
|
||||
|
||||
def check_prediction(bins, scratch, out, seconds):
|
||||
folder = scratch / 'prediction'
|
||||
# The other checks stay out of it: this one is about the prediction alone.
|
||||
toml = '[anticheat]\nenabled = true\n[anticheat.timer]\nenabled = false\n[anticheat.ground]\nenabled = false\n'
|
||||
# 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')
|
||||
from lan_e2e import Game # noqa: E402 (only this check drives a client)
|
||||
game = Game('client', bins / f'horizon-ctl{EXE}', lab.RESOURCES.resolve())
|
||||
@@ -387,6 +404,7 @@ class VanillaClient:
|
||||
self.log = open(log, 'w')
|
||||
self.ready = threading.Event()
|
||||
self.positions = []
|
||||
self.hits = 0
|
||||
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()
|
||||
@@ -396,6 +414,8 @@ class VanillaClient:
|
||||
self.log.write(line)
|
||||
if '[VanillaMover] ready' in line:
|
||||
self.ready.set()
|
||||
elif '[VanillaMover] attacked ENTITY' in line:
|
||||
self.hits += 1
|
||||
elif '[VanillaMover] at ' in line:
|
||||
self.positions.append([float(v) for v in line.split('[VanillaMover] at ')[1].split()[:3]])
|
||||
|
||||
@@ -436,8 +456,12 @@ def vanilla_play(keys, console):
|
||||
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)
|
||||
at('summon minecraft:cow ~ ~ ~2')
|
||||
# Hits on it, half a second apart, looking down at it (for `combat`).
|
||||
keys('look 0 25', then=0.5)
|
||||
for _ in range(4):
|
||||
keys('attack', then=0.5)
|
||||
keys('look 0 0', 'down forward', then=2)
|
||||
keys('up forward')
|
||||
console('kill @e[type=minecraft:cow]')
|
||||
at('setblock ^ ^ ^1 minecraft:stone')
|
||||
@@ -461,7 +485,8 @@ def vanilla_play(keys, console):
|
||||
|
||||
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'
|
||||
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-vanilla', gamemode='survival', difficulty='peaceful')
|
||||
relay = Jitter(port)
|
||||
client = None
|
||||
@@ -490,16 +515,66 @@ def check_prediction_vanilla(bins, scratch, out, seconds):
|
||||
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 client.hits == 0:
|
||||
raise Failed('prediction-vanilla: the vanilla client never hit the cow')
|
||||
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')
|
||||
f'from where it joined, and its {client.hits} hits on a cow were never flagged by combat')
|
||||
|
||||
|
||||
|
||||
def check_combat(bins, scratch, out, seconds):
|
||||
folder = scratch / 'combat'
|
||||
server, port = start(bins, folder, None, out, 'combat', gamemode='survival', difficulty='peaceful')
|
||||
try:
|
||||
# Everyone spawns on one spot, within reach of the others, and a hit's damage stays.
|
||||
server.console('gamerule respawn_radius 0')
|
||||
server.console('gamerule natural_health_regeneration false')
|
||||
with ThreadPoolExecutor(4) as pool:
|
||||
targets = pool.submit(bots, bins, port, out, 'Target', 3, 'stand', seconds + 10)
|
||||
server.wait_for('Target2 joined the game', 120)
|
||||
runs = {
|
||||
'Fair': pool.submit(bots, bins, port, out, 'Fair', 1, 'stand', seconds, '--attack', 'Target0'),
|
||||
'NoSwing': pool.submit(bots, bins, port, out, 'NoSwing', 1, 'stand', seconds,
|
||||
'--attack', 'Target1', '--no-swing'),
|
||||
'Aura': pool.submit(bots, bins, port, out, 'Aura', 1, 'stand', seconds,
|
||||
'--attack', 'Target1,Target2'),
|
||||
}
|
||||
runs = {k: v.result() for k, v in runs.items()}
|
||||
runs['Target'] = targets.result()
|
||||
finally:
|
||||
server.stop()
|
||||
log = folder / 'logs/anticheat.log'
|
||||
# The other checks only log by default, and the bots move by their own rules.
|
||||
log = '\n'.join(l for l in (log.read_text() if log.exists() else '').splitlines() if 'failed combat' in l)
|
||||
fair = runs['Fair']['per_bot'][0]
|
||||
if fair['attacks'] < 10:
|
||||
raise Failed(f'combat: the fair bot attacked {fair["attacks"]} times')
|
||||
for bot in runs['Fair']['per_bot'] + runs['Target']['per_bot']:
|
||||
if bot['name'] in log:
|
||||
raise Failed(f'combat: legit {bot["name"]} was flagged:\n{log}')
|
||||
health = {bot['name']: bot['health'] for bot in runs['Target']['per_bot']}
|
||||
# The fair bot's hits land; the cheaters' land only until their level reaches `setback`.
|
||||
if not health['Target0'] < 10 < min(health['Target1'], health['Target2']):
|
||||
raise Failed(f'combat: the targets ended with {health} health')
|
||||
for name, what in (('NoSwing', 'attacked without swinging'), ('Aura', 'in one tick')):
|
||||
cheater = runs[name]['per_bot'][0]
|
||||
flags = [l for l in log.splitlines() if f'{cheater["name"]} failed combat' in l]
|
||||
if len(flags) < 3 or not all(what in l for l in flags):
|
||||
raise Failed(f'combat: {cheater["name"]} was flagged {len(flags)} times ({what}):\n{log}')
|
||||
if not any('attacks dropped' in l for l in flags):
|
||||
raise Failed(f'combat: {cheater["name"]}\'s attacks were never dropped:\n{log}')
|
||||
if 'disconnected' not in (cheater['error'] or ''):
|
||||
raise Failed(f'combat: {cheater["name"]} was not kicked ({cheater["error"]})')
|
||||
print(f'combat: the no-swing and two-target bots are flagged, their attacks dropped, and kicked; the fair '
|
||||
f'bot attacked {fair["attacks"]} times, never flagged; health left: {health}')
|
||||
|
||||
|
||||
CHECKS = {'off': check_off, 'timer': check_timer, 'ground': check_ground, 'prediction': check_prediction,
|
||||
'prediction-vanilla': check_prediction_vanilla}
|
||||
'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 = ['ground', 'off', 'prediction', 'timer']
|
||||
DEFAULT = ['combat', 'ground', 'off', 'prediction', 'timer']
|
||||
|
||||
|
||||
def main():
|
||||
|
||||
@@ -266,6 +266,9 @@ def server_command(kind, directory, args):
|
||||
# tolerance, flags none (an honest player's moves agree).
|
||||
toml.write_text(toml.read_text()
|
||||
+ '\n[anticheat]\nenabled = true\n[anticheat.prediction]\ntolerance = 1000.0\n')
|
||||
else:
|
||||
# The anticheat is on by default: off, to measure the server without it.
|
||||
toml.write_text(toml.read_text() + '\n[anticheat]\nenabled = false\n')
|
||||
return [server, '--dir', directory, 'run']
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ exactly:
|
||||
brand (Horizon's server says "horizon", vanilla's "vanilla"), features,
|
||||
known packs, every registry, tags);
|
||||
- the play packets up to the first chunk batch, in order, with entity ids,
|
||||
keep-alive ids and times left out (the recipe property sets and
|
||||
keep-alive ids and times and the anticheat's pings left out (the recipe property sets and
|
||||
advancements, which vanilla keeps in hash maps, sorted); the command tree
|
||||
node by node; the chunks sent and their bytes; the replies to the chat
|
||||
message, /list and the failing commands, as text components;
|
||||
@@ -239,6 +239,7 @@ def legacy_ping(port):
|
||||
|
||||
|
||||
CONSOLE_LINE = re.compile(r'^\[\d\d:\d\d:\d\d\] \[([^\]]+)\]: (.*)$')
|
||||
PLAY_PING = re.compile(r'play-\S* minecraft:ping\b')
|
||||
KEPT = re.compile(r'Tester|\[Server\]|\[Not Secure\]|There are|Stopping|Saving|ThreadedAnvilChunkStorage|All dimensions|Unknown or incomplete')
|
||||
|
||||
|
||||
@@ -354,6 +355,9 @@ def run_side(label, command, directory, client):
|
||||
finally:
|
||||
server.stop()
|
||||
transcript = (directory.parent / f'{label}-client.txt').read_text(encoding='utf-8').splitlines()
|
||||
# Horizon's anticheat, on by default, ends each server tick with a play ping for its movement
|
||||
# prediction (docs/anticheat.md); vanilla's server sends none, and a client only answers them.
|
||||
transcript = [l for l in transcript if not PLAY_PING.match(l)]
|
||||
return (['legacy-ping ' + legacy] + transcript + ['console ' + l for l in console_lines(server.lines)]
|
||||
+ ['file ' + l for l in server_files(directory)])
|
||||
|
||||
|
||||
Reference in new issue
Block a user