diff --git a/crates/horizon-client/examples/scene_collision_probe.rs b/crates/horizon-client/examples/scene_collision_probe.rs index f46723326..f7ed87ea7 100644 --- a/crates/horizon-client/examples/scene_collision_probe.rs +++ b/crates/horizon-client/examples/scene_collision_probe.rs @@ -40,6 +40,7 @@ fn main() -> Result<()> { let mut scene = collisions::SceneCollisions { world: &world, binding: &binding, + subject: None, }; fs::write( dir.join("scene.json"), diff --git a/crates/horizon-client/examples/scene_motion_probe.rs b/crates/horizon-client/examples/scene_motion_probe.rs index 6cdec8587..e5309912b 100644 --- a/crates/horizon-client/examples/scene_motion_probe.rs +++ b/crates/horizon-client/examples/scene_motion_probe.rs @@ -165,6 +165,17 @@ fn snapshot(out: &mut impl Write, player: &PlayerMotion) -> Result<()> { .filter_map(|e| match e { MotionEvent::Fall(e) => Some(e.trace()), MotionEvent::Health(e) => Some(e.trace()), + MotionEvent::Snowflake { position, velocity } => Some(format!( + "snowflake {:016x},{:016x},{:016x} {:016x},{:016x},{:016x}", + position.x.to_bits(), + position.y.to_bits(), + position.z.to_bits(), + velocity.x.to_bits(), + velocity.y.to_bits(), + velocity.z.to_bits() + )), + // The oracle marks every getAdvancements call (ENTER_BLOCK.trigger) so. + MotionEvent::EnterBlock { .. } => Some("hurt-criterion".into()), MotionEvent::Waypoint { receiver, enabled } => Some(format!( "waypoint {} {enabled}", if *receiver { "receive" } else { "transmit" } @@ -232,6 +243,8 @@ fn main() -> Result<()> { instances.next().expect("unexpected instance allocation") }), )?; + // The oracle runs block-inside effects only in "snow_tick". + player.block_effects = false; for op in f["ops"].as_array().unwrap() { let mut world = MotionWorld { player: &mut player, @@ -409,6 +422,26 @@ fn main() -> Result<()> { op["jumping"].as_bool().unwrap(), )?; } + "snow_tick" => { + // The clock, then the player tick: baseTick's powder snow + // flags lead PlayerMotion::player_movement_tick. + world.player.health.advance_clock(); + let v = vector(&op["value"]); + world.player.block_effects = true; + let ticked = world.player_movement_tick( + [v.x as f32, v.z as f32], + op["jumping"].as_bool().unwrap(), + ); + world.player.block_effects = false; + ticked?; + } + "boots" => { + // Leather boots are also freeze-immune wearables. + let boots = op["value"].as_bool().unwrap(); + world.player.leather_boots = boots; + world.player.freeze_immune = boots; + } + "frozen" => world.player.ticks_frozen = op["value"].as_i64().unwrap() as i32, "trigger" => world.player.jump_trigger_time = op["value"].as_i64().unwrap() as i32, "delay" => world.player.no_jump_delay = op["value"].as_i64().unwrap() as i32, "move" => world.move_self(vector(&op["value"]))?, @@ -478,7 +511,18 @@ fn main() -> Result<()> { } let check_block = scene.block(25, 3, 25); snapshot(&mut out, &player)?; - writeln!(out, "block {}", check_block.0)?; + let stuck = player.stuck_multiplier; + writeln!( + out, + "block {} frozen {} {} {} {:016x},{:016x},{:016x}", + check_block.0, + player.ticks_frozen, + player.in_powder_snow, + player.was_in_powder_snow, + stuck.x.to_bits(), + stuck.y.to_bits(), + stuck.z.to_bits() + )?; let h = &player.health; let canonical = |v: f32| { if v.is_nan() { diff --git a/crates/horizon-client/src/advancements.rs b/crates/horizon-client/src/advancements.rs index fb111d8d6..856bc5edd 100644 --- a/crates/horizon-client/src/advancements.rs +++ b/crates/horizon-client/src/advancements.rs @@ -312,7 +312,7 @@ fn range_i(v: &Value, x: i64) -> bool { range_f(v, x as f64) } /// A registry id, `#tag`, or list matched against one id. -fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool { +pub fn id_matches(v: &Value, registry: &str, id: &str, w: &dyn World) -> bool { match v { Value::Null => true, Value::String(s) => match s.strip_prefix('#') { diff --git a/crates/horizon-client/src/collisions.rs b/crates/horizon-client/src/collisions.rs index 47b5d7981..d65851636 100644 --- a/crates/horizon-client/src/collisions.rs +++ b/crates/horizon-client/src/collisions.rs @@ -39,9 +39,24 @@ pub fn load<'r>(root: &Path, registry: &'r BlockRegistry) -> Result { pub world: &'w World, pub binding: &'w BlockCollisionRegistry<'r>, + /// The entity colliding, for powder snow; None: no fall, no boots. + pub subject: Option, } impl SceneCollisions<'_, '_> { /// Position contexts permit geometric movement probes without inventing a @@ -169,18 +184,18 @@ impl LiquidCollisionWorld for SceneCollisions<'_, '_> { } impl PowderSnowWorld for SceneCollisions<'_, '_> { type Error = io::Error; - // Entities here are never falling blocks and wear no leather boots. fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result { + let subject = self.subject.unwrap_or_default(); Ok(PowderSnowEntity { - fall_distance: 0.0, - falling_block: false, + fall_distance: subject.fall_distance, + falling_block: subject.falling_block, }) } fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result { - Ok(false) + Ok(self.subject.is_some_and(|s| s.walkable_type)) } fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result { - Ok(false) + Ok(self.subject.is_some_and(|s| s.leather_boots)) } } diff --git a/crates/horizon-client/src/entities.rs b/crates/horizon-client/src/entities.rs index c0295a688..645b479db 100644 --- a/crates/horizon-client/src/entities.rs +++ b/crates/horizon-client/src/entities.rs @@ -67,6 +67,7 @@ impl ItemEntity { let mut scene = SceneCollisions { world, binding: collision, + subject: None, }; let wanted = self.velocity; let moved = scene @@ -268,6 +269,7 @@ impl ExperienceOrb { let mut scene = SceneCollisions { world, binding: collision, + subject: None, }; let wanted = self.velocity; let moved = scene diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 2bcae4b47..2e9dadd03 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -74,6 +74,7 @@ mod perspective; mod piglins; mod postprocess; mod potions; +mod powder_snow; mod raids; mod recipe_book; mod remote; @@ -534,6 +535,8 @@ struct FallingBlock { no_gravity: bool, /// `FallingBlockEntity.dropItem`. drop_item: bool, + /// `Entity.fallDistance` (powder snow holds a block that fell far at 0.9). + fall_distance: f64, /// The UUID and compound it was loaded with, kept while it moves. saved: Option>, } @@ -1621,6 +1624,7 @@ impl Game<'_> { age: 0, no_gravity: false, drop_item: true, + fall_distance: 0.0, saved: None, }); } @@ -1832,7 +1836,17 @@ impl Game<'_> { next.y.floor() as i32, next.z.floor() as i32, ); - let solid = self.res.facts.get(below).full_collision && f.velocity.y < 0.0; + let below_facts = self.res.facts.get(below); + // PowderSnowBlock.getCollisionShape for a FallingBlockEntity: the + // whole block, or 0.9 high after a fall of more than 2.5 blocks, + // where the block it rests in is the snow itself. + let powder_snow = &*below_facts.name == "minecraft:powder_snow"; + let sunk = powder_snow + && f.fall_distance > 2.5 + && next.y - next.y.floor() < f64::from(0.9_f32); + let solid = + (below_facts.full_collision || powder_snow && f.fall_distance <= 2.5 || sunk) + && f.velocity.y < 0.0; // FallingBlockEntity.tick: off the ground, it gives up after 600 // ticks (100 outside the world) and drops itself as an item. let y = next.y.floor() as i32; @@ -1849,12 +1863,15 @@ impl Game<'_> { f.state, [ next.x.floor() as i32, - next.y.floor() as i32 + 1, + next.y.floor() as i32 + i32::from(!sunk), next.z.floor() as i32, ], )); return false; } + if next.y < f.position.y { + f.fall_distance += f.position.y - next.y; + } f.position = next; f.velocity = Vec3::new( f.velocity.x * 0.98, @@ -2198,8 +2215,15 @@ impl Game<'_> { f64::from(target[1]) + 0.5, f64::from(target[2]) + 0.5, ]; + // SolidBucketItem.getPlaceSound: the bucket's own empty sound. + let solid_bucket = &*stack.item == "minecraft:powder_snow_bucket"; + let place_sound = if solid_bucket { + "minecraft:item.bucket.empty_powder_snow" + } else { + &sounds.place + }; self.audio.play( - &sounds.place, + place_sound, Some(center), (sounds.volume + 1.0) / 2.0, sounds.pitch * 0.8, @@ -2211,6 +2235,10 @@ impl Game<'_> { self.check_wither_spawn(target)?; self.check_golem_spawn(target)?; self.consume_held(); + // SolidBucketItem.useOn: BucketItem.getEmptySuccessItem in hand. + if solid_bucket && !self.creative { + self.inventory[self.selected] = Some(ItemStack::new("minecraft:bucket", 1)); + } Ok(()) } fn consume_held(&mut self) { @@ -2699,6 +2727,20 @@ impl Game<'_> { }) .and_then(|s| self.res.items.get(&s.item)) .map(|i| i.use_speed); + // PowderSnowBlock.canEntityWalkOnPowderSnow and LivingEntity.canFreeze. + player.leather_boots = self.armor[3] + .as_ref() + .is_some_and(|s| &*s.item == "minecraft:leather_boots"); + let immune = self + .res + .items + .item_tags + .get("minecraft:freeze_immune_wearables"); + player.freeze_immune = self + .armor + .iter() + .flatten() + .any(|s| immune.is_some_and(|t| t.contains(&*s.item))); let grounded = player.ground.on_ground; let before = ( grounded, @@ -2782,9 +2824,12 @@ impl Game<'_> { self.audio .play("minecraft:entity.player.hurt", Some(feet), 1.0, 1.0); } + MotionEvent::Snowflake { position, velocity } => self.snowflake(position, velocity), + MotionEvent::EnterBlock { state } => self.trigger_enter_block(state), _ => {} } } + self.powder_snow_fire()?; self.apply_totem(); let Some(player) = self.player.as_mut() else { return Ok(()); @@ -3144,6 +3189,8 @@ impl Game<'_> { hud_layout::HeartKind::Poisoned } else if effects.get("wither").is_some() { hud_layout::HeartKind::Withered + } else if p.is_fully_frozen() { + hud_layout::HeartKind::Frozen } else { hud_layout::HeartKind::Normal }; @@ -6318,6 +6365,23 @@ impl Game<'_> { return Ok(true); } if item == "bucket" { + // getPlayerPOVHitResult(SOURCE_ONLY): powder snow's outline before + // a fluid source behind it. + if let Some(hit) = self.pick() + && self.is_powder_snow(hit.pos) + { + let eye = self.eye_position().unwrap_or(hit.point); + let d = |p: [f64; 3]| { + (p[0] - eye[0]).powi(2) + (p[1] - eye[1]).powi(2) + (p[2] - eye[2]).powi(2) + }; + if self + .pick_fluid() + .is_none_or(|fluid| d(fluid.point) >= d(hit.point)) + { + self.pick_up_powder_snow(hit.pos)?; + return Ok(true); + } + } let Some(hit) = self.pick_fluid() else { return Ok(false); }; diff --git a/crates/horizon-client/src/game/advancements.rs b/crates/horizon-client/src/game/advancements.rs index 9323ed8ea..a56d6fa64 100644 --- a/crates/horizon-client/src/game/advancements.rs +++ b/crates/horizon-client/src/game/advancements.rs @@ -581,6 +581,28 @@ impl Game<'_> { adv::conditions(&c["player"], &pctx, w) && adv::item(&c["item"], &it, w) }); } + /// `EnterBlockTrigger`: the block, its state properties and the player. + pub(super) fn trigger_enter_block(&mut self, state: horizon_world::registry::BlockStateId) { + let Some(definition) = self.res.assets.registry.state(state).cloned() else { + return; + }; + let me = self.player_entity(); + self.trigger("minecraft:enter_block", move |c, w| { + let block_ok = adv::id_matches(&c["blocks"], "block", &definition.name, w); + let state_ok = c["state"] + .as_object() + .into_iter() + .flatten() + .all(|(k, v)| definition.properties.get(k).map(String::as_str) == v.as_str()); + let pctx = adv::Entities { + this: Some(&me), + origin: me.position, + tool: None, + block_pos: None, + }; + block_ok && state_ok && adv::conditions(&c["player"], &pctx, w) + }); + } /// `ItemUsedOnLocationTrigger` (placed_block, item_used_on_block). pub(super) fn trigger_block(&mut self, name: &str, pos: [i32; 3], stack: &ItemStack) { let me = self.player_entity(); diff --git a/crates/horizon-client/src/game/armor_stands.rs b/crates/horizon-client/src/game/armor_stands.rs index 23aeeb092..6cb0dd1f1 100644 --- a/crates/horizon-client/src/game/armor_stands.rs +++ b/crates/horizon-client/src/game/armor_stands.rs @@ -159,6 +159,7 @@ impl Game<'_> { let mut scene = horizon_client::collisions::SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; if !scene.unobstructed(bounds).unwrap_or(false) || self @@ -362,6 +363,7 @@ impl Game<'_> { let mut scene = horizon_client::collisions::SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; for a in &mut self.armor_stands { a.tick(&mut scene); diff --git a/crates/horizon-client/src/game/block_entity_anims.rs b/crates/horizon-client/src/game/block_entity_anims.rs index 5444ea2fd..c448767c1 100644 --- a/crates/horizon-client/src/game/block_entity_anims.rs +++ b/crates/horizon-client/src/game/block_entity_anims.rs @@ -142,6 +142,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; no_collision_empty( &mut scene, diff --git a/crates/horizon-client/src/game/boats.rs b/crates/horizon-client/src/game/boats.rs index 473c7df46..b31127e1e 100644 --- a/crates/horizon-client/src/game/boats.rs +++ b/crates/horizon-client/src/game/boats.rs @@ -318,6 +318,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; if b.old_status == Status::InAir && b.status != Status::InAir && b.status != Status::OnLand { @@ -583,6 +584,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; if !scene.unobstructed(inner).unwrap_or(false) { return false; diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 82338433d..7aff7847f 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -47,6 +47,7 @@ impl ProjectileWorld for ProjWorld<'_, '_> { let mut scene = SceneCollisions { world: self.w.world, binding: self.w.collision, + subject: None, }; let r = traverse_blocks(from, to, 256, |p: BlockPos| { let state = self.w.world.block(p.x, p.y, p.z); diff --git a/crates/horizon-client/src/game/dispenser.rs b/crates/horizon-client/src/game/dispenser.rs index d86c41255..8b7f304f9 100644 --- a/crates/horizon-client/src/game/dispenser.rs +++ b/crates/horizon-client/src/game/dispenser.rs @@ -722,6 +722,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; let mut amount = -1.0; // Level.getCollisions(null, aabb): CollisionContext.empty(). diff --git a/crates/horizon-client/src/game/fireworks.rs b/crates/horizon-client/src/game/fireworks.rs index 60fca288e..120728424 100644 --- a/crates/horizon-client/src/game/fireworks.rs +++ b/crates/horizon-client/src/game/fireworks.rs @@ -128,6 +128,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; let h = 0.125; let bounds = Aabb::new( diff --git a/crates/horizon-client/src/game/fishing.rs b/crates/horizon-client/src/game/fishing.rs index a551d8e92..8285dcf72 100644 --- a/crates/horizon-client/src/game/fishing.rs +++ b/crates/horizon-client/src/game/fishing.rs @@ -444,6 +444,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; if let Ok(moved) = scene.resolve(bounds(h.position), h.velocity, 0.0, h.on_ground) { let horizontal = diff --git a/crates/horizon-client/src/game/frames.rs b/crates/horizon-client/src/game/frames.rs index 773fd2193..c09f5ad17 100644 --- a/crates/horizon-client/src/game/frames.rs +++ b/crates/horizon-client/src/game/frames.rs @@ -104,6 +104,7 @@ impl Game<'_> { let mut scene = horizon_client::collisions::SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; if !scene.unobstructed(bounds).unwrap_or(false) { return false; diff --git a/crates/horizon-client/src/game/minecarts.rs b/crates/horizon-client/src/game/minecarts.rs index 15a6b72b7..62d90648b 100644 --- a/crates/horizon-client/src/game/minecarts.rs +++ b/crates/horizon-client/src/game/minecarts.rs @@ -304,6 +304,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; if let Ok(moved) = scene.resolve(c.bounds(), delta, 0.0, c.on_ground) { c.position = Vec3::new( diff --git a/crates/horizon-client/src/game/natural_spawning.rs b/crates/horizon-client/src/game/natural_spawning.rs index 852984823..fc37143b7 100644 --- a/crates/horizon-client/src/game/natural_spawning.rs +++ b/crates/horizon-client/src/game/natural_spawning.rs @@ -163,6 +163,7 @@ impl NaturalLevel for GameLevel<'_, '_> { let mut scene = SceneCollisions { world: &self.game.world, binding: self.w.collision, + subject: None, }; no_collision_empty(&mut scene, bounds) } diff --git a/crates/horizon-client/src/game/paintings.rs b/crates/horizon-client/src/game/paintings.rs index 9e50a91eb..09d6d4a36 100644 --- a/crates/horizon-client/src/game/paintings.rs +++ b/crates/horizon-client/src/game/paintings.rs @@ -80,6 +80,7 @@ impl Game<'_> { let mut scene = horizon_client::collisions::SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; if !scene.unobstructed(bounds).unwrap_or(false) { return false; diff --git a/crates/horizon-client/src/game/persist.rs b/crates/horizon-client/src/game/persist.rs index 85788caef..b3117aeba 100644 --- a/crates/horizon-client/src/game/persist.rs +++ b/crates/horizon-client/src/game/persist.rs @@ -204,6 +204,7 @@ impl Game<'_> { age, no_gravity, drop_item, + fall_distance: 0.0, saved: uuid.map(|u| Box::new((u, tag.clone()))), }); } diff --git a/crates/horizon-client/src/game/perspective.rs b/crates/horizon-client/src/game/perspective.rs index 7da906515..cb2e9853a 100644 --- a/crates/horizon-client/src/game/perspective.rs +++ b/crates/horizon-client/src/game/perspective.rs @@ -259,6 +259,7 @@ impl Game<'_> { scene: SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }, facts: &self.res.facts, context, diff --git a/crates/horizon-client/src/game/powder_snow.rs b/crates/horizon-client/src/game/powder_snow.rs new file mode 100644 index 000000000..5268185f4 --- /dev/null +++ b/crates/horizon-client/src/game/powder_snow.rs @@ -0,0 +1,133 @@ +//! Powder snow around the player: its snowflakes, fire put out by it, and the +//! bucket that picks it up (`BucketItem.use` on a `BucketPickup` block). The +//! sinking, slowing, climbing and freezing are `PlayerMotion`'s +//! (`crate::motion`); placing it is the powder snow bucket's `BlockItem.place`. +use super::Game; +use horizon_client::{ + Result, + items::ItemStack, + mobs::Who, + particles::{ParticleOptions, ParticleType}, +}; +use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource}; + +impl Game<'_> { + /// `ClientPacketListener.handleParticleEvent` for the one ALTERNATIVE + /// snowflake `PowderSnowBlock.entityInside` sends: no spread, its speed. + pub(super) fn snowflake(&mut self, position: Vec3, velocity: Vec3) { + let r = self.particles.random(); + // nextDouble() * dist for each axis, the distances being 0. + let offset = [ + r.next_double() * 0.0, + r.next_double() * 0.0, + r.next_double() * 0.0, + ]; + let options = ParticleOptions::simple(ParticleType::Snowflake); + self.particles.add( + &options, + [ + position.x + offset[0], + position.y + offset[1], + position.z + offset[2], + ], + [velocity.x, velocity.y, velocity.z], + ); + } + /// The EXTINGUISH effect of the powder snow the player went into this + /// tick: burning, it destroys those blocks (a player always may), then + /// its fire goes out (`Entity.clearFire`) with the extinguish sound. + pub(super) fn powder_snow_fire(&mut self) -> Result<()> { + let Some(player) = self.player.as_mut() else { + return Ok(()); + }; + let entered = std::mem::take(&mut player.powder_snow_entered); + let at = [player.position.x, player.position.y, player.position.z]; + if entered.is_empty() || self.creatures.player_fire <= 0 { + return Ok(()); + } + for pos in entered { + self.with_level(|b, level, ticks| b.destroy_block(level, ticks, pos, false)); + self.world.modified.insert((pos.x >> 4, pos.z >> 4)); + } + self.process_level_events()?; + self.creatures.player_fire = 0; + // Entity.playEntityOnFireExtinguishedSound + let r = self.particles.random(); + let pitch = 1.6 + (r.next_float() - r.next_float()) * 0.4; + self.audio.play( + "minecraft:entity.generic.extinguish_fire", + Some(at), + 0.7, + pitch, + ); + Ok(()) + } + /// Whether the block at `pos` is powder snow. + pub(super) fn is_powder_snow(&self, pos: [i32; 3]) -> bool { + &*self + .res + .facts + .get(self.world.block(pos[0], pos[1], pos[2])) + .name + == "minecraft:powder_snow" + } + /// `BucketItem.use` with an empty bucket on powder snow: + /// `PowderSnowBlock.pickupBlock` (air, level event 2001), the used + /// statistic, the fill sound, FLUID_PICKUP, `ItemUtils.createFilledResult` + /// and the filled_bucket trigger. + pub(super) fn pick_up_powder_snow(&mut self, pos: [i32; 3]) -> Result<()> { + let at = BlockPos::new(pos[0], pos[1], pos[2]); + let picked = self.with_level(|b, level, ticks| b.bucket_pickup(level, ticks, at)); + self.process_level_events()?; + let Some(filled) = picked else { + return Ok(()); + }; + self.world.modified.insert((pos[0] >> 4, pos[2] >> 4)); + self.stats.add("used", "minecraft:bucket", 1); + if let Some(eye) = self + .player + .as_ref() + .map(|p| [p.position.x, p.position.y, p.position.z]) + { + self.audio.play( + "minecraft:item.bucket.fill_powder_snow", + Some(eye), + 1.0, + 1.0, + ); + } + self.post_block_game_event("minecraft:fluid_pickup", pos, Some(Who::Player), None); + let filled = ItemStack::new(filled, 1); + self.filled_result(filled.clone()); + self.trigger_item("minecraft:filled_bucket", &filled); + self.swing_arm(); + Ok(()) + } + /// `ItemUtils.createFilledResult(held, player, filled)` for the held stack. + fn filled_result(&mut self, filled: ItemStack) { + if self.creative { + // Kept, and the filled one added unless the inventory has it. + let has = self + .inventory + .iter() + .flatten() + .any(|s| s.same_item_same_components(&filled)); + if !has { + let _ = self.give(filled); + } + return; + } + let held = &mut self.inventory[self.selected]; + let last = held.as_ref().is_none_or(|s| s.count <= 1); + if last { + *held = Some(filled); + return; + } + if let Some(s) = held.as_mut() { + s.count -= 1; + } + if let Some(rest) = self.give(filled) { + self.player_drop(rest); + } + } +} diff --git a/crates/horizon-client/src/game/render_tick.rs b/crates/horizon-client/src/game/render_tick.rs index 50ca678c0..7bf040508 100644 --- a/crates/horizon-client/src/game/render_tick.rs +++ b/crates/horizon-client/src/game/render_tick.rs @@ -161,8 +161,8 @@ impl Game<'_> { } _ => Vec::new(), }, - ticks_frozen: 0, - percent_frozen: 0.0, + ticks_frozen: p.ticks_frozen, + percent_frozen: p.percent_frozen(), partial, portal: [fx.o_portal, fx.portal], nausea: 0.0, diff --git a/crates/horizon-client/src/game/tnt.rs b/crates/horizon-client/src/game/tnt.rs index a7f3fde46..38889cf98 100644 --- a/crates/horizon-client/src/game/tnt.rs +++ b/crates/horizon-client/src/game/tnt.rs @@ -88,6 +88,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; let wanted = t.velocity; let moved = scene diff --git a/crates/horizon-client/src/game/trident.rs b/crates/horizon-client/src/game/trident.rs index 66a0fd93a..aef884601 100644 --- a/crates/horizon-client/src/game/trident.rs +++ b/crates/horizon-client/src/game/trident.rs @@ -274,6 +274,7 @@ impl Game<'_> { let mut scene = SceneCollisions { world: &self.world, binding: self.collision, + subject: None, }; if let Ok(moved) = scene.resolve(bounds(t.position), t.velocity, 0.0, false) { let blocked = (moved.x - t.velocity.x).abs() > 1e-7 diff --git a/crates/horizon-client/src/health.rs b/crates/horizon-client/src/health.rs index f71b53373..f9c0ed51c 100644 --- a/crates/horizon-client/src/health.rs +++ b/crates/horizon-client/src/health.rs @@ -79,6 +79,8 @@ impl HealthResources { #[derive(Clone, Copy, Debug)] pub enum HealthEvent { StarvationDamage(f32), + /// `LivingEntity.aiStep` hurting a fully frozen entity. + FreezeDamage(f32), Updated { id: u8, value: f32, @@ -103,6 +105,7 @@ impl HealthEvent { pub fn trace(self) -> String { match self { Self::StarvationDamage(amount) => format!("damage starve {:08x}", amount.to_bits()), + Self::FreezeDamage(amount) => format!("damage freeze {:08x}", amount.to_bits()), Self::Updated { id, value, dirty } => { format!("updated {id} {:08x} {dirty}", value.to_bits()) } diff --git a/crates/horizon-client/src/landing.rs b/crates/horizon-client/src/landing.rs index 9e5b17530..07230023c 100644 --- a/crates/horizon-client/src/landing.rs +++ b/crates/horizon-client/src/landing.rs @@ -295,6 +295,7 @@ impl BlockPushWorld for MotionWorld<'_, '_, '_> { let mut world = SceneCollisions { world: self.scene, binding: self.collision_binding, + subject: None, }; self.collision_binding .collision_shape(&mut world, state, [p.x, p.y, p.z]) diff --git a/crates/horizon-client/src/mobs/creaking_heart.rs b/crates/horizon-client/src/mobs/creaking_heart.rs index 038e24515..e3c0e4cb7 100644 --- a/crates/horizon-client/src/mobs/creaking_heart.rs +++ b/crates/horizon-client/src/mobs/creaking_heart.rs @@ -360,6 +360,7 @@ fn can_spawn_on(b: &Blocks, pos: [i32; 3], state: BlockStateId, above: BlockStat let mut scene = SceneCollisions { world: b.w.world, binding: b.w.collision, + subject: None, }; let above_pos = [pos[0], pos[1] + 1, pos[2]]; let above_empty = @@ -422,6 +423,7 @@ fn spawn_protector( let mut scene = SceneCollisions { world: w.world, binding: w.collision, + subject: None, }; if !scene.unobstructed(bounds).unwrap_or(false) { continue; diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index 00309ee7d..22b8f2a29 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -122,6 +122,7 @@ impl MobWorld<'_> { let mut scene = SceneCollisions { world: self.world, binding: self.collision, + subject: None, }; let state = self.world.block(x, y, z); let at = horizon_core::position::BlockPos::new(x, y, z); @@ -168,6 +169,7 @@ impl MobWorld<'_> { let mut scene = SceneCollisions { world: self.world, binding: self.collision, + subject: None, }; scene.unobstructed(b).unwrap_or(false) } @@ -311,6 +313,7 @@ impl Mob { let mut scene = SceneCollisions { world: w.world, binding: w.collision, + subject: None, }; let moved = scene .resolve(self.bounds(), wanted, step, self.on_ground) diff --git a/crates/horizon-client/src/mobs/world.rs b/crates/horizon-client/src/mobs/world.rs index 40931fcff..4a8c6471c 100644 --- a/crates/horizon-client/src/mobs/world.rs +++ b/crates/horizon-client/src/mobs/world.rs @@ -62,6 +62,7 @@ impl MobWorld<'_> { let mut scene = SceneCollisions { world: self.world, binding: self.collision, + subject: None, }; let result = traverse_blocks(from, to, 512, |p: BlockPos| { let state = self.world.block(p.x, p.y, p.z); @@ -86,6 +87,7 @@ impl MobWorld<'_> { let mut scene = SceneCollisions { world: self.world, binding: self.collision, + subject: None, }; let full = horizon_core::geometry::Aabb::new(Vec3::ZERO, Vec3::new(1.0, 1.0, 1.0)); let result = traverse_blocks(from, to, 512, |p: BlockPos| { @@ -117,6 +119,7 @@ impl MobWorld<'_> { let mut scene = SceneCollisions { world: self.world, binding: self.collision, + subject: None, }; let result = traverse_blocks(from, to, 512, |p: BlockPos| { let state = self.world.block(p.x, p.y, p.z); @@ -137,6 +140,7 @@ impl MobWorld<'_> { let mut scene = SceneCollisions { world: self.world, binding: self.collision, + subject: None, }; let result = traverse_blocks(from, to, 512, |p: BlockPos| { let state = self.world.block(p.x, p.y, p.z); @@ -156,6 +160,7 @@ impl MobWorld<'_> { let mut scene = SceneCollisions { world: self.world, binding: self.collision, + subject: None, }; let state = self.world.block(p[0], p[1], p[2]); self.collision diff --git a/crates/horizon-client/src/motion.rs b/crates/horizon-client/src/motion.rs index 9d9a5aa1e..779e50b8a 100644 --- a/crates/horizon-client/src/motion.rs +++ b/crates/horizon-client/src/motion.rs @@ -1,10 +1,12 @@ //! Stored movement state for the resident scene. This binds the validated air, -//! jump, collision, support, restitution and stored health bindings. Entity lifecycle, +//! jump, collision, support, restitution and stored health bindings, and +//! powder snow's block-inside effects and freezing. Entity lifecycle, other //! block-inside effects and audiovisual event consumers remain separate work. use crate::{Result, assets::Assets, collisions::SceneCollisions, world::World}; use horizon_core::{ - geometry::{Aabb, Vec3, VoxelShape, traverse_blocks}, + geometry::{Aabb, Axis, Direction, Vec3, VoxelShape, traverse_blocks}, position::BlockPos, + random::RandomSource, }; use horizon_world::{ attribute_map::ValueKind, @@ -126,6 +128,16 @@ pub enum MotionEvent { state: BlockStateId, fall_distance: f64, }, + /// `PowderSnowBlock.entityInside`'s server particle: one snowflake at + /// `position` with `velocity` (`RandomizationType.ALTERNATIVE`). + Snowflake { + position: Vec3, + velocity: Vec3, + }, + /// `ServerPlayer.onInsideBlock`: the enter_block criterion trigger. + EnterBlock { + state: BlockStateId, + }, } pub struct PlayerMotion { pub position: Vec3, @@ -186,6 +198,23 @@ pub struct PlayerMotion { pub health: crate::health::PlayerHealth, /// Status effects and armor protection seen by movement and damage. pub status: crate::effects::StatusFlags, + /// `Entity.isInPowderSnow` / `wasInPowderSnow`. + pub in_powder_snow: bool, + pub was_in_powder_snow: bool, + /// `Entity.DATA_TICKS_FROZEN`. + pub ticks_frozen: i32, + /// Leather boots on the feet (`PowderSnowBlock.canEntityWalkOnPowderSnow`). + pub leather_boots: bool, + /// An armor piece in `#freeze_immune_wearables` (`LivingEntity.canFreeze`). + pub freeze_immune: bool, + /// `Entity.oldPosition`: where this tick started. + pub old_position: Vec3, + /// `Entity.applyEffectsFromBlocks` runs after travel (always in the game; + /// differential fixtures turn it off where their reference does). + pub block_effects: bool, + /// The powder snow blocks this tick's movement went into: the game + /// destroys them if the player is burning, then puts the fire out. + pub powder_snow_entered: Vec, } fn attribute_error(error: impl std::fmt::Debug) -> io::Error { io::Error::other(format!("movement attribute: {error:?}")) @@ -268,8 +297,68 @@ impl PlayerMotion { sync_position: false, events: Vec::new(), attributes, + in_powder_snow: false, + was_in_powder_snow: false, + ticks_frozen: 0, + leather_boots: false, + freeze_immune: false, + old_position: position, + block_effects: true, + powder_snow_entered: Vec::new(), }) } + /// `Entity.getTicksRequiredToFreeze`. + pub const TICKS_REQUIRED_TO_FREEZE: i32 = 140; + /// `Entity.getPercentFrozen`. + pub fn percent_frozen(&self) -> f32 { + self.ticks_frozen.min(Self::TICKS_REQUIRED_TO_FREEZE) as f32 + / Self::TICKS_REQUIRED_TO_FREEZE as f32 + } + /// `Entity.isFullyFrozen`. + pub fn is_fully_frozen(&self) -> bool { + self.ticks_frozen >= Self::TICKS_REQUIRED_TO_FREEZE + } + /// `LivingEntity.canFreeze`: not a spectator, no freeze-immune armor. + pub fn can_freeze(&self) -> bool { + !self.spectator && !self.freeze_immune + } + /// What powder snow's contextual collision asks of the player. + pub fn powder_snow_subject(&self) -> crate::collisions::PowderSnowSubject { + crate::collisions::PowderSnowSubject { + fall_distance: self.fall_distance, + falling_block: false, + leather_boots: self.leather_boots, + walkable_type: false, + } + } + /// `LivingEntity.tryAddFrost`: the frozen slowdown, on anything but air. + fn try_add_frost(&mut self, on_air: bool) -> Result<()> { + if on_air || self.ticks_frozen <= 0 { + return Ok(()); + } + let Some(key) = self.attributes.key("minecraft:movement_speed") else { + return Ok(()); + }; + let Some(handle) = self + .attributes + .get_instance(Some(key)) + .map_err(attribute_error)? + else { + return Ok(()); + }; + let slow = -0.05f32 * self.percent_frozen(); + let modifier = Modifier::new( + "minecraft:powder_snow", + f64::from(slow), + Operation::AddValue, + )?; + self.attributes + .edit(&handle, |value, observer| { + value.add_transient(modifier, observer) + }) + .map_err(attribute_error)?; + Ok(()) + } pub fn bounds(&self) -> Aabb { self.dimensions.bounds(self.position) } @@ -416,6 +505,7 @@ impl MotionWorld<'_, '_, '_> { SceneCollisions { world: self.scene, binding: self.collision_binding, + subject: Some(self.player.powder_snow_subject()), } } pub(crate) fn no_player_collision(&mut self, bounds: Aabb) -> Result { @@ -470,9 +560,10 @@ impl MotionWorld<'_, '_, '_> { pub fn travel_in_air(&mut self, input: Vec3) -> Result<()> { living_air::travel_in_air(self, input) } - /// Dry, standing movement phases of LivingEntity.aiStep, including server - /// frost-modifier cleanup with zero frozen ticks. Equipment, interpolation, - /// effects and world/entity effects remain unbound. Inputs are the native + /// Dry, standing movement phases of LivingEntity.aiStep, including powder + /// snow's block-inside effects and server freezing (thawing, the frost + /// modifier, freeze damage). Equipment, interpolation, effects and other + /// world/entity effects remain unbound. Inputs are the native /// float xxa/yya/zza fields, before LivingEntity.applyInput damping. pub fn movement_tick(&mut self, input: [f32; 3]) -> Result<()> { self.dry_tick(TickInput::Living(input)) @@ -481,6 +572,10 @@ impl MotionWorld<'_, '_, '_> { /// caller applies LocalPlayer input preparation once. Empty inventory, /// equipment and nearby-entity sets are the current scene domain. pub fn player_movement_tick(&mut self, direction: [f32; 2], jumping: bool) -> Result<()> { + // Entity.baseTick + self.player.was_in_powder_snow = self.player.in_powder_snow; + self.player.in_powder_snow = false; + self.player.old_position = self.player.position; if self.player.jump_trigger_time > 0 { self.player.jump_trigger_time -= 1; } @@ -639,7 +734,269 @@ impl MotionWorld<'_, '_, '_> { } else { self.travel_in_air(input)?; } - self.player.remove_frost() + if self.player.block_effects { + self.apply_effects_from_blocks()?; + } + // LivingEntity.aiStep "freezing" (server). + if !self.player.in_powder_snow || !self.player.can_freeze() { + self.player.ticks_frozen = (self.player.ticks_frozen - 2).max(0); + } + self.player.remove_frost()?; + let on = self.support(movement_support::LEGACY_SUPPORT_OFFSET)?; + let on_air = self.assets.registry.state(self.block(on)).is_none_or(|s| { + matches!( + s.name.as_str(), + "minecraft:air" | "minecraft:cave_air" | "minecraft:void_air" + ) + }); + self.player.try_add_frost(on_air)?; + if self.player.health.tick_count % 40 == 0 + && self.player.is_fully_frozen() + && self.player.can_freeze() + { + self.freeze_damage()?; + } + Ok(()) + } + /// `hurtServer(damageSources().freeze(), 1)` for a fully frozen player. + fn freeze_damage(&mut self) -> Result<()> { + let id = self + .resources + .health + .damage + .id("minecraft:freeze") + .ok_or("missing freeze damage type")?; + self.player.events.push(MotionEvent::Health( + crate::health::HealthEvent::FreezeDamage(1.0), + )); + let rules = self.scene.health_rules; + self.player.hurt(&self.resources.health, rules, id, 1.0)?; + Ok(()) + } + /// `Entity.applyEffectsFromBlocks` for the blocks Horizon's player reacts + /// to inside its movement path: powder snow (`PowderSnowBlock.entityInside` + /// and the FREEZE / EXTINGUISH effects). Pressure plates, tripwires and + /// rails are the world tick's scan. + pub fn apply_effects_from_blocks(&mut self) -> Result<()> { + use horizon_world::entity_move::MovementRecord; + let mut movements: Vec = + self.player.history.records().iter().copied().collect(); + self.player.history.clear(); + let position = self.player.position; + match movements.last() { + None => movements.push(MovementRecord { + from: self.player.old_position, + to: position, + original: None, + }), + Some(last) if last.to.distance_squared(position) > f64::from(9.999_999_4e-11_f32) => { + movements.push(MovementRecord { + from: last.to, + to: position, + original: None, + }) + } + _ => {} + } + // isAffectedByBlocks: no physics for spectators. + if self.player.spectator { + return Ok(()); + } + let mut visited = std::collections::HashSet::new(); + let mut entered = Vec::new(); + for movement in &movements { + let mut from = movement.from; + let delta = movement.to.subtract(movement.from); + let mut budget = 16; + match movement.original { + Some(original) if delta.length_squared() > 0.0 => { + for axis in Axis::step_order(original) { + let step = delta.component(axis); + if step != 0.0 { + let to = from.relative( + match axis { + Axis::X => Direction::East, + Axis::Y => Direction::Up, + Axis::Z => Direction::South, + }, + step, + ); + budget -= self.check_inside_blocks( + from, + to, + &mut visited, + &mut entered, + budget, + )?; + from = to; + } + } + } + _ => { + budget -= self.check_inside_blocks( + movement.from, + movement.to, + &mut visited, + &mut entered, + 16, + )?; + } + } + if budget <= 0 { + self.check_inside_blocks(movement.to, movement.to, &mut visited, &mut entered, 1)?; + } + } + // InsideBlockEffectApplier.StepBasedCollector: the blocks entered + // at one step (a sweep iteration differing from the last) form a + // group whose EXTINGUISH callbacks (the game destroys those blocks if + // the player burns), FREEZE and EXTINGUISH apply once, in order. + let mut last_step = -1; + let mut groups = 0; + for (index, &(step, p)) in entered.iter().enumerate() { + if index == 0 || step != last_step { + groups += 1; + last_step = step; + } + if groups == 1 { + // A later group finds the fire already out. + self.player.powder_snow_entered.push(p); + } + } + for _ in 0..groups { + if self.player.health.dead_or_dying() { + break; + } + self.player.in_powder_snow = true; + if self.player.can_freeze() { + self.player.ticks_frozen = + (self.player.ticks_frozen + 1).min(PlayerMotion::TICKS_REQUIRED_TO_FREEZE); + } + } + Ok(()) + } + /// `Entity.checkInsideBlocks(from, to, ...)`: the blocks the box at `to` + /// sweeps through from `from`; returns the iterations used plus one. + fn check_inside_blocks( + &mut self, + from: Vec3, + to: Vec3, + visited: &mut std::collections::HashSet, + entered: &mut Vec<(i32, BlockPos)>, + max_iterations: i32, + ) -> Result { + let deflated = self.player.dimensions.bounds(to).deflate(Vec3::new( + f64::from(1.0e-5_f32), + f64::from(1.0e-5_f32), + f64::from(1.0e-5_f32), + )); + let mut hits = Vec::new(); + let mut iterations = 0; + let alive = !self.player.health.dead_or_dying(); + horizon_core::geometry::sweep_intersected_blocks( + from, + to, + deflated, + 1_000_000, + |p, iteration| { + if !alive || iteration >= max_iterations { + return false; + } + iterations = iteration; + hits.push((iteration, p)); + true + }, + ) + .map_err(|e| format!("block sweep: {e:?}"))?; + for (step, p) in hits { + let state = self.block(p); + let Some(definition) = self.assets.registry.state(state) else { + continue; + }; + let name = definition.name.as_str(); + if matches!( + name, + "minecraft:air" | "minecraft:cave_air" | "minecraft:void_air" + ) { + continue; + } + let powder_snow = name == "minecraft:powder_snow"; + // getEntityInsideCollisionShape is the whole block but for powder + // snow (its collision shape, the whole block when it is empty), + // filled cauldrons, end portals and tripwires; Horizon doesn't + // follow the player into those last three. + if matches!( + name, + "minecraft:water_cauldron" + | "minecraft:lava_cauldron" + | "minecraft:powder_snow_cauldron" + | "minecraft:end_portal" + | "minecraft:tripwire" + ) { + continue; + } + let inside = !powder_snow || { + let context = self.context(); + let binding = self.collision_binding; + let shape = binding.collision_shape_with_context( + &mut self.collisions(), + state, + p, + &context, + )?; + shape.is_empty() || shape.boxes() == VoxelShape::block().boxes() || { + let boxes: Vec<_> = shape + .moved(Vec3::new(f64::from(p.x), f64::from(p.y), f64::from(p.z))) + .boxes() + .to_vec(); + self.player + .dimensions + .bounds(from) + .collided_along_vector(to.subtract(from), &boxes) + } + }; + if inside && visited.insert(p.pack()) { + if powder_snow { + self.powder_snow_inside(p)?; + entered.push((step, p)); + } + // ServerPlayer.onInsideBlock + self.player.events.push(MotionEvent::EnterBlock { state }); + } + } + Ok(iterations + 1) + } + /// `PowderSnowBlock.entityInside` for the player: stuck while the block + /// at its feet is powder snow, with a snowflake now and then (server). + fn powder_snow_inside(&mut self, p: BlockPos) -> Result<()> { + let feet = BlockPos::containing(self.player.position); + let in_block = self + .assets + .registry + .state(self.block(feet)) + .is_some_and(|s| s.name == "minecraft:powder_snow"); + if !in_block { + return Ok(()); + } + // Entity.makeStuckInBlock + EntityMoveWorld::reset_fall_distance(self)?; + self.player.stuck_multiplier = Vec3::new(f64::from(0.9_f32), 1.5, f64::from(0.9_f32)); + // ServerPlayer.getKnownMovement: this tick's movement. + let known = self.player.position.subtract(self.player.old_position); + let random = &mut self.scene.random; + if (known.x != 0.0 || known.z != 0.0) && random.next_boolean() { + // Mth.randomBetween(random, -1, 1) * (1 / 12) + let xa = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32; + let za = (random.next_float() * 2.0 - 1.0) * 0.083_333_336_f32; + self.player.events.push(MotionEvent::Snowflake { + position: Vec3::new( + self.player.position.x, + f64::from(p.y + 1), + self.player.position.z, + ), + velocity: Vec3::new(f64::from(xa), f64::from(0.05_f32), f64::from(za)), + }); + } + Ok(()) } pub fn jump_from_ground(&mut self) -> Result<()> { living_motion::jump_from_ground(self)?; @@ -932,6 +1289,7 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> { let mut collisions = SceneCollisions { world: self.scene, binding: self.collision_binding, + subject: Some(self.player.powder_snow_subject()), }; self.player.ground.set_with_movement( below, @@ -1264,10 +1622,10 @@ impl LivingAirWorld for MotionWorld<'_, '_, '_> { self.player.jumping } fn was_in_powder_snow(&self) -> bool { - false + self.player.was_in_powder_snow } fn can_walk_powder_snow(&mut self) -> Result { - Ok(false) + Ok(self.player.leather_boots) } fn levitation_amplifier(&mut self) -> Result> { Ok(self.player.status.levitation) diff --git a/crates/horizon-client/src/particles.rs b/crates/horizon-client/src/particles.rs index a25f04405..023369376 100644 --- a/crates/horizon-client/src/particles.rs +++ b/crates/horizon-client/src/particles.rs @@ -685,6 +685,7 @@ impl ParticleLevel<'_> { let mut scene = SceneCollisions { world: self.world, binding: self.collision, + subject: None, }; match scene.resolve(bb, Vec3::new(d[0], d[1], d[2]), 0.0, false) { Ok(v) => [v.x, v.y, v.z], diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneMotionOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneMotionOracle.java index 7dab6e62a..446f40501 100644 --- a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneMotionOracle.java +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneMotionOracle.java @@ -74,7 +74,8 @@ public final class VanillaSceneMotionOracle { return VanillaBlockLandingOracle.sound(s); } static final class Sources extends DamageSources { - DamageSource fall,stalagmite,starve;Sources(){super(null);} + DamageSource fall,stalagmite,starve,freeze;Sources(){super(null);} + @Override public DamageSource freeze(){return freeze;} @Override public DamageSource starve(){return starve;} @Override public DamageSource fall(){return fall;} @Override public DamageSource stalagmite(){return stalagmite;} @@ -111,9 +112,14 @@ public final class VanillaSceneMotionOracle { @Override public WorldBorder getWorldBorder(){return border;} @Override public DamageSources damageSources(){return sources;} @Override public net.minecraft.util.RandomSource getRandom(){return rng;} + @Override public boolean isRainingAt(BlockPos p){return false;} @Override public net.minecraft.server.MinecraftServer getServer(){return server;} @Override public void gameEvent(Holder event,Vec3 pos,GameEvent.Context context){if(event==GameEvent.HIT_GROUND)actor.events.add("hit "+vectorBits(pos)+" "+Block.getId(context.affectedState()));} @Override public void gameEvent(Holder event,BlockPos pos,GameEvent.Context context){actor.events.add("event "+(event==GameEvent.BLOCK_CHANGE?"change":event==GameEvent.BLOCK_DESTROY?"destroy":"unknown")+" "+blockPos(pos)+" "+Block.getId(context.affectedState())+" "+(context.sourceEntity()!=null));} + @Override public int sendParticles(T particle,double x,double y,double z,int count,double dx,double dy,double dz,double xs,double ys,double zs,net.minecraft.network.protocol.game.ClientboundLevelParticlesPacket.RandomizationType type){ + if(particle!=net.minecraft.core.particles.ParticleTypes.SNOWFLAKE||count!=1||dx!=0||dy!=0||dz!=0||type!=net.minecraft.network.protocol.game.ClientboundLevelParticlesPacket.RandomizationType.ALTERNATIVE)throw new AssertionError(); + actor.events.add("snowflake "+vectorBits(new Vec3(x,y,z))+" "+vectorBits(new Vec3(xs,ys,zs)));return 0; + } @Override public int sendParticles(T particle,double x,double y,double z,int count,double dx,double dy,double dz,double speed){ var b=(net.minecraft.core.particles.BlockParticleOption)particle;if(dx!=0||dy!=0||dz!=0)throw new AssertionError(); actor.events.add(String.format("particles %d %s %d %016x",Block.getId(b.getState()),vectorBits(new Vec3(x,y,z)),count,Double.doubleToLongBits(speed)));return 0; @@ -199,16 +205,23 @@ public final class VanillaSceneMotionOracle { String name=stat==net.minecraft.stats.Stats.DAMAGE_ABSORBED?"absorbed":stat==net.minecraft.stats.Stats.DAMAGE_TAKEN?"taken":stat==net.minecraft.stats.Stats.DAMAGE_RESISTED?"resisted":null; if(name==null){name=stat.getPath();if(!Set.of("swim_one_cm","walk_under_water_one_cm","walk_on_water_one_cm","climb_one_cm","sprint_one_cm","crouch_one_cm","walk_one_cm","aviate_one_cm","fly_one_cm").contains(name))throw new AssertionError(stat);}events.add("health-stat "+name+" "+value); } - @Override public boolean hurtServer(net.minecraft.server.level.ServerLevel level,DamageSource source,float damage){var sources=((Level)level).sources;events.add(String.format("damage %s %08x",source==sources.fall?"fall":source==sources.stalagmite?"stalagmite":source==sources.starve?"starve":"unknown",Float.floatToIntBits(damage)));return super.hurtServer(level,source,damage);} + @Override public boolean hurtServer(net.minecraft.server.level.ServerLevel level,DamageSource source,float damage){var sources=((Level)level).sources;events.add(String.format("damage %s %08x",source==sources.fall?"fall":source==sources.stalagmite?"stalagmite":source==sources.starve?"starve":source==sources.freeze?"freeze":"unknown",Float.floatToIntBits(damage)));return super.hurtServer(level,source,damage);} @Override public void teleportRelative(double x,double y,double z){setPos(getX()+x,getY()+y,getZ()+z);} @Override protected void playStepSound(BlockPos p,BlockState state){} @Override public void gameEvent(Holder event,Entity source){} @Override public LivingEntity getControllingPassenger(){return null;} @Override public double getFluidHeight(net.minecraft.tags.TagKey tag){return 0;} @Override public double getFluidJumpThreshold(){return .4;} - @Override public int getTicksFrozen(){return 0;} - @Override public void setTicksFrozen(int value){if(value!=0)throw new AssertionError(value);} - @Override protected void applyEffectsFromBlocks(){} + // Block-inside effects and frozen ticks run only in "snow_tick" operations. + boolean effects;int frozen;Vec3 tickStart; + @Override public int getTicksFrozen(){return frozen;} + @Override public void setTicksFrozen(int value){if(!effects&&value!=0)throw new AssertionError(value);frozen=value;} + @Override protected void applyEffectsFromBlocks(){if(effects)super.applyEffectsFromBlocks();} + // LivingEntity.canFreeze with #freeze_immune_wearables, whose only + // member a scene wears is leather boots (item tags aren't bound here). + @Override public boolean canFreeze(){return !isSpectator()&&!getItemBySlot(net.minecraft.world.entity.EquipmentSlot.FEET).is(net.minecraft.world.item.Items.LEATHER_BOOTS);} + // ServerPlayer.lastKnownClientMovement: this tick's movement. + @Override public Vec3 getKnownMovement(){return tickStart==null?Vec3.ZERO:position().subtract(tickStart);} @Override protected void applyInput(){if(!preparedInput)super.applyInput();} void peaceful(){tickRegeneration();} void clock()throws Exception{sceneLevel.time++;tickCount++;if(cooldown.getInt(this)>0)cooldown.setInt(this,cooldown.getInt(this)-1);if(hurtTime>0)hurtTime--;} @@ -240,7 +253,7 @@ public final class VanillaSceneMotionOracle { out.printf("%s %s %s %s %s %s %s %016x %s %s %s %08x %d %08x %d %d %s | %s%n",p.horizontalCollision,p.verticalCollision,p.verticalCollisionBelow,p.minorHorizontalCollision, p.onGround(),noBlocks.getBoolean(p),pos(support.get(p)),Double.doubleToLongBits(p.fallDistance),pos(last.get(p)),p.needsSync,p.syncPosition,Float.floatToIntBits(storedSpeed.getFloat(p)),delay.getInt(p),Float.floatToIntBits(p.yHeadRot),trigger.getInt(p),grace.getInt(p),impact.get(p)==null?"none":vectorBits((Vec3)impact.get(p)),String.join(";",attributes)); out.println("events "+String.join(" | ",p.events));p.events.clear(); - out.println("block "+Block.getId(p.level().getBlockState(new BlockPos(25,3,25)))); + out.println("block "+Block.getId(p.level().getBlockState(new BlockPos(25,3,25)))+" frozen "+p.frozen+" "+p.isInPowderSnow+" "+p.wasInPowderSnow+" "+vectorBits((Vec3)stuck.get(p))); out.printf("health %08x %08x %08x %d %d %08x %d %d %s %d %s %s %s %s %s %d%n", Float.floatToIntBits(p.getHealth()),Float.floatToIntBits(p.getAbsorptionAmount()),Float.floatToIntBits(foodExhaustion.getFloat(p.getFoodData())), noAction.getInt(p),cooldown.getInt(p),Float.floatToIntBits(lastHurt.getFloat(p)),p.hurtDuration,p.hurtTime,sourceName((DamageSource)lastDamage.get(p)),lastStamp.getLong(p),healthDead.getBoolean(p),p.syncVelocity, @@ -270,11 +283,11 @@ public final class VanillaSceneMotionOracle { var trace=new Trace();trace.out=new PrintWriter(OutputStream.nullOutputStream());trace.fail=-1; var level=(Level)allocate.invoke(unsafe,Level.class);level.chunks=VanillaSceneCollisionOracle.scene(description,trace);level.border=new Border(); level.rng=net.minecraft.util.RandomSource.create(0);level.sources=(Sources)allocate.invoke(unsafe,Sources.class); - level.sources.fall=damageSources.get("minecraft:fall");level.sources.stalagmite=damageSources.get("minecraft:stalagmite");level.sources.starve=damageSources.get("minecraft:starve"); + level.sources.fall=damageSources.get("minecraft:fall");level.sources.stalagmite=damageSources.get("minecraft:stalagmite");level.sources.starve=damageSources.get("minecraft:starve");level.sources.freeze=damageSources.get("minecraft:freeze"); level.waypoints=(Waypoints)allocate.invoke(unsafe,Waypoints.class);level.waypoints.level=level; level.rules=new Rules();level.difficulty=net.minecraft.world.Difficulty.NORMAL; var server=(VanillaBlockLandingOracle.Server)allocate.invoke(unsafe,VanillaBlockLandingOracle.Server.class); - server.d=new VanillaBlockLandingOracle.Data();server.d.out=new PrintWriter(OutputStream.nullOutputStream());server.d.f=JsonParser.parseString("{\"fail\":-1,\"protected\":false}").getAsJsonObject();level.server=server; + server.d=new VanillaBlockLandingOracle.Data();server.d.out=new PrintWriter(OutputStream.nullOutputStream());server.d.f=JsonParser.parseString("{\"fail\":-1,\"protected\":false}").getAsJsonObject();level.server=server;write(field(net.minecraft.server.MinecraftServer.class,"debugSubscribers"),server,new net.minecraft.util.debug.ServerDebugSubscribers(server)); try(var out=new PrintWriter(Files.newBufferedWriter(dir.resolve(args[0]+".txt")));var enriched=new PrintWriter(Files.newBufferedWriter(dir.resolve("inputs-"+args[0]+".txt")));var lines=Files.lines(dir.resolve("inputs.txt"))){ for(var line:(Iterable)lines::iterator){ var row=JsonParser.parseString(line).getAsJsonObject();var p=(Actor)allocate.invoke(unsafe,Actor.class);p.configure(level);p.events=new ArrayList<>();level.actor=p; @@ -285,6 +298,8 @@ public final class VanillaSceneMotionOracle { var equipment=new EntityEquipment();write(field(LivingEntity.class,"equipment"),p,equipment); write(field(Player.class,"inventory"),p,new Inventory(p,equipment)); p.attrs=new TrackedAttributes(VanillaEntityAttributeOracle.supplier(EntityTypes.PLAYER));write(field(LivingEntity.class,"attributes"),p,p.attrs); + write(field(Entity.class,"finalMovementsThisTick"),p,new ArrayList<>());write(field(Entity.class,"visitedBlocks"),p,new it.unimi.dsi.fastutil.longs.LongOpenHashSet()); + write(field(Entity.class,"insideEffectCollector"),p,new net.minecraft.world.entity.InsideBlockEffectApplier.StepBasedCollector()); write(history,p,new ArrayDeque<>());write(support,p,Optional.empty());write(last,p,Optional.empty());write(stuck,p,Vec3.ZERO);write(abilities,p,new Abilities()); write(field(ServerPlayer.class,"shoulderEntityLeft"),p,new net.minecraft.nbt.CompoundTag());write(field(ServerPlayer.class,"shoulderEntityRight"),p,new net.minecraft.nbt.CompoundTag()); p.connection=(Connection)allocate.invoke(unsafe,Connection.class); @@ -334,6 +349,27 @@ public final class VanillaSceneMotionOracle { case "travel"->p.travel(VanillaSceneCollisionOracle.vector(op.get("value"))); case "tick"->p.movementTick(VanillaSceneCollisionOracle.vector(op.get("value"))); case "player_tick"->p.playerMovementTick(VanillaSceneCollisionOracle.vector(op.get("value")),op.get("jumping").getAsBoolean()); + case "snow_tick"->{ + // The clock, Entity.baseTick's powder snow flags and old + // position, then aiStep with its block-inside effects. + p.clock();p.wasInPowderSnow=p.isInPowderSnow;p.isInPowderSnow=false; + p.xOld=p.getX();p.yOld=p.getY();p.zOld=p.getZ();p.tickStart=p.position(); + p.effects=true; + try{p.playerMovementTick(VanillaSceneCollisionOracle.vector(op.get("value")),op.get("jumping").getAsBoolean());}finally{p.effects=false;} + } + case "boots"->{ + // Leather boots without data components (item components aren't + // bound here), straight into the equipment: no equip callbacks. + var stack=net.minecraft.world.item.ItemStack.EMPTY; + if(op.get("value").getAsBoolean()){ + stack=(net.minecraft.world.item.ItemStack)allocate.invoke(unsafe,net.minecraft.world.item.ItemStack.class); + write(field(net.minecraft.world.item.ItemStack.class,"item"),stack,net.minecraft.world.item.Items.LEATHER_BOOTS.builtInRegistryHolder()); + field(net.minecraft.world.item.ItemStack.class,"count").setInt(stack,1); + write(field(net.minecraft.world.item.ItemStack.class,"components"),stack,new net.minecraft.core.component.PatchedDataComponentMap(net.minecraft.core.component.DataComponentMap.EMPTY)); + } + ((EntityEquipment)field(LivingEntity.class,"equipment").get(p)).set(net.minecraft.world.entity.EquipmentSlot.FEET,stack); + } + case "frozen"->p.frozen=op.get("value").getAsInt(); case "trigger"->trigger.setInt(p,op.get("value").getAsInt()); case "delay"->delay.setInt(p,op.get("value").getAsInt()); case "ack_attributes"->{p.attrs.getAttributesToUpdate().clear();p.attrs.getAttributesToSync().clear();} diff --git a/tools/test_native_scene_motion.py b/tools/test_native_scene_motion.py index 64ba930b4..09eeee966 100644 --- a/tools/test_native_scene_motion.py +++ b/tools/test_native_scene_motion.py @@ -101,9 +101,54 @@ def pose_corpus(controlled): ops=ticks(33,10)+ticks(32,10)+ticks(0,10)+ticks(49,10)+ticks(0,10) yield dict(position=vec(start),ops=ops) +def powder_snow_corpus(): + """Powder snow under and around the player, with LivingEntity.aiStep's + block-inside effects ("snow_tick"): sinking and the stuck multiplier, + walking on it and climbing out in leather boots, falling into it, + freezing, the frost slowdown and freeze damage, and snowflakes.""" + def snow(positions): + return [op('block',name='minecraft:powder_snow',position=list(p)) for p in positions] + def restore(positions): + return [op('block',name='minecraft:stone' if p[1]<=3 else 'minecraft:air',position=list(p)) for p in positions] + pit=[(x,y,z) for x in range(24,27) for y in (2,3) for z in range(24,27)] + tower=[(25,y,25) for y in range(4,7)] + def tick(forward=0.,strafe=0.,jump=False): + return op('snow_tick',vec((strafe,0,forward)),jumping=jump) + rng=random.Random(0x534e4f57) + for boots in [False,True]: + for shift in [False,True]: + for start in [(25.5,4,25.5),(25.5,5.5,25.5),(25.5,12,25.5),(24.2,4,26.7)]: + for walk in [(0,0,False),(.98,0,False),(.98,0,True),(0,.98,True),(-.98,.3,False)]: + ops=[op('seed',rng.randrange(1<<31)),op('boots',boots),op('shift',shift),op('yaw',single(rng.choice([0,45,90,180,-135])))]+snow(pit) + for i in range(70): + ops.append(tick(*walk) if i<45 else tick()) + ops += restore(pit) + yield dict(position=vec(start),ops=ops) + # Freezing all the way: freeze damage every 40 ticks once fully frozen, + # then thawing two ticks at a time outside, slowed on the ground. + for frozen in [0,100,139,140,200]: + for tick_count in [0,1,39]: + ops=[op('seed',7),op('tick_count',tick_count),op('frozen',frozen)]+snow(pit) + ops += [tick() for _ in range(190)] + ops += restore(pit) + ops += [tick(.98 if i<30 else 0) for i in range(90)] + yield dict(position=vec((25.5,4,25.5)),ops=ops) + # Thawing without snow: on stone the slowdown stays while frozen; in the air it does not. + for frozen in [1,2,3,60,140]: + for start in [(25.5,4,25.5),(25.5,9,25.5)]: + yield dict(position=vec(start),ops=[op('frozen',frozen)]+[tick(.98) for _ in range(40)]) + # A column of snow: walking into it sideways and jumping out of it in boots. + for boots in [False,True]: + for yaw in [0,90,180,270]: + ops=[op('boots',boots),op('yaw',single(yaw))]+snow(tower) + ops += [tick(.98,0,i%10<5) for i in range(80)] + ops += restore(tower) + yield dict(position=vec((25.5,4,23.5) if yaw in (0,180) else (23.5,4,25.5)),ops=ops) + def corpus(): yield from attribute_corpus(False) yield from pose_corpus(False) + yield from powder_snow_corpus() def food(level=20,saturation=5,exhaustion=0,timer=0): return op('food',food=level,saturation=single(saturation),exhaustion=single(exhaustion),timer=timer) # Actual ServerPlayer.checkMovementStatistics with all query contexts. These @@ -419,7 +464,7 @@ def main(): 'java':subprocess.check_output(['java','--version'],text=True).splitlines()[0],'fixtures':len(fixtures), 'operations':operations,'sources_sha256':tested,'resources':resource_hash,'input_sha256':input_hash, 'references':references, - 'scope':('Client sprint scene executes the real LocalPlayer.aiStep prefix through sprint decisions, stopping at the riding/parent movement boundary, followed by real LocalPlayer.modifyInput and the native server actor simulation. Sprint setters dispatch to that server actor, sharing its actual FoodData and collision conditions. Compares local sprint timer/input history and actual attribute changes, movement, distance exhaustion and hunger. Actual local crouch selection precedes new keyboard input; slowdown, sprint eligibility and pose-dependent dimensions share resident state. No effects, equipment, vehicles or fluid physics. Full client/server tick and networking remain unfinished. ' if args.sprint_input else '')+'Stored PlayerMotion binds resident client chunks and registered attributes/coefficients to air travel, jumping, Entity.move, step resolution, ground/support updates, restitution and speed factors. Repeated native Player.travel/jump/move and actual Player.aiStep / direct LivingEntity.aiStep compare position, velocity, bounds, collision/ground flags, support, climb state, fall-reset field, both sync flags, speed, head yaw, jump trigger and jump delay by exact bits. Prepared-local-input Player.aiStep paths verify the stored speed refresh after movement, with empty inventory and no nearby entities. Travel uses the actual Player.getSpeed movement-attribute query, independently of that stored base-living speed field; both getter and setter execute native bodies. Dry aiStep includes small-velocity cleanup, input damping, held/released jump timing and server frost-modifier removal. Snapshots also compare all allocated attribute instances, base/cache/dirty state, modifier values/operations/permanence and pending update/sync membership. Per-reference JVM holder/instance hashes are replayed so pending callback order and resulting ordered events also match. Dry resident scene with standing, crouching and crawling bounds, cached eye height, native pose-fit selection and ledge backoff; zero frozen ticks, empty equipment, no interpolation, fluids, vehicles, effects or other entities. The oracle controls pose storage and invokes actual native refreshDimensions on changes; full synchronized pose schema and network notifications remain separate. Actual pending attribute refresh runs after player movement and before pose selection in simulation operations, clamping health/absorption and rebuilding scale-dependent dimensions. Native ServerPlayer waypoint-receive and inherited transmit callbacks are compared as ordered requests; the waypoint manager and its network delivery remain controlled consumers. Direct refresh operations and the full enclosing tick remain separate boundaries. Actual LivingEntity.checkFallDamage, registered Block.fallOn/bounceOn and Player/LivingEntity.causeFallDamage are composed, including float-narrowed fall-distance accumulation, reset, last-climb clearing, safe-fall and damage-multiplier attributes, impulse exemptions and ordered landing particle/sound/statistic/hit-ground/damage requests. Farmland pushes the stored player and replaces actual chunk state; repeated turtle-egg changes preserve hatch. Block state at the landing fixture is compared after every operation. World RNG is a seeded native legacy stream. Actual ServerPlayer/Player/LivingEntity hurt dispatch now updates native synchronized health and player absorption, modified armor/toughness reduction, exhaustion, damage cooldown/last-source state and velocity synchronization. Native health storage and dirty flags are compared after every operation. Damage sources use actual decoded/tagged pinned damage registries. ServerPlayer jump statistics and exhaustion are included; dead-or-dying state disables movement input and jumping. Empty equipment/hands/effects, configurable difficulty and natural regeneration, damage gamerules enabled, loaded client and no spawn-extra fall particles define this scene domain. Ordered health/statistic/combat-record/broadcast/sound/criterion/death requests are compared; criterion argument payloads retain the separate combat gate. Actual health changes are applied, but audiovisual playback, persistent statistics, combat tracker internals, full death/drop/respawn services, block neighbor/drop/block-entity callbacks, movement emission, block-inside and networking remain controlled consumers. Actual FoodData.tick and ServerPlayer.tickRegeneration now share the same stored health, attributes, exhaustion and registered starvation damage. Snapshots compare food level, saturation, exhaustion, timer and tick count. Cases cover all four difficulties, regeneration rule changes, absorption during starvation, Peaceful recovery, timer overflow and nonfinite food values. Simulation operations compose clock setup, actual player movement and actual food ticking in their native order, including fall damage followed by regeneration in the same tick. Explicit clock operations establish cooldown/hurt-time/game-time/tick-count state; full enclosing native tick/doTick methods are not invoked by those setup operations. Input preparation has its own client comparison. ServerPlayer.checkMovementStatistics executes on accepted resident deltas after movement and before food ticking. Additional direct method fixtures cover all 512 overlapping passenger/swim/eye-water/water/climb/ground/sprint/crouch/glide flag combinations, invulnerable abilities, centimeter rounding, overflow, signed zero and nonfinite deltas. Those query contexts apply only to the statistics method; fluid/vehicle/glide physics remain unbound. ServerGamePacketListener packet acceptance/rejection and scheduling are not executed. No full Player.tick, consumable item-use lifecycle, local input lifecycle, nonempty inventory/equipment ticks, full freezing, complete death lifecycle or full gameplay parity claim.'}) + 'scope':('Client sprint scene executes the real LocalPlayer.aiStep prefix through sprint decisions, stopping at the riding/parent movement boundary, followed by real LocalPlayer.modifyInput and the native server actor simulation. Sprint setters dispatch to that server actor, sharing its actual FoodData and collision conditions. Compares local sprint timer/input history and actual attribute changes, movement, distance exhaustion and hunger. Actual local crouch selection precedes new keyboard input; slowdown, sprint eligibility and pose-dependent dimensions share resident state. No effects, equipment, vehicles or fluid physics. Full client/server tick and networking remain unfinished. ' if args.sprint_input else '')+'Stored PlayerMotion binds resident client chunks and registered attributes/coefficients to air travel, jumping, Entity.move, step resolution, ground/support updates, restitution and speed factors. Repeated native Player.travel/jump/move and actual Player.aiStep / direct LivingEntity.aiStep compare position, velocity, bounds, collision/ground flags, support, climb state, fall-reset field, both sync flags, speed, head yaw, jump trigger and jump delay by exact bits. Prepared-local-input Player.aiStep paths verify the stored speed refresh after movement, with empty inventory and no nearby entities. Travel uses the actual Player.getSpeed movement-attribute query, independently of that stored base-living speed field; both getter and setter execute native bodies. Dry aiStep includes small-velocity cleanup, input damping, held/released jump timing and server frost-modifier removal. Snapshots also compare all allocated attribute instances, base/cache/dirty state, modifier values/operations/permanence and pending update/sync membership. Per-reference JVM holder/instance hashes are replayed so pending callback order and resulting ordered events also match. Dry resident scene with standing, crouching and crawling bounds, cached eye height, native pose-fit selection and ledge backoff; zero frozen ticks, empty equipment, no interpolation, fluids, vehicles, effects or other entities. The oracle controls pose storage and invokes actual native refreshDimensions on changes; full synchronized pose schema and network notifications remain separate. Actual pending attribute refresh runs after player movement and before pose selection in simulation operations, clamping health/absorption and rebuilding scale-dependent dimensions. Native ServerPlayer waypoint-receive and inherited transmit callbacks are compared as ordered requests; the waypoint manager and its network delivery remain controlled consumers. Direct refresh operations and the full enclosing tick remain separate boundaries. Actual LivingEntity.checkFallDamage, registered Block.fallOn/bounceOn and Player/LivingEntity.causeFallDamage are composed, including float-narrowed fall-distance accumulation, reset, last-climb clearing, safe-fall and damage-multiplier attributes, impulse exemptions and ordered landing particle/sound/statistic/hit-ground/damage requests. Farmland pushes the stored player and replaces actual chunk state; repeated turtle-egg changes preserve hatch. Block state at the landing fixture is compared after every operation. World RNG is a seeded native legacy stream. Actual ServerPlayer/Player/LivingEntity hurt dispatch now updates native synchronized health and player absorption, modified armor/toughness reduction, exhaustion, damage cooldown/last-source state and velocity synchronization. Native health storage and dirty flags are compared after every operation. Damage sources use actual decoded/tagged pinned damage registries. ServerPlayer jump statistics and exhaustion are included; dead-or-dying state disables movement input and jumping. Empty equipment/hands/effects, configurable difficulty and natural regeneration, damage gamerules enabled, loaded client and no spawn-extra fall particles define this scene domain. Ordered health/statistic/combat-record/broadcast/sound/criterion/death requests are compared; criterion argument payloads retain the separate combat gate. Actual health changes are applied, but audiovisual playback, persistent statistics, combat tracker internals, full death/drop/respawn services, block neighbor/drop/block-entity callbacks, movement emission, block-inside and networking remain controlled consumers. Actual FoodData.tick and ServerPlayer.tickRegeneration now share the same stored health, attributes, exhaustion and registered starvation damage. Snapshots compare food level, saturation, exhaustion, timer and tick count. Cases cover all four difficulties, regeneration rule changes, absorption during starvation, Peaceful recovery, timer overflow and nonfinite food values. Simulation operations compose clock setup, actual player movement and actual food ticking in their native order, including fall damage followed by regeneration in the same tick. Explicit clock operations establish cooldown/hurt-time/game-time/tick-count state; full enclosing native tick/doTick methods are not invoked by those setup operations. Input preparation has its own client comparison. ServerPlayer.checkMovementStatistics executes on accepted resident deltas after movement and before food ticking. Additional direct method fixtures cover all 512 overlapping passenger/swim/eye-water/water/climb/ground/sprint/crouch/glide flag combinations, invulnerable abilities, centimeter rounding, overflow, signed zero and nonfinite deltas. Those query contexts apply only to the statistics method; fluid/vehicle/glide physics remain unbound. ServerGamePacketListener packet acceptance/rejection and scheduling are not executed. Powder snow operations (snow_tick) run the clock, the powder snow flags and old position of baseTick, then actual aiStep with the real Entity.applyEffectsFromBlocks (movement-path block sweep, StepBasedCollector, PowderSnowBlock.entityInside: stuck multiplier, ALTERNATIVE snowflake particles from the level random, FREEZE/EXTINGUISH, ServerPlayer.onInsideBlock criteria) and the server freezing phase (thaw, frost modifier, freeze damage every 40 ticks); leather boots are a component-less stack and #freeze_immune_wearables is emulated by them. Outside snow_tick block-inside effects stay disabled on both sides. No full Player.tick, consumable item-use lifecycle, local input lifecycle, nonempty inventory/equipment ticks, full freezing, complete death lifecycle or full gameplay parity claim.'}) print(f'Scoped scene motion parity passed: {report}',flush=True) if __name__=='__main__':main()