A player's attributes stay through a respawn and a change of level (#3684)

The server made a new body for a player from the defaults when they died and
respawned or went to another level, so /attribute @s minecraft:scale base set
0.1 was 1.0 again (box, eye height and reach as well), and so was every other
attribute a command or a datapack had set. Vanilla keeps the same ServerPlayer
through teleport, and ServerPlayer.restoreFrom copies the base values to the
body after a death (assignBaseValues). The old body's attributes now go to the
next one (with their permanent modifiers for a level change, base values only
after a death), and a player saved between two bodies writes them to the file.

restoreFrom then gives the respawned player its max health
(setHealth(getMaxHealth())): a max health base of 40 comes back with 40, not
with the fixed 20 the respawn set.

Also: a glide the client announced before the body's own tick used a
scale-1 box for that tick (Guest::dimensions); it takes the body's scale. The
port map's LivingEntity.getScale row still said there was no scale attribute.

Tests: the respawn health, the glide box, the carried attributes, a save
between bodies; lan_e2e check scale-body (host and guest: the Nether and back,
a death and respawn, the health after it).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RodDbPWCByh96WKcbiEQpB
This commit is contained in:
MariuslozoandClaude Opus 5.5 committed 2026-11-02 00:39:35 +01:00
1 parent 8d93d68f1d
commit 3c0a4db876
8 files changed
+316 -15

No files matched your search

+16 -4
View File
@@ -383,6 +383,15 @@ impl Game<'_> {
let _ =
m.apply_saved_attributes(g.saved.as_ref().and_then(|t| t.get("attributes")));
}
// ServerPlayer.restoreFrom: a body that follows another takes its
// attributes (a scale of 0.1 stays 0.1 after a respawn or a trip
// to another level).
let g = &mut host.hub.guests[i];
if let Some(carried) = g.carried_attributes.take()
&& let Some(m) = g.motion.as_mut()
{
let _ = m.apply_saved_attributes(Some(&carried));
}
// The saved golden hearts go on the first body of a player who
// has been here before.
let saved = host.hub.guests[i].saved_absorption.take();
@@ -2508,8 +2517,9 @@ impl Game<'_> {
}
let g = &mut host.hub.guests[gi];
(g.yaw, g.pitch) = (yaw, pitch);
// A new body there: no fall distance or motion carried over.
g.motion = None;
// A new body there: no fall distance or motion carried over, but the
// player's attributes (ServerPlayer.teleport keeps the entity).
g.new_body(true);
host.hub.teleport(gi, Some(at));
}
fn guest_tick_death(&mut self, host: &mut LanHost, gi: usize) {
@@ -3233,14 +3243,16 @@ impl Game<'_> {
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
g.saturation = 5.0;
g.sent_health = None;
if let Some(m) = g.motion.take() {
// ServerPlayer.restoreFrom(old, false): the base values come back,
// and the new player has the max health they give.
if let Some(m) = g.new_body(false) {
let m = *m;
g.logs_kept = Some((m.hurt_log, m.attack_log));
}
g.health = g.respawn_health(self.motion);
let mut xp = Experience {
level: g.xp_level,
progress: g.xp_progress,
@@ -670,8 +670,9 @@ impl Game<'_> {
host.play.entry(trip.conn).or_default().portal.cooldown = 10;
let g = &mut host.hub.guests[i];
g.yaw = yaw;
// A new body at the exit: no fall distance or motion carried over.
g.motion = None;
// A new body at the exit: no fall distance or motion carried over,
// but the player's attributes (ServerPlayer.teleport keeps the entity).
g.new_body(true);
let at = [position.x, position.y, position.z];
if portal {
host.hub.land(i, at);
@@ -727,8 +728,9 @@ impl Game<'_> {
let g = &mut host.hub.guests[i];
let [yaw, pitch] = t.rotation.unwrap_or([g.yaw, g.pitch]);
let origin = g.position;
// A new body there: no fall distance or motion carried over.
g.motion = None;
// A new body there: no fall distance or motion carried over, but
// the player's attributes (ServerPlayer.teleport keeps the entity).
g.new_body(true);
let cooldown = host.play.get(&t.conn).map_or(0, |p| p.portal.cooldown);
let ctx = self.server_ctx();
host.hub
+131 -2
View File
@@ -214,6 +214,11 @@ pub struct Guest {
/// What the player's file says of the body (`Entity.load`: the fire
/// and air ticks and the fall distance), for the first body it gets.
pub loaded_body: Option<SavedBody>,
/// The attribute list the player's last body hands to its next one
/// (`ServerPlayer.restoreFrom`, `PlayerMotion::carried_attributes`),
/// until that body exists: the scale a player has set stays through a
/// respawn and a change of level (#3684).
pub carried_attributes: Option<horizon_world::nbt::Tag>,
/// The vehicle the player's file names (`ServerPlayer.saveParentVehicle`'s
/// "RootVehicle"), for the ride to come back on the next login.
pub saved_vehicle: Option<[i32; 4]>,
@@ -408,6 +413,7 @@ impl Guest {
tracker: Tracker::default(),
motion: None,
loaded_body: None,
carried_attributes: None,
saved_vehicle: None,
logs_kept: None,
inventory: vec![None; 36],
@@ -597,14 +603,39 @@ impl Guest {
Pose::Standing
}
}
/// `Avatar.POSES` for the guest's pose, with its scale attribute.
/// `Avatar.POSES` for the guest's pose, with its scale attribute: also for
/// a pose the client has announced (gliding) before the body's own tick
/// has taken it.
pub fn dimensions(&self) -> Dimensions {
let pose = self.pose();
match &self.motion {
Some(m) if m.pose == pose => m.dimensions,
_ => pose.dimensions(1.0),
Some(m) => pose.dimensions(m.scale),
None => pose.dimensions(1.0),
}
}
/// The player's body is replaced (`ServerPlayer.restoreFrom`): the next
/// one starts from the old one's attributes, all of them when it is the
/// same player in another level (`restore_all`), only their base values
/// after a death. The old body, for what else it hands over.
pub fn new_body(&mut self, restore_all: bool) -> Option<Box<PlayerMotion>> {
let m = self.motion.take()?;
self.carried_attributes = Some(m.carried_attributes(restore_all));
Some(m)
}
/// `ServerPlayer.restoreFrom(old, false)`'s `setHealth(getMaxHealth())`:
/// the health a player comes back from a death with, the max health of
/// the base values their next body takes over, before its equipment
/// modifies it (40 after `/attribute @s minecraft:max_health base set
/// 40`, not 20).
pub fn respawn_health(&self, resources: &crate::motion::MotionResources) -> f32 {
let Ok(mut body) = PlayerMotion::new(resources, Vec3::ZERO) else {
return 20.0;
};
let _ = body.apply_saved_attributes(self.carried_attributes.as_ref());
body.attribute("minecraft:max_health")
.map_or(20.0, |max| max as f32)
}
/// `Entity.getBoundingBox`.
pub fn bounds(&self) -> Aabb {
let [x, y, z] = self.position;
@@ -1381,4 +1412,102 @@ mod tests {
Some(Component::Text(_))
));
}
/// The prepared game data a body needs, or `None` without it.
fn resources() -> Option<(crate::assets::Assets, crate::motion::MotionResources)> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
eprintln!("skipped: prepared resources unavailable");
return None;
}
let assets = crate::assets::Assets::load(&root).ok()?;
let resources = crate::motion::MotionResources::load(&root, &assets).ok()?;
Some((assets, resources))
}
/// `/attribute @s <attribute> base set`, as `AttributeCommand` runs it,
/// and the next tick's `refreshDirtyAttributes` (the box of a new scale).
fn set_base(m: &mut PlayerMotion, attribute: &str, value: f64) {
let key = m.attributes.key(attribute).unwrap();
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
m.attributes
.edit(&handle, |attribute, observer| {
attribute.set_base_value(value, observer)
})
.unwrap();
m.refresh_attributes().unwrap();
}
fn assert_dims(got: Dimensions, want: (f32, f32, f32), what: &str) {
let close = |a: f32, b: f32| (a - b).abs() <= 1e-5 * b.abs().max(1.0);
assert!(
close(got.width, want.0) && close(got.height, want.1) && close(got.eye_height, want.2),
"{what}: {got:?}, want {want:?}"
);
}
/// `ServerPlayer.restoreFrom(old, false)` takes the base values
/// (`assignBaseValues`), then `setHealth(getMaxHealth())`: a player whose
/// max health base was set comes back with that much, not 20, and a
/// permanent modifier (`/attribute ... modifier add`) does not count, as
/// it stays with the dead body. The attribute's range is 1..1024.
#[test]
fn a_respawned_player_has_the_max_health_of_their_base_values() {
use horizon_world::attributes::{Modifier, Operation};
let Some((_assets, resources)) = resources() else {
return;
};
for (base, bonus, want) in [
(40.0, 0.0, 40.0),
(10.0, 0.0, 10.0),
(20.0, 20.0, 20.0),
(0.0, 0.0, 1.0),
(2048.0, 0.0, 1024.0),
] {
let mut g = guest_at([0.5, 64.0, 0.5]);
let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
set_base(&mut m, "minecraft:max_health", base);
let key = m.attributes.key("minecraft:max_health").unwrap();
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
let bonus = Modifier::new("test:bonus", bonus, Operation::AddValue).unwrap();
m.attributes
.edit(&handle, |a, o| a.add_permanent(bonus, o))
.unwrap();
g.motion = Some(Box::new(m));
assert!(g.new_body(false).is_some());
assert_eq!(g.respawn_health(&resources), want, "base {base}");
}
// No body to take anything from: the default.
let g = guest_at([0.5, 64.0, 0.5]);
assert_eq!(g.respawn_health(&resources), 20.0);
}
/// #3684: a glide the client announced (`START_FALL_FLYING`) is the
/// player's pose before its body's own tick has taken it: the box in
/// between is the gliding one at the player's scale, not at scale 1 (a
/// 0.6 box on a player 0.1 or 2 times as big sent them back from a wall).
#[test]
fn a_glide_announced_before_the_body_ticks_has_the_scaled_box() {
let Some((_assets, resources)) = resources() else {
return;
};
for (scale, side) in [(0.1_f64, 0.06_f32), (2.0, 1.2)] {
let mut g = guest_at([0.5, 64.0, 0.5]);
let mut m = PlayerMotion::new(&resources, Vec3::new(0.5, 64.0, 0.5)).unwrap();
set_base(&mut m, "minecraft:scale", scale);
m.set_pose(Pose::Standing).unwrap();
g.motion = Some(Box::new(m));
g.fall_flying = true;
assert_eq!(g.pose(), Pose::FallFlying);
assert_ne!(g.motion.as_ref().unwrap().pose, Pose::FallFlying);
let d = g.dimensions();
assert_dims(
d,
(side, side, side * (0.4 / 0.6)),
&format!("scale {scale}"),
);
}
}
}
+92 -2
View File
@@ -250,12 +250,19 @@ pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<Tag>) {
}
// LivingEntity.addAdditionalSaveData: the live body's AttributeMap.pack,
// or the list the file had while the player has no body yet.
if let Some(m) = guest.motion.as_deref() {
// A player between two bodies (a respawn, a change of level) saves what
// the next one takes over.
let attributes = guest
.motion
.as_deref()
.map(crate::motion::PlayerMotion::saved_attributes)
.or_else(|| guest.carried_attributes.clone());
if let Some(attributes) = attributes {
let extra = level
.player_extra
.get_or_insert_with(|| horizon_world::nbt::Tag::Compound(Default::default()));
if let horizon_world::nbt::Tag::Compound(map) = extra {
map.insert("attributes".into(), m.saved_attributes());
map.insert("attributes".into(), attributes);
}
}
level.recipe_book = guest.recipe_book.clone();
@@ -490,6 +497,89 @@ mod tests {
Some((assets, resources))
}
/// A body at scale 0.1 with a permanent +0.4 on its step height, such as
/// a command or a datapack gives.
fn small_body(resources: &crate::motion::MotionResources) -> crate::motion::PlayerMotion {
use horizon_world::attributes::{Modifier, Operation};
let mut m = crate::motion::PlayerMotion::new(resources, horizon_core::geometry::Vec3::ZERO)
.expect("a body");
let key = m.attributes.key("minecraft:scale").unwrap();
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
m.attributes
.edit(&handle, |a, o| a.set_base_value(0.1, o))
.unwrap();
let key = m.attributes.key("minecraft:step_height").unwrap();
let handle = m.attributes.get_instance(Some(key)).unwrap().unwrap();
let step = Modifier::new("test:step", 0.4, Operation::AddValue).unwrap();
m.attributes
.edit(&handle, |a, o| a.add_permanent(step, o))
.unwrap();
m
}
#[test]
fn a_new_body_takes_the_scale_of_the_old_one() {
// #3684: ServerPlayer.restoreFrom copies the base values to the body
// that follows a death (assignBaseValues) and, for the same player in
// another level, the permanent modifiers too (assignPermanentModifiers).
// The body Horizon made on a respawn or a level change started from
// the defaults: a player at /attribute scale 0.1 was 1.0 again.
let Some((_assets, resources)) = resources() else {
eprintln!("skipped: prepared resources unavailable");
return;
};
let next = |g: &Guest| {
let mut m =
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
.expect("a body");
m.apply_saved_attributes(g.carried_attributes.as_ref())
.unwrap();
m
};
for (restore_all, step) in [(false, 0.6), (true, 1.0)] {
let mut g = guest();
g.motion = Some(Box::new(small_body(&resources)));
g.new_body(restore_all);
assert!(g.motion.is_none());
let mut m = next(&g);
assert_eq!(m.attribute("minecraft:scale").unwrap(), 0.1);
assert!((m.attribute("minecraft:step_height").unwrap() - step).abs() < 1e-9);
// The tick that follows rebuilds the box at that scale.
m.refresh_attributes().unwrap();
assert_eq!(m.dimensions.width, 0.6 * 0.1);
assert_eq!(m.dimensions.height, 1.8 * 0.1);
}
// No body before: nothing to hand over.
let mut g = guest();
g.new_body(true);
assert!(g.carried_attributes.is_none());
}
#[test]
fn a_player_between_two_bodies_saves_the_attributes_the_next_one_takes() {
// #3684: logging out after a respawn, before the new body has ticked,
// must not write the file's older list over the scale.
let Some((_assets, resources)) = resources() else {
eprintln!("skipped: prepared resources unavailable");
return;
};
let mut g = guest();
g.motion = Some(Box::new(small_body(&resources)));
g.new_body(false);
let mut level = Level::default();
store(&g, &mut level, None);
let tag = level.player_tag();
let mut read = Level::default();
read.read_player(&tag);
read.player_extra = Some(tag);
let mut back =
crate::motion::PlayerMotion::new(&resources, horizon_core::geometry::Vec3::ZERO)
.expect("a body");
back.apply_saved_attributes(read.player_extra.as_ref().and_then(|t| t.get("attributes")))
.unwrap();
assert_eq!(back.attribute("minecraft:scale").unwrap(), 0.1);
}
#[test]
fn a_saved_air_comes_back() {
// A player saved underwater (#1321): the file keeps the remaining air
+18 -1
View File
@@ -344,6 +344,10 @@ pub struct PlayerMotion {
pub local_crouching: bool,
pub pose: crate::pose::Pose,
pub dimensions: crate::pose::Dimensions,
/// The `minecraft:scale` the dimensions were last built with
/// (`Entity.refreshDimensions`), for the poses the body takes before it
/// next refreshes.
pub scale: f32,
pub speed: f32,
/// `LocalPlayer.itemUseSpeedMultiplier` while using an item (not riding).
pub item_use_speed: Option<f32>,
@@ -559,6 +563,7 @@ impl PlayerMotion {
local_crouching: false,
pose: crate::pose::Pose::Standing,
dimensions: crate::pose::Pose::Standing.dimensions(1.0),
scale: 1.0,
swimming: false,
is_passenger: false,
fire_ticks: -20,
@@ -717,6 +722,18 @@ impl PlayerMotion {
/// player's `attributes` list (LivingEntity.addAdditionalSaveData), each
/// instance's id, base and permanent modifiers.
pub fn saved_attributes(&self) -> horizon_world::nbt::Tag {
self.packed_attributes(true)
}
/// `ServerPlayer.restoreFrom`: the attributes a player's new body takes
/// over from the old one. A body that comes back from a death takes the
/// base values only (`assignBaseValues`); the same player in another
/// level (`ServerPlayer.teleport`) is the same entity, with its permanent
/// modifiers too (`restoreAll`, `assignPermanentModifiers`). A scale of
/// 0.1 is still 0.1 after a respawn or a trip through a portal (#3684).
pub fn carried_attributes(&self, restore_all: bool) -> horizon_world::nbt::Tag {
self.packed_attributes(restore_all)
}
fn packed_attributes(&self, modifiers: bool) -> horizon_world::nbt::Tag {
use horizon_client_core::nbt_value::string;
use horizon_world::nbt::Tag;
let list = self
@@ -729,7 +746,7 @@ impl PlayerMotion {
("base", Tag::Double(packed.base.to_bits())),
];
// `modifiers` is an optional field whose default is the empty list.
if !packed.modifiers.is_empty() {
if modifiers && !packed.modifiers.is_empty() {
let modifiers = packed
.modifiers
.iter()
+2 -1
View File
@@ -94,7 +94,8 @@ impl PlayerMotion {
Ok(pose.dimensions(self.attribute("minecraft:scale")? as f32))
}
pub fn refresh_dimensions(&mut self) -> Result<()> {
self.dimensions = self.dimensions_for_pose(self.pose)?;
self.scale = self.attribute("minecraft:scale")? as f32;
self.dimensions = self.pose.dimensions(self.scale);
Ok(())
}
pub fn set_pose(&mut self, pose: Pose) -> Result<()> {
+50
View File
@@ -11747,5 +11747,55 @@ CHECKS += ['mining-drop-latency']
CHECK_FNS['mining-drop-latency'] = check_mining_drop_latency
def check_scale_body(host, guest, out):
"""#3684: a player's `minecraft:scale` is the same entity's attribute through a change of level and
a death. ServerPlayer.teleport keeps the player; ServerPlayer.restoreFrom copies the base values to
the body after a death (assignBaseValues), then sets its health to the max they give. The eye the
client sees stays at 0.162 (1.62 * 0.1), not back at 1.62, and a max health base of 40 respawns the
player with 40."""
seen = {}
def scale_of(player):
# The eye height the player's client has: the server's scale reaches it as an attribute update.
state = player('state')['player']
return round(state['eye'][1] - state['position'][1], 3)
for label, player in (('host', host), ('guest', guest)):
s = seen[label] = {}
player('command', command='gamemode creative')
player('command', command='attribute @s minecraft:scale base set 0.1')
player('command', command='attribute @s minecraft:max_health base set 40')
player.wait(20)
s['set'] = scale_of(player)
x, z = (int(v // 1) for v in pos_of(player)[::2])
into_level(player, NETHER, x, 100, z)
s['nether'] = scale_of(player)
player('command', command=f'execute in {OVERWORLD} run tp @s {x + 0.5} 200 {z + 0.5}')
wait_dimension(player, OVERWORLD, 400)
player.wait(40)
s['overworld'] = scale_of(player)
player('command', command='kill @s')
player.wait(30)
player.raw('respawn')
player.wait(60)
back_to_game(player)
s['respawned'] = scale_of(player)
s['health'] = player('state')['player']['health']
player('command', command='attribute @s minecraft:scale base set 1')
player('command', command='attribute @s minecraft:max_health base set 20')
player.wait(20)
s['reset'] = scale_of(player)
for label, s in seen.items():
for step in ('set', 'nether', 'overworld', 'respawned'):
assert s[step] == 0.162, f'{label} after {step}: {s}'
assert s['reset'] == 1.62, f'{label} reset: {s}'
assert s['health'] == 40, f'{label} respawned health: {s}'
return seen
CHECKS += ['scale-body']
CHECK_FNS['scale-body'] = check_scale_body
if __name__ == '__main__':
main()
+1 -1
View File
@@ -32,7 +32,7 @@ LinearInterpolationHandler.doInterpolate inline crates/horizon-client/src/game/r
LivingEntity.<init> port crates/horizon-client/src/mobs.rs::Mob::new box, health, random yaw
LivingEntity.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: player: arrow count (12) and stinger count (13) are never kept or sent, so arrows and bee stingers do not stay stuck in a body
LivingEntity.hasLandedInLiquid port crates/horizon-world/src/living_fall.rs::ImpulseContext::landed_in_liquid water or lava with getDeltaMovement().y below 9.9999997E-6, from the guest move loop; the grace time still defers it
LivingEntity.getScale missing minor: no scale attribute, so /attribute scale and datapack scaling do nothing; baby and slime sizes come from tables in Mob::dims
LivingEntity.getScale port crates/horizon-client/src/mobs/riders.rs::scale the minecraft:scale a client has for any living entity; the server's player reads its own in PlayerMotion::refresh_dimensions (PlayerMotion.scale keeps it for Guest::dimensions)
LivingEntity.shouldDropLoot inline crates/horizon-client/src/mobs/drops.rs::die mob_drops rule and not a baby (monsters excepted)
LivingEntity.decreaseAirSupply port crates/horizon-client/src/motion/environment.rs::MotionWorld::tick_environment_damage gap: minor: only the player reads oxygen_bonus (Respiration); a mob loses one air per tick in ai.rs::base_tick
LivingEntity.setLastHurtByPlayer inline crates/horizon-client/src/mobs.rs::Mob::damage_with ai.last_hurt_by_player = 100 on a player's hit; Game::last_hurt_by_player records which guest
1 Interaction.defineSynchedData port crates/horizon-client/src/game/interactions.rs::RemoteInteraction::new width 1.0, height 1.0, response false
32 LivingEntity.<init> port crates/horizon-client/src/mobs.rs::Mob::new box, health, random yaw
33 LivingEntity.defineSynchedData port crates/horizon-client/src/game/lan.rs::mob_meta gap: player: arrow count (12) and stinger count (13) are never kept or sent, so arrows and bee stingers do not stay stuck in a body
34 LivingEntity.hasLandedInLiquid port crates/horizon-world/src/living_fall.rs::ImpulseContext::landed_in_liquid water or lava with getDeltaMovement().y below 9.9999997E-6, from the guest move loop; the grace time still defers it
35 LivingEntity.getScale missing port crates/horizon-client/src/mobs/riders.rs::scale minor: no scale attribute, so /attribute scale and datapack scaling do nothing; baby and slime sizes come from tables in Mob::dims the minecraft:scale a client has for any living entity; the server's player reads its own in PlayerMotion::refresh_dimensions (PlayerMotion.scale keeps it for Guest::dimensions)
36 LivingEntity.shouldDropLoot inline crates/horizon-client/src/mobs/drops.rs::die mob_drops rule and not a baby (monsters excepted)
37 LivingEntity.decreaseAirSupply port crates/horizon-client/src/motion/environment.rs::MotionWorld::tick_environment_damage gap: minor: only the player reads oxygen_bonus (Respiration); a mob loses one air per tick in ai.rs::base_tick
38 LivingEntity.setLastHurtByPlayer inline crates/horizon-client/src/mobs.rs::Mob::damage_with ai.last_hurt_by_player = 100 on a player's hit; Game::last_hurt_by_player records which guest