Match client arrow fluid simulation

Co-Authored-By: Codex <[email protected]>
This commit is contained in:
voidsipandCodex committed 2026-11-04 10:28:06 -06:00
1 parent 5d8f9fc2fc
commit 7ce774c5d6
7 files changed
+648 -174

No files matched your search

+43 -15
View File
@@ -186,6 +186,8 @@ pub struct ArrowState {
pub pierced: Vec<Who>,
/// `Entity.remainingFireTicks`.
pub fire_ticks: i32,
/// The server's shared fire flag, independent of the client-local fire timer.
pub synced_fire: bool,
pub fall_distance: f64,
pub fluids: EntityFluidInteraction,
pub was_touching_water: bool,
@@ -214,6 +216,7 @@ impl fmt::Debug for ArrowState {
.field("in_ground", &self.in_ground)
.field("life", &self.life)
.field("fire_ticks", &self.fire_ticks)
.field("synced_fire", &self.synced_fire)
.finish_non_exhaustive()
}
}
@@ -223,6 +226,7 @@ impl PartialEq for ArrowState {
&& self.life == other.life
&& self.last_state == other.last_state
&& self.fire_ticks == other.fire_ticks
&& self.synced_fire == other.synced_fire
}
}
impl ArrowState {
@@ -243,6 +247,7 @@ impl ArrowState {
pierce: 0,
pierced: Vec::new(),
fire_ticks: 0,
synced_fire: false,
fall_distance: 0.0,
fluids: EntityFluidInteraction::new([WATER, LAVA]),
was_touching_water: false,
@@ -297,7 +302,7 @@ impl ArrowState {
}
/// `Entity.isOnFire` (arrows are not fire immune).
pub fn on_fire(&self) -> bool {
self.fire_ticks > 0
self.fire_ticks > 0 || self.synced_fire
}
/// `Entity.clearFire`.
pub fn clear_fire(&mut self) {
@@ -534,7 +539,7 @@ fn signum(v: f64) -> f64 {
/// entity stops there and is not kept: the game hands it to
/// `finish_entity_hit` once it has hurt the entity.
pub fn tick(p: &mut Projectile, scene: &ArrowScene, targets: &[(Who, Aabb)]) -> (bool, Hit) {
tick_with(p, scene, targets, true)
tick_with(p, scene, targets, true, false)
}
/// One arrow tick whose hit is canceled (`ProjectileImpactEvent`): the arrow
/// flies through its full movement: not stuck, nothing hurt, no hit.
@@ -543,13 +548,19 @@ pub fn tick_through(
scene: &ArrowScene,
targets: &[(Who, Aabb)],
) -> (bool, Hit) {
tick_with(p, scene, targets, false)
tick_with(p, scene, targets, false, false)
}
/// Simulate a remote arrow without server-only despawn or block reactions.
/// Entity hits remain authoritative on the server.
pub fn tick_client(p: &mut Projectile, scene: &ArrowScene) -> (bool, Hit) {
tick_with(p, scene, &[], true, true)
}
fn tick_with(
p: &mut Projectile,
scene: &ArrowScene,
targets: &[(Who, Aabb)],
impact: bool,
client: bool,
) -> (bool, Hit) {
let Some(mut st) = p.arrow.take() else {
return (false, Hit::None);
@@ -562,6 +573,7 @@ fn tick_with(
p,
st: &mut st,
scene,
client,
}
.tick(targets, impact);
p.stuck = st.in_ground;
@@ -593,6 +605,7 @@ pub fn finish_entity_hit(p: &mut Projectile, scene: &ArrowScene, outcome: Entity
p,
st: &mut st,
scene,
client: false,
};
match outcome {
EntityHit::Hurt | EntityHit::Refused if trident => ticker.trident_bounce(),
@@ -623,6 +636,7 @@ struct Ticker<'p, 's, 'a, 'r> {
p: &'p mut Projectile,
st: &'s mut ArrowState,
scene: &'s ArrowScene<'a, 'r>,
client: bool,
}
impl Ticker<'_, '_, '_, '_> {
fn position(&self) -> Vec3 {
@@ -676,13 +690,15 @@ impl Ticker<'_, '_, '_, '_> {
self.st.clear_fire();
}
if self.st.in_ground && physics {
if self.st.last_state != Some(state) && self.should_fall() {
self.start_falling();
} else {
// AbstractArrow.tickDespawn.
self.st.life += 1;
if self.st.life >= 1200 {
self.st.removed = true;
if !self.client {
if self.st.last_state != Some(state) && self.should_fall() {
self.start_falling();
} else {
// AbstractArrow.tickDespawn.
self.st.life += 1;
if self.st.life >= 1200 {
self.st.removed = true;
}
}
}
self.st.in_ground_time += 1;
@@ -694,7 +710,8 @@ impl Ticker<'_, '_, '_, '_> {
// entity event 0 (the clients' burst of particles in its colour,
// `handleEntityEvent`) and turns into a plain arrow. The flight
// and ground particles are the clients' own (`Level.isClientSide`).
if self.st.in_ground_time >= 600
if !self.client
&& self.st.in_ground_time >= 600
&& let Some(color) = self.st.potion_color()
{
self.st.events.push(ArrowEvent::PotionPuff {
@@ -781,14 +798,18 @@ impl Ticker<'_, '_, '_, '_> {
}
// Projectile.tick, then Entity.baseTick's fluid and fire phases.
if !self.st.has_been_shot {
self.st.events.push(ArrowEvent::ShootEvent);
if !self.client {
self.st.events.push(ArrowEvent::ShootEvent);
}
self.st.has_been_shot = true;
}
self.st.was_eye_in_water = self.st.fluids.is_eye_in_fluid(&Tags, WATER);
let mut fluids = std::mem::take(&mut self.st.fluids);
let _ = entity_fluid_tick::update(&mut fluids, self, WATER, LAVA, 1_000_000);
self.st.fluids = fluids;
if self.st.fire_ticks > 0 {
if self.client {
self.st.clear_fire();
} else if self.st.fire_ticks > 0 {
self.st.fire_ticks -= 1;
}
if !self.st.first_tick && self.st.fluids.is_in_fluid(&Tags, LAVA) {
@@ -1001,7 +1022,8 @@ impl Ticker<'_, '_, '_, '_> {
// BlockState.onProjectileHit still sees the deltaMovement it hit at.
self.st.hit_speed = self.p.velocity.length();
// Projectile.onHitBlock: BlockState.onProjectileHit.
if self.st.on_fire()
if !self.client
&& self.st.on_fire()
&& let Some(reaction) = self.block_reaction(state)
{
self.st.events.push(ArrowEvent::Block {
@@ -1545,7 +1567,13 @@ impl WaterSplashWorld for Ticker<'_, '_, '_, '_> {
Ok(())
}
fn splash_event(&mut self) -> Result<(), Error> {
self.st.events.push(ArrowEvent::SplashEvent);
if !self.client {
self.st.events.push(ArrowEvent::SplashEvent);
}
Ok(())
}
}
#[cfg(test)]
#[path = "arrows_client_tests.rs"]
mod client_tests;
@@ -0,0 +1,371 @@
use super::*;
use horizon_core::geometry::{Axis, VoxelShape};
use horizon_world::{collision::CachedCollisionRegistry, nbt::Tag, registry::BlockRegistry};
use std::{collections::BTreeSet, sync::Arc};
fn text(value: &str) -> Tag {
Tag::String(value.into())
}
fn shape_tag(shape: &VoxelShape) -> Tag {
let sizes = shape.sizes();
let mut bits = Vec::new();
for z in 0..sizes[2] {
for y in 0..sizes[1] {
for x in 0..sizes[0] {
bits.push(u8::from(shape.is_full(x as i32, y as i32, z as i32)));
}
}
}
let (min, max) = shape.discrete_bounds();
let mut fields = vec![
("occupied", Tag::ByteArray(bits)),
("cube", Tag::Byte(i8::from(shape.is_cube()))),
(
"cube_axes",
Tag::Byte(
shape
.cube_coordinate_axes()
.iter()
.enumerate()
.fold(0, |mask, (i, on)| mask | (i8::from(*on) << i)),
),
),
("discrete_min", Tag::IntArray(min.to_vec())),
("discrete_max", Tag::IntArray(max.to_vec())),
];
for (axis, name) in [(Axis::X, "x"), (Axis::Y, "y"), (Axis::Z, "z")] {
fields.push((
name,
Tag::LongArray(
shape
.coordinates(axis)
.iter()
.map(|v| v.to_bits() as i64)
.collect(),
),
));
}
Tag::compound(fields)
}
fn run(cells: &[([i32; 3], u32)], ticks: usize, shared_fire: bool) -> Vec<Projectile> {
run_with(cells, ticks, shared_fire, |_| {})
}
fn run_with(
cells: &[([i32; 3], u32)],
ticks: usize,
shared_fire: bool,
setup: impl FnOnce(&mut Projectile),
) -> Vec<Projectile> {
let mut definitions = vec![("air", None), ("stone", None)];
for level in 0..16 {
definitions.push(("water", Some(("level", level.to_string()))));
}
for drag in [false, true] {
definitions.push(("bubble_column", Some(("drag", drag.to_string()))));
}
let states: Vec<_> = definitions
.iter()
.map(|(name, property)| {
let mut fields = vec![("id", text(&format!("minecraft:{name}")))];
if let Some((key, value)) = property {
fields.push(("properties", Tag::compound([(*key, text(value))])));
}
Tag::compound(fields)
})
.collect();
let registry = BlockRegistry::from_nbt(&Tag::compound([
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
("states", Tag::List(states.clone())),
(
"defaults",
Tag::compound([
("minecraft:air", Tag::Int(0)),
("minecraft:stone", Tag::Int(1)),
("minecraft:water", Tag::Int(2)),
("minecraft:bubble_column", Tag::Int(18)),
]),
),
]))
.unwrap();
static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let root = std::env::temp_dir().join(format!(
"horizon-client-arrow-{}-{}",
std::process::id(),
NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
));
std::fs::create_dir(&root).unwrap();
std::fs::write(
root.join("gameplay-facts.json"),
serde_json::json!({"states":(0..states.len()).map(|id|serde_json::json!({"id":id})).collect::<Vec<_>>()} ).to_string(),
)
.unwrap();
let behaviours = serde_json::json!({"states":(0..states.len()).map(|id|if id==1{[63,0]}else{[0,0]}).collect::<Vec<_>>(),"blocks":(["air","stone","water","bubble_column"].iter().map(|name|serde_json::json!({"name":format!("minecraft:{name}"),"class":["Block"]})).collect::<Vec<_>>())});
let facts = BlockFacts::load_with_behaviour_json(&root, &registry, &behaviours).unwrap();
std::fs::remove_dir_all(root).unwrap();
let table = Tag::compound([
("DataVersion", Tag::Int(horizon_world::DATA_VERSION)),
("states", Tag::List(states)),
(
"shapes",
Tag::List(vec![
shape_tag(&VoxelShape::empty()),
shape_tag(&VoxelShape::block()),
]),
),
(
"cached_shapes",
Tag::IntArray((0..20).map(|id| i32::from(id == 1)).collect()),
),
]);
let collision = BlockCollisionRegistry::new(Arc::new(
CachedCollisionRegistry::from_nbt(&table, &registry).unwrap(),
))
.unwrap();
let fluids = FluidResources::from_registry(&registry, &facts, &BTreeSet::new()).unwrap();
let mut world = World::new(BlockStateId(0), 0);
for x in -1..=1 {
for z in -1..=1 {
world
.chunks
.insert((x, z), Arc::new(crate::chunk::Chunk::empty(x, z)));
}
}
for &(p, state) in cells {
world.set(p[0], p[1], p[2], BlockStateId(state)).unwrap();
}
let scene = ArrowScene {
world: &world,
registry: &registry,
facts: &facts,
collision: &collision,
fluids: &fluids,
raining: &|_| false,
mob_griefing: true,
tnt_explodes: true,
};
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical: false,
effect: None,
},
Vec3::new(0.5, 18.5, 0.5),
None,
);
p.velocity = Vec3::new(1.0, 0.2, 0.0);
p.arrow.as_mut().unwrap().synced_fire = shared_fire;
p.arrow.as_mut().unwrap().fire_ticks = 80;
setup(&mut p);
let mut result = Vec::new();
for _ in 0..ticks {
p.arrow.as_mut().unwrap().events.clear();
tick_client(&mut p, &scene);
result.push(p.clone());
}
result
}
#[test]
fn first_water_tick_moves_before_water_inertia_and_keeps_synced_flames() {
let mut cells = Vec::new();
for x in -2..6 {
for y in 16..21 {
for z in -2..3 {
cells.push(([x, y, z], 2));
}
}
}
for shared_fire in [false, true] {
let steps = run(&cells, 5, shared_fire);
let p = &steps[0];
assert_eq!(p.position, Vec3::new(1.5, 18.7, 0.5));
assert_eq!(p.velocity.x.to_bits(), 0x3fef_ae14_8000_0000);
assert_eq!(p.velocity.y.to_bits(), 0x3fc2_f1aa_0000_0000);
for p in steps {
let st = p.arrow.as_ref().unwrap();
assert!(st.was_touching_water);
assert_eq!(st.fire_ticks, 0);
assert_eq!(st.on_fire(), shared_fire);
}
}
}
#[test]
#[ignore = "run by client-arrow differential harness"]
fn client_arrow_parity_fixture() {
use std::io::Write;
let input =
std::fs::read_to_string(std::env::var("HORIZON_CLIENT_ARROW_INPUT").unwrap()).unwrap();
let mut output =
std::fs::File::create(std::env::var("HORIZON_CLIENT_ARROW_OUTPUT").unwrap()).unwrap();
let bits = |v: Vec3| {
format!(
"{:016x},{:016x},{:016x}",
v.x.to_bits(),
v.y.to_bits(),
v.z.to_bits()
)
};
for line in input.lines() {
let row: serde_json::Value = serde_json::from_str(line).unwrap();
let cells: Vec<_> = row["cells"]
.as_array()
.unwrap()
.iter()
.map(|cell| {
let at = std::array::from_fn(|i| cell[i].as_i64().unwrap() as i32);
let id = match cell[3].as_str().unwrap() {
"minecraft:air" => 0,
"minecraft:stone" => 1,
"minecraft:water" => {
2 + cell.get(4).and_then(|v| v.as_i64()).unwrap_or(0) as u32
}
"minecraft:bubble_column" => {
18 + u32::from(cell.get(4).and_then(|v| v.as_bool()).unwrap_or(false))
}
other => panic!("unsupported fixture block {other}"),
};
(at, id)
})
.collect();
assert_eq!(row["position"], serde_json::json!([0.5, 18.5, 0.5]));
assert_eq!(row["velocity"], serde_json::json!([1, 0.2, 0]));
let steps = run_with(
&cells,
row["ticks"].as_u64().unwrap() as usize,
row["sharedFire"].as_bool().unwrap_or(false),
|p| {
p.no_gravity = row["noGravity"].as_bool().unwrap_or(false);
let st = p.arrow.as_mut().unwrap();
st.no_physics = row["noPhysics"].as_bool().unwrap_or(false);
if let Some(in_ground) = row["inGround"].as_bool() {
st.in_ground = in_ground;
p.stuck = in_ground;
st.life = 1199;
}
},
);
let records: Vec<_> = steps
.iter()
.map(|p| {
let st = p.arrow.as_ref().unwrap();
let mut record = format!(
"{}/{}/{}",
bits(p.position),
bits(p.velocity),
st.was_touching_water
);
if row.get("sharedFire").is_some() {
record.push_str(&format!("/{}/{}", st.fire_ticks, st.on_fire()));
}
if row.get("inGround").is_some() {
record.push_str(&format!("/{}", st.removed));
}
record
})
.collect();
writeln!(output, "{}", records.join(" ")).unwrap();
}
}
#[test]
fn water_bubbles_use_damped_motion_and_crit_streaks_use_original_motion() {
let cells: Vec<_> = (-2..6)
.flat_map(|x| (16..21).flat_map(move |y| (-2..3).map(move |z| ([x, y, z], 2))))
.collect();
let steps = run_with(&cells, 2, false, |p| p.set_critical(true));
let previous = &steps[0];
let current = &steps[1];
let original = previous.velocity;
let damped = original.scale(f64::from(0.6f32));
let events = &current.arrow.as_ref().unwrap().events;
let bubbles: Vec<_> = events
.iter()
.filter_map(|e| match e {
ArrowEvent::Particle {
kind: ArrowParticle::Bubble,
position,
velocity,
} => Some((*position, *velocity)),
_ => None,
})
.collect();
assert_eq!(bubbles.len(), 4);
for (position, velocity) in bubbles {
assert_eq!(velocity, damped);
assert_eq!(position, previous.position.add_vector(damped.scale(-0.25)));
}
let crits: Vec<_> = events
.iter()
.filter_map(|e| match e {
ArrowEvent::Particle {
kind: ArrowParticle::Crit,
position,
velocity,
} => Some((*position, *velocity)),
_ => None,
})
.collect();
assert_eq!(crits.len(), 4);
for (i, (position, velocity)) in crits.into_iter().enumerate() {
assert_eq!(
position,
previous.position.add_vector(original.scale(i as f64 / 4.0))
);
assert_eq!(
velocity,
Vec3::new(-original.x, -original.y + 0.2, -original.z)
);
}
}
#[test]
fn air_and_stuck_arrows_leave_no_water_bubbles() {
for critical in [false, true] {
let steps = run_with(&[], 1, false, |p| p.set_critical(critical));
let events = &steps[0].arrow.as_ref().unwrap().events;
assert!(!events.iter().any(|e| matches!(
e,
ArrowEvent::Particle {
kind: ArrowParticle::Bubble,
..
}
)));
assert_eq!(
events
.iter()
.filter(|e| matches!(
e,
ArrowEvent::Particle {
kind: ArrowParticle::Crit,
..
}
))
.count(),
if critical { 4 } else { 0 }
);
}
let steps = run_with(&[([0, 18, 0], 1)], 2, false, |p| p.set_critical(true));
assert!(steps[1].stuck);
assert!(
!steps[1]
.arrow
.as_ref()
.unwrap()
.events
.iter()
.any(|e| matches!(e, ArrowEvent::Particle { .. }))
);
}
#[test]
fn remote_arrow_preserves_previous_rotation_as_it_turns_and_falls() {
let steps = run_with(&[], 30, false, |p| {
p.shoot(&mut LegacyRandom::new(7), [0.0, 1.0, 0.0], 0.3, 0.0);
assert_eq!(p.previous_pitch, p.pitch);
assert_eq!(p.previous_yaw, p.yaw);
assert!((p.pitch - 90.0).abs() < 1e-4);
});
for pair in steps.windows(2) {
assert_eq!(pair[1].previous_pitch, pair[0].pitch);
assert_eq!(pair[1].previous_yaw, pair[0].yaw);
}
assert!(steps.last().unwrap().pitch < 0.0);
}
+1 -1
View File
@@ -4320,7 +4320,7 @@ impl Game<'_> {
let _ = self.destroy_block_dropping(pos);
}
/// What an arrow's tick asked for: its particles and sounds.
fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
pub(super) fn arrow_event(&mut self, at: Vec3, event: horizon_client::arrows::ArrowEvent) {
use horizon_client::arrows::{ArrowEvent, ArrowParticle, ArrowSound, BlockReaction};
use horizon_client::particles::{ParticleOptions, ParticleType};
let kind = |k| match k {
@@ -109,56 +109,6 @@ fn projectile_kind(kind: &str) -> Option<ProjectileKind> {
})
}
/// `AbstractArrow.tick` on a client: water drag before the move, air drag
/// after it, then gravity; a block it reaches holds it (`inGround`).
/// Returns the tick's bubble and critical particles (`Projectile::arrow_trail`).
fn remote_arrow_tick(
p: &mut Projectile,
w: &impl ProjectileWorld,
) -> Vec<(ParticleType, [f64; 3], [f64; 3])> {
p.set_old_pos_and_rot();
p.age += 1;
// AbstractArrow.tick: the shake timer runs down, and `inGroundTime` counts
// the ticks in a block.
if let Some(a) = p.arrow.as_mut() {
a.shake = (a.shake - 1).max(0);
a.in_ground_time = if p.stuck { a.in_ground_time + 1 } else { 0 };
}
if p.stuck {
return Vec::new();
}
let water = w.in_water(p.bounds());
// The trail is taken from the position before the move and the drag.
let trail = p.arrow_trail(water);
if water {
p.velocity = p.velocity.scale(0.6);
}
let (movement_x, movement_y, movement_z) = (p.velocity.x, p.velocity.y, p.velocity.z);
let sd = (movement_x * movement_x + movement_z * movement_z).sqrt();
let y_rot = horizon_core::geometry::trig::atan2_degrees(movement_x, movement_z);
let x_rot = horizon_core::geometry::trig::atan2_degrees(movement_y, sd);
p.pitch = horizon_client::arrows::lerp_rotation(p.pitch, x_rot);
p.yaw = horizon_client::arrows::lerp_rotation(p.yaw, y_rot);
let to = p.position.add_vector(p.velocity);
if let Some((at, _, _)) = w.clip(p.position, to) {
p.position = at;
p.velocity = Vec3::ZERO;
p.stuck = true;
// AbstractArrow.onSyncedDataUpdated: IN_GROUND turning true starts the shake.
if let Some(a) = p.arrow.as_mut()
&& a.shake <= 0
{
a.shake = 7;
}
return trail;
}
p.position = to;
if !water {
p.velocity = p.velocity.scale(0.99);
}
p.velocity.y -= 0.05;
trail
}
/// A server's client ticks a projectile itself: it flies on between the
/// tracker's updates (10 to 20 ticks apart) with its own gravity and drag,
/// and a block stops it there; only the server decides what it hits.
@@ -563,12 +513,18 @@ impl Game<'_> {
// fire down: the flag goes off when the server's fire is out.
(_, 0, Value::Byte(flags)) => {
if let Some(a) = p.arrow.as_mut() {
a.fire_ticks = i32::from(flags & 1);
a.synced_fire = flags & 1 != 0;
}
}
// AbstractArrow.ID_FLAGS (critical) and IN_GROUND.
(ProjectileKind::Arrow { critical, .. }, 8, Value::Byte(flags)) => {
*critical = flags & 1 != 0;
if let Some(a) = p.arrow.as_mut() {
a.no_physics = flags & 2 != 0;
}
}
(ProjectileKind::Arrow { .. }, 5, Value::Bool(no_gravity)) => {
p.no_gravity = *no_gravity;
}
(ProjectileKind::Arrow { .. }, 10, Value::Bool(in_ground)) => {
// AbstractArrow.onSyncedDataUpdated: IN_GROUND turning true after
@@ -582,6 +538,9 @@ impl Game<'_> {
a.shake = 7;
}
p.stuck = *in_ground;
if let Some(a) = p.arrow.as_mut() {
a.in_ground = *in_ground;
}
}
// Arrow.ID_EFFECT_COLOR.
(ProjectileKind::Arrow { .. }, 11, Value::Int(color)) => {
@@ -704,7 +663,9 @@ impl Game<'_> {
.filter_map(Projectile::water_bubbles)
.collect();
let mut tridents = std::mem::take(&mut self.tridents);
let mut arrow_trails = Vec::new();
let mut arrow_events = Vec::new();
let rain = self.arrow_rain();
let arrow_scene = self.arrow_scene(&rain);
{
let w = self.mob_world_base(&[]);
let world = super::creatures::ProjWorld {
@@ -715,7 +676,14 @@ impl Game<'_> {
for p in &mut projectiles {
match p.kind {
ProjectileKind::Arrow { .. } => {
arrow_trails.extend(remote_arrow_tick(p, &world));
let _ = horizon_client::arrows::tick_client(p, &arrow_scene);
if let Some(a) = p.arrow.as_mut() {
arrow_events.extend(
std::mem::take(&mut a.events)
.into_iter()
.map(|event| (p.position, event)),
);
}
}
// ShulkerBullet.tick: only the server steers it.
ProjectileKind::ShulkerBullet { .. } => {
@@ -730,11 +698,11 @@ impl Game<'_> {
t.remote_tick(&world, self.remote_eye(t.owner));
}
}
drop(rain);
self.creatures.projectiles = projectiles;
self.tridents = tridents;
for (kind, at, speed) in arrow_trails {
self.particles
.add(&ParticleOptions::simple(kind), at, speed);
for (at, event) in arrow_events {
self.arrow_event(at, event);
}
self.arrow_effect_particles();
let trails: Vec<Vec3> = self
@@ -913,75 +881,6 @@ mod tests {
}
}
struct Wet;
impl ProjectileWorld for Wet {
fn clip(&self, _: Vec3, _: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
None
}
fn in_water(&self, _: Aabb) -> bool {
true
}
fn targets(&self) -> &[(Who, Aabb)] {
&[]
}
}
fn arrow_at(critical: bool, velocity: Vec3) -> Projectile {
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical,
effect: None,
},
Vec3::new(10.0, 50.0, -3.0),
None,
);
p.velocity = velocity;
p
}
/// `AbstractArrow.tick`: in water, four BUBBLE at `pos - motion * 0.25`
/// with the drag-0.6 motion; a crit arrow adds four CRIT along the move.
#[test]
fn a_remote_arrow_in_water_leaves_bubbles_and_crit_streaks() {
let v = Vec3::new(1.0, 0.5, -2.0);
let trail = remote_arrow_tick(&mut arrow_at(true, v), &Wet);
let bubbles: Vec<_> = trail
.iter()
.filter(|t| t.0 == ParticleType::Bubble)
.collect();
assert_eq!(bubbles.len(), 4);
let m = [0.6f32 as f64, 0.3, -1.2];
for b in bubbles {
for k in 0..3 {
assert!((b.2[k] - m[k]).abs() < 1e-6, "{b:?}");
}
assert!((b.1[0] - (10.0 - 0.15)).abs() < 1e-6, "{b:?}");
assert!((b.1[1] - (50.0 - 0.075)).abs() < 1e-6, "{b:?}");
assert!((b.1[2] - (-3.0 + 0.3)).abs() < 1e-6, "{b:?}");
}
let crits: Vec<_> = trail.iter().filter(|t| t.0 == ParticleType::Crit).collect();
assert_eq!(crits.len(), 4);
for (i, c) in crits.iter().enumerate() {
let f = i as f64 / 4.0;
assert!((c.1[0] - (10.0 + f)).abs() < 1e-6, "{c:?}");
assert!((c.1[2] - (-3.0 - 2.0 * f)).abs() < 1e-6, "{c:?}");
assert!((c.2[1] - (-0.5 + 0.2)).abs() < 1e-6, "{c:?}");
}
}
#[test]
fn a_remote_arrow_in_air_or_stuck_leaves_no_bubbles() {
let v = Vec3::new(1.0, 0.0, 0.0);
assert!(remote_arrow_tick(&mut arrow_at(false, v), &Empty).is_empty());
let mut stuck = arrow_at(true, v);
stuck.stuck = true;
assert!(remote_arrow_tick(&mut stuck, &Wet).is_empty());
let air = remote_arrow_tick(&mut arrow_at(true, v), &Empty);
assert!(air.iter().all(|t| t.0 == ParticleType::Crit));
assert_eq!(air.len(), 4);
}
/// A thrown item, as a server's client tracks it: the spawn position and
/// the motion the server gave it.
fn thrown(kind: ProjectileKind) -> Projectile {
@@ -1013,37 +912,6 @@ mod tests {
/// rotation from is the rotation the last tick ended with. An arrow shot
/// straight up at the lowest power a bow fires turns over within a few
/// ticks, so every tick has to carry the value of the one before it.
#[test]
fn a_remote_arrow_keeps_the_rotation_the_last_tick_drew() {
let mut p = Projectile::new(
ProjectileKind::Arrow {
base_damage: 2.0,
critical: false,
effect: None,
},
Vec3::new(0.0, 100.0, 0.0),
None,
);
let mut random = horizon_core::random::LegacyRandom::new(7);
p.shoot(&mut random, [0.0, 1.0, 0.0], 0.3, 0.0);
// `Projectile.shoot` ends with `yRotO = getYRot(); xRotO = getXRot()`.
assert_eq!(p.previous_pitch, p.pitch);
assert_eq!(p.previous_yaw, p.yaw);
assert!((p.pitch - 90.0).abs() < 1.0e-4, "aimed up: {}", p.pitch);
let (mut yaw, mut pitch) = (p.yaw, p.pitch);
for _ in 0..30 {
remote_arrow_tick(&mut p, &Empty);
assert_eq!(p.previous_pitch, pitch);
assert_eq!(p.previous_yaw, yaw);
(yaw, pitch) = (p.yaw, p.pitch);
}
assert!(
p.pitch < 0.0,
"the arrow turned over as it fell: {}",
p.pitch
);
}
#[test]
fn a_thrown_item_flies_on_between_the_server_updates() {
// A thrown egg, an ender pearl and a wind charge (#1762): the server
+10 -2
View File
@@ -34,13 +34,21 @@ pub struct FluidResources {
}
impl FluidResources {
pub fn new(assets: &Assets, facts: &crate::blocks::BlockFacts) -> Result<Self> {
Self::from_registry(assets.registry, facts, &assets.blocks_fluid_flow)
}
/// Build fluid queries from the same registry, block facts and flow tag as the scene.
pub fn from_registry(
registry: &horizon_world::registry::BlockRegistry,
facts: &crate::blocks::BlockFacts,
blocks_fluid_flow: &BTreeSet<String>,
) -> Result<Self> {
Ok(Self {
states: FluidStateTable::new(assets.registry)?,
states: FluidStateTable::new(registry)?,
sturdy: facts.states.iter().map(|s| s.sturdy_full).collect(),
blocks_flow: facts
.states
.iter()
.map(|s| assets.blocks_fluid_flow.contains(&*s.name))
.map(|s| blocks_fluid_flow.contains(&*s.name))
.collect(),
ice: facts
.states
@@ -0,0 +1,104 @@
package dev.horizonlab.parity;
import com.google.gson.JsonParser;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import net.minecraft.client.multiplayer.ClientLevel;
import net.minecraft.core.*;
import net.minecraft.core.registries.Registries;
import net.minecraft.util.RandomSource;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.projectile.arrow.Arrow;
import net.minecraft.world.entity.projectile.arrow.AbstractArrow;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.*;
import net.minecraft.world.level.biome.Biomes;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.border.WorldBorder;
import net.minecraft.world.level.chunk.*;
import net.minecraft.world.level.dimension.*;
import net.minecraft.world.level.material.FluidState;
import net.minecraft.world.phys.*;
import net.minecraft.world.phys.shapes.VoxelShape;
import static dev.horizonlab.parity.VanillaRegistryCollisionScanOracle.*;
/** Original oracle: actual client-side Arrow.tick in a recording resident world. */
public final class VanillaClientArrowOracle {
static final class World extends ClientLevel {
Map<Long,Chunk> chunks;
RandomSource rng;
World() { super(null,null,null,null,0,0,null,false,0L,0); }
@Override public boolean isClientSide() { return true; }
@Override public int getNextEntityId() { return 1; }
@Override public int getMinY() { return -64; }
@Override public int getHeight() { return 384; }
@Override public RegistryAccess registryAccess() { return ParityData.access; }
@Override public DimensionType dimensionType() { return ParityData.access.lookupOrThrow(Registries.DIMENSION_TYPE).getOrThrow(BuiltinDimensionTypes.OVERWORLD).value(); }
@Override public net.minecraft.world.attribute.EnvironmentAttributeSystem environmentAttributes() { return VanillaArrowOracle.Level.ATTRIBUTES; }
@Override public RandomSource getRandom() { return rng; }
@Override public BlockGetter getChunkForCollisions(int x,int z) { return chunks.get(key(x,z)); }
@Override public ChunkAccess getChunk(int x,int z,net.minecraft.world.level.chunk.status.ChunkStatus status,boolean load) { return chunks.get(key(x,z)); }
@Override public BlockState getBlockState(BlockPos p) { var c=chunks.get(key(p.getX()>>4,p.getZ()>>4)); return c==null?Blocks.AIR.defaultBlockState():c.getBlockState(p); }
@Override public FluidState getFluidState(BlockPos p) { return getBlockState(p).getFluidState(); }
@Override public WorldBorder getWorldBorder() { return new VanillaArrowOracle.Border(); }
@Override public List<VoxelShape> getEntityCollisions(Entity entity,AABB bounds) { return List.of(); }
@Override public List<Entity> getEntities(Entity entity,AABB bounds,java.util.function.Predicate<? super Entity> filter) { return List.of(); }
@Override public boolean isRainingAt(BlockPos pos) { return false; }
@Override public void addParticle(net.minecraft.core.particles.ParticleOptions particle,double x,double y,double z,double dx,double dy,double dz) { }
@Override public void playSound(Entity except,double x,double y,double z,net.minecraft.sounds.SoundEvent event,net.minecraft.sounds.SoundSource source,float volume,float pitch) { }
@Override public void playSound(Entity except,Entity entity,net.minecraft.sounds.SoundEvent event,net.minecraft.sounds.SoundSource source,float volume,float pitch) { }
@Override public void gameEvent(Holder<net.minecraft.world.level.gameevent.GameEvent> event,Vec3 pos,net.minecraft.world.level.gameevent.GameEvent.Context context) { }
@Override public void gameEvent(Holder<net.minecraft.world.level.gameevent.GameEvent> event,BlockPos pos,net.minecraft.world.level.gameevent.GameEvent.Context context) { }
}
static java.lang.reflect.Field field(Class<?> owner,String name) throws Exception { var f=owner.getDeclaredField(name);f.setAccessible(true);return f; }
static String bits(Vec3 v) { return String.format("%016x,%016x,%016x",Double.doubleToRawLongBits(v.x),Double.doubleToRawLongBits(v.y),Double.doubleToRawLongBits(v.z)); }
public static void main(String[] args) throws Exception {
ParityData.init();
var allocator=Class.forName("sun.misc.Unsafe");var unsafe=field(allocator,"theUnsafe").get(null);var allocate=allocator.getMethod("allocateInstance",Class.class);
var biome=ParityData.access.lookupOrThrow(Registries.BIOME).getOrThrow(Biomes.PLAINS);
var ids=new IdMapper<Holder<net.minecraft.world.level.biome.Biome>>();ids.add(biome);
factory=new PalettedContainerFactory(Strategy.createForBlockStates(Block.BLOCK_STATE_REGISTRY),Blocks.AIR.defaultBlockState(),null,Strategy.createForBiomes(ids),biome,null);
var trace=new Trace();trace.out=new PrintWriter(java.io.OutputStream.nullOutputStream());trace.fail=-1;
try(var out=new PrintWriter(Files.newBufferedWriter(Path.of(args[1]),StandardCharsets.UTF_8))) {
for(var line:Files.readAllLines(Path.of(args[0]))) {
var row=JsonParser.parseString(line).getAsJsonObject();
var world=(World)allocate.invoke(unsafe,World.class);world.rng=RandomSource.create(42);world.chunks=new HashMap<>();
field(Level.class,"random").set(world,world.rng);
for(int x=-1;x<=1;x++)for(int z=-1;z<=1;z++)world.chunks.put(key(x,z),new Chunk(x,z,trace,null));
for(var entry:row.getAsJsonArray("cells")) {
var c=entry.getAsJsonArray();int x=c.get(0).getAsInt(),y=c.get(1).getAsInt(),z=c.get(2).getAsInt();
var state=net.minecraft.core.registries.BuiltInRegistries.BLOCK.getValue(net.minecraft.resources.Identifier.parse(c.get(3).getAsString())).defaultBlockState();
if(c.size()>4) {
if(state.is(Blocks.BUBBLE_COLUMN))state=state.setValue(net.minecraft.world.level.block.state.properties.BlockStateProperties.DRAG,c.get(4).getAsBoolean());
else state=VanillaSceneCollisionOracle.property(state,net.minecraft.world.level.block.state.properties.BlockStateProperties.LEVEL,c.get(4).getAsString());
}
world.chunks.get(key(x>>4,z>>4)).write(new BlockPos(x,y,z),Block.getId(state));
}
var pos=row.getAsJsonArray("position");var v=row.getAsJsonArray("velocity");
var arrow=new Arrow(world,pos.get(0).getAsDouble(),pos.get(1).getAsDouble(),pos.get(2).getAsDouble(),new ItemStack(Items.ARROW),null);
field(Entity.class,"random").set(arrow,RandomSource.create(42));
arrow.setDeltaMovement(new Vec3(v.get(0).getAsDouble(),v.get(1).getAsDouble(),v.get(2).getAsDouble()));
if(row.has("fireTicks"))arrow.setRemainingFireTicks(row.get("fireTicks").getAsInt());
if(row.has("sharedFire"))arrow.setSharedFlagOnFire(row.get("sharedFire").getAsBoolean());
if(row.has("noPhysics"))arrow.setNoPhysics(row.get("noPhysics").getAsBoolean());
if(row.has("noGravity"))arrow.setNoGravity(row.get("noGravity").getAsBoolean());
if(row.has("inGround")) {
var setter=AbstractArrow.class.getDeclaredMethod("setInGround",boolean.class);setter.setAccessible(true);setter.invoke(arrow,row.get("inGround").getAsBoolean());
field(AbstractArrow.class,"life").setInt(arrow,1199);
}
var result=new ArrayList<String>();
for(int tick=0;tick<row.get("ticks").getAsInt();tick++) {
arrow.setOldPosAndRot();arrow.tickCount++;arrow.tick();
result.add(bits(arrow.position())+"/"+bits(arrow.getDeltaMovement())+"/"+arrow.isInWater()+(row.has("sharedFire")?"/"+arrow.getRemainingFireTicks()+"/"+arrow.isOnFire():"")+(row.has("inGround")?"/"+arrow.isRemoved():""));
}
out.println(String.join(" ",result));
}
}
}
}
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Compare native client arrow ticks with Horizon's production client arrow simulation."""
import os
from pathlib import Path
import json
import tempfile
try:
from . import lab, oracle_harness as harness, test_rust_parity as base, integration_env
except ImportError:
import lab, oracle_harness as harness, test_rust_parity as base, integration_env
def cases():
water=[[x,y,z,'minecraft:water'] for x in range(-2,6) for y in range(16,21) for z in range(-2,3)]
entering=[[x,y,z,'minecraft:water'] for x in range(2,6) for y in range(16,21) for z in range(-2,3)]
for cells in [[],water,entering,[[2,18,0,'minecraft:stone']]]:
yield json.dumps(dict(cells=cells,position=[.5,18.5,.5],velocity=[1,.2,0],ticks=5))
for cells in [[],water]:
for shared in [False,True]:
yield json.dumps(dict(cells=cells,position=[.5,18.5,.5],velocity=[1,.2,0],ticks=5,fireTicks=80,sharedFire=shared))
# Exit, every water level, sloping currents and both bubble-column directions.
exit_water=[[x,y,z,'minecraft:water'] for x in range(-2,2) for y in range(16,21) for z in range(-2,3)]
yield json.dumps(dict(cells=exit_water,position=[.5,18.5,.5],velocity=[1,.2,0],ticks=8))
for level in range(16):
cells=[[x,18,z,'minecraft:water',level] for x in range(-2,8) for z in range(-2,3)]
yield json.dumps(dict(cells=cells,position=[.5,18.5,.5],velocity=[1,.2,0],ticks=5))
for drag in [False,True]:
for top in [18,20]:
cells=[[x,y,z,'minecraft:bubble_column',drag] for x in range(-2,6) for y in range(16,top+1) for z in range(-2,3)]
yield json.dumps(dict(cells=cells,position=[.5,18.5,.5],velocity=[1,.2,0],ticks=5))
cells=[[x,18,z,'minecraft:water',max(0,min(7,x+2))] for x in range(-2,8) for z in range(-2,3)]
yield json.dumps(dict(cells=cells,position=[.5,18.5,.5],velocity=[1,.2,0],ticks=5))
for cells in [[],water,[[2,18,0,'minecraft:stone']]]:
for physics,gravity in [(False,True),(True,False),(False,False)]:
yield json.dumps(dict(cells=cells,position=[.5,18.5,.5],velocity=[1,.2,0],ticks=5,noPhysics=not physics,noGravity=not gravity))
for cells in [[],[[0,18,0,'minecraft:stone']]]:
yield json.dumps(dict(cells=cells,position=[.5,18.5,.5],velocity=[1,.2,0],ticks=5,inGround=True))
def main():
lock, directory = lab.config()
manifest, client = lab.import_client(lock, directory)
natives = {v['downloads']['artifact']['path'] for v in manifest['libraries']
if v['name'].split(':')[-1].startswith('natives-')}
libraries = [lab.import_library(a, directory / 'libraries/client')
for a in lab.client_library_artifacts(manifest) if a['path'] not in natives]
build = lab.LOCAL / 'rust-client-arrow-parity'
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
sources = [Path(__file__), lab.ROOT / 'tools/lab.py', lab.ROOT / 'tools/oracle_harness.py',
lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'tools/integration_env.py',
lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock',
lab.ROOT / 'crates/horizon-client/src/arrows.rs',
lab.ROOT / 'crates/horizon-client/src/arrows_client_tests.rs',
lab.ROOT / 'crates/horizon-client/src/game/remote_projectiles.rs',
lab.ROOT / 'crates/horizon-client/src/motion/fluids.rs',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaClientArrowOracle.java',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaArrowOracle.java',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaRegistryCollisionScanOracle.java',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSceneCollisionOracle.java',
lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java']
hashes = base.source_hashes(sources)
rows = list(cases())
inputs = build / 'inputs.txt'
inputs.write_text('\n'.join(rows) + '\n', encoding='utf-8')
input_hash = lab.digest(inputs, 'sha256')
classes, dependencies, _ = harness.compile_oracle(build, 'VanillaClientArrowOracle',
{'client': client}, libraries, lock['minecraft']['java_major'])
harness.java_run(build, classes, dependencies, 'VanillaClientArrowOracle',
[inputs, build / 'client.txt'], 'client')
with tempfile.TemporaryDirectory(prefix='horizon-client-arrow-parity-') as scratch:
env = integration_env.game_environment(os.environ, scratch)
env.update(HORIZON_CLIENT_ARROW_INPUT=str(inputs), HORIZON_CLIENT_ARROW_OUTPUT=str(build / 'rust.txt'))
harness.run_logged(['cargo', 'test', '--locked', '-p', 'horizon-client-lib', '--lib',
'arrows::client_tests::client_arrow_parity_fixture',
'--', '--exact', '--ignored'], build, 'rust', env=env)
matched = harness.compare(build / 'client.txt', build / 'rust.txt', len(rows))
base.ensure_sources_unchanged(sources, hashes)
if lab.digest(inputs, 'sha256') != input_hash:
raise AssertionError('Input fixtures changed')
lab.write_json(report, dict(schema=1, minecraft=lock['minecraft']['version'], sources_sha256=hashes,
client_jar_sha256=lab.digest(client, 'sha256'), input_sha256=input_hash,
matched_output_lines=matched, output_sha256=lab.digest(build / 'rust.txt', 'sha256'),
scope='Actual client Arrow.tick in a recording ClientLevel, compared with shared production client simulation. '
'Air,source water,water entry/exit,all water levels,sloping currents,bubble columns,stone impact and synced fire controls. '
'Exact position/motion bits,water state and fire state. Original minimal Rust facts and collision fixtures. '
'NoPhysics/NoGravity and client ground retention at despawn threshold. No entity targets,rendering or network comparison.'))
print(f'{matched} client-arrow cases match the pinned client: {report}', flush=True)
if __name__ == '__main__':
main()