mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-09 17:19:15 -10:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ded09f53d | ||
|
|
c444303d55 | ||
|
|
e32ef2f8ee | ||
|
|
90d0076f43 | ||
|
|
39b9db52c4 | ||
|
|
1340e54376 | ||
|
|
5ae9f06129 | ||
|
|
13061c109f | ||
|
|
40eb5c7fd7 | ||
|
|
04a8acc5a3 |
No files matched your search
@@ -63,7 +63,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-prepare` | Builds `.local/horizon-client/26.3` from the player's own Minecraft install (runs the Java exporters) |
|
||||
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
|
||||
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
|
||||
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the `hub` feature runs them on the engine's hook (`lan/watch.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
|
||||
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
|
||||
| `horizon-addresses` | Every built-in address of an outside service vanilla doesn't contact (Horizon layer; [docs/layers.md](docs/layers.md#built-in-addresses)) |
|
||||
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
|
||||
@@ -174,7 +174,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Multiplayer client | L: `net.rs` (network thread); G: `remote.rs` (`fn handle_packet`, `fn tick_remote`), `remote_*.rs` (mob data, objects, projectiles, vehicles, stations) |
|
||||
| LAN host / server side | L: `lan/` (`conn.rs` handshake + login threads, `hub.rs` + `hub/` play state, `chunks.rs`, `entity.rs`, `tracking.rs`, `guest.rs`, `playerdata.rs`); G: `lan.rs` (`LanHost`), `lan_actions.rs` (`fn as_guest`, `fn tick_guests`), `lan_*.rs` per feature, `lan_entities.rs` (tracked entities) |
|
||||
| Integrated server | G: `integrated.rs` (the server thread), `levels.rs` (one level per dimension, `fn tick_server`), `server_travel.rs` |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (L: `lan/anticheat.rs`) |
|
||||
| Dedicated server | G: `dedicated.rs`, `dedicated/status.rs`; crates `horizon-server-files`, `horizon-server`, `horizon-anticheat` (its `hub` feature; L: `lan/watch.rs`, the hook) |
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities), `world_session.rs` (opening and closing a world); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`; G: `play_keys.rs` (`fn key_pressed`), `play_input.rs` (`fn play_mouse`, toggled keys, the hotbar wheel); crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs`, `pad_craft.rs`, `pointer_capture.rs` (mouse look, pointer grab) |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
|
||||
Generated
+6
@@ -1382,7 +1382,13 @@ version = "0.1.0"
|
||||
name = "horizon-anticheat"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"horizon-client-lib",
|
||||
"horizon-core",
|
||||
"horizon-protocol",
|
||||
"horizon-world",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
|
||||
@@ -6,9 +6,28 @@ license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "The dedicated server's anticheat: its [anticheat] settings, violation levels and the checks' rules, with no game state (docs/anticheat.md)"
|
||||
|
||||
[features]
|
||||
# The checks on the game's server hub (`hub`): the engine's hook (`lan::watch`) filled. Without it,
|
||||
# only the settings and the rules, which horizon-server reads.
|
||||
hub = [
|
||||
"dep:horizon-client-lib",
|
||||
"dep:horizon-core",
|
||||
"dep:horizon-protocol",
|
||||
"dep:horizon-world",
|
||||
"dep:chrono",
|
||||
"dep:serde_json",
|
||||
]
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "1.1"
|
||||
# The `hub` feature. The engine's own features stay the game's (`default-features = false`).
|
||||
horizon-client-lib = { path = "../horizon-client-lib", default-features = false, optional = true }
|
||||
horizon-core = { path = "../horizon-core", optional = true }
|
||||
horizon-protocol = { path = "../horizon-protocol", optional = true }
|
||||
horizon-world = { path = "../horizon-world", optional = true }
|
||||
chrono = { version = "0.4.42", default-features = false, features = ["clock", "std"], optional = true }
|
||||
serde_json = { version = "1.0.151", optional = true }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+170
-142
@@ -1,12 +1,15 @@
|
||||
//! 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, or a ban the game adds to
|
||||
//! `banned-players.json`: [`HostEvent::Ban`]). The rules and the
|
||||
//! 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, Strikes};
|
||||
//! The anticheat on a dedicated server's hub (docs/anticheat.md), the `hub` feature: the checks run
|
||||
//! through the engine's hook (`lan::watch`) where the hub handles what players send, and a flag is
|
||||
//! carried out here (send back, alert, kick, or a ban the game adds to `banned-players.json`:
|
||||
//! [`HostEvent::Ban`]). The rules and the violation levels are this crate'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 crate::{Check, Config, Player, Strikes};
|
||||
use horizon_client_lib::collisions::SceneCollisions;
|
||||
use horizon_client_lib::lan::guest::Guest;
|
||||
use horizon_client_lib::lan::hub::{HostEvent, Hub, TickCtx};
|
||||
use horizon_client_lib::lan::prediction::Over;
|
||||
use horizon_client_lib::lan::watch::Watch;
|
||||
use horizon_core::geometry::{Aabb, Vec3};
|
||||
use horizon_world::nbt::{JavaString, Tag};
|
||||
use std::{io::Write, time::Instant};
|
||||
@@ -114,17 +117,33 @@ fn text(s: &str, color: &str) -> Tag {
|
||||
])
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// Turn the anticheat on with these settings, or off (`config.enabled` false).
|
||||
pub fn set_anticheat(&mut self, config: Config) {
|
||||
self.anticheat = config.enabled.then(|| Box::new(Anticheat::new(config)));
|
||||
}
|
||||
/// Guest `g`'s violation levels and the checks' state, made at its first use.
|
||||
fn player(g: &mut Guest) -> &mut Player {
|
||||
g.anticheat
|
||||
.get_or_insert_with(|| Box::new(Player::default()))
|
||||
.downcast_mut()
|
||||
.expect("the guest's anticheat state is the anticheat's Player")
|
||||
}
|
||||
|
||||
/// The hub, while its hook runs the checks with the anticheat (`ac`) it lent.
|
||||
struct On<'a>(&'a mut Hub);
|
||||
|
||||
impl std::ops::Deref for On<'_> {
|
||||
type Target = Hub;
|
||||
fn deref(&self) -> &Hub {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for On<'_> {
|
||||
fn deref_mut(&mut self) -> &mut Hub {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl On<'_> {
|
||||
/// The start of a server tick: the time since the previous one fills each player's timer balance.
|
||||
pub(super) fn anticheat_tick(&mut self, tick_rate: f32) {
|
||||
let Some(ac) = self.anticheat.as_deref_mut() else {
|
||||
return;
|
||||
};
|
||||
fn anticheat_tick(&mut self, ac: &mut Anticheat, tick_rate: f32) {
|
||||
let now = Instant::now();
|
||||
let elapsed = ac.last_tick.replace(now).map(|t| now - t);
|
||||
if !ac.config.runs(Check::Timer) {
|
||||
@@ -136,39 +155,26 @@ impl Hub {
|
||||
let ms = elapsed.map_or(0.0, |e| e.as_secs_f64() * 1000.0);
|
||||
for g in &mut self.guests {
|
||||
if restart {
|
||||
g.anticheat.timer.reset();
|
||||
player(g).timer.reset();
|
||||
}
|
||||
g.anticheat.timer.elapse(ms, &ac.config.timer);
|
||||
player(g).timer.elapse(ms, &ac.config.timer);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
};
|
||||
fn anticheat_tick_end(&mut self, ac: &mut Anticheat, i: usize, events: &mut Vec<HostEvent>) {
|
||||
if ac.config.runs(Check::Combat)
|
||||
&& let Some(foul) = self.guests[i].anticheat.attacks.tick_end()
|
||||
&& let Some(foul) = player(&mut self.guests[i]).attacks.tick_end()
|
||||
{
|
||||
self.combat_foul(i, foul, events);
|
||||
}
|
||||
let Some(ac) = self.anticheat.as_deref() else {
|
||||
return;
|
||||
};
|
||||
if ac.config.runs(Check::Prediction) {
|
||||
let g = &mut self.guests[i];
|
||||
let (input, sprinting) = (g.input, g.anticheat.sprinting);
|
||||
g.anticheat
|
||||
.record
|
||||
.get_or_insert_default()
|
||||
.tick_end(input, sprinting);
|
||||
self.combat_foul(ac, i, foul, events);
|
||||
}
|
||||
if !ac.config.runs(Check::Timer) {
|
||||
return;
|
||||
}
|
||||
let (rate, config) = (ac.tick_rate, &ac.config.timer);
|
||||
if let Some(ahead) = self.guests[i].anticheat.timer.tick_end(rate, config) {
|
||||
if let Some(ahead) = player(&mut self.guests[i]).timer.tick_end(rate, config) {
|
||||
self.flag(
|
||||
ac,
|
||||
i,
|
||||
Check::Timer,
|
||||
1.0,
|
||||
@@ -180,48 +186,38 @@ 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(
|
||||
fn anticheat_attack(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
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)
|
||||
if let Some(foul) = player(&mut self.guests[i]).attacks.attack(target)
|
||||
&& self.combat_foul(ac, 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
|
||||
let (setback, ticks) = (ac.config.combat.setback, self.ticks);
|
||||
setback > 0.0 && player(&mut self.guests[i]).vl(&ac.config, Check::Combat, 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 anticheat_swing(&mut self, i: usize) {
|
||||
player(&mut self.guests[i]).attacks.swing();
|
||||
}
|
||||
|
||||
fn combat_foul(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
foul: horizon_anticheat::combat::Foul,
|
||||
foul: crate::combat::Foul,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
use horizon_anticheat::combat::Foul;
|
||||
use crate::combat::Foul;
|
||||
let detail = match foul {
|
||||
Foul::NoSwing(n) => {
|
||||
format!("attacked without swinging ({n} more attacks than swings in a tick)")
|
||||
@@ -230,70 +226,7 @@ impl Hub {
|
||||
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
|
||||
.as_deref()
|
||||
.is_some_and(|ac| ac.config.runs(Check::Prediction))
|
||||
}
|
||||
|
||||
/// A move packet from guest `i`, as it came: the prediction's record of the client tick.
|
||||
pub(super) fn anticheat_moved(
|
||||
&mut self,
|
||||
i: usize,
|
||||
pos: Option<[f64; 3]>,
|
||||
rot: Option<(f32, f32)>,
|
||||
flags: u8,
|
||||
) {
|
||||
if self.recording() {
|
||||
let record = self.guests[i].anticheat.record.get_or_insert_default();
|
||||
record.moved(pos, rot, flags);
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerboundPongPacket: the client has handled every packet of the server tick `id` (the ping
|
||||
/// [`Hub::anticheat_ping`] sent at its end).
|
||||
pub(super) fn anticheat_pong(&mut self, i: usize, id: i32) {
|
||||
let ticks = self.ticks;
|
||||
if self.recording() {
|
||||
let record = self.guests[i].anticheat.record.get_or_insert_default();
|
||||
// Ids are server ticks, cut to 32 bits.
|
||||
record.pong(u64::from(id as u32), ticks);
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of a server tick, after everything it sent: a ping whose id is the tick to each
|
||||
/// player in the world, so the prediction knows from which client tick on that tick's packets
|
||||
/// were in effect.
|
||||
pub fn anticheat_ping(&mut self) {
|
||||
if !self.recording() {
|
||||
return;
|
||||
}
|
||||
// What the tick sent the players that changes their movement went out before this ping.
|
||||
let ticks = self.ticks;
|
||||
for g in &mut self.guests {
|
||||
let changes = std::mem::take(
|
||||
g.changes
|
||||
.get_mut()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
||||
);
|
||||
if let Some(record) = g.anticheat.record.as_deref_mut() {
|
||||
for change in changes {
|
||||
record.changed(ticks, change);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ping = horizon_protocol::packets::play::Clientbound::Ping(
|
||||
horizon_protocol::packets::configuration::Ping {
|
||||
id: self.ticks as u32 as i32,
|
||||
},
|
||||
);
|
||||
for g in self.guests.iter().filter(|g| g.placed) {
|
||||
g.send(&ping);
|
||||
}
|
||||
self.flag(ac, i, Check::Combat, 1.0, &detail, events)
|
||||
}
|
||||
|
||||
/// `ground`, for a move from guest `i` that says it ends on the ground at `to` (`stands`: the move
|
||||
@@ -303,15 +236,17 @@ impl Hub {
|
||||
/// move doesn't count toward vanilla's floating kick. 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(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn anticheat_ground(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
to: [f64; 3],
|
||||
stands: bool,
|
||||
ctx: &TickCtx,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> Option<bool> {
|
||||
if !self.anticheat.as_deref()?.config.runs(Check::Ground) {
|
||||
if !ac.config.runs(Check::Ground) {
|
||||
return None;
|
||||
}
|
||||
// Standing still: no fall to skip, whatever holds the player up (a block the server doesn't
|
||||
@@ -326,10 +261,10 @@ impl Hub {
|
||||
let from = self.guests[i].position;
|
||||
let held_at_start = || self.ground_holds(i, [from[0], to[1], from[2]], ctx) != Some(false);
|
||||
if stands || self.ground_holds(i, to, ctx)? || held_at_start() {
|
||||
self.guests[i].anticheat.ground = Some((ticks, to[1]));
|
||||
player(&mut self.guests[i]).ground = Some((ticks, to[1]));
|
||||
return None;
|
||||
}
|
||||
if self.guests[i].anticheat.ground.is_some_and(|(t, y)| {
|
||||
if player(&mut self.guests[i]).ground.is_some_and(|(t, y)| {
|
||||
ticks.saturating_sub(t) <= GROUND_GRACE && (y - to[1]).abs() < 1.0E-3
|
||||
}) {
|
||||
return None;
|
||||
@@ -347,8 +282,8 @@ impl Hub {
|
||||
"claims the ground {below} at {:.1} {:.1} {:.1}",
|
||||
to[0], to[1], to[2]
|
||||
);
|
||||
self.flag(i, Check::Ground, 1.0, &detail, events);
|
||||
if self.anticheat.as_deref()?.config.ground.setback == 0.0 {
|
||||
self.flag(ac, i, Check::Ground, 1.0, &detail, events);
|
||||
if ac.config.ground.setback == 0.0 {
|
||||
return None;
|
||||
}
|
||||
// The correction is the dropped claim, never a setback: sent back into the air it claimed to
|
||||
@@ -413,23 +348,25 @@ impl Hub {
|
||||
|
||||
/// `check` failed for guest `i`: raise its level, log it, and act on it. Returns whether the move or
|
||||
/// action is cancelled (the player is sent back, or kicked).
|
||||
pub(super) fn flag(
|
||||
fn flag(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
check: Check,
|
||||
weight: f32,
|
||||
detail: &str,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
self.flag_to(i, check, weight, detail, events, None)
|
||||
self.flag_to(ac, i, check, weight, detail, events, None)
|
||||
}
|
||||
|
||||
/// `prediction`: guest `conn`'s ticks that landed off the replay. A setback sends the player
|
||||
/// back to `agreed`, where its moves last agreed.
|
||||
pub fn anticheat_prediction(
|
||||
fn anticheat_prediction(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
conn: u32,
|
||||
over: &super::prediction::Over,
|
||||
over: &Over,
|
||||
agreed: [f64; 3],
|
||||
) -> Vec<HostEvent> {
|
||||
let mut events = Vec::new();
|
||||
@@ -445,6 +382,7 @@ impl Hub {
|
||||
);
|
||||
let weight = over.weight;
|
||||
self.flag_to(
|
||||
ac,
|
||||
i,
|
||||
Check::Prediction,
|
||||
weight,
|
||||
@@ -456,9 +394,11 @@ impl Hub {
|
||||
events
|
||||
}
|
||||
|
||||
/// [`Hub::flag`], whose setback sends the player to `back` (where it was by default).
|
||||
/// [`On::flag`], whose setback sends the player to `back` (where it was by default).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn flag_to(
|
||||
&mut self,
|
||||
ac: &mut Anticheat,
|
||||
i: usize,
|
||||
check: Check,
|
||||
weight: f32,
|
||||
@@ -467,16 +407,13 @@ impl Hub {
|
||||
back: Option<[f64; 3]>,
|
||||
) -> bool {
|
||||
let ticks = self.ticks;
|
||||
let Some(ac) = self.anticheat.as_deref_mut() else {
|
||||
return false;
|
||||
};
|
||||
// Kicked or banned already, and leaving: what it still had queued (a lagging client's ticks)
|
||||
// is dropped, and counts no second strike in the session the first kick warned.
|
||||
if !self.guests[i].left_reason.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let g = &mut self.guests[i];
|
||||
let v = g.anticheat.flag(&ac.config, check, weight, ticks);
|
||||
let v = player(g).flag(&ac.config, check, weight, ticks);
|
||||
let (uuid, name) = (g.uuid, g.name.clone());
|
||||
let ban_after = ac.config.actions(check).ban_after;
|
||||
let (kicks, ban) = if v.kick {
|
||||
@@ -540,7 +477,8 @@ impl Hub {
|
||||
reason: format!("Horizon anticheat: {}", check.name()),
|
||||
});
|
||||
} else if v.kick {
|
||||
self.anticheat_kick(self.guests[i].conn);
|
||||
let conn = self.guests[i].conn;
|
||||
self.anticheat_kick(conn);
|
||||
} else if v.setback
|
||||
&& !matches!(check, Check::Combat | Check::Ground)
|
||||
&& self.guests[i].awaiting_teleport.is_none()
|
||||
@@ -551,10 +489,100 @@ impl Hub {
|
||||
}
|
||||
v.setback || v.kick
|
||||
}
|
||||
}
|
||||
|
||||
/// Disconnects `conn` for the anticheat.
|
||||
pub fn anticheat_kick(&mut self, conn: u32) {
|
||||
self.kick(conn, text("Kicked by the server's anticheat", "white"));
|
||||
/// What [`load`] gives: the anticheat (`None`: off) and the lines to log, as (level, line).
|
||||
#[derive(Default)]
|
||||
pub struct Loaded {
|
||||
pub watch: Option<Box<dyn Watch>>,
|
||||
pub log: Vec<(&'static str, String)>,
|
||||
}
|
||||
|
||||
/// The dedicated server's anticheat from `server.toml`'s `[anticheat]` (`None`: no file): the
|
||||
/// default (on) without the file or the section, off (with an error logged) when the section is wrong.
|
||||
pub fn load(server_toml: Option<&str>) -> Loaded {
|
||||
let mut loaded = Loaded::default();
|
||||
let config = match server_toml.map(Config::from_server_toml) {
|
||||
None => Config::default(),
|
||||
Some(Ok(config)) => {
|
||||
if config.enabled {
|
||||
loaded.log.push((
|
||||
"INFO",
|
||||
"Anticheat on (server.toml [anticheat]; enabled = false turns it off)".into(),
|
||||
));
|
||||
}
|
||||
config
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
loaded.log.push((
|
||||
"ERROR",
|
||||
format!("server.toml: {e}; the anticheat stays off"),
|
||||
));
|
||||
return loaded;
|
||||
}
|
||||
};
|
||||
loaded.watch = watch(config);
|
||||
loaded
|
||||
}
|
||||
|
||||
/// The hub's anticheat with these settings; none when they turn it off.
|
||||
pub fn watch(config: Config) -> Option<Box<dyn Watch>> {
|
||||
config
|
||||
.enabled
|
||||
.then(|| Box::new(Anticheat::new(config)) as Box<dyn Watch>)
|
||||
}
|
||||
|
||||
impl Watch for Anticheat {
|
||||
fn records(&self) -> bool {
|
||||
self.config.runs(Check::Prediction)
|
||||
}
|
||||
|
||||
fn tolerance(&self) -> f64 {
|
||||
self.config.prediction.tolerance
|
||||
}
|
||||
|
||||
fn tick(&mut self, hub: &mut Hub, tick_rate: f32) {
|
||||
On(hub).anticheat_tick(self, tick_rate);
|
||||
}
|
||||
|
||||
fn tick_end(&mut self, hub: &mut Hub, i: usize, events: &mut Vec<HostEvent>) {
|
||||
On(hub).anticheat_tick_end(self, i, events);
|
||||
}
|
||||
|
||||
fn attack(
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
i: usize,
|
||||
target: i32,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
On(hub).anticheat_attack(self, i, target, events)
|
||||
}
|
||||
|
||||
fn swing(&mut self, hub: &mut Hub, i: usize) {
|
||||
On(hub).anticheat_swing(i);
|
||||
}
|
||||
|
||||
fn ground(
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
i: usize,
|
||||
to: [f64; 3],
|
||||
stands: bool,
|
||||
ctx: &TickCtx,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> Option<bool> {
|
||||
On(hub).anticheat_ground(self, i, to, stands, ctx, events)
|
||||
}
|
||||
|
||||
fn prediction(
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
conn: u32,
|
||||
over: &Over,
|
||||
agreed: [f64; 3],
|
||||
) -> Vec<HostEvent> {
|
||||
On(hub).anticheat_prediction(self, conn, over, agreed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! The dedicated server's anticheat (docs/anticheat.md), without the game: the `[anticheat]` section of
|
||||
//! `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.
|
||||
//! (`horizon_client::lan`) feeds it what players send, through the engine's hook the `hub` feature fills,
|
||||
//! and carries out what it decides.
|
||||
//!
|
||||
//! 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
|
||||
@@ -21,7 +22,8 @@ use std::collections::BTreeMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod combat;
|
||||
pub mod prediction;
|
||||
#[cfg(feature = "hub")]
|
||||
pub mod hub;
|
||||
pub mod timer;
|
||||
|
||||
/// What one check's violation level leads to.
|
||||
@@ -162,8 +164,8 @@ checks! {
|
||||
|
||||
/// 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
|
||||
/// (`prediction::Record`), with a ping a tick to place what the server changes, and played again
|
||||
/// (horizon-client's `lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// (horizon-client's `lan::prediction::record`), with a ping a tick to place what the server changes,
|
||||
/// and played again (`lan::prediction`). A move that lands farther than `tolerance` from the
|
||||
/// replay flags, by how far it lands (one `tolerance` off weighs 1, at most 5 a tick), and the
|
||||
/// 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,
|
||||
@@ -279,15 +281,6 @@ pub struct Player {
|
||||
pub timer: timer::Balance,
|
||||
/// The server tick and height where the ground last held the player.
|
||||
pub ground: Option<(u64, f64)>,
|
||||
/// The client ticks the prediction plays again (only while it runs).
|
||||
pub record: Option<Box<prediction::Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub sprinting: bool,
|
||||
/// The attacks of the client tick being heard.
|
||||
pub attacks: combat::Attacks,
|
||||
}
|
||||
|
||||
@@ -34,8 +34,6 @@ horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The control endpoint's token (control.rs), as horizon-server's and the relay's.
|
||||
getrandom = "0.3"
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
@@ -141,6 +139,8 @@ horizon-client-render = { path = "../horizon-client-render", features = ["test-s
|
||||
horizon-items = { path = "../horizon-items", features = ["test-support"] }
|
||||
# Every WGSL shader translated as wgpu's DirectX 12 backend does (gfx.rs tests).
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "hlsl-out", "msl-out", "spv-out"] }
|
||||
# lan_bot turns the dedicated server's anticheat on, as the game installs it (`lan::watch`).
|
||||
horizon-anticheat = { path = "../horizon-anticheat", features = ["hub"] }
|
||||
|
||||
[[bin]]
|
||||
name = "horizon-ctl"
|
||||
|
||||
@@ -29,8 +29,9 @@ horizon-world = { path = "../horizon-world" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
# server.properties, eula.txt and the user lists, shared with horizon-server.
|
||||
horizon-server-files = { path = "../horizon-server-files" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md).
|
||||
horizon-anticheat = { path = "../horizon-anticheat" }
|
||||
# The dedicated server's anticheat, off unless server.toml's [anticheat] turns it on (docs/anticheat.md);
|
||||
# its `hub` feature fills the engine's hook (`lan::watch`).
|
||||
horizon-anticheat = { path = "../horizon-anticheat", features = ["hub"] }
|
||||
# The dedicated server's chat filter: [chat_filter], the slur list, mutes (docs/chat-filter.md).
|
||||
horizon-chat-filter = { path = "../horizon-chat-filter" }
|
||||
# Shader packs: reading them and turning their GLSL into naga modules.
|
||||
|
||||
@@ -181,31 +181,13 @@ pub(super) fn cached_user_by_id(id: u128) -> Option<NameAndId> {
|
||||
|
||||
/// `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();
|
||||
};
|
||||
match horizon_anticheat::Config::from_server_toml(&text) {
|
||||
Ok(config) => {
|
||||
if config.enabled {
|
||||
log(
|
||||
"INFO",
|
||||
"Anticheat on (server.toml [anticheat]; enabled = false turns it off)",
|
||||
);
|
||||
}
|
||||
config
|
||||
}
|
||||
Err(e) => {
|
||||
log(
|
||||
"ERROR",
|
||||
&format!("server.toml: {e}; the anticheat stays off"),
|
||||
);
|
||||
horizon_anticheat::Config {
|
||||
enabled: false,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
fn load_anticheat() -> Option<Box<dyn horizon_client::lan::watch::Watch>> {
|
||||
let text = std::fs::read_to_string("server.toml").ok();
|
||||
let loaded = horizon_anticheat::hub::load(text.as_deref());
|
||||
for (level, line) in &loaded.log {
|
||||
log(level, line);
|
||||
}
|
||||
loaded.watch
|
||||
}
|
||||
|
||||
/// `server.toml`'s `[chat_filter]` (docs/chat-filter.md), like `[anticheat]`: on by default, off (with an
|
||||
|
||||
@@ -15,11 +15,7 @@ impl Game<'_> {
|
||||
if !host.hub.recording() {
|
||||
return;
|
||||
}
|
||||
let tolerance = host
|
||||
.hub
|
||||
.anticheat
|
||||
.as_deref()
|
||||
.map_or(f64::INFINITY, |ac| ac.config.prediction.tolerance);
|
||||
let tolerance = host.hub.prediction_tolerance();
|
||||
// The item the guest is using (eating, a bow, a shield): its client moves at the item's
|
||||
// `use_effects` speed (ItemUseModifier, 0.2 for most), and may not sprint. The server's copy
|
||||
// of a guest doesn't follow it (Game.sync_player_body is the window's player's), so the
|
||||
@@ -66,8 +62,7 @@ impl Game<'_> {
|
||||
motion.item_use_speed = item_use.map(|(speed, _)| speed);
|
||||
motion.item_use_slows_sprint = item_use.is_some_and(|(_, slows)| slows);
|
||||
}
|
||||
let (Some(record), Some(server)) = (g.anticheat.record.as_deref_mut(), g.motion.as_deref())
|
||||
else {
|
||||
let (Some(record), Some(server)) = (g.record.as_deref_mut(), g.motion.as_deref()) else {
|
||||
return;
|
||||
};
|
||||
let mut run = |body: &mut PlayerMotion, keys, sprinting| {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! A remote player: connection, position, chunk and entity views, and the
|
||||
//! player state (inventory, health, game mode) that the host's gameplay code
|
||||
//! operates on when it acts for them.
|
||||
use super::prediction::record::{Change, Record};
|
||||
use super::{Joined, PlayerSender, entity::Tracker};
|
||||
use crate::{
|
||||
items::ItemStack,
|
||||
@@ -219,12 +220,21 @@ pub struct Guest {
|
||||
/// A position move arrived since the client's last ClientTickEnd
|
||||
/// (receivedPositionThisTick): a second one is a protocol error.
|
||||
pub position_this_tick: bool,
|
||||
/// Violation levels and the checks' state (the anticheat, when on).
|
||||
pub anticheat: horizon_anticheat::Player,
|
||||
/// The anticheat's own state for this player (`lan::watch`), made at its first use.
|
||||
pub anticheat: Option<Box<dyn std::any::Any + Send + Sync>>,
|
||||
/// The client ticks the movement prediction plays again (only while it records).
|
||||
pub record: Option<Box<Record>>,
|
||||
/// Sprinting as the client's own commands left it (ServerboundPlayerCommandPacket's
|
||||
/// START_SPRINTING and STOP_SPRINTING: LocalPlayer.wasSprinting), which the record's ticks take.
|
||||
/// Not the server's player: a hit with extra knockback stops its sprint
|
||||
/// (Player.causeExtraKnockback), the client's own `Player.attack` doesn't (a mob's `hurtClient`
|
||||
/// is false), and once the server's flag reaches it LocalPlayer.aiStep starts the sprint again
|
||||
/// while the sprint key is held, with nothing to say: wasSprinting never changed.
|
||||
pub client_sprinting: bool,
|
||||
/// The movement prediction's replay of the client's own moves (only while it runs).
|
||||
pub replay: Option<Box<super::prediction::Replay>>,
|
||||
/// What the server sent this tick that changes how the client moves (only while it records).
|
||||
pub changes: std::sync::Mutex<Vec<horizon_anticheat::prediction::Change>>,
|
||||
pub changes: std::sync::Mutex<Vec<Change>>,
|
||||
/// It accepted a move since the last entity range checks
|
||||
/// (ServerChunkCache.move), which then check all of its pairs again.
|
||||
pub moved: bool,
|
||||
@@ -514,7 +524,9 @@ impl Guest {
|
||||
rests_on_ground: false,
|
||||
support_blocks_sent: 0,
|
||||
position_this_tick: false,
|
||||
anticheat: Default::default(),
|
||||
anticheat: None,
|
||||
record: None,
|
||||
client_sprinting: false,
|
||||
replay: None,
|
||||
changes: Default::default(),
|
||||
moved: false,
|
||||
@@ -626,7 +638,7 @@ impl Guest {
|
||||
if self.fake && !super::conn::fake_wants_id(packet.id()) {
|
||||
return;
|
||||
}
|
||||
if self.anticheat.record.is_some() {
|
||||
if self.record.is_some() {
|
||||
self.note_change(packet);
|
||||
}
|
||||
self.sender.send(packet.encode());
|
||||
@@ -653,7 +665,6 @@ impl Guest {
|
||||
/// What `packet` changes about how the client moves, for the prediction's replay: kept until
|
||||
/// the tick's ping places it (`Hub::anticheat_ping`).
|
||||
fn note_change(&self, packet: &play::Clientbound) {
|
||||
use horizon_anticheat::prediction::Change;
|
||||
use play::Clientbound as C;
|
||||
let own = |id: i32| id == self.entity_id;
|
||||
let change = match packet {
|
||||
|
||||
@@ -343,7 +343,7 @@ pub struct Hub {
|
||||
/// set by the game before each tick.
|
||||
pub movement_checks: (bool, bool),
|
||||
/// The anticheat, when `server.toml` turns it on (dedicated servers).
|
||||
pub anticheat: Option<Box<super::anticheat::Anticheat>>,
|
||||
pub anticheat: Option<Box<dyn super::watch::Watch>>,
|
||||
/// The chat filter, when `server.toml` turns it on (dedicated servers).
|
||||
pub chat_filter: Option<Box<horizon_chat_filter::Guard>>,
|
||||
/// The far ring, when `server.toml`'s `max_view_distance` turns it on (dedicated servers).
|
||||
@@ -2153,7 +2153,7 @@ impl Hub {
|
||||
|
||||
/// `ServerGamePacketListenerImpl.teleport` alone (a refused or too fast
|
||||
/// move, a teleport sent again): the flying ticks go on.
|
||||
pub(super) fn resend_teleport(&mut self, i: usize, position: Option<[f64; 3]>) {
|
||||
pub fn resend_teleport(&mut self, i: usize, position: Option<[f64; 3]>) {
|
||||
self.teleport_relative(i, position, 0);
|
||||
}
|
||||
|
||||
@@ -3168,7 +3168,7 @@ impl Hub {
|
||||
// changed, so the one nearest the player stands for them.
|
||||
for &i in &viewers {
|
||||
let g = &mut self.guests[i];
|
||||
let Some(record) = g.anticheat.record.as_deref_mut() else {
|
||||
let Some(record) = g.record.as_deref_mut() else {
|
||||
continue;
|
||||
};
|
||||
let slack = super::prediction::slack(std::array::from_fn(|i| {
|
||||
@@ -3252,7 +3252,7 @@ impl Hub {
|
||||
if recording {
|
||||
for &i in &viewers {
|
||||
let g = &mut self.guests[i];
|
||||
let Some(record) = g.anticheat.record.as_deref_mut() else {
|
||||
let Some(record) = g.record.as_deref_mut() else {
|
||||
continue;
|
||||
};
|
||||
let slack = super::prediction::slack(std::array::from_fn(|i| {
|
||||
@@ -3643,7 +3643,7 @@ impl Hub {
|
||||
}));
|
||||
for at in gone.drain(..) {
|
||||
let block = at.map(|v| v.floor() as i32);
|
||||
if let Some(record) = g.anticheat.record.as_deref_mut()
|
||||
if let Some(record) = g.record.as_deref_mut()
|
||||
&& super::prediction::near(g.position, block, slack)
|
||||
{
|
||||
record.entity_gone(ticks);
|
||||
@@ -4045,7 +4045,7 @@ impl Hub {
|
||||
fn set_sprinting(&mut self, i: usize, sprinting: bool) {
|
||||
let g = &mut self.guests[i];
|
||||
g.sprinting = sprinting;
|
||||
g.anticheat.sprinting = sprinting;
|
||||
g.client_sprinting = sprinting;
|
||||
if let Some(m) = g.motion.as_mut() {
|
||||
let _ = m.set_sprint_modifier(sprinting);
|
||||
}
|
||||
@@ -4132,10 +4132,7 @@ impl Hub {
|
||||
// The prediction's replay starts again where the client now is.
|
||||
if recording {
|
||||
let kept = g.awaiting_keeps_motion;
|
||||
g.anticheat
|
||||
.record
|
||||
.get_or_insert_default()
|
||||
.teleported(at, kept);
|
||||
g.record.get_or_insert_default().teleported(at, kept);
|
||||
}
|
||||
let rot = (p.yaw, p.pitch);
|
||||
self.position_change(i, [p.x, p.y, p.z], rot, 0, ctx, events);
|
||||
@@ -5119,7 +5116,7 @@ impl Hub {
|
||||
// ClientPacketListener.handleRespawn without KEEP_ENTITY_DATA: the client's new player
|
||||
// starts with wasSprinting false. (A level change keeps it: the new player sprints on, or
|
||||
// says STOP.)
|
||||
g.anticheat.sprinting = false;
|
||||
g.client_sprinting = false;
|
||||
// The respawned player is a new one, without effects; so is the
|
||||
// client's (ClientPacketListener.handleRespawn).
|
||||
g.effects = Default::default();
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
//! decoded packets with these threads, so a world that is not open to the
|
||||
//! network pays nothing.
|
||||
mod advancements;
|
||||
pub mod anticheat;
|
||||
pub mod arrivals;
|
||||
pub mod attributes;
|
||||
pub mod border;
|
||||
@@ -32,9 +31,11 @@ pub mod menu;
|
||||
pub mod playerdata;
|
||||
pub mod prediction;
|
||||
pub mod recipes;
|
||||
mod recording;
|
||||
pub mod scoreboard;
|
||||
mod sound_order;
|
||||
pub mod tracking;
|
||||
pub mod watch;
|
||||
pub mod waypoints;
|
||||
|
||||
pub use conn::{Holding, PlayerSender, flush_all};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The movement prediction's replay (docs/anticheat.md): each client tick a player's record holds
|
||||
//! ([`horizon_anticheat::prediction::Record`]) is played again with the client's own movement code,
|
||||
//! ([`Record`]) is played again with the client's own movement code,
|
||||
//! from where the last one ended, and compared with where the client said it went.
|
||||
//!
|
||||
//! The inputs are known exactly: a vanilla client sends ServerboundPlayerInputPacket after its tick's
|
||||
@@ -21,8 +21,10 @@
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use horizon_anticheat::prediction::{Change, ClientTick, KEPT, Record, Restart};
|
||||
pub mod record;
|
||||
|
||||
use horizon_core::geometry::Vec3;
|
||||
use record::{Change, ClientTick, KEPT, Record, Restart};
|
||||
|
||||
use crate::motion::PlayerMotion;
|
||||
use crate::sprint::Keys;
|
||||
@@ -650,13 +652,13 @@ fn sync(body: &mut PlayerMotion, server: &PlayerMotion) {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::record::Record;
|
||||
use super::*;
|
||||
use crate::{
|
||||
assets::Assets,
|
||||
motion::{MotionResources, MotionWorld},
|
||||
world::World,
|
||||
};
|
||||
use horizon_anticheat::prediction::Record;
|
||||
use std::path::Path;
|
||||
|
||||
/// Keys for each tick: 20 walking, 12 sprint-jumping, 12 strafing while turning, 6 still.
|
||||
|
||||
File renamed without changes.
@@ -0,0 +1,67 @@
|
||||
//! The movement record's upkeep on the server's hub (docs/anticheat.md): while the anticheat's
|
||||
//! prediction runs, each guest's client ticks (its moves, and its pongs to the ping that ends each server
|
||||
//! tick) go into its record (`lan::prediction::record`), which the engine's movement replay plays again.
|
||||
use super::hub::Hub;
|
||||
|
||||
impl Hub {
|
||||
/// Whether the prediction records the players' client ticks.
|
||||
pub fn recording(&self) -> bool {
|
||||
self.anticheat.as_deref().is_some_and(|w| w.records())
|
||||
}
|
||||
|
||||
/// A move packet from guest `i`, as it came: the prediction's record of the client tick.
|
||||
pub(super) fn anticheat_moved(
|
||||
&mut self,
|
||||
i: usize,
|
||||
pos: Option<[f64; 3]>,
|
||||
rot: Option<(f32, f32)>,
|
||||
flags: u8,
|
||||
) {
|
||||
if self.recording() {
|
||||
let record = self.guests[i].record.get_or_insert_default();
|
||||
record.moved(pos, rot, flags);
|
||||
}
|
||||
}
|
||||
|
||||
/// ServerboundPongPacket: the client has handled every packet of the server tick `id` (the ping
|
||||
/// [`Hub::anticheat_ping`] sent at its end).
|
||||
pub(super) fn anticheat_pong(&mut self, i: usize, id: i32) {
|
||||
let ticks = self.ticks;
|
||||
if self.recording() {
|
||||
let record = self.guests[i].record.get_or_insert_default();
|
||||
// Ids are server ticks, cut to 32 bits.
|
||||
record.pong(u64::from(id as u32), ticks);
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of a server tick, after everything it sent: a ping whose id is the tick to each
|
||||
/// player in the world, so the prediction knows from which client tick on that tick's packets
|
||||
/// were in effect.
|
||||
pub fn anticheat_ping(&mut self) {
|
||||
if !self.recording() {
|
||||
return;
|
||||
}
|
||||
// What the tick sent the players that changes their movement went out before this ping.
|
||||
let ticks = self.ticks;
|
||||
for g in &mut self.guests {
|
||||
let changes = std::mem::take(
|
||||
g.changes
|
||||
.get_mut()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner),
|
||||
);
|
||||
if let Some(record) = g.record.as_deref_mut() {
|
||||
for change in changes {
|
||||
record.changed(ticks, change);
|
||||
}
|
||||
}
|
||||
}
|
||||
let ping = horizon_protocol::packets::play::Clientbound::Ping(
|
||||
horizon_protocol::packets::configuration::Ping {
|
||||
id: self.ticks as u32 as i32,
|
||||
},
|
||||
);
|
||||
for g in self.guests.iter().filter(|g| g.placed) {
|
||||
g.send(&ping);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
//! The hook a dedicated server's anticheat fills (docs/anticheat.md): the hub tells a [`Watch`] what
|
||||
//! players send (moves, attacks, swings, the ends of their client ticks) and the movement replay's
|
||||
//! results, and the watch may drop an action, send the player back, kick or ban. The engine has no
|
||||
//! checks of its own; the dedicated server sets the platform's (`horizon-anticheat`, its `hub` feature)
|
||||
//! from `server.toml`. Without a watch (the default, and every LAN world) the
|
||||
//! hub calls nothing and does exactly what vanilla's server does.
|
||||
//!
|
||||
//! The engine keeps the movement record the replay plays again (`recording.rs`): it records while the
|
||||
//! watch asks for it ([`Watch::records`]).
|
||||
use super::hub::{HostEvent, Hub, TickCtx};
|
||||
use crate::lan::prediction::Over;
|
||||
|
||||
/// The anticheat's checks, called by the hub with itself (the watch taken out of it for the call).
|
||||
/// `i` is the guest's index in `hub.guests`.
|
||||
pub trait Watch: Send + Sync {
|
||||
/// Whether the hub records the players' client ticks for the movement replay.
|
||||
fn records(&self) -> bool;
|
||||
/// How far, in blocks, a replayed tick may land from where the client said it went.
|
||||
fn tolerance(&self) -> f64;
|
||||
/// The start of a server tick, at `tick_rate` ticks a second.
|
||||
fn tick(&mut self, hub: &mut Hub, tick_rate: f32);
|
||||
/// ServerboundClientTickEndPacket: one client tick spent (after the hub recorded it).
|
||||
fn tick_end(&mut self, hub: &mut Hub, i: usize, events: &mut Vec<HostEvent>);
|
||||
/// ServerboundAttackPacket on entity `target`: whether the attack is dropped.
|
||||
fn attack(&mut self, hub: &mut Hub, i: usize, target: i32, events: &mut Vec<HostEvent>)
|
||||
-> bool;
|
||||
/// ServerboundPunchPacket: the arm swung.
|
||||
fn swing(&mut self, hub: &mut Hub, i: usize);
|
||||
/// A move that says it ends on the ground at `to` (`stands`: something below stopped it): None
|
||||
/// when the claim stands or isn't judged, else whether the move is refused (the player was
|
||||
/// kicked); `Some(false)` drops the claim.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn ground(
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
i: usize,
|
||||
to: [f64; 3],
|
||||
stands: bool,
|
||||
ctx: &TickCtx,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> Option<bool>;
|
||||
/// The replay found guest `conn`'s ticks that landed off by more than [`Watch::tolerance`]; a
|
||||
/// setback sends the player to `agreed`, where its moves last agreed. What to log.
|
||||
fn prediction(
|
||||
&mut self,
|
||||
hub: &mut Hub,
|
||||
conn: u32,
|
||||
over: &Over,
|
||||
agreed: [f64; 3],
|
||||
) -> Vec<HostEvent>;
|
||||
}
|
||||
|
||||
impl Hub {
|
||||
/// Turn the anticheat on (`Some`) or off.
|
||||
pub fn set_anticheat(&mut self, watch: Option<Box<dyn Watch>>) {
|
||||
self.anticheat = watch;
|
||||
}
|
||||
|
||||
/// Calls the watch with the hub, if there is one; `off` without.
|
||||
fn watch<R>(&mut self, off: R, call: impl FnOnce(&mut dyn Watch, &mut Hub) -> R) -> R {
|
||||
let Some(mut watch) = self.anticheat.take() else {
|
||||
return off;
|
||||
};
|
||||
let result = call(watch.as_mut(), self);
|
||||
self.anticheat = Some(watch);
|
||||
result
|
||||
}
|
||||
|
||||
/// The start of a server tick.
|
||||
pub(super) fn anticheat_tick(&mut self, tick_rate: f32) {
|
||||
self.watch((), |w, hub| w.tick(hub, tick_rate));
|
||||
}
|
||||
|
||||
/// ServerboundClientTickEndPacket: the record's tick ends, then the watch hears it.
|
||||
pub(super) fn anticheat_tick_end(&mut self, i: usize, events: &mut Vec<HostEvent>) {
|
||||
if self.anticheat.is_none() {
|
||||
return;
|
||||
}
|
||||
if self.recording() {
|
||||
let g = &mut self.guests[i];
|
||||
let (input, sprinting) = (g.input, g.client_sprinting);
|
||||
g.record.get_or_insert_default().tick_end(input, sprinting);
|
||||
}
|
||||
self.watch((), |w, hub| w.tick_end(hub, i, events));
|
||||
}
|
||||
|
||||
/// ServerboundAttackPacket on entity `target`: whether the watch drops the attack.
|
||||
pub(super) fn anticheat_attack(
|
||||
&mut self,
|
||||
i: usize,
|
||||
target: i32,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> bool {
|
||||
self.watch(false, |w, hub| w.attack(hub, i, target, events))
|
||||
}
|
||||
|
||||
/// ServerboundPunchPacket: the arm swung.
|
||||
pub(super) fn anticheat_swing(&mut self, i: usize) {
|
||||
self.watch((), |w, hub| w.swing(hub, i));
|
||||
}
|
||||
|
||||
/// [`Watch::ground`] for a move from guest `i` claiming the ground at `to`.
|
||||
pub(super) fn anticheat_ground(
|
||||
&mut self,
|
||||
i: usize,
|
||||
to: [f64; 3],
|
||||
stands: bool,
|
||||
ctx: &TickCtx,
|
||||
events: &mut Vec<HostEvent>,
|
||||
) -> Option<bool> {
|
||||
self.watch(None, |w, hub| w.ground(hub, i, to, stands, ctx, events))
|
||||
}
|
||||
|
||||
/// How far a replayed tick may land from the client's; infinite without a watch.
|
||||
pub fn prediction_tolerance(&self) -> f64 {
|
||||
self.anticheat
|
||||
.as_deref()
|
||||
.map_or(f64::INFINITY, |w| w.tolerance())
|
||||
}
|
||||
|
||||
/// The replay's ticks of guest `conn` that landed off: the watch flags them.
|
||||
pub fn anticheat_prediction(
|
||||
&mut self,
|
||||
conn: u32,
|
||||
over: &Over,
|
||||
agreed: [f64; 3],
|
||||
) -> Vec<HostEvent> {
|
||||
self.watch(Vec::new(), |w, hub| w.prediction(hub, conn, over, agreed))
|
||||
}
|
||||
|
||||
/// Disconnects `conn` for the anticheat.
|
||||
pub fn anticheat_kick(&mut self, conn: u32) {
|
||||
let reason = horizon_world::nbt::Tag::compound([
|
||||
(
|
||||
"text",
|
||||
horizon_world::nbt::Tag::String("Kicked by the server's anticheat".into()),
|
||||
),
|
||||
("color", horizon_world::nbt::Tag::String("white".into())),
|
||||
]);
|
||||
self.kick(conn, reason);
|
||||
}
|
||||
}
|
||||
@@ -1485,7 +1485,7 @@ fn the_prediction_records_each_client_tick_and_the_server_ticks_it_had_seen() {
|
||||
};
|
||||
config.timer.enabled = false;
|
||||
config.ground.enabled = false;
|
||||
s.hub.set_anticheat(config);
|
||||
s.hub.set_anticheat(horizon_anticheat::hub::watch(config));
|
||||
let port = s.hub.lan.port;
|
||||
let _bot = run(&mut s, move || connect(port, "Recorded", true));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
@@ -1522,7 +1522,6 @@ fn the_prediction_records_each_client_tick_and_the_server_ticks_it_had_seen() {
|
||||
inbox.push_back((S::ClientTickEnd(Empty), 0));
|
||||
s.tick(&[]);
|
||||
let record = s.hub.guests[0]
|
||||
.anticheat
|
||||
.record
|
||||
.as_deref()
|
||||
.expect("the prediction records");
|
||||
@@ -1546,7 +1545,7 @@ fn the_prediction_records_each_client_tick_and_the_server_ticks_it_had_seen() {
|
||||
0,
|
||||
));
|
||||
s.tick(&[]);
|
||||
let record = s.hub.guests[0].anticheat.record.as_deref().unwrap();
|
||||
let record = s.hub.guests[0].record.as_deref().unwrap();
|
||||
assert_eq!(record.seen(), Some(pinged));
|
||||
}
|
||||
|
||||
|
||||
+7
-5
@@ -32,8 +32,9 @@ ban_after = 0 # the kick (counting this check's earlier ones) that
|
||||
| `ground` | yes: 10, 15, 30, and `ban_after = 2`. On the official server's first two hours (67 players, log only), every player not flying or walking on water peaked at level 7; those who were, at 113 to 3749 | |
|
||||
| `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
|
||||
It is a platform feature ([layers.md](layers.md)): the crate `horizon-anticheat` holds the rules and, with its
|
||||
`hub` feature, the checks on the server (`src/hub.rs`), which fill the engine's hook (`lan/watch.rs`: the hub
|
||||
tells it what players send; without it the hub calls nothing). It isn't a native mod yet, 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.
|
||||
|
||||
@@ -90,9 +91,10 @@ ones in this version.
|
||||
|
||||
## Writing a check
|
||||
|
||||
- The rules are in `crates/horizon-anticheat` (no game state: unit-test them there); the hub runs them
|
||||
where it handles the packet (`crates/horizon-client/src/lan/`) and calls `Hub::flag`, which carries out
|
||||
setback, alert and kick (`lan/anticheat.rs`).
|
||||
- The rules are in `crates/horizon-anticheat` (no game state: unit-test them there); the hub calls its hook
|
||||
(`Watch`, `crates/horizon-client/src/lan/watch.rs`) where it handles the packet, and the checks
|
||||
(`horizon-anticheat/src/hub.rs`) call `Anticheat::flag`, which carries out setback, alert and kick. A check
|
||||
that needs another event adds a method to `Watch`, called from the hub.
|
||||
- A check is one entry in the `checks!` table of `horizon-anticheat/src/lib.rs`: its name in
|
||||
`server.toml`, its default thresholds and its own settings.
|
||||
- Predict what vanilla allows (effects, ice, elytra, riptide, vehicles, knockback, levitation, slime,
|
||||
|
||||
@@ -92,7 +92,6 @@
|
||||
"horizon-client -> horizon-theme",
|
||||
"horizon-client-input -> horizon-dualsense",
|
||||
"horizon-client-input -> horizon-theme",
|
||||
"horizon-client-lib -> horizon-anticheat",
|
||||
"horizon-client-lib -> horizon-bench",
|
||||
"horizon-client-lib -> horizon-chat-filter",
|
||||
"horizon-client-lib -> horizon-cosmetics",
|
||||
|
||||
Reference in new issue
Block a user