mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 02:56:17 -10:00
Match client arrow fluid simulation
Co-Authored-By: Codex <[email protected]>
This commit is contained in:
1 parent
5d8f9fc2fc
commit
7ce774c5d6
7 files changed
+648
-174
No files matched your search
@@ -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, ®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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
Reference in new issue
Block a user