Merge pull request #9993 from MSedgeMC/fix/prediction-snap

A refused block change that comes back inside the player snaps it out
This commit is contained in:
rdbt authored and GitHub committed 2026-11-06 21:20:30 +00:00
commit ff31439c92
2 files changed
+172 -18

No files matched your search

+171 -17
View File
@@ -548,6 +548,9 @@ pub(super) struct Remote {
/// `BlockStatePredictionHandler.currentSequenceNr`: the level's, so a new level (a login,
/// another dimension) counts from 0 again.
sequence: i32,
/// `BlockStatePredictionHandler.lastTeleportSequence`: the sequence of the last
/// teleport, so a prediction made before it keeps its block but not its snap.
last_teleport_sequence: i32,
position_sender: horizon_client::player_sync::PositionSender,
last_input: u8,
was_sprinting: bool,
@@ -573,10 +576,10 @@ pub(super) struct Remote {
/// again when a view distance, Chat, skin or Main Hand option changes).
sent_info: horizon_protocol::packets::configuration::ClientInformation,
/// BlockStatePredictionHandler: blocks changed ahead of the server (a predicted break),
/// with the sequence of the last action that changed them and the state the server
/// last said they have. Until the server acknowledges that sequence its block updates
/// only replace the remembered state; then the block takes it.
predictions: HashMap<[i32; 3], (i32, i32)>,
/// with what it remembers of each of them. Until the server acknowledges the
/// sequence its block updates only replace the remembered state; then the block
/// takes it.
predictions: HashMap<[i32; 3], Prediction>,
/// Open container: (window id, menu name).
pub(super) menu: Option<(i32, String)>,
/// The crafting result slot of the open menu as the server last set it
@@ -1117,6 +1120,7 @@ impl Game<'_> {
view_distance: self.settings.render_distance,
simulation_distance: self.settings.simulation_distance,
sequence: 0,
last_teleport_sequence: -1,
position_sender: Default::default(),
last_input: 0,
was_sprinting: false,
@@ -2620,12 +2624,12 @@ impl Game<'_> {
/// predicted state until the server acknowledges the action; the server's state is
/// remembered for then.
fn server_block(&mut self, pos: [i32; 3], state: i32) {
if let Some((_, known)) = self
if let Some(p) = self
.remote
.as_mut()
.and_then(|r| r.predictions.get_mut(&pos))
{
*known = state;
p.state = state;
return;
}
self.set_block(pos, state);
@@ -2637,21 +2641,82 @@ impl Game<'_> {
let Some(r) = self.remote.as_mut() else {
return;
};
let last_teleport = r.last_teleport_sequence;
let settled: Vec<_> = r
.predictions
.iter()
.filter(|(_, (s, _))| *s <= sequence)
.map(|(&pos, &(_, state))| (pos, state))
.filter(|(_, p)| p.sequence <= sequence)
.map(|(&pos, &p)| (pos, p))
.collect();
for (pos, state) in settled {
for (pos, prediction) in settled {
if let Some(r) = self.remote.as_mut() {
r.predictions.remove(&pos);
}
if self.world.block(pos[0], pos[1], pos[2]).0 as i32 != state {
self.set_block(pos, state);
if self.world.block(pos[0], pos[1], pos[2]).0 as i32 == prediction.state {
continue;
}
self.set_block(pos, prediction.state);
// ClientLevel.syncBlockState: the block that comes back may now hold
// the player, and then it goes to the position the prediction
// remembered.
let meets = self.block_meets_player(pos, prediction.state);
if let Some(at) = snap_target(prediction.player_pos, last_teleport, sequence, meets) {
self.snap_player_to(at);
}
}
}
/// The player's position, for `retainKnownServerState`'s `player.position()`.
fn player_position(&self) -> [f64; 3] {
let at = self
.player
.as_ref()
.map(|p| p.position)
.unwrap_or(Vec3::ZERO);
[at.x, at.y, at.z]
}
/// `Entity.isColliding(pos, state)` for the local player: the state's own
/// collision shape, at the player's pose, against the player's box.
fn block_meets_player(&self, pos: [i32; 3], state: i32) -> bool {
let Some(player) = self.player.as_ref() else {
return false;
};
let entity = player.bounds();
let context = horizon_world::collision_context::CollisionContext::for_entity(
horizon_world::collision_context::CollisionEntity {
identity: horizon_world::entity_obstruction::EntityIdentity(0),
y: player.position.y,
descending: player.shift_key_down,
main_hand: None,
},
false,
);
let mut scene = horizon_client::collisions::SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let at = horizon_core::position::BlockPos::new(pos[0], pos[1], pos[2]);
match self.collision.collision_shape_with_context(
&mut scene,
BlockStateId(state.max(0) as u32),
at,
&context,
) {
Ok(shape) => shape_meets(&shape, pos, entity),
Err(_) => false,
}
}
/// `Entity.absSnapTo`: the remembered point, clamped to the level's edge.
/// The velocity is left alone, and the player has no separate previous
/// position to reset (its box is drawn from `position`).
fn snap_player_to(&mut self, at: [f64; 3]) {
let Some(player) = self.player.as_mut() else {
return;
};
let x = at[0].clamp(-3.0e7, 3.0e7);
let z = at[2].clamp(-3.0e7, 3.0e7);
player.position = Vec3::new(x, at[1], z);
}
fn set_block(&mut self, pos: [i32; 3], state: i32) {
// A state we don't have (a server with mods we lack) reads as air, as in its chunks.
let state = state.max(0) as u32;
@@ -5984,6 +6049,11 @@ impl Game<'_> {
(yaw, pitch),
p,
);
// BlockStatePredictionHandler.onTeleport: a prediction made before this
// one keeps its block but not the player position it remembered.
if let Some(r) = self.remote.as_mut() {
r.last_teleport_sequence = r.sequence;
}
match self.player.as_mut() {
Some(player) => {
player.position = position;
@@ -7685,11 +7755,16 @@ impl Game<'_> {
fn predict_break(&mut self, pos: [i32; 3], state: BlockStateId, sequence: i32) {
self.tutorial_destroy(state, 1.0);
// BlockStatePredictionHandler.retainKnownServerState.
let here = self.player_position();
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|p| p.0 = sequence)
.or_insert((sequence, state.0 as i32));
.and_modify(|p| p.sequence = sequence)
.or_insert(Prediction {
sequence,
state: state.0 as i32,
player_pos: here,
});
}
// Block.playerWillDestroy's destroy event (particles and the break
// sound), which BaseFireBlock skips: the server's extinguish sound
@@ -8430,11 +8505,16 @@ impl Game<'_> {
for &(p, state) in changes {
let pos = [p.x, p.y, p.z];
let before = self.world.block(p.x, p.y, p.z);
let here = self.player_position();
if let Some(r) = self.remote.as_mut() {
r.predictions
.entry(pos)
.and_modify(|e| e.0 = seq)
.or_insert((seq, before.0 as i32));
.and_modify(|e| e.sequence = seq)
.or_insert(Prediction {
sequence: seq,
state: before.0 as i32,
player_pos: here,
});
}
self.set_block(pos, state.0 as i32);
}
@@ -11113,6 +11193,45 @@ fn hold_server_fire(fire_ticks: i32, server_on_fire: bool) -> i32 {
}
}
/// `BlockStatePredictionHandler.ServerVerifiedState`: the sequence of the action
/// that last changed the block, the state the server last said it has, and where
/// the player stood when this client first predicted a change there.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(super) struct Prediction {
pub sequence: i32,
pub state: i32,
pub player_pos: [f64; 3],
}
/// `Entity.isColliding`: whether the collision `shape` of a block at `pos`
/// meets `entity`'s box. Shapes are block-local, so they move onto the block
/// first (`VoxelShape.move`), and any box that overlaps is a collision
/// (`Shapes.joinIsNotEmpty` with `BooleanOp.AND`).
pub(super) fn shape_meets(
shape: &horizon_core::geometry::VoxelShape,
pos: [i32; 3],
entity: horizon_core::geometry::Aabb,
) -> bool {
let at = Vec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
shape
.boxes()
.iter()
.any(|b| b.move_by(at).intersects(entity))
}
/// `ClientLevel.syncBlockState`'s snap: a restored block that meets the player
/// sends it back to the position the prediction remembered, unless the player
/// was teleported after that prediction was made
/// (`lastTeleportSequence < sequence`: the remembered position is stale).
pub(super) fn snap_target(
recorded: [f64; 3],
last_teleport_sequence: i32,
sequence: i32,
meets: bool,
) -> Option<[f64; 3]> {
(meets && last_teleport_sequence < sequence).then_some(recorded)
}
/// [`Remote::has_level`]: logged in (a Login arrived) and no reconfiguration
/// since (`reconfiguring` is set by StartConfiguration, cleared by the next
/// Login).
@@ -11124,8 +11243,8 @@ fn client_has_level(logged_in: bool, reconfiguring: Option<u64>) -> bool {
mod tests {
use super::{
boat_interact_passes, client_has_level, hold_server_fire, item_frame_interact_passes,
keep_client_state, open_hash_order, remote_object_swings, remote_use_swings,
synced_local_use,
keep_client_state, open_hash_order, remote_object_swings, remote_use_swings, shape_meets,
snap_target, synced_local_use,
};
/// The vanilla 26.3 client's ServerboundContainerClickPacket for a shift-click from hotbar slot 37
@@ -11368,6 +11487,41 @@ mod tests {
assert!(game.native_send_chat("/warp spawn").is_err());
}
/// `Entity.isColliding` and the snap `ClientLevel.syncBlockState` makes: a
/// block the server puts back where the player stands sends it to the
/// position the prediction remembered, and a block that comes back clear of
/// the player, an empty one, or one the player has been teleported since
/// leaves it where it is.
#[test]
fn a_block_the_server_puts_back_snaps_the_player_out_of_it() {
use horizon_core::geometry::{Aabb, Vec3, VoxelShape};
// A player standing in block (0, 64, 0): 0.6 wide, 1.8 tall.
let player = Aabb::new(Vec3::new(0.2, 64.0, 0.2), Vec3::new(0.8, 65.8, 0.8));
let cube = VoxelShape::block();
assert!(
shape_meets(&cube, [0, 64, 0], player),
"the block the player is standing in"
);
assert!(!shape_meets(&cube, [0, 66, 0], player), "above its head");
assert!(
!shape_meets(&cube, [1, 64, 0], player),
"the block beside it"
);
assert!(!shape_meets(&VoxelShape::empty(), [0, 64, 0], player));
// The snap itself: the remembered position, unless a teleport came after
// the prediction (`lastTeleportSequence < sequence`).
let at = [0.5, 64.0, 0.5];
assert_eq!(snap_target(at, -1, 3, true), Some(at));
assert_eq!(snap_target(at, -1, 3, false), None, "a block it clears");
assert_eq!(snap_target(at, 3, 3, true), None, "teleported at 3");
assert_eq!(
snap_target(at, 2, 3, true),
Some(at),
"teleported before it"
);
}
/// `MultiPlayerGameMode.useItem`'s `shouldSwing`: the SUCCESS answers
/// swing, CONSUME's do not (#6425).
#[test]
+1 -1
View File
@@ -1,7 +1,7 @@
Entity.<init> port crates/horizon-client/src/mobs.rs::Mob::new mobs only; players and projectiles are built by their own structs and the synched defaults (flags, air, name, pose) are plain fields
Entity.get port crates/horizon-client/src/game/advancements.rs::Game::mob_entity mob predicate projection: live custom_name Text and the existing type-variant component; gap: custom_data is not projected
Entity.getMovementEmission port crates/horizon-client/src/mobs/ai.rs::movement_sounds EVENTS for silverfish, endermite, bats, squids, glow squids, guardians, elder guardians and breezes (game events, no sounds), NONE for a shulker (ai.rs::emits_movement), ALL for every other type
Entity.isColliding missing minor: a block change that a server refuses and that comes back inside the player does not snap the player to the position of the prediction (end_predictions only restores the block)
Entity.isColliding port crates/horizon-client/src/game/remote.rs::Game::block_meets_player the state's collision shape at the player's pose against the player's box; `end_predictions` asks it for a restored block and snaps the player to the position the prediction remembered (`snap_target`, guarded by `onTeleport`)
Entity.canInteractWithLevel inline crates/horizon-client/src/mobs/leash.rs::tick gap: minor: only the leashed mob's life is tested (a dead one drops its lead); a dead or spectating holder is not tested
Entity.unRide inline crates/horizon-client/src/game/consume.rs::Game::stop_riding gap: minor: the player's half (boat, minecart or mob mount); riders of a vehicle are let go by sync_riders and eject_boat_mobs
Entity.addTag port crates/horizon-client/src/game/commands/data.rs::Game::cmd_tag the tag.add branch, with the 1024 tag limit
1 Entity.<init> port crates/horizon-client/src/mobs.rs::Mob::new mobs only; players and projectiles are built by their own structs and the synched defaults (flags, air, name, pose) are plain fields
2 Entity.get port crates/horizon-client/src/game/advancements.rs::Game::mob_entity mob predicate projection: live custom_name Text and the existing type-variant component; gap: custom_data is not projected
3 Entity.getMovementEmission port crates/horizon-client/src/mobs/ai.rs::movement_sounds EVENTS for silverfish, endermite, bats, squids, glow squids, guardians, elder guardians and breezes (game events, no sounds), NONE for a shulker (ai.rs::emits_movement), ALL for every other type
4 Entity.isColliding missing port crates/horizon-client/src/game/remote.rs::Game::block_meets_player minor: a block change that a server refuses and that comes back inside the player does not snap the player to the position of the prediction (end_predictions only restores the block) the state's collision shape at the player's pose against the player's box; `end_predictions` asks it for a restored block and snaps the player to the position the prediction remembered (`snap_target`, guarded by `onTeleport`)
5 Entity.canInteractWithLevel inline crates/horizon-client/src/mobs/leash.rs::tick gap: minor: only the leashed mob's life is tested (a dead one drops its lead); a dead or spectating holder is not tested
6 Entity.unRide inline crates/horizon-client/src/game/consume.rs::Game::stop_riding gap: minor: the player's half (boat, minecart or mob mount); riders of a vehicle are let go by sync_riders and eject_boat_mobs
7 Entity.addTag port crates/horizon-client/src/game/commands/data.rs::Game::cmd_tag the tag.add branch, with the 1024 tag limit