Push entities with moving pistons

Move players, mobs and common objects when piston block entities advance, applying collision and the per-tick piston limit. Exercise host, guest, inventory-open and mob pushes in both single-player paths, and compare the push geometry with vanilla.

Co-Authored-By: Codex (GPT-6) <noreply@openai.com>
This commit is contained in:
levzzz
2026-10-27 01:01:31 +05:00
parent fff9cd1580
commit 6af81b600f
18 changed files with 1542 additions and 22 deletions
+46 -1
View File
@@ -54,6 +54,27 @@ fn strider_liquid_shape() -> VoxelShape {
VoxelShape::from_cube([1, 2, 1], &[true, false]).expect("a valid half-block column")
}
// Vanilla scopes this direction to Entity.move while a piston pushes it.
thread_local! {
static PISTON_NOCLIP: std::cell::Cell<Option<horizon_core::geometry::Direction>> = const { std::cell::Cell::new(None) };
}
/// Suppress the moving part of pistons moving in this direction during a push.
pub fn with_piston_noclip<T>(
direction: horizon_core::geometry::Direction,
f: impl FnOnce() -> T,
) -> T {
struct Reset(Option<horizon_core::geometry::Direction>);
impl Drop for Reset {
fn drop(&mut self) {
PISTON_NOCLIP.with(|value| value.set(self.0));
}
}
let previous = PISTON_NOCLIP.with(|value| value.replace(Some(direction)));
let _reset = Reset(previous);
f()
}
pub struct SceneCollisions<'w, 'r> {
pub world: &'w World,
pub binding: &'w BlockCollisionRegistry<'r>,
@@ -324,7 +345,7 @@ impl DynamicSupportWorld for SceneCollisions<'_, '_> {
return Ok(None);
};
pistons
.collision_shape(self, p, piston, None)
.collision_shape(self, p, piston, PISTON_NOCLIP.with(|value| value.get()))
.map(Some)
.map_err(io::Error::other)
}
@@ -475,3 +496,27 @@ impl PowderSnowWorld for LookupCollisions<'_, '_> {
Ok(false)
}
}
#[cfg(test)]
mod piston_noclip_tests {
use super::*;
use horizon_core::geometry::Direction;
#[test]
fn piston_noclip_is_scoped_and_restored_after_unwind() {
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
with_piston_noclip(Direction::East, || {
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
let _ =
std::panic::catch_unwind(|| with_piston_noclip(Direction::West, || panic!("test")));
assert_eq!(
PISTON_NOCLIP.with(|value| value.get()),
Some(Direction::East)
);
});
assert_eq!(PISTON_NOCLIP.with(|value| value.get()), None);
}
}
+19 -9
View File
@@ -127,6 +127,7 @@ mod persist;
mod perspective;
mod pickups;
mod piglins;
mod piston_push;
mod player_equip;
mod postprocess;
mod potions;
@@ -2821,20 +2822,29 @@ impl Game<'_> {
b.run_block_events(level, ticks);
b.tick_block_entities(level, ticks);
});
// `PistonMovingBlockEntity.tick` pushes what its block just ran into,
// with the progress it is about to take.
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.process_level_events()?;
self.sync_moving_pistons();
self.process_level_events()
Ok(())
}
/// Copies the moving-piston block entities into the world, where the
/// collision queries and the renderer read them.
fn sync_moving_pistons(&mut self) {
let pistons = &mut self.world.moving_pistons;
pistons.clear();
pistons.extend(
self.block_ticks
.moving_pistons
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state())),
);
let previous = std::mem::take(&mut self.world.moving_pistons);
let current: Vec<_> = self
.block_ticks
.moving_pistons
.iter()
.map(|(p, piston)| ([p.x, p.y, p.z], piston.shape_state()))
.collect();
self.world.moving_piston_fallback = previous
.into_iter()
.filter(|(pos, _)| !current.iter().any(|(next, _)| next == pos))
.collect();
self.world.moving_pistons = current;
}
/// `ServerLevel.tick`'s scheduled block then fluid ticks, one at a time:
/// the side effects of each (a shrieker summoning a warden, drops, game
+1 -1
View File
@@ -73,7 +73,7 @@ pub(super) struct Minecart {
pub yaw: f32,
pub previous_yaw: f32,
pub flipped: bool,
on_ground: bool,
pub(super) on_ground: bool,
horizontal_collision: bool,
fall_distance: f64,
pub hurt_time: i32,
@@ -14,7 +14,9 @@ impl Game<'_> {
out: &mut Vec<Vertex>,
) {
let partial = alpha as f32;
for &(p, piston) in &self.world.moving_pistons {
let mut states = self.world.moving_pistons.clone();
states.extend(self.world.moving_piston_fallback.iter().copied());
for (p, piston) in states {
let Some((block, base)) = self.moving_piston_blocks(piston, partial) else {
continue;
};
@@ -0,0 +1,479 @@
//! `PistonMovingBlockEntity.moveCollidedEntities` and `moveStuckEntities` for
//! the entities the game moves itself: the players and the mobs.
//!
//! The geometry is in [`horizon_world::piston_push`]; this file supplies the
//! bodies and applies the movement the way `Entity.move(MoverType.PISTON, …)`
//! does: limited to 0.51 blocks per tick per axis (`Entity.limitPistonMovement`),
//! then run through the block collision scan, so a pushed entity stops at a
//! wall instead of passing through it.
//!
use super::Game;
use horizon_client::collisions::{SceneCollisions, with_piston_noclip};
use horizon_core::geometry::{Aabb, Direction, Vec3};
use horizon_core::position::BlockPos;
use horizon_world::{level::pistons::MovingPiston, piston_movement::PistonMovement, piston_push};
/// What one piston asks for this tick: where it is, its state, and the progress
/// `moveCollidedEntities` runs at (before the tick stores it).
pub(super) type Push = (BlockPos, MovingPiston, f32);
/// Which entity was pushed, so its storage can be reached after the world
/// borrow ends.
#[derive(Clone, Copy, Hash, PartialEq, Eq)]
enum Target {
Player,
Guest(u32),
Mob(usize),
Item(usize),
Boat(usize),
Cart(usize),
ArmorStand(usize),
}
/// `Entity.getStepHeight` of each body, read before the push.
struct StepHeights {
player: f32,
guests: std::collections::HashMap<u32, f32>,
}
/// What the push needs from one entity.
#[derive(Clone, Copy)]
struct Body {
bounds: Aabb,
position: Vec3,
on_ground: bool,
step_height: f32,
/// Standing on the piston's own block (`Entity.isSupportedBy`).
supported: bool,
}
impl Game<'_> {
/// Push whatever this tick's moving pistons ran into, in the order the
/// pistons ticked: vanilla pushes from each block entity's own tick.
pub(super) fn push_piston_entities(&mut self, pushes: Vec<Push>) {
let mut accumulators = std::collections::HashMap::new();
for push in pushes {
self.push_for_piston(push, &mut accumulators);
}
}
fn push_for_piston(
&mut self,
(pos, piston, new_progress): Push,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) {
let delta_progress = f64::from(new_progress - piston.progress);
if delta_progress <= 0.0 {
return;
}
let Some(bindings) = self.collision.pistons() else {
return;
};
let Ok(state) = bindings.collision_related_state(piston.shape_state()) else {
return;
};
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let Ok(shape) = self
.collision
.collision_shape(&mut scene, state, [pos.x, pos.y, pos.z])
else {
return;
};
if shape.is_empty() {
return;
}
let movement = piston.movement_direction();
let boxes: Vec<Aabb> = shape.boxes().to_vec();
let facts = self.res.facts();
let slime = &*facts.get(piston.moved_state).name == "minecraft:slime_block";
let honey = &*facts.get(piston.moved_state).name == "minecraft:honey_block";
// `moveStuckEntities` asks the moved state's own top for its box.
let sticky_top = if honey {
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
self.collision
.collision_shape(&mut scene, piston.moved_state, [pos.x, pos.y, pos.z])
.ok()
.and_then(|s| s.bounds())
.map_or(0.0, |b| b.max_position().y)
} else {
0.0
};
let steps = self.push_step_heights();
let search = shape.bounds().map(|bounds| {
piston_push::movement_search_area(
piston_push::move_by_position_and_progress(pos, bounds, piston.shape_state()),
movement,
delta_progress,
)
});
let targets = self.push_targets();
for &target in &targets {
let Some(body) = self.push_body(target, pos, &steps) else {
continue;
};
let amount = piston_push::collided_amount(
&boxes,
pos,
piston.shape_state(),
new_progress,
movement,
body.bounds,
);
if slime && search.is_some_and(|area| area.intersects(body.bounds)) {
self.bounce(target, movement);
}
if amount > 0.0 {
let step = piston_push::step_vector(movement);
let before = body.bounds;
self.move_pushed(
target,
before,
body.step_height,
body.on_ground,
step.scale(amount),
movement,
accumulators,
);
// `fixEntityWithinPistonBase`: a retracting source piston drags
// an entity that is still inside its own block.
if !piston.extending
&& piston.source
&& let Some(after) = self.push_body(target, pos, &steps)
&& let Some(fix) = piston_push::fix_within_piston_base(
pos,
after.bounds,
movement,
delta_progress,
)
{
let back = piston_push::step_vector(movement.opposite());
self.move_pushed(
target,
after.bounds,
after.step_height,
after.on_ground,
back.scale(fix),
movement,
accumulators,
);
}
}
}
// Vanilla selects honey passengers after all collided entities have moved.
if honey && movement.normal()[1] == 0 {
let area = piston_push::sticky_area(pos, piston.shape_state(), sticky_top);
for target in targets {
let Some(body) = self.push_body(target, pos, &steps) else {
continue;
};
if piston_push::sticky_entity_selected(
area,
body.bounds,
horizon_world::level::data::PushReaction::PushPull,
body.on_ground,
body.supported,
body.position,
) {
self.move_pushed(
target,
body.bounds,
body.step_height,
body.on_ground,
piston_push::step_vector(movement).scale(delta_progress),
movement,
accumulators,
);
}
}
}
}
/// `Entity.getStepHeight` for each body, read once: the attribute lookup
/// needs the mutable body, and the push only needs the number.
fn push_step_heights(&mut self) -> StepHeights {
let player = self
.player
.as_mut()
.and_then(|p| p.attribute("minecraft:step_height").ok())
.map_or(0.6, |v| v as f32);
let mut guests = std::collections::HashMap::new();
if let Some(host) = self.lan.as_mut() {
for g in &mut host.hub.guests {
let step = g
.motion
.as_mut()
.and_then(|m| m.attribute("minecraft:step_height").ok())
.map_or(0.6, |v| v as f32);
guests.insert(g.conn, step);
}
}
StepHeights { player, guests }
}
/// The entities a moving block can run into: the window's player, the LAN
/// guests in the level, then the mobs.
fn push_targets(&self) -> Vec<Target> {
let mut targets = Vec::new();
if self.player.is_some() {
targets.push(Target::Player);
}
if let Some(host) = self.lan.as_ref() {
targets.extend(
host.hub
.guests
.iter()
.filter(|g| host.hub.here(g) && g.loaded && !g.dead)
.map(|g| Target::Guest(g.conn)),
);
}
targets.extend((0..self.mobs.len()).map(Target::Mob));
targets.extend((0..self.dropped.len()).map(Target::Item));
targets.extend((0..self.boats.len()).map(Target::Boat));
targets.extend((0..self.carts.len()).map(Target::Cart));
targets.extend((0..self.armor_stands.len()).map(Target::ArmorStand));
targets
}
/// What the push needs from one entity, or `None` when it is not pushed:
/// removed, dead, a spectator, or an armor-stand marker.
fn push_body(&self, target: Target, pos: BlockPos, steps: &StepHeights) -> Option<Body> {
let at = [pos.x, pos.y, pos.z];
match target {
Target::Player => {
let p = self.player.as_ref()?;
if p.spectator {
return None;
}
Some(Body {
bounds: p.bounds(),
position: p.position,
on_ground: p.ground.on_ground,
step_height: steps.player,
supported: p.ground.supporting_block == Some(pos),
})
}
Target::Guest(conn) => {
let host = self.lan.as_ref()?;
let g = host.hub.guests.iter().find(|g| g.conn == conn)?;
if g.dead || g.game_mode == 3 {
return None;
}
let p = g.position;
Some(Body {
bounds: Aabb::new(
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
),
position: Vec3::new(p[0], p[1], p[2]),
on_ground: g.on_ground,
step_height: steps.guests.get(&conn).copied().unwrap_or(0.6),
supported: g
.motion
.as_ref()
.is_some_and(|m| m.ground.supporting_block == Some(pos)),
})
}
Target::Mob(i) => {
let m = &self.mobs[i];
if m.removed || !m.alive() {
return None;
}
Some(Body {
bounds: m.bounds(),
position: m.position,
on_ground: m.on_ground,
step_height: m.kind.step_height as f32,
supported: m.supporting == Some(at),
})
}
Target::Item(i) => {
let entity = &self.dropped[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::Boat(i) => {
let entity = &self.boats[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::Cart(i) => {
let entity = &self.carts[i];
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
Target::ArmorStand(i) => {
let entity = &self.armor_stands[i];
if entity.marker {
return None;
}
Some(Body {
bounds: entity.bounds(),
position: entity.position,
on_ground: entity.on_ground,
step_height: 0.0,
supported: false,
})
}
}
}
/// `Entity.move(MoverType.PISTON, movement)` for one pushed entity: the
/// per-tick piston limit, then the block collision scan.
fn move_pushed(
&mut self,
target: Target,
before: Aabb,
step_height: f32,
on_ground: bool,
movement: Vec3,
piston_direction: Direction,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) {
let Some(limited) = self.limit_piston(target, movement, accumulators) else {
return;
};
// `limitPistonMovement` returns zero once the tick's budget is spent.
if limited == Vec3::ZERO {
return;
}
let resolved = with_piston_noclip(piston_direction, || {
SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
}
.resolve(before, limited, step_height, on_ground)
.unwrap_or(Vec3::ZERO)
});
if resolved.length_squared() <= 1.0e-7
&& limited.length_squared() - resolved.length_squared() >= 1.0e-7
{
return;
}
self.apply_push(target, resolved);
}
/// `Entity.limitPistonMovement`: each axis may travel at most 0.51 blocks
/// per game tick, and a move touches only its first non-zero axis.
fn limit_piston(
&mut self,
target: Target,
movement: Vec3,
accumulators: &mut std::collections::HashMap<Target, PistonMovement>,
) -> Option<Vec3> {
let time = self.world.game_time;
let limit = |state: &mut PistonMovement| state.limit(movement, || Ok::<i64, ()>(time)).ok();
match target {
Target::Player => {
let p = self.player.as_mut()?;
limit(&mut p.piston)
}
Target::Guest(conn) => {
let host = self.lan.as_mut()?;
let g = host.hub.guests.iter_mut().find(|g| g.conn == conn)?;
limit(&mut g.motion.as_deref_mut()?.piston)
}
Target::Mob(i) => limit(&mut self.mobs[i].piston),
_ => limit(accumulators.entry(target).or_default()),
}
}
fn apply_push(&mut self, target: Target, movement: Vec3) {
match target {
Target::Player => {
if let Some(p) = self.player.as_mut() {
p.position = p.position.add_vector(movement);
}
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_mut()
&& let Some(g) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
{
// The body and the hub's copy of its position move together:
// `leave_guest` writes the body back over the hub's.
for (slot, delta) in g.position.iter_mut().zip(movement.components()) {
*slot += delta;
}
if let Some(m) = g.motion.as_deref_mut() {
m.position = m.position.add_vector(movement);
}
}
}
Target::Mob(i) => {
let m = &mut self.mobs[i];
m.position = m.position.add_vector(movement);
}
Target::Item(i) => {
self.dropped[i].position = self.dropped[i].position.add_vector(movement)
}
Target::Boat(i) => self.boats[i].position = self.boats[i].position.add_vector(movement),
Target::Cart(i) => self.carts[i].position = self.carts[i].position.add_vector(movement),
Target::ArmorStand(i) => {
self.armor_stands[i].position = self.armor_stands[i].position.add_vector(movement)
}
}
}
/// `causeBounce`: a slime block sets the velocity along the movement axis.
fn bounce(&mut self, target: Target, movement: Direction) {
match target {
Target::Player => {
if let Some(p) = self.player.as_mut() {
p.velocity = piston_push::bounce_velocity(movement, p.velocity);
}
}
Target::Mob(i) => {
let m = &mut self.mobs[i];
m.velocity = piston_push::bounce_velocity(movement, m.velocity);
}
Target::Guest(conn) => {
if let Some(host) = self.lan.as_mut()
&& let Some(guest) = host.hub.guests.iter_mut().find(|g| g.conn == conn)
&& let Some(motion) = guest.motion.as_deref_mut()
{
motion.velocity = piston_push::bounce_velocity(movement, motion.velocity);
}
}
Target::Item(i) => {
self.dropped[i].velocity =
piston_push::bounce_velocity(movement, self.dropped[i].velocity)
}
Target::Boat(i) => {
self.boats[i].velocity =
piston_push::bounce_velocity(movement, self.boats[i].velocity)
}
Target::Cart(i) => {
self.carts[i].velocity =
piston_push::bounce_velocity(movement, self.carts[i].velocity)
}
Target::ArmorStand(i) => {
self.armor_stands[i].velocity =
piston_push::bounce_velocity(movement, self.armor_stands[i].velocity)
}
}
}
}
+2
View File
@@ -3185,6 +3185,8 @@ impl Game<'_> {
self.with_level(|b, level, ticks| {
b.client_tick_block_entities(level, ticks);
});
let pushes = std::mem::take(&mut self.block_ticks.piston_pushes);
self.push_piston_entities(pushes);
self.sync_moving_pistons();
self.tick_hand();
self.tick_particles();
+4
View File
@@ -819,6 +819,9 @@ pub struct Mob {
pub previous_swell: i32,
pub ambient_timer: i32,
pub fall_distance: f64,
/// `Entity.pistonDeltas` and `pistonDeltasGameTime`: how far a moving
/// block has pushed this mob this tick (0.51 at most on any axis).
pub piston: horizon_world::piston_movement::PistonMovement,
pub persistent: bool,
/// `CustomName` (plain text) and `CustomNameVisible`.
pub custom_name: Option<String>,
@@ -1648,6 +1651,7 @@ impl Mob {
// Mob.ambientSoundTime starts at 0.
ambient_timer: 0,
fall_distance: 0.0,
piston: Default::default(),
persistent: false,
random,
ai: Box::default(),
+12 -2
View File
@@ -223,6 +223,9 @@ pub struct PlayerMotion {
pub history: MovementHistory,
pub fall_distance: f64,
pub stuck_multiplier: Vec3,
/// `Entity.pistonDeltas` and `pistonDeltasGameTime`: how far a moving
/// block has pushed this entity this tick (0.51 at most on any axis).
pub piston: horizon_world::piston_movement::PistonMovement,
pub yaw: f32,
pub head_yaw: f32,
pub jumping: bool,
@@ -428,6 +431,7 @@ impl PlayerMotion {
history: MovementHistory::default(),
fall_distance: 0.0,
stuck_multiplier: Vec3::ZERO,
piston: Default::default(),
yaw: 0.0,
head_yaw: 0.0,
jumping: false,
@@ -1795,8 +1799,14 @@ impl EntityMoveWorld for MotionWorld<'_, '_, '_> {
fn set_collision_flags(&mut self, value: CollisionFlags) {
self.player.collision = value;
}
fn limit_piston_movement(&mut self, _: Vec3) -> Result<Vec3> {
Err("piston movement requires a ticking entity runtime".into())
/// `Entity.limitPistonMovement`: the per-tick 0.51-block budget shared by
/// every move on an axis. A failed game-time read leaves the accumulator
/// as it was, as native does.
fn limit_piston_movement(&mut self, movement: Vec3) -> Result<Vec3> {
let time = self.scene.game_time;
self.player
.piston
.limit(movement, || Ok::<i64, Box<dyn std::error::Error>>(time))
}
fn stuck_multiplier(&self) -> Vec3 {
self.player.stuck_multiplier
+4 -1
View File
@@ -93,11 +93,13 @@ pub struct World {
pub light_log: Option<crate::fast_hash::FxHashMap<(i32, i32), u32>>,
/// The villager POIs of each section (`PoiManager`'s sections).
pub pois: crate::poi_sections::PoiSections<crate::mobs::villager_brain::Poi>,
/// Moving-piston states removed during the last tick; retained until the
/// final block mesh is visible.
pub moving_piston_fallback: Vec<([i32; 3], horizon_world::piston_shape::MovingPistonState)>,
/// The moving-piston block entities of the level, by position, copied
/// from the block ticks after each tick: collision and rendering read them.
pub moving_pistons: Vec<([i32; 3], horizon_world::piston_shape::MovingPistonState)>,
}
/// ServerLevel weather state (advanceWeatherCycle).
#[derive(Clone, Copy, Debug, Default)]
pub struct Weather {
pub clear_time: i32,
@@ -187,6 +189,7 @@ impl World {
weather: Weather::default(),
block_log: None,
light_log: None,
moving_piston_fallback: Vec::new(),
moving_pistons: Vec::new(),
pois: Default::default(),
}
@@ -137,6 +137,7 @@ mod movement_support_fixture;
mod object_open_map_fixture;
mod object_open_set_fixture;
mod piston_movement_fixture;
mod piston_push_fixture;
mod piston_shape_fixture;
mod placement_fixture;
mod player_equipment_fixture;
@@ -309,6 +310,7 @@ probes! {
object_open_map_fixture,
object_open_set_fixture,
piston_movement_fixture,
piston_push_fixture,
piston_shape_fixture,
placement_fixture,
player_equipment_fixture,
@@ -0,0 +1,163 @@
//! The moving-piston push maths against the real game: `PistonMath.getMovementArea`,
//! `moveByPositionAndProgress`, `getMovement`, the `moveCollidedEntities` loop
//! over the block's own boxes, and `moveStuckEntities`' box with
//! `matchesStickyCritera`.
use crate::support::common::f64_hex;
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
};
use horizon_world::{level::data::PushReaction, piston_push, piston_shape::MovingPistonState};
use serde_json::Value;
use std::{
fs,
io::{self, BufWriter, Write},
path::Path,
};
fn number(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn float(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn aabb(v: &Value) -> Aabb {
let a = v.as_array().unwrap();
Aabb::new(
Vec3::new(number(&a[0]), number(&a[1]), number(&a[2])),
Vec3::new(number(&a[3]), number(&a[4]), number(&a[5])),
)
}
fn direction(index: i64) -> Direction {
Direction::ALL[index.rem_euclid(6) as usize]
}
fn reaction(index: i64) -> PushReaction {
match index.rem_euclid(5) {
0 => PushReaction::PushPull,
1 => PushReaction::Push,
2 => PushReaction::Popped,
3 => PushReaction::Immoveable,
_ => PushReaction::IgnoreEntity,
}
}
fn line(out: &mut impl Write, name: &str, b: Aabb) -> io::Result<()> {
let (min, max) = (b.min_position(), b.max_position());
writeln!(
out,
"{name} {} {} {} {} {} {}",
f64_hex(min.x),
f64_hex(min.y),
f64_hex(min.z),
f64_hex(max.x),
f64_hex(max.y),
f64_hex(max.z)
)
}
pub fn main() -> Result<(), Box<dyn std::error::Error>> {
let arg = crate::args().nth(1).expect("private fixture directory");
let dir = Path::new(&arg);
let mut out = BufWriter::new(fs::File::create(dir.join("rust.txt"))?);
for text in fs::read_to_string(dir.join("inputs.txt"))?.lines() {
let f: Value = serde_json::from_str(text)?;
let p = &f["pos"];
let pos = BlockPos::new(
p[0].as_i64().unwrap() as i32,
p[1].as_i64().unwrap() as i32,
p[2].as_i64().unwrap() as i32,
);
let progress = float(&f["progress"]);
let piston = MovingPistonState {
moved_state: horizon_world::registry::BlockStateId(0),
direction: direction(f["direction"].as_i64().unwrap()),
extending: f["extending"].as_bool().unwrap(),
source: f["source"].as_bool().unwrap(),
progress,
previous_progress: progress,
};
let new_progress = float(&f["new_progress"]);
let delta_progress = f64::from(new_progress - progress);
let movement = piston.movement_direction();
let block_box = aabb(&f["block_box"]);
let entity = aabb(&f["entity"]);
let movement_box = aabb(&f["movement_box"]);
writeln!(
out,
"extended {:08x}",
piston_push::extended_progress(piston, progress).to_bits()
)?;
let origin = piston_push::move_by_position_and_progress(pos, block_box, piston);
line(&mut out, "origin", origin)?;
line(
&mut out,
"area",
piston_push::movement_area(block_box, movement, delta_progress),
)?;
for (name, face) in [
("east", Direction::East),
("west", Direction::West),
("up", Direction::Up),
("down", Direction::Down),
("south", Direction::South),
("north", Direction::North),
] {
writeln!(
out,
"movement {name} {}",
f64_hex(piston_push::get_movement(movement_box, face, entity))
)?;
}
// `moveCollidedEntities`' loop: the furthest box reaches into the
// entity, capped at the progress delta, plus the push offset.
let boxes: Vec<Aabb> = f["boxes"].as_array().unwrap().iter().map(aabb).collect();
let mut delta: f64 = 0.0;
let mut capped = false;
for block in &boxes {
let moving = piston_push::movement_area(
piston_push::move_by_position_and_progress(pos, *block, piston),
movement,
delta_progress,
);
if moving.intersects(entity) {
delta = delta.max(piston_push::get_movement(moving, movement, entity));
if delta >= delta_progress {
capped = true;
break;
}
}
}
let collided = if delta <= 0.0 {
0.0
} else {
delta.min(delta_progress) + piston_push::PUSH_OFFSET
};
writeln!(out, "collided {} {capped}", f64_hex(collided))?;
line(
&mut out,
"search",
piston_push::movement_search_area(origin, movement, delta_progress),
)?;
let sticky = piston_push::sticky_area(pos, piston, number(&f["sticky_top"]));
line(&mut out, "sticky", sticky)?;
writeln!(
out,
"matches {}",
piston_push::matches_sticky_criteria(
sticky,
reaction(f["reaction"].as_i64().unwrap()),
f["on_ground"].as_bool().unwrap(),
f["supported"].as_bool().unwrap(),
Vec3::new(number(&f["entity_x"]), 0.0, number(&f["entity_z"])),
)
)?;
}
out.flush()?;
Ok(())
}
+13 -5
View File
@@ -182,7 +182,9 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
self.neighbor_changed_at(pos, block);
}
}
/// `PistonMovingBlockEntity.tick` (entity pushing excluded).
/// `PistonMovingBlockEntity.tick`. A piston that is still moving reports
/// the push `moveCollidedEntities` would do at its new progress; the caller
/// moves the entities, because `Ticks` has none (`piston_pushes`).
fn moving_piston_tick(&mut self, pos: BlockPos) {
let Some(mut piston) = self.moving_piston(pos) else {
return;
@@ -227,14 +229,20 @@ impl<L: LevelAccess + ?Sized> Sim<'_, L> {
}
}
} else {
piston.progress = (piston.progress + 0.5).min(1.0);
if let Some(entry) = self
// moveCollidedEntities and moveStuckEntities run with the new
// progress but read the old one off the entity (their delta),
// so the push carries the piston as it was before the advance.
let old = piston;
let new_progress = (old.progress + 0.5).min(1.0);
let current = self
.ticks
.moving_pistons
.iter_mut()
.find(|(p, _)| *p == pos)
{
.find(|(p, _)| *p == pos);
if let Some(entry) = current {
piston.progress = new_progress;
entry.1 = piston;
self.ticks.piston_pushes.push((pos, old, new_progress));
}
}
}
+6
View File
@@ -29,6 +29,11 @@ pub struct Ticks {
/// Moving-piston block entities in ticker order (render and collide
/// moving blocks from these; see `piston_shape`).
pub moving_pistons: Vec<(BlockPos, super::pistons::MovingPiston)>,
/// The pushes this tick's moving-piston block entities asked for: each
/// piston with the progress it is about to reach, in the order they ticked
/// (`PistonMovingBlockEntity.tick`'s `moveCollidedEntities` call). The
/// caller moves entities by these; `Ticks` has no entities of its own.
pub piston_pushes: Vec<(BlockPos, super::pistons::MovingPiston, f32)>,
pub(crate) next_block_entity_id: u64,
/// `ServerLevel.blockEvents`, run by `Behaviours::run_block_events`.
pub block_events: Vec<super::pistons::BlockEventData>,
@@ -65,6 +70,7 @@ impl Ticks {
comparator_outputs: Default::default(),
torch_toggles: Vec::new(),
moving_pistons: Vec::new(),
piston_pushes: Vec::new(),
next_block_entity_id: 0,
block_events: Vec::new(),
handling_tick: false,
+1
View File
@@ -94,6 +94,7 @@ pub mod nbt;
pub mod object_open_map;
pub mod object_open_set;
pub mod piston_movement;
pub mod piston_push;
pub mod piston_shape;
pub mod player_food;
pub mod player_health;
+461
View File
@@ -0,0 +1,461 @@
//! `PistonMovingBlockEntity.moveCollidedEntities`, `moveStuckEntities` and
//! `fixEntityWithinPistonBase` over immutable geometry.
//!
//! Entity storage, the moving entities themselves and the blocks they collide
//! with belong to the caller: these functions decide *how far* an entity is
//! pushed, which is the part that must match vanilla to the bit. The movement
//! they describe is an `Entity.move(MoverType.PISTON, …)`, so the caller still
//! applies it through its own collision resolution and per-tick piston limit.
use crate::{level::data::PushReaction, piston_shape::MovingPistonState};
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
};
/// `PistonMovingBlockEntity.PUSH_OFFSET`.
pub const PUSH_OFFSET: f64 = 0.01;
/// `PistonMovingBlockEntity.getExtendedProgress`: where along its travel the
/// moved block is, in blocks (negative before it starts, 0 when it arrives).
pub fn extended_progress(piston: MovingPistonState, progress: f32) -> f32 {
if piston.extending {
progress - 1.0
} else {
1.0 - progress
}
}
/// `Direction.getAxisDirection().getStep()`.
fn step_along(direction: Direction) -> i32 {
match direction {
Direction::East | Direction::Up | Direction::South => 1,
Direction::West | Direction::Down | Direction::North => -1,
}
}
/// `Direction.getStepX/Y/Z` as a vector.
pub fn step_vector(direction: Direction) -> Vec3 {
let [x, y, z] = direction.normal();
Vec3::new(f64::from(x), f64::from(y), f64::from(z))
}
/// `PistonMath.getMovementArea`: the slab an entity's box must reach into to be
/// run over by `amount` of further travel along `direction`.
pub fn movement_area(aabb: Aabb, direction: Direction, amount: f64) -> Aabb {
let delta = amount * f64::from(step_along(direction));
let low = delta.min(0.0);
let high = delta.max(0.0);
let (min, max) = (aabb.min_position(), aabb.max_position());
match direction {
Direction::West => Aabb::new(
Vec3::new(min.x + low, min.y, min.z),
Vec3::new(min.x + high, max.y, max.z),
),
Direction::East => Aabb::new(
Vec3::new(max.x + low, min.y, min.z),
Vec3::new(max.x + high, max.y, max.z),
),
Direction::Down => Aabb::new(
Vec3::new(min.x, min.y + low, min.z),
Vec3::new(max.x, min.y + high, max.z),
),
Direction::Up => Aabb::new(
Vec3::new(min.x, max.y + low, min.z),
Vec3::new(max.x, max.y + high, max.z),
),
Direction::North => Aabb::new(
Vec3::new(min.x, min.y, min.z + low),
Vec3::new(max.x, max.y, min.z + high),
),
Direction::South => Aabb::new(
Vec3::new(min.x, min.y, max.z + low),
Vec3::new(max.x, max.y, max.z + high),
),
}
}
/// `PistonMovingBlockEntity.moveByPositionAndProgress`: a box moved to the
/// block position and on to where the moved block currently is.
pub fn move_by_position_and_progress(pos: BlockPos, aabb: Aabb, piston: MovingPistonState) -> Aabb {
let current = f64::from(extended_progress(piston, piston.progress));
let step = step_vector(piston.direction);
aabb.move_by(Vec3::new(
f64::from(pos.x) + current * step.x,
f64::from(pos.y) + current * step.y,
f64::from(pos.z) + current * step.z,
))
}
/// `PistonMovingBlockEntity.getMovement`: how far `entity` stands inside the
/// block it must be pushed out of, measured along `movement`'s axis.
pub fn get_movement(block: Aabb, movement: Direction, entity: Aabb) -> f64 {
let (bmin, bmax) = (block.min_position(), block.max_position());
let (emin, emax) = (entity.min_position(), entity.max_position());
match movement {
Direction::East => bmax.x - emin.x,
Direction::West => emax.x - bmin.x,
Direction::Up => bmax.y - emin.y,
Direction::Down => emax.y - bmin.y,
Direction::South => bmax.z - emin.z,
Direction::North => emax.z - bmin.z,
}
}
/// `moveCollidedEntities`' inner loop: the furthest any of the moved block's
/// boxes reaches into `entity`, capped at the progress delta, plus
/// [`PUSH_OFFSET`]. `0.0` when the block does not touch the entity.
pub fn collided_amount(
boxes: &[Aabb],
pos: BlockPos,
piston: MovingPistonState,
new_progress: f32,
movement: Direction,
entity: Aabb,
) -> f64 {
let delta_progress = f64::from(new_progress - piston.progress);
let mut delta: f64 = 0.0;
for block in boxes {
let moving = movement_area(
move_by_position_and_progress(pos, *block, piston),
movement,
delta_progress,
);
if moving.intersects(entity) {
delta = delta.max(get_movement(moving, movement, entity));
if delta >= delta_progress {
break;
}
}
}
if delta <= 0.0 {
return 0.0;
}
delta.min(delta_progress) + PUSH_OFFSET
}
/// The box `level.getEntities` searches for a push: the moved block's own box
/// widened by the slab it is about to sweep.
pub fn movement_search_area(block_box: Aabb, movement: Direction, delta_progress: f64) -> Aabb {
movement_area(block_box, movement, delta_progress).union_bounds(block_box)
}
/// `moveStuckEntities`: the box of standing entities a moving honey block drags
/// with it. `sticky_top` is the moved state's top collision height.
pub fn sticky_area(pos: BlockPos, piston: MovingPistonState, sticky_top: f64) -> Aabb {
let area = Aabb::new(
Vec3::new(0.0, sticky_top, 0.0),
Vec3::new(1.0, 1.500_001_000_000_000_1, 1.0),
);
move_by_position_and_progress(pos, area, piston)
}
/// `PistonMovingBlockEntity.matchesStickyCritera`.
pub fn matches_sticky_criteria(
area: Aabb,
reaction: PushReaction,
on_ground: bool,
supported_by_piston: bool,
entity_position: Vec3,
) -> bool {
if reaction != PushReaction::PushPull || !on_ground {
return false;
}
if supported_by_piston {
return true;
}
let (min, max) = (area.min_position(), area.max_position());
entity_position.x >= min.x
&& entity_position.x <= max.x
&& entity_position.z >= min.z
&& entity_position.z <= max.z
}
/// The level query intersects the sticky box before applying the predicate.
pub fn sticky_entity_selected(
area: Aabb,
bounds: Aabb,
reaction: PushReaction,
on_ground: bool,
supported_by_piston: bool,
position: Vec3,
) -> bool {
area.intersects(bounds)
&& matches_sticky_criteria(area, reaction, on_ground, supported_by_piston, position)
}
/// `fixEntityWithinPistonBase`: a retracting source piston drags an entity that
/// is still inside the piston's own block out along the opposite of `movement`.
/// The returned distance is measured along that opposite direction.
pub fn fix_within_piston_base(
pos: BlockPos,
entity: Aabb,
movement: Direction,
delta_progress: f64,
) -> Option<f64> {
let block = Aabb::unit_cube(Vec3::from_block(pos));
if !entity.intersects(block) {
return None;
}
let opposite = movement.opposite();
let delta = get_movement(block, opposite, entity) + PUSH_OFFSET;
let intersected = entity.intersect(block);
let delta_intersected = get_movement(block, opposite, intersected) + PUSH_OFFSET;
// As deep inside as the block allows: it is stuck in the base itself, so it
// leaves along with the retraction.
if (delta - delta_intersected).abs() < PUSH_OFFSET {
Some(delta.min(delta_progress) + PUSH_OFFSET)
} else {
None
}
}
/// `PistonMovingBlockEntity.getCollisionRelatedBlockState` uses the moved state
/// unless a source piston is retracting, where it shows a piston head instead.
/// The state itself comes from [`PistonBlockBindings::collision_related_state`].
///
/// [`PistonBlockBindings::collision_related_state`]:
/// crate::piston_shape::PistonBlockBindings::collision_related_state
pub fn uses_moved_state(piston: MovingPistonState) -> bool {
piston.extending || !piston.source
}
/// `moveCollidedEntities`' slime bounce: a slime block sets the velocity along
/// the movement axis to that direction's step, leaving the other axes.
pub fn bounce_velocity(movement: Direction, velocity: Vec3) -> Vec3 {
let mut velocity = velocity;
match movement {
Direction::East => velocity.x = 1.0,
Direction::West => velocity.x = -1.0,
Direction::Up => velocity.y = 1.0,
Direction::Down => velocity.y = -1.0,
Direction::South => velocity.z = 1.0,
Direction::North => velocity.z = -1.0,
}
velocity
}
/// `Entity.getPistonPushReaction() == PushReaction.IGNORE_ENTITY`.
///
/// `Marker`, `Interaction`, `Display`, `AreaEffectCloud` and
/// `OminousItemSpawner` return `IGNORE_ENTITY`, and an armor stand does when it
/// is a marker. Horizon gives those kinds no pushed body, so a caller that has
/// no box to push never reaches the maths above; this names the predicate for
/// the kinds that do have one.
pub fn ignores_push(marker: bool) -> bool {
marker
}
#[cfg(test)]
mod tests {
use super::*;
fn unit() -> Aabb {
Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.0, 1.0))
}
fn moving(direction: Direction, extending: bool, progress: f32) -> MovingPistonState {
MovingPistonState {
moved_state: crate::registry::BlockStateId(0),
direction,
extending,
source: false,
progress,
previous_progress: progress,
}
}
/// `getMovementArea` puts the slab on the leading face and widens it by the
/// travel in that direction only.
#[test]
fn movement_area_sits_on_the_leading_face() {
let east = movement_area(unit(), Direction::East, 0.5);
assert_eq!(east.min_position(), Vec3::new(1.0, 0.0, 0.0));
assert_eq!(east.max_position(), Vec3::new(1.5, 1.0, 1.0));
let west = movement_area(unit(), Direction::West, 0.5);
assert_eq!(west.min_position(), Vec3::new(-0.5, 0.0, 0.0));
assert_eq!(west.max_position(), Vec3::new(0.0, 1.0, 1.0));
let up = movement_area(unit(), Direction::Up, 0.5);
assert_eq!(up.min_position(), Vec3::new(0.0, 1.0, 0.0));
assert_eq!(up.max_position(), Vec3::new(1.0, 1.5, 1.0));
let north = movement_area(unit(), Direction::North, 0.5);
assert_eq!(north.min_position(), Vec3::new(0.0, 0.0, -0.5));
assert_eq!(north.max_position(), Vec3::new(1.0, 1.0, 0.0));
}
/// A negative amount sweeps behind the leading face.
#[test]
fn movement_area_follows_a_negative_amount() {
let area = movement_area(unit(), Direction::East, -0.5);
assert_eq!(area.min_position(), Vec3::new(0.5, 0.0, 0.0));
assert_eq!(area.max_position(), Vec3::new(1.0, 1.0, 1.0));
}
/// The arm of a piston at the origin: the block entity sits in the block it
/// pushes into, and at progress 0 its shape is still a block back.
const ARM: BlockPos = BlockPos::new(1, 0, 0);
/// An entity in the way is pushed the progress delta, plus the push offset;
/// here it hangs 0.6 into the slab and the delta caps it at 0.5.
#[test]
fn a_collided_entity_moves_the_progress_delta() {
let piston = moving(Direction::East, true, 0.0);
let entity = Aabb::new(Vec3::new(0.9, 0.0, 0.2), Vec3::new(1.5, 1.8, 0.8));
let amount = collided_amount(&[unit()], ARM, piston, 0.5, Direction::East, entity);
assert!((amount - (0.5 + PUSH_OFFSET)).abs() < 1.0e-12, "{amount}");
}
/// The delta is not a floor: an entity barely nicking the slab moves by only
/// what it is owed, plus the offset.
#[test]
fn a_grazed_entity_moves_only_what_it_overlaps() {
let piston = moving(Direction::East, true, 0.0);
let entity = Aabb::new(Vec3::new(1.4, 0.0, 0.2), Vec3::new(2.0, 1.8, 0.8));
let amount = collided_amount(&[unit()], ARM, piston, 0.5, Direction::East, entity);
assert!((amount - (0.1 + PUSH_OFFSET)).abs() < 1.0e-12, "{amount}");
}
/// Clear of the block, nothing moves.
#[test]
fn an_entity_out_of_the_way_is_left_alone() {
let piston = moving(Direction::East, true, 0.0);
let entity = Aabb::new(Vec3::new(3.0, 0.0, 0.2), Vec3::new(3.6, 1.8, 0.8));
let amount = collided_amount(&[unit()], ARM, piston, 0.5, Direction::East, entity);
assert_eq!(amount, 0.0);
}
/// A retracting base drags an entity stuck inside its own block, limited by
/// the progress delta.
#[test]
fn a_retracting_base_drags_a_stuck_entity() {
let entity = Aabb::new(Vec3::new(0.75, 0.0, 0.2), Vec3::new(1.35, 1.8, 0.8));
let amount = fix_within_piston_base(BlockPos::new(0, 0, 0), entity, Direction::West, 0.5)
.expect("stuck in the base");
assert!(amount > 0.0 && amount <= 0.5 + PUSH_OFFSET, "{amount}");
}
/// An entity outside the base's block is not dragged by it.
#[test]
fn a_free_entity_is_not_dragged_by_the_base() {
let entity = Aabb::new(Vec3::new(2.0, 0.0, 0.2), Vec3::new(2.6, 1.8, 0.8));
assert_eq!(
fix_within_piston_base(BlockPos::new(0, 0, 0), entity, Direction::West, 0.5),
None
);
}
/// Honey drags only entities that stand on it and that blocks may push.
#[test]
fn honey_drags_standing_entities() {
let area = Aabb::new(Vec3::new(0.0, 0.0, 0.0), Vec3::new(1.0, 1.5, 1.0));
let on_top = Vec3::new(0.5, 0.0, 0.5);
assert!(matches_sticky_criteria(
area,
PushReaction::PushPull,
true,
false,
on_top
));
assert!(!matches_sticky_criteria(
area,
PushReaction::PushPull,
false,
false,
on_top
));
assert!(!matches_sticky_criteria(
area,
PushReaction::Immoveable,
true,
false,
on_top
));
// Beside it: only standing on the piston's own block holds it.
let beside = Vec3::new(2.0, 0.0, 0.5);
assert!(!matches_sticky_criteria(
area,
PushReaction::PushPull,
true,
false,
beside
));
assert!(matches_sticky_criteria(
area,
PushReaction::PushPull,
true,
true,
beside
));
}
#[test]
fn honey_selection_uses_entity_position_and_intersection() {
let area = Aabb::new(Vec3::new(0.0, 1.0, 0.0), Vec3::new(1.0, 1.5, 1.0));
let position = Vec3::new(0.1, 1.0, 0.5);
let bounds = Aabb::new(Vec3::new(-0.2, 1.0, 0.2), Vec3::new(0.4, 2.8, 0.8));
assert!(sticky_entity_selected(
area,
bounds,
PushReaction::PushPull,
true,
false,
position
));
let above = bounds.move_by(Vec3::new(0.0, 3.0, 0.0));
assert!(!sticky_entity_selected(
area,
above,
PushReaction::PushPull,
true,
true,
position
));
}
/// The slime bounce rewrites only the movement axis.
#[test]
fn a_slime_block_bounces_along_its_axis() {
let v = Vec3::new(0.2, -0.5, 0.3);
assert_eq!(
bounce_velocity(Direction::East, v),
Vec3::new(1.0, -0.5, 0.3)
);
assert_eq!(bounce_velocity(Direction::Up, v), Vec3::new(0.2, 1.0, 0.3));
assert_eq!(
bounce_velocity(Direction::North, v),
Vec3::new(0.2, -0.5, -1.0)
);
}
/// The moved block sits along the piston's own direction, half a block short
/// at mid-travel and in place once landed.
#[test]
fn the_moved_block_sits_along_the_piston_direction() {
let half = moving(Direction::East, true, 0.5);
let moved = move_by_position_and_progress(BlockPos::new(2, 3, 4), unit(), half);
assert_eq!(moved.min_position(), Vec3::new(1.5, 3.0, 4.0));
let landed = moving(Direction::East, true, 1.0);
assert_eq!(
move_by_position_and_progress(BlockPos::new(2, 3, 4), unit(), landed).min_position(),
Vec3::new(2.0, 3.0, 4.0)
);
// Retracting runs the other way: two thirds out at progress 1/3.
let pulling = moving(Direction::East, false, 0.5);
assert_eq!(
move_by_position_and_progress(BlockPos::new(2, 3, 4), unit(), pulling).min_position(),
Vec3::new(2.5, 3.0, 4.0)
);
}
/// The search area covers the block and the slab in front of it.
#[test]
fn the_search_area_covers_block_and_sweep() {
let area = movement_search_area(unit(), Direction::East, 0.5);
assert_eq!(area.min_position(), Vec3::new(0.0, 0.0, 0.0));
assert_eq!(area.max_position(), Vec3::new(1.5, 1.0, 1.0));
}
}
@@ -0,0 +1,78 @@
package dev.horizonlab.parity;
import com.google.gson.*;
import java.io.*;
import java.lang.reflect.*;
import java.nio.file.*;
import java.util.*;
import net.minecraft.core.*;
import net.minecraft.world.entity.*;
import net.minecraft.world.entity.monster.zombie.Zombie;
import net.minecraft.world.level.block.*;
import net.minecraft.world.level.block.piston.*;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.*;
/**
* The real moving-piston push maths: PistonMath.getMovementArea,
* moveByPositionAndProgress, getMovement, the moveCollidedEntities loop over
* the block's own boxes, and moveStuckEntities' box and matchesStickyCritera.
* fixEntityWithinPistonBase is left out: it moves the entity
* (`Entity.move(MoverType.PISTON, ...)`), which needs a live level, so the
* Rust side is covered by its own unit tests.
*/
public final class VanillaPistonPushOracle {
private static float number(JsonElement v){return Float.intBitsToFloat(Integer.parseUnsignedInt(v.getAsString(),16));}
private static double dbl(JsonElement v){return Double.longBitsToDouble(Long.parseUnsignedLong(v.getAsString(),16));}
private static AABB box(JsonElement v){var a=v.getAsJsonArray();return new AABB(dbl(a.get(0)),dbl(a.get(1)),dbl(a.get(2)),dbl(a.get(3)),dbl(a.get(4)),dbl(a.get(5)));}
private static void line(PrintWriter out,String name,AABB b){out.printf(name+" %016x %016x %016x %016x %016x %016x%n",Double.doubleToLongBits(b.minX),Double.doubleToLongBits(b.minY),Double.doubleToLongBits(b.minZ),Double.doubleToLongBits(b.maxX),Double.doubleToLongBits(b.maxY),Double.doubleToLongBits(b.maxZ));}
private static Field field(Class<?> c,String name)throws Exception{var f=c.getDeclaredField(name);f.setAccessible(true);return f;}
private static Method method(Class<?> c,String name,Class<?>...types)throws Exception{var m=c.getDeclaredMethod(name,types);m.setAccessible(true);return m;}
@SuppressWarnings("unchecked") public static void main(String[] args)throws Exception {
net.minecraft.SharedConstants.tryDetectVersion();net.minecraft.server.Bootstrap.bootStrap();Path dir=Path.of(args[1]);
var allocator=Class.forName("sun.misc.Unsafe");var uf=allocator.getDeclaredField("theUnsafe");uf.setAccessible(true);var unsafe=uf.get(null);var allocate=allocator.getMethod("allocateInstance",Class.class);
var progress=field(PistonMovingBlockEntity.class,"progress");
var movedBy=method(PistonMovingBlockEntity.class,"moveByPositionAndProgress",BlockPos.class,AABB.class,PistonMovingBlockEntity.class);
var extended=method(PistonMovingBlockEntity.class,"getExtendedProgress",float.class);
var getMovement=method(PistonMovingBlockEntity.class,"getMovement",AABB.class,Direction.class,AABB.class);
var matches=method(PistonMovingBlockEntity.class,"matchesStickyCritera",AABB.class,Entity.class,BlockPos.class);
var movementDir=method(PistonMovingBlockEntity.class,"getMovementDirection");
var position=field(Entity.class,"position");var onGroundField=field(Entity.class,"onGround");var supporting=field(Entity.class,"mainSupportingBlockPos");var bbField=field(Entity.class,"bb");
var faces=new Direction[]{Direction.EAST,Direction.WEST,Direction.UP,Direction.DOWN,Direction.SOUTH,Direction.NORTH};
try(var out=new PrintWriter(Files.newBufferedWriter(dir.resolve(args[0]+".txt")));var inputs=Files.lines(dir.resolve("inputs.txt"))) {
for(var line:(Iterable<String>)inputs::iterator) {
var f=JsonParser.parseString(line).getAsJsonObject();var v=f.getAsJsonArray("pos");var p=new BlockPos(v.get(0).getAsInt(),v.get(1).getAsInt(),v.get(2).getAsInt());
var direction=Direction.values()[f.get("direction").getAsInt()];
var piston=new PistonMovingBlockEntity(p,Blocks.MOVING_PISTON.defaultBlockState(),Blocks.STONE.defaultBlockState(),direction,f.get("extending").getAsBoolean(),f.get("source").getAsBoolean());
float current=number(f.get("progress"));
progress.setFloat(piston,current);
float newProgress=number(f.get("new_progress"));
double deltaProgress=newProgress-current;
Direction movement=(Direction)movementDir.invoke(piston);
AABB blockBox=box(f.get("block_box"));
AABB entityBox=box(f.get("entity"));
AABB movementBox=box(f.get("movement_box"));
out.printf("extended %08x%n",Float.floatToIntBits((float)extended.invoke(piston,current)));
AABB origin=(AABB)movedBy.invoke(null,p,blockBox,piston);
line(out,"origin",origin);
line(out,"area",PistonMath.getMovementArea(blockBox,movement,deltaProgress));
for(var face:faces)out.printf("movement %s %016x%n",face.getSerializedName(),Double.doubleToLongBits((double)getMovement.invoke(null,movementBox,face,entityBox)));
double delta=0.0;boolean capped=false;
for(var value:f.getAsJsonArray("boxes")){
AABB movingAABB=PistonMath.getMovementArea((AABB)movedBy.invoke(null,p,box(value),piston),movement,deltaProgress);
if(movingAABB.intersects(entityBox)){delta=Math.max(delta,(double)getMovement.invoke(null,movingAABB,movement,entityBox));if(delta>=deltaProgress){capped=true;break;}}
}
double collided=delta<=0.0?0.0:Math.min(delta,deltaProgress)+0.01;
out.printf("collided %016x %b%n",Double.doubleToLongBits(collided),capped);
line(out,"search",PistonMath.getMovementArea(origin,movement,deltaProgress).minmax(origin));
AABB sticky=(AABB)movedBy.invoke(null,p,new AABB(0.0,dbl(f.get("sticky_top")),0.0,1.0,1.5000010000000001,1.0),piston);
line(out,"sticky",sticky);
boolean pushable=f.get("reaction").getAsInt()==0;
var entity=(Entity)allocate.invoke(unsafe,pushable?Zombie.class:Marker.class);
position.set(entity,new Vec3(dbl(f.get("entity_x")),0.0,dbl(f.get("entity_z"))));
bbField.set(entity,entityBox);
onGroundField.setBoolean(entity,f.get("on_ground").getAsBoolean());
supporting.set(entity,f.get("supported").getAsBoolean()?Optional.of(p):Optional.empty());
out.printf("matches %b%n",(boolean)matches.invoke(null,sticky,entity,p));
}
}
}
}
+55 -2
View File
@@ -296,7 +296,7 @@ CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'si
'arrows', 'kills', 'hurt', 'looks', 'leads', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'styles',
'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing',
'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push',
'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
'piston-push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'thrown', 'sparks', 'hud', 'sidebar', 'border', 'particles',
'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects',
'portals', 'distance', 'growth', 'doors', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'hands',
'lightning', 'riding', 'mounts', 'worldspawn', 'beds', 'shield', 'deathmining', 'contacts', 'absorption',
@@ -2368,6 +2368,59 @@ def wait_state(game, pos, key, want, ticks):
return state_of(game, pos, key)
def check_piston_push(host, guest, out):
"""A moving piston pushes both players and a stationary mob on each host path."""
hx, _, hz = pos_of(host)
x, y, z = (int(hx // 16) * 16 + 8, 100, int(hz // 16) * 16 + 8)
host('command', command=f'fill {x-4} {y-1} {z-4} {x+7} {y-1} {z+4} minecraft:stone')
host('command', command=f'fill {x-4} {y} {z-4} {x+7} {y+5} {z+4} minecraft:air')
host('command', command=f'setblock {x} {y} {z} minecraft:piston[facing=east]')
results = {}
for player, name in ((host, host.player), (guest, GUEST_NAME)):
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
host('command', command=f'tp {"@s" if player is host else GUEST_NAME} {x+1.65} {y} {z+0.5}')
player.wait(15)
before = pos_of(player)
assert abs(before[0] - (x + 1.65)) < 0.1, f'{name} did not reach piston: {before}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
player.wait(30)
after = pos_of(player)
results[name] = after[0] - before[0]
assert results[name] > 0.3, f'piston did not push {name}: {before} -> {after}'
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
host('command', command=f'tp {GUEST_NAME} {x+5.5} {y} {z-1.5}')
host('command', command=f'tp @s {x+1.65} {y} {z+0.5}')
host.wait(10)
host.raw('key', 'e', 'tap')
host.raw('wait-for', 'screen', 'inventory')
before = pos_of(host)
guest('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
host.wait(30)
after = pos_of(host)
results['inventory'] = after[0] - before[0]
assert results['inventory'] > 0.3, f'piston did not push player with inventory open: {before} -> {after}'
host.raw('key', 'esc', 'tap')
host('command', command=f'setblock {x-1} {y} {z} minecraft:air')
host.wait(10)
host('command', command=f'tp @s {x+5.5} {y} {z+2.5}')
host('command', command=f'summon minecraft:pig {x+1.65} {y} {z+0.5} {{NoAI:1b}}')
host.wait(10)
pigs = host('entities', radius=16, type='pig')['entities']
pig = min(pigs, key=lambda p: abs(p['pos'][0] - (x + 1.65)) + abs(p['pos'][2] - (z + 0.5)))
before = pig['pos'][0]
host('command', command=f'setblock {x-1} {y} {z} minecraft:redstone_block')
host.wait(30)
pigs = host('entities', radius=16, type='pig')['entities']
after = next(p['pos'][0] for p in pigs if p['id'] == pig['id'])
results['pig'] = after - before
assert results['pig'] > 0.3, f'piston did not push pig: {before} -> {after}'
return results
def check_plates(host, guest, out):
guest('command', command='gamemode survival')
x, y, z = (int(v // 1) for v in pos_of(guest))
@@ -4679,7 +4732,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour,
'cartography': check_cartography, 'hurt': check_hurt, 'commands': check_commands,
'recipes': check_recipes, 'trials': check_trials, 'walls': check_walls,
'effects': check_effects, 'portals': check_portals, 'distance': check_distance,
'clicks': check_clicks, 'sleep': check_sleep, 'push': check_push,
'clicks': check_clicks, 'sleep': check_sleep, 'push': check_push, 'piston-push': check_piston_push,
'eat': check_eat, 'eating': check_eating, 'xp': check_xp,
'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast,
'light': check_light, 'plates': check_plates, 'swings': check_swings,
+193
View File
@@ -0,0 +1,193 @@
#!/usr/bin/env python3
"""Compare moving-piston push geometry and predicates with pinned vanilla.
`PistonMovingBlockEntity.moveCollidedEntities` decides how far an entity is
pushed; the movement it describes is `Entity.move(MoverType.PISTON, ...)`, whose
collision and per-tick limit the game owns. This harness covers the deciding
half: the movement area, the offset block box, `getMovement` on every face, the
per-box loop, `moveStuckEntities`' box and `matchesStickyCritera`, and
`fixEntityWithinPistonBase`.
"""
import argparse
import json
import os
from pathlib import Path
import platform
import random
import struct
import subprocess
try:
from . import lab, test_rust_parity as base
except ImportError:
import lab
import test_rust_parity as base
def corpus():
def f32(v): return struct.pack('>f', float(v)).hex()
def f64(v): return struct.pack('>d', float(v)).hex()
def box(values): return [f64(v) for v in values]
step = [1.0 / 3.0, 0.25, 0.5, 0.75, 1.0]
unit = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
offsets = [[0, 0, 0], [-17, -64, 30000000], [2147483647, -2147483648, -2147483648]]
faces = [0, 1, 2, 3, 4, 5]
# Progress pairs around the 0.25/0.5/1.0 thresholds the head and the
# extended-progress offset change at.
progress = [0.0, 0.25, 0.49999997, 0.5, 0.75, 0.99999994, 1.0]
entities = [
[0.9, 0.0, 0.2, 1.5, 1.8, 0.8],
[1.4, 0.0, 0.2, 2.0, 1.8, 0.8],
[0.75, 0.0, 0.2, 1.35, 1.8, 0.8],
[3.0, 0.0, 0.2, 3.6, 1.8, 0.8],
[0.49, 0.0, 0.49, 0.51, 1.8, 0.51],
[0.0, 1.0, 0.0, 1.0, 2.8, 1.0],
]
for index, position in enumerate(offsets):
for direction in faces:
for extending in (False, True):
for source in (False, True):
for current in progress:
advanced = min(current + 0.5, 1.0)
for entity in entities:
yield {
'pos': position,
'direction': direction,
'extending': extending,
'source': source,
'progress': f32(current),
'new_progress': f32(advanced),
'block_box': box(unit),
'movement_box': box(unit),
'entity': box(entity),
'boxes': [box(unit)],
'sticky_top': f64(0.0),
'reaction': 0,
'on_ground': True,
'supported': False,
'entity_x': f64(entity[0]),
'entity_z': f64(entity[2]),
}
# Split boxes, a retracting honey drag with every push reaction, and the
# predicate's supported/on-ground corners.
for index in range(4000):
rng = random.Random(0x50555348 + index)
current = rng.choice(progress)
advanced = min(current + 0.5, 1.0)
count = rng.randint(1, 4)
boxes = []
for _ in range(count):
low = [rng.choice([0.0, 0.25, 0.5]), rng.choice([0.0, 0.5]), rng.choice([0.0, 0.5])]
high = [min(v + rng.choice([0.25, 0.5, 1.0]), 1.0) for v in low]
boxes.append(box(low + high))
yield {
'pos': rng.choice(offsets),
'direction': rng.randrange(6),
'extending': rng.random() < 0.5,
'source': rng.random() < 0.5,
'progress': f32(current),
'new_progress': f32(advanced),
'block_box': boxes[0],
'movement_box': boxes[rng.randrange(len(boxes))],
'entity': box([
rng.choice([0.0, 0.49, 0.75, 0.9, 1.4]),
rng.choice([0.0, 0.5]),
rng.choice([0.0, 0.2, 0.49]),
rng.choice([0.51, 1.35, 1.5, 2.0]),
rng.choice([1.8, 2.8]),
rng.choice([0.51, 0.8, 1.0]),
]),
'boxes': boxes,
'sticky_top': f64(rng.choice([0.0, 0.5, 0.9375])),
'reaction': rng.randrange(5),
'on_ground': rng.random() < 0.5,
'supported': rng.random() < 0.5,
'entity_x': f64(rng.choice([0.0, 0.5, 1.0, 2.0])),
'entity_z': f64(rng.choice([0.0, 0.5, 1.0, 2.0])),
}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--prepare', action='store_true')
args = parser.parse_args()
build = lab.LOCAL / 'rust-piston-push-parity'
build.mkdir(parents=True, exist_ok=True)
report_path = build / 'report.json'
report_path.unlink(missing_ok=True)
if args.prepare:
lab.prepare()
lock, jars, libraries = base.verify_artifacts()
oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaPistonPushOracle.java'
helpers = [oracle.with_name(name + '.java')
for name in ['VanillaCollisionOracle', 'VanillaBlockStateCodecOracle', 'VanillaFaceSupportOracle']]
sources = [oracle, *helpers, Path(__file__), lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'tools/lab.py',
lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock',
lab.ROOT / 'crates/horizon-core/Cargo.toml', lab.ROOT / 'crates/horizon-world/Cargo.toml',
*sorted((lab.ROOT / 'crates/horizon-core/src').rglob('*.rs')),
*sorted((lab.ROOT / 'crates/horizon-world/src').rglob('*.rs')),
lab.ROOT / 'crates/horizon-world/examples/probe/piston_push_fixture.rs']
tested_sources = base.source_hashes(sources)
classes = build / 'classes'
classes.mkdir(exist_ok=True)
def run(command, name):
with (build / (name + '.log')).open('w', encoding='utf-8') as log:
base.run(list(map(str, command)), stdout=log, stderr=subprocess.STDOUT)
dependency_path = os.pathsep.join(map(str, libraries))
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none',
'-sourcepath', str(lab.ROOT / 'mods/horizon/src/test/java'),
'-cp', str(jars['server']) + os.pathsep + dependency_path,
'-d', classes, oracle, *helpers], 'compile-java')
def java(side, mode, path):
run(['java', '-Xmx512M', '-cp', os.pathsep.join([str(classes), str(jars[side]), dependency_path]),
'dev.horizonlab.parity.VanillaPistonPushOracle', mode, path], side + '-' + mode)
fixtures = list(corpus())
inputs = build / 'inputs.txt'
inputs.write_text(''.join(json.dumps(value, separators=(',', ':')) + '\n' for value in fixtures), encoding='utf-8')
fixture_paths = [inputs]
tested_fixtures = base.source_hashes(fixture_paths)
run(['cargo', 'build', '--release', '-p', 'horizon-world', '--example', 'world_probe'], 'compile-rust')
metadata = json.loads(subprocess.check_output(
['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'release/examples' / ('world_probe.exe' if os.name == 'nt' else 'world_probe')
run([binary, 'piston_push_fixture', build], 'rust')
references = []
for side, jar in jars.items():
java(side, side, build)
reference = build / f'{side}.txt'
matched = base.compare(reference, build / 'rust.txt')
references.append({'side': side, 'jar_sha256': lab.digest(jar, 'sha256'),
'output_sha256': lab.digest(reference, 'sha256'), 'matched_output_lines': matched})
print(f'{side}: {matched:,} piston-push output lines match Rust', flush=True)
base.ensure_sources_unchanged(sources, tested_sources)
base.ensure_sources_unchanged(fixture_paths, tested_fixtures)
report = {
'schema': 1,
'minecraft': lock['minecraft']['version'],
'manifest_sha1': lock['minecraft']['manifest_sha1'],
'platform': platform.platform(),
'java': subprocess.check_output(['java', '--version'], text=True).splitlines()[0],
'rustc': subprocess.check_output(['rustc', '--version'], text=True).strip(),
'fixtures': len(fixtures),
'sources_sha256': tested_sources,
'fixtures_sha256': tested_fixtures,
'rust_output_sha256': lab.digest(build / 'rust.txt', 'sha256'),
'references': references,
'scope': 'Actual PistonMovingBlockEntity push geometry and predicates: getExtendedProgress, '
'moveByPositionAndProgress, PistonMath.getMovementArea, getMovement on every face, the '
'moveCollidedEntities loop over split shapes with its delta cap and PUSH_OFFSET, the '
'getEntities search box, and moveStuckEntities\' box with matchesStickyCritera for every push '
'reaction. fixEntityWithinPistonBase is covered by unit tests only: it calls '
'Entity.move(MoverType.PISTON, ...) and so needs a live level. Entity selection order, that '
'collision pass, the per-tick 0.51 limit, block behaviour of the moved state and full gameplay '
'remain elsewhere.',
}
lab.write_json(report_path, report)
print(f'Scoped piston-push parity passed: {report_path}')
if __name__ == '__main__':
main()