Anticheat prediction: the server's sprinting flag reaches the replay

A client whose STOP_SPRINTING and START_SPRINTING reach the server in two
server ticks gets the STOP's shared flags and movement speed (no sprint
modifier) after it sprints again: its flag cleared, LocalPlayer.aiStep
starts the sprint again, modifier included, and sends nothing. The
replay took the attributes but not the flag, so it sprinted on without
the modifier until the START's attributes came: 0.029 then 0.045 off,
the official server's "ground sprinting" flags (#5935).

Record the sprinting bit of the shared flags the server sends the player
(Change::Sprinting) and apply it to the replay's flag alone, as
SynchedEntityData.assignValues does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 14:04:46 +01:00
1 parent 8c02c0b853
commit 2a1dd555d0
4 files changed
+87 -1

No files matched your search

@@ -39,6 +39,11 @@ pub enum Change {
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
Attributes { sprint: Option<bool> },
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
Sprinting(bool),
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
/// are the server's: they reach the client as attributes.)
+7
View File
@@ -489,6 +489,13 @@ impl Guest {
.map(|a| a.modifiers.iter().any(|m| m.id == "minecraft:sprinting"));
Change::Attributes { sprint }
}
// Entity.DATA_SHARED_FLAGS_ID: its sprinting bit (0x08).
C::SetEntityData(p) if own(p.id) => match p.values.iter().find(|v| v.0 == 0) {
Some(&(_, _, horizon_protocol::entity_data::Value::Byte(flags))) => {
Change::Sprinting(flags & 0x08 != 0)
}
_ => return,
},
C::UpdateMobEffect(p) if own(p.id) => Change::Effects,
C::RemoveMobEffect(p) if own(p.id) => Change::Effects,
C::PlayerAbilities(_) => Change::Abilities,
@@ -417,6 +417,10 @@ impl Replay {
body.attributes = server.attributes.clone();
let _ = body.set_sprint_modifier(sprint);
}
// ClientPacketListener.handleSetEntityData: the flag alone. A sprint the tick goes on
// with is then started again (`MotionWorld::replayed_tick`), its modifier with it, as
// LocalPlayer.aiStep does when the sprint key is held.
Change::Sprinting(sprinting) => body.sprinting = sprinting,
Change::Effects => body.status = server.status.clone(),
// `sync` takes them every tick.
Change::Abilities => {}
@@ -696,6 +700,10 @@ mod tests {
/// On tick `.0`, the server sends the player attributes (the server's body has no sprint
/// modifier): `.1` as in [`Change::Attributes`], which the client takes.
attributes: Option<(usize, Option<bool>)>,
/// On each tick `.0`, the client handles what the server sent when it heard the client's
/// sprint `.1`: the shared flags with that sprinting bit, and the movement speed with the
/// sprint modifier or without ([`Change::Sprinting`], [`Change::Attributes`]).
server_sprint: Vec<(usize, bool)>,
/// The first ticks the client is on its loading screen: it ends them without moving or
/// sending a move.
frozen: usize,
@@ -715,6 +723,7 @@ mod tests {
burst: None,
blocks: Vec::new(),
attributes: None,
server_sprint: Vec::new(),
frozen: 0,
item_use: None,
}
@@ -815,6 +824,17 @@ mod tests {
client.set_sprint_modifier(kept).unwrap();
}
}
for &(_, sprint) in run.server_sprint.iter().filter(|&&(at, _)| at == i) {
record.changed(tick, Change::Sprinting(sprint));
record.changed(
tick,
Change::Attributes {
sprint: Some(sprint),
},
);
client.sprinting = sprint;
client.set_sprint_modifier(sprint).unwrap();
}
record.pong(tick, tick);
let before = client.position;
if run_teleport.is_some_and(|(burst, _)| burst == i) {
@@ -1199,6 +1219,60 @@ mod tests {
assert!(counted.len() >= 30, "{} of 40: {counted:?}", counted.len());
}
/// A sprint stopped for one tick (forward let go) and started again: the server hears the STOP
/// and the START in two of its ticks, and sends each back (the shared flags and the movement
/// speed). The STOP's reach the client `late` ticks after it sprints again: its flag cleared, it
/// starts the sprint again in its next aiStep, its modifier with it, and says nothing. The
/// START's come one tick later (the live server's `ground sprinting` flags, 0.045 off).
fn sprint_restarted(late: usize, gap: usize) -> Run {
let mut script = vec![(1 | 64, 0.0); 20];
script.push((0, 0.0));
script.extend(vec![(1 | 64, 0.0); 20]);
Run {
script,
server_sprint: vec![(21 + late, false), (21 + late + gap, true)],
..Run::walk()
}
}
#[test]
fn a_sprint_the_server_cleared_is_started_again() {
for (late, gap) in [(1, 1), (2, 1), (3, 2)] {
let Some(steps) = steps_in(sprint_restarted(late, gap), |_, _, honest| honest, None)
else {
return;
};
for (i, s) in steps.iter().enumerate() {
assert!(
s.played.offset <= EXACT && s.over == Over::default(),
"late {late}, gap {gap}, tick {i}: {:?}",
s.played
);
}
}
}
/// The same with a speed lie (a twentieth of a block a tick) from the tick the STOP's packets
/// reach the client: it counts from that tick on.
#[test]
fn a_speed_lie_through_a_sprint_restart_still_counts() {
let Some(steps) = steps_in(
sprint_restarted(2, 1),
|i, _, honest| honest.add_vector(Vec3::new(0.0, 0.0, if i >= 23 { 0.05 } else { 0.0 })),
None,
) else {
return;
};
let counted: Vec<usize> = steps
.iter()
.enumerate()
.filter(|(_, s)| s.over.ticks > 0)
.map(|(i, _)| i)
.collect();
assert_eq!(counted.first(), Some(&23), "{counted:?}");
assert!(counted.len() >= 10, "{} of 18: {counted:?}", counted.len());
}
/// An elytra: a jump while falling starts a glide, played again exactly.
#[test]
fn a_glide_is_played_again_exactly() {
+1 -1
View File
@@ -82,7 +82,7 @@ there.
|---|---|---|
| `timer` | a sped-up game (more ticks a second than the server's clock) | vanilla's client ends each tick with ServerboundClientTickEndPacket, at the server's tick rate. Each one spends one tick of a balance that wall-clock time fills. A client more than `tolerance_ms` (300) ahead is flagged; a quiet connection banks at most `max_lag_ms` (1000), so a lag spike's backlog isn't flagged and a pause can't be saved for a burst. A slow server tick adds its own time before its backlog is counted. |
| `ground` | no-fall (claiming the ground to skip fall damage) and walking on water | a move that says it ends on the ground is judged only when every block around the feet (0.6 below them, 1/16 wider than the body) is air or a fluid, nothing collides a tenth of a block down, and no boat, minecart, shulker or happy ghast is near. Anything else (powder snow, scaffolding, a waterlogged slab...) isn't judged. A false claim is dropped, so vanilla's fall damage follows (not while the check only logs); the player is never sent back, and a move whose claim was dropped never counts toward vanilla's floating kick (sent back into the air it claimed to stand in, an honest player on a block the server doesn't have claimed it again until that kick: only the anticheat's own kick, logged and told to the operators, ends a false claim); creative, spectators and gliders are skipped (no fall damage to dodge), and so is a claim standing still (no fall to skip; hovering is vanilla's floating kick). |
| `prediction` | speed, step, fly, jesus: any move vanilla's movement can't make from the player's own keys | each client tick (the packets between two ServerboundClientTickEndPackets: keys, sprinting, rotation, the move) is recorded, and every server tick ends with a ping so the server knows from which client tick on what it changed (a push, a teleport, an effect) was in effect. Each tick is then played again with the client's own movement code, from where the last one ended (it starts at a teleport the client accepted), and compared with where the client said it went; the replay then goes on from the client's position. What the server sent the player that changes its movement (a velocity, an explosion's knockback, attributes, effects) is applied on the first client tick that had seen it, so a client that ignores a push (anti-knockback) is off too. The keys and sprinting are the client's own, so they are never guessed; what the server can't know is tried (the ground on the first tick after a teleport, the item use on the tick the server's changed). Not played: riding, sleeping, dead, spectators. A move that lands farther than `tolerance` (0.03 blocks) from the replay flags, weighing its offset in tolerances (at most 5 a tick); the setback puts the player back where its moves last agreed. What the replay can't play yet excuses the tick, and the ticks after it until the moves agree again (2 seconds at most): another entity or player within 3 blocks (a push, a boat or shulker to stand on, a firework to fly with), an entity within 4 blocks that the server removed and the client hadn't heard of yet, a block change within 4 blocks the client hadn't seen or the server hasn't sent yet, abilities (a game mode, flight, a glider put on) the client hadn't seen, a moving piston, a chunk around the player the client doesn't have, a riptide launch, a teleport that keeps the player's motion (an End gateway's). Around a fast player (an elytra) these distances grow by two ticks of its speed, as what the server has around it is a tick or so behind. Under an excuse the replay takes the client's velocity, so it agrees again within a few ticks. It costs one ping and one replayed tick a tick per player. |
| `prediction` | speed, step, fly, jesus: any move vanilla's movement can't make from the player's own keys | each client tick (the packets between two ServerboundClientTickEndPackets: keys, sprinting, rotation, the move) is recorded, and every server tick ends with a ping so the server knows from which client tick on what it changed (a push, a teleport, an effect) was in effect. Each tick is then played again with the client's own movement code, from where the last one ended (it starts at a teleport the client accepted), and compared with where the client said it went; the replay then goes on from the client's position. What the server sent the player that changes its movement (a velocity, an explosion's knockback, attributes, effects, the sprinting flag) is applied on the first client tick that had seen it, so a client that ignores a push (anti-knockback) is off too. The keys and sprinting are the client's own, so they are never guessed; what the server can't know is tried (the ground on the first tick after a teleport, the item use on the tick the server's changed). Not played: riding, sleeping, dead, spectators. A move that lands farther than `tolerance` (0.03 blocks) from the replay flags, weighing its offset in tolerances (at most 5 a tick); the setback puts the player back where its moves last agreed. What the replay can't play yet excuses the tick, and the ticks after it until the moves agree again (2 seconds at most): another entity or player within 3 blocks (a push, a boat or shulker to stand on, a firework to fly with), an entity within 4 blocks that the server removed and the client hadn't heard of yet, a block change within 4 blocks the client hadn't seen or the server hasn't sent yet, abilities (a game mode, flight, a glider put on) the client hadn't seen, a moving piston, a chunk around the player the client doesn't have, a riptide launch, a teleport that keeps the player's motion (an End gateway's). Around a fast player (an elytra) these distances grow by two ticks of its speed, as what the server has around it is a tick or so behind. Under an excuse the replay takes the client's velocity, so it agrees again within a few ticks. It costs one ping and one replayed tick a tick per player. |
| `combat` | a kill aura: attacks a vanilla client can't send | vanilla's client attacks from one place only (`Minecraft.startAttack`, once a client tick, with no screen open): the entity under the crosshair, picked once a tick, then it swings (ServerboundPunchPacket) at once. An attack with no swing before the next attack or the tick's end (a kill aura hiding its swing), or a second entity attacked in the same client tick (a multi-target aura), flags once. From `setback` (2) on the player's attacks are dropped until the level decays below it (`decay` 0.5 a second); `kick` is 20. It costs nothing until a player attacks. |
More checks (aim, world, anti-xray) come in their own pull requests; this table lists the