A small player's server box follows minecraft:scale

The server's copy of a player never ran LivingEntity.refreshDirtyAttributes,
so after /attribute ... minecraft:scale it kept the old 0.6 x 1.8 box until
the pose changed: a small player was sent back from every wall they walked
along and suffocated next to one. guest_server_tick now runs aiStep's spin
countdown, refreshDirtyAttributes (onAttributeUpdated(SCALE) ->
refreshDimensions) and updatePlayerPose in vanilla's order, and in_wall
takes the eye box from the body's width (Entity.isInWall, width * 0.8)
instead of a fixed 0.48.

Part of #3684.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
d3grandoandClaude Opus 5.5 committed 2026-10-30 05:34:25 +07:00
1 parent 6daad36de2
commit 5b3b108515
3 files changed
+111 -14

No files matched your search

+50 -14
View File
@@ -20,6 +20,7 @@ use horizon_client::{
menu::{Rules, Slots},
},
motion::{MotionEvent, MotionWorld, PlayerMotion},
pose::Dimensions,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
@@ -2569,7 +2570,7 @@ impl Game<'_> {
self.as_guest(host, conn, |game| game.handle_shoulder_entities());
return;
}
let in_wall = !g.sleeping && self.in_wall(g.position, g.eye_height());
let in_wall = !g.sleeping && self.in_wall(g.position, g.dimensions());
let at = g.position;
let (sneaking, sleeping) = (g.sneaking, g.sleeping);
let riding = g.vehicle.is_some();
@@ -2658,10 +2659,10 @@ impl Game<'_> {
let _ = w.server_hazards_tick(in_wall);
// LivingEntity.aiStep: a Riptide spin counts down on the server's
// copy too, or it would stay in the spin pose (eye 0.4) for good.
w.player.count_down_server_spin();
// Player.tick ends with updatePlayerPose: a swimmer, or a player
// crawling under a block, takes the short box its moves are checked with.
let _ = w.update_player_pose();
// LivingEntity.tick: a changed scale rebuilds the box. Player.tick
// ends with updatePlayerPose: a swimmer, or a player crawling
// under a block, takes the short box its moves are checked with.
let _ = w.server_player_pose_tick();
let mut events = std::mem::take(&mut w.player.events);
// LayeredCauldronBlock.handleEntityOnFireInside along those moves.
for event in &events {
@@ -2884,15 +2885,16 @@ impl Game<'_> {
});
}
/// `Player.isInWall`: a suffocating block in the 0.48-wide box at the
/// eyes. Suffocating is a full collision box that is also a full render
/// cube (`BlockBehaviour.isSuffocating`'s default; glass and the like
/// opt out), as the mobs' check has it.
fn in_wall(&self, at: [f64; 3], eye_height: f64) -> bool {
let (y, half) = ((at[1] + eye_height).floor() as i32, 0.24);
let range = |v: f64| (v - half).floor() as i32..=(v + half).floor() as i32;
range(at[0]).any(|x| {
range(at[2]).any(|z| {
/// `Entity.isInWall`: a suffocating block in the box at the eyes as wide
/// as 0.8 of the body (0.48 at scale 1, 0.048 at scale 0.1). Suffocating
/// is a full collision box that is also a full render cube
/// (`BlockBehaviour.isSuffocating`'s default; glass and the like opt
/// out), as the mobs' check has it.
fn in_wall(&self, at: [f64; 3], body: Dimensions) -> bool {
let y = (at[1] + f64::from(body.eye_height)).floor() as i32;
let [xs, zs] = eye_columns(at, body);
xs.into_iter().any(|x| {
zs.clone().any(|z| {
let f = self.res.facts().get(self.world.block(x, y, z));
f.full_collision && f.solid_render
})
@@ -3373,3 +3375,37 @@ fn swing_packet(id: i32, hand: Hand, animation: SwingAnimation) -> C {
duration: animation.duration,
})
}
/// The block columns `Entity.isInWall` looks at: `AABB.ofSize` around the
/// eyes, 0.8 of the body's width across (`BlockPos.betweenClosedStream`).
fn eye_columns(at: [f64; 3], body: Dimensions) -> [std::ops::RangeInclusive<i32>; 2] {
let half = f64::from(body.width * 0.8) / 2.0;
let range = |v: f64| (v - half).floor() as i32..=(v + half).floor() as i32;
[range(at[0]), range(at[2])]
}
#[cfg(test)]
mod tests {
use super::eye_columns;
use horizon_client::pose::Pose;
/// #3684: a player shrunk to `minecraft:scale` 0.1 who stands 0.05 from a
/// wall has eyes 0.048 wide, which don't reach into it: they don't
/// suffocate there, as a full-sized player 0.3 from the wall doesn't.
#[test]
fn a_small_player_checks_only_the_blocks_at_their_small_eyes() {
let at = [205.95, 100.0, 530.5];
assert_eq!(
eye_columns(at, Pose::Standing.dimensions(0.1)),
[205..=205, 530..=530]
);
assert_eq!(
eye_columns(at, Pose::Standing.dimensions(1.0)),
[205..=206, 530..=530]
);
assert_eq!(
eye_columns([205.7, 100.0, 530.5], Pose::Standing.dimensions(1.0)),
[205..=205, 530..=530]
);
}
}
+50
View File
@@ -3585,6 +3585,56 @@ mod tests {
w.player.count_down_server_spin();
assert_eq!(w.player.auto_spin_attack_ticks, 0);
}
/// #3684: `/attribute @s minecraft:scale base set 0.1` on the server's
/// copy of a player. `LivingEntity.tick`'s `refreshDirtyAttributes` runs
/// `onAttributeUpdated(SCALE)`, which calls `refreshDimensions`: the next
/// tick the server checks the player's moves with a 0.06 × 0.18 box, not
/// the 0.6 × 1.8 one that sent a small player back from every wall.
#[test]
fn a_server_copy_takes_its_new_scale_box_the_next_tick() {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let binding = crate::collisions::load(&root, assets.registry).unwrap();
let resources = MotionResources::load(&root, &assets).unwrap();
let mut scene = World::test_scene(&assets, &render, 2).unwrap();
let mut player = PlayerMotion::new(&resources, Vec3::new(20.5, 10.0, 20.5)).unwrap();
let mut w = MotionWorld {
entities: &[],
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
};
w.server_player_pose_tick().unwrap();
assert_eq!(w.player.dimensions.width, 0.6);
assert_eq!(w.player.dimensions.height, 1.8);
// AttributeCommand's base set, as the server runs it.
let key = w.player.attributes.key("minecraft:scale").unwrap();
let handle = w
.player
.attributes
.get_instance(Some(key))
.unwrap()
.unwrap();
w.player
.attributes
.edit(&handle, |value, observer| {
value.set_base_value(0.1, observer)
})
.unwrap();
w.server_player_pose_tick().unwrap();
assert_eq!(w.player.pose, crate::pose::Pose::Standing);
assert_eq!(w.player.dimensions.width, 0.6 * 0.1);
assert_eq!(w.player.dimensions.height, 1.8 * 0.1);
assert_eq!(w.player.dimensions.eye_height, 1.62 * 0.1);
}
/// #3308: a swimmer who boards a boat stops swimming
/// (`Entity.updateSwimming` needs `!isPassenger()`) and `updatePlayerPose`
/// sits them up, eye at 1.62 instead of the swim's 0.4.
+11
View File
@@ -167,6 +167,17 @@ impl MotionWorld<'_, '_, '_> {
};
self.player.set_pose(pose)
}
/// The end of a server's copy of a player's tick (`ServerPlayer.doTick`
/// runs `Player.tick`): `LivingEntity.aiStep` counts a Riptide spin down,
/// `LivingEntity.tick` then runs `refreshDirtyAttributes` (a changed
/// `minecraft:scale` rebuilds the box, `onAttributeUpdated`), and
/// `Player.tick` ends with `updatePlayerPose`. The server checks the
/// player's moves with that box (#3684).
pub fn server_player_pose_tick(&mut self) -> Result<()> {
self.player.count_down_server_spin();
self.player.refresh_attributes()?;
self.update_player_pose()
}
/// The pose part of `Player.tick` for a passenger (`Entity.rideTick` runs
/// `tick`, not the travel): `Entity.updateSwimming` stops a swim, since it
/// needs `!isPassenger()`, and `updatePlayerPose` follows. A player who