From 7ce774c5d6dca65c7619fa48d073dcc7345d13cf Mon Sep 17 00:00:00 2001 From: voidsip Date: Wed, 4 Nov 2026 10:28:06 -0600 Subject: [PATCH] Match client arrow fluid simulation Co-Authored-By: Codex --- crates/horizon-client/src/arrows.rs | 58 ++- .../horizon-client/src/arrows_client_tests.rs | 371 ++++++++++++++++++ crates/horizon-client/src/game/creatures.rs | 2 +- .../src/game/remote_projectiles.rs | 180 ++------- crates/horizon-client/src/motion/fluids.rs | 12 +- .../parity/VanillaClientArrowOracle.java | 104 +++++ tools/test_client_arrow_parity.py | 95 +++++ 7 files changed, 648 insertions(+), 174 deletions(-) create mode 100644 crates/horizon-client/src/arrows_client_tests.rs create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/VanillaClientArrowOracle.java create mode 100644 tools/test_client_arrow_parity.py diff --git a/crates/horizon-client/src/arrows.rs b/crates/horizon-client/src/arrows.rs index 263e89da80..10386a8885 100644 --- a/crates/horizon-client/src/arrows.rs +++ b/crates/horizon-client/src/arrows.rs @@ -186,6 +186,8 @@ pub struct ArrowState { pub pierced: Vec, /// `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; diff --git a/crates/horizon-client/src/arrows_client_tests.rs b/crates/horizon-client/src/arrows_client_tests.rs new file mode 100644 index 0000000000..fcf34b9091 --- /dev/null +++ b/crates/horizon-client/src/arrows_client_tests.rs @@ -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 { + run_with(cells, ticks, shared_fire, |_| {}) +} +fn run_with( + cells: &[([i32; 3], u32)], + ticks: usize, + shared_fire: bool, + setup: impl FnOnce(&mut Projectile), +) -> Vec { + 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::>()} ).to_string(), + ) + .unwrap(); + let behaviours = serde_json::json!({"states":(0..states.len()).map(|id|if id==1{[63,0]}else{[0,0]}).collect::>(),"blocks":(["air","stone","water","bubble_column"].iter().map(|name|serde_json::json!({"name":format!("minecraft:{name}"),"class":["Block"]})).collect::>())}); + let facts = BlockFacts::load_with_behaviour_json(&root, ®istry, &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, ®istry).unwrap(), + )) + .unwrap(); + let fluids = FluidResources::from_registry(®istry, &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: ®istry, + 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 = ¤t.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); +} diff --git a/crates/horizon-client/src/game/creatures.rs b/crates/horizon-client/src/game/creatures.rs index 08008ae9b4..b9fe93e691 100644 --- a/crates/horizon-client/src/game/creatures.rs +++ b/crates/horizon-client/src/game/creatures.rs @@ -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 { diff --git a/crates/horizon-client/src/game/remote_projectiles.rs b/crates/horizon-client/src/game/remote_projectiles.rs index 355e28e198..52605e0828 100644 --- a/crates/horizon-client/src/game/remote_projectiles.rs +++ b/crates/horizon-client/src/game/remote_projectiles.rs @@ -109,56 +109,6 @@ fn projectile_kind(kind: &str) -> Option { }) } -/// `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 = 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 diff --git a/crates/horizon-client/src/motion/fluids.rs b/crates/horizon-client/src/motion/fluids.rs index 9ef5e4ad3f..253c325fa1 100644 --- a/crates/horizon-client/src/motion/fluids.rs +++ b/crates/horizon-client/src/motion/fluids.rs @@ -34,13 +34,21 @@ pub struct FluidResources { } impl FluidResources { pub fn new(assets: &Assets, facts: &crate::blocks::BlockFacts) -> Result { + 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, + ) -> Result { 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 diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaClientArrowOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaClientArrowOracle.java new file mode 100644 index 0000000000..ba09d7a076 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaClientArrowOracle.java @@ -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 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 getEntityCollisions(Entity entity,AABB bounds) { return List.of(); } + @Override public List getEntities(Entity entity,AABB bounds,java.util.function.Predicate 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 event,Vec3 pos,net.minecraft.world.level.gameevent.GameEvent.Context context) { } + @Override public void gameEvent(Holder 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>();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(); + for(int tick=0;tick