Compare commits

...
Author SHA1 Message Date
Horizon agentandClaude Opus 5.5 4ded09f53d cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:37:22 +01:00
Horizon agentandClaude Opus 5.5 c444303d55 Anticheat hub: the kick's conn read before the call
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:31:21 +01:00
Horizon agentandClaude Opus 5.5 e32ef2f8ee Anticheat: the hub calls an engine hook, horizon-client-lib no longer names horizon-anticheat
Layer plan M6. The engine offers lan::hub:⌚ the Watch trait the hub calls (with itself) where it
handles what the checks read. The hub's checks (lan/anticheat.rs) move to horizon-anticheat's new
`hub` feature (src/hub.rs), their bodies kept: they run on the hub through a Deref newtype, with the
anticheat the hook lends. Each guest keeps the anticheat's own state in an opaque slot. Off (no
watch): the hub calls nothing. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:31:20 +01:00
Horizon agentandClaude Opus 5.5 90d0076f43 Movement record: its upkeep lives in lan/recording.rs, so lan/hub.rs doesn't grow
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:29:53 +01:00
Horizon agentandClaude Opus 5.5 39b9db52c4 Movement record: its upkeep moves from lan/anticheat.rs into the hub (lan/hub/recording.rs)
Layer plan M6, before the anticheat's engine hook: what keeps the movement record (moves, pongs, the
tick's ping) is the engine replay's, not a check. A pure move. Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:40 +01:00
Horizon agentandClaude Opus 5.5 1340e54376 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:23:08 +01:00
Horizon agentandClaude Opus 5.5 5ae9f06129 cargo fmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:06:26 +01:00
Horizon agentandClaude Opus 5.5 13061c109f lan_bot: the record is the guest's
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:41:16 +01:00
Horizon agentandClaude Opus 5.5 40eb5c7fd7 Record: the teleport's restart reads the guest's record
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:40:35 +01:00
Horizon agentandClaude Opus 5.5 04a8acc5a3 Movement prediction: the client-tick record moves from horizon-anticheat into the engine's replay
Layer plan M6, step toward dropping horizon-client-lib -> horizon-anticheat. The record
(horizon_anticheat::prediction) is what the engine's movement replay (lan::prediction) plays again:
it moves to lan/prediction/record.rs unchanged. The guest holds it and the client's sprint
(was Player.record / Player.sprinting). Behaviour unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:32:06 +01:00
19 changed files with 466 additions and 222 deletions

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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",
]
+19
View File
@@ -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
@@ -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)
}
}
+6 -13
View File
@@ -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,
}
+2 -2
View File
@@ -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"
+3 -2
View File
@@ -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.
+6 -24
View File
@@ -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
+2 -7
View File
@@ -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| {
+17 -6
View File
@@ -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 {
+8 -11
View File
@@ -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();
+2 -1
View File
@@ -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};
+5 -3
View File
@@ -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.
@@ -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);
}
}
}
+142
View File
@@ -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);
}
}
+2 -3
View File
@@ -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
View File
@@ -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,
-1
View File
@@ -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",