diff --git a/crates/horizon-client/src/game/dragon.rs b/crates/horizon-client/src/game/dragon.rs index 222e59229..6dd02893d 100644 --- a/crates/horizon-client/src/game/dragon.rs +++ b/crates/horizon-client/src/game/dragon.rs @@ -2700,6 +2700,7 @@ impl Game<'_> { equipment: Vec::new(), attributes: Vec::new(), precise: false, + needs_sync: false, }) } } diff --git a/crates/horizon-client/src/game/lan.rs b/crates/horizon-client/src/game/lan.rs index 4e05ea600..e2c218a34 100644 --- a/crates/horizon-client/src/game/lan.rs +++ b/crates/horizon-client/src/game/lan.rs @@ -760,6 +760,7 @@ impl Game<'_> { (e.yaw, e.pitch, e.head_yaw) = (m.yaw, m.head_pitch, m.head_yaw); e.on_ground = m.on_ground; e.velocity = [m.velocity.x, m.velocity.y, m.velocity.z]; + e.needs_sync = m.needs_sync; mob_meta(m, hub, &mut e.meta); mob_equipment(m, hub, &mut e.equipment); self.mob_attributes(m, &mut e.attributes); @@ -828,6 +829,10 @@ impl Game<'_> { } else { states.clear(); } + // ServerEntity.sendChanges clears needsSync once it has looked. + for m in &mut self.mobs { + m.needs_sync = false; + } let entities = states.as_slice(); let spawn = self.spawn.map_or([0.5, 80.0, 0.5], |s| [s.x, s.y, s.z]); let events = { diff --git a/crates/horizon-client/src/game/lan_entities.rs b/crates/horizon-client/src/game/lan_entities.rs index 5f46f7c21..631498edd 100644 --- a/crates/horizon-client/src/game/lan_entities.rs +++ b/crates/horizon-client/src/game/lan_entities.rs @@ -39,6 +39,7 @@ fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> equipment: Vec::new(), attributes: Vec::new(), precise: false, + needs_sync: false, } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 5461a4cd0..454773e38 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -40,7 +40,11 @@ use horizon_protocol::{ registry::{Builtin, DimensionType, Dynamic, ceil_log2}, text::{Language, format_java, plain}, }; -use horizon_world::{nbt::Tag, registry::BlockStateId}; +use horizon_world::{ + nbt::Tag, + registry::BlockStateId, + stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation}, +}; use std::{ collections::{HashMap, VecDeque}, sync::Arc, @@ -85,6 +89,15 @@ struct Lerp { steps: u32, } +/// `SteppedInterpolationHandler.doInterpolate`'s `setPos` and `setRot` on a +/// mob: its yaw kept continuous (the same turn as vanilla's, without the +/// wrap that would spin it in the frame between ticks), its pitch clamped. +fn place_interpolated(m: &mut Mob, at: PosRot) { + m.position = Vec3::new(at.position[0], at.position[1], at.position[2]); + m.yaw += wrap_degrees(at.yaw - m.yaw); + m.head_pitch = (at.pitch % 360.0).clamp(-90.0, 90.0); +} + pub(super) struct Remote { pub session: Session, pub brand: String, @@ -123,6 +136,8 @@ pub(super) struct Remote { use_target: Option<[i32; 3]>, entities: HashMap, lerps: HashMap, + /// Living entities' `SteppedInterpolationHandler`s. + stepped: HashMap, /// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it. head_lerps: HashMap, /// Item entities waiting for their item data. @@ -380,6 +395,7 @@ impl Game<'_> { use_target: None, entities: HashMap::new(), lerps: HashMap::new(), + stepped: HashMap::new(), head_lerps: HashMap::new(), pending_items: HashMap::new(), health: (20.0, 20, 5.0), @@ -931,6 +947,7 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.entities.clear(); r.lerps.clear(); + r.stepped.clear(); r.head_lerps.clear(); r.pending_items.clear(); // A new ClientLevel: a new chunk storage, centred on 0, 0. @@ -1322,6 +1339,9 @@ impl Game<'_> { mob.persistent = true; self.mobs.retain(|m| m.id != id as u32); self.mobs.push(mob); + if let Some(r) = self.remote.as_mut() { + r.stepped.remove(&id); + } } if let Some(r) = self.remote.as_mut() { if kind == "minecraft:item" { @@ -1336,23 +1356,77 @@ impl Game<'_> { ); } } + /// A move packet for entity `id`: to the end of `path` (None: a + /// rotation alone), turned to `rotation` if it has one. `far_snaps`: + /// an `EntityPositionSync`, which puts an entity more than 64 blocks + /// away there at once. fn move_entity( &mut self, id: i32, - position: [f64; 3], + path: Option, rotation: Option<(f32, f32)>, on_ground: bool, + far_snaps: bool, ) { let mob = self.mobs.iter_mut().find(|m| m.id == id as u32); + let tracked = self + .remote + .as_ref() + .and_then(|r| r.entities.get(&id)) + .map(|t| t.position); + let position = path + .as_ref() + .map(Path::end) + .or(tracked) + .or_else(|| { + mob.as_ref() + .map(|m| [m.position.x, m.position.y, m.position.z]) + }) + .unwrap_or([0.0; 3]); if let Some(r) = self.remote.as_mut() { if let Some(t) = r.entities.get_mut(&id) { t.position = position; } + if let Some(m) = mob { + // A living entity: LivingEntity.createInterpolationHandler's + // SteppedInterpolationHandler, over its type's update interval. + let entity = PosRot { + position: [m.position.x, m.position.y, m.position.z], + yaw: m.yaw, + pitch: m.head_pitch, + }; + let far = far_snaps && { + let d = [0, 1, 2].map(|i| position[i] - entity.position[i]); + d[0] * d[0] + d[1] * d[1] + d[2] * d[2] > 4096.0 + }; + let handler = r.stepped.entry(id).or_insert_with(|| { + let interval = horizon_client::lan::tracking::settings(&m.kind.id).1; + SteppedInterpolation::new(interval as i32) + }); + let snap = if far { + handler.cancel(); + let (yaw, pitch) = rotation.unwrap_or((m.yaw, m.head_pitch)); + Some(PosRot { + position, + yaw, + pitch, + }) + } else { + match handler.interpolate_to(entity, path, rotation) { + Start::Snap(at) => Some(at), + Start::Interpolating | Start::Unchanged => None, + } + }; + if let Some(at) = snap { + place_interpolated(m, at); + m.previous = m.position; + } + m.on_ground = on_ground; + return; + } // AbstractInterpolationHandler.interpolateTo: without a rotation, - // the one it was heading for (or has). - let rotation = rotation - .or_else(|| r.lerps.get(&id).and_then(|l| l.rotation)) - .or_else(|| mob.as_ref().map(|m| (m.yaw, m.head_pitch))); + // the one it was heading for. + let rotation = rotation.or_else(|| r.lerps.get(&id).and_then(|l| l.rotation)); r.lerps.insert( id, Lerp { @@ -1365,9 +1439,6 @@ impl Game<'_> { *p = position; } } - if let Some(m) = mob { - m.on_ground = on_ground; - } self.turn_remote_object(id, rotation.map(|(yaw, _)| yaw), on_ground); self.turn_remote_projectile(id, rotation); } @@ -1384,6 +1455,7 @@ impl Game<'_> { if let Some(r) = self.remote.as_mut() { r.entities.remove(&id); r.lerps.remove(&id); + r.stepped.remove(&id); r.head_lerps.remove(&id); r.pending_items.remove(&id); } @@ -1743,17 +1815,36 @@ impl Game<'_> { .and_then(|r| r.entities.get(&m.id)) .map(|t| t.position); let Some(base) = base else { return Ok(()) }; - let position = m.delta.as_ref().map_or(base, |d| d.end(base)); + // VecDeltaCodec.decode: each step's position from the last. + let path = m.delta.as_ref().map(|d| match d { + play::VecDelta::Linear(_) => Path::Linear(d.end(base)), + play::VecDelta::Stepped(steps) => { + let mut at = base; + Path::Stepped( + steps + .iter() + .map(|(delta, ticks)| { + at = play::VecDelta::apply(at, *delta); + (at, *ticks) + }) + .collect(), + ) + } + }); let rotation = m.rotation.map(|(y, x)| { ( horizon_protocol::buf::unpack_degrees(y), horizon_protocol::buf::unpack_degrees(x), ) }); - self.move_entity(m.id, position, rotation, m.on_ground); + self.move_entity(m.id, path, rotation, m.on_ground, false); } C::EntityPositionSync(p) => { - self.move_entity(p.id, p.path.end(), Some((p.yaw, p.pitch)), p.on_ground) + let path = match p.path { + play::PositionPath::Linear(at) => Path::Linear(at), + play::PositionPath::Stepped(steps) => Path::Stepped(steps), + }; + self.move_entity(p.id, Some(path), Some((p.yaw, p.pitch)), p.on_ground, true) } C::TeleportEntity(p) => { let base = self @@ -1773,7 +1864,13 @@ impl Game<'_> { rel(play::relative::Y, 1), rel(play::relative::Z, 2), ]; - self.move_entity(p.id, position, Some((p.yaw, p.pitch)), p.on_ground); + self.move_entity( + p.id, + Some(Path::Linear(position)), + Some((p.yaw, p.pitch)), + p.on_ground, + false, + ); } C::SetPassengers(p) => self.remote_set_passengers(p.vehicle, &p.passengers), C::MoveVehicle(p) => self.remote_move_vehicle(p.position, p.yaw), @@ -3149,6 +3246,7 @@ impl Game<'_> { return; }; let mut lerps = std::mem::take(&mut r.lerps); + let stepped = &mut r.stepped; let head_lerps = &mut r.head_lerps; let players = &r.level_players; let mut using = Vec::new(); @@ -3179,22 +3277,12 @@ impl Game<'_> { // LivingEntity.updateSwingTime. m.anim.attack_anim_o = m.anim.attack_anim; horizon_client::mobs::ai::tick_swing(m); - // Entity.commonTick: LinearInterpolationHandler.interpolate. - if let Some(l) = lerps.get_mut(&(m.id as i32)) - && l.steps > 0 + // Entity.tick: the SteppedInterpolationHandler's step. + if let Some(at) = stepped + .get_mut(&(m.id as i32)) + .and_then(|h| h.interpolate()) { - let n = f64::from(l.steps); - m.position = Vec3::new( - m.position.x + (l.target[0] - m.position.x) / n, - m.position.y + (l.target[1] - m.position.y) / n, - m.position.z + (l.target[2] - m.position.z) / n, - ); - if let Some((yaw, pitch)) = l.rotation { - let alpha = 1.0 / l.steps as f32; - m.yaw += wrap_degrees(yaw - m.yaw) * alpha; - m.head_pitch += (pitch - m.head_pitch) * alpha; - } - l.steps -= 1; + place_interpolated(m, at); } // LivingEntity.aiStep: the head's own interpolation. if let Some((yaw, steps)) = head_lerps.get_mut(&(m.id as i32)) diff --git a/crates/horizon-client/src/lan/entity.rs b/crates/horizon-client/src/lan/entity.rs index c4e8052c0..86fea8b65 100644 --- a/crates/horizon-client/src/lan/entity.rs +++ b/crates/horizon-client/src/lan/entity.rs @@ -59,6 +59,8 @@ pub struct EntityState { /// `Entity.getRequiresPrecisePosition` (primed TNT and falling blocks /// touching a block): moves are sent as full position syncs. pub precise: bool, + /// `Entity.needsSync`: send its changes this tick, due or not. + pub needs_sync: bool, } impl EntityState { @@ -79,6 +81,7 @@ impl EntityState { self.equipment.clear(); self.attributes.clear(); self.precise = false; + self.needs_sync = false; } } @@ -322,7 +325,7 @@ impl ServerEntity { // SynchedEntityData.isDirty: once for every client. let dirty = e.meta != self.meta; let due = self.interval != 0 && self.tick.is_multiple_of(self.interval); - if due || dirty { + if due || dirty || e.needs_sync { let rotation = (pack_degrees(e.pitch), pack_degrees(e.yaw)); let turned = |a: i8, b: i8| (i32::from(a) - i32::from(b)).abs() >= 1; let rotate = self.arrow @@ -398,7 +401,7 @@ impl ServerEntity { } else { None }; - if self.track_deltas { + if self.track_deltas || e.needs_sync { let diff: f64 = (0..3) .map(|k| (e.velocity[k] - self.velocity[k]).powi(2)) .sum(); @@ -855,6 +858,7 @@ mod tests { equipment: Vec::new(), attributes: Vec::new(), precise: false, + needs_sync: false, } } @@ -918,6 +922,37 @@ mod tests { assert!(matches!(jump[0], play::Clientbound::EntityPositionSync(_))); } + #[test] + fn a_knocked_back_mob_is_sent_at_once() { + let mut s = added(&state(0.0)); + changes(&mut s, &state(0.0)); + // Not due (tick 1 of 3), but LivingEntity.knockback set needsSync: + // its move and its new motion go out now. + let mut knocked = state(0.3); + knocked.velocity = [0.2, 0.3, 0.0]; + knocked.needs_sync = true; + let out = changes(&mut s, &knocked); + assert!( + matches!( + out[..], + [ + play::Clientbound::SetEntityMotion(_), + play::Clientbound::MoveEntityPos(_) + ] + ), + "{out:?}" + ); + // Then every third tick again. + assert!(changes(&mut s, &state(0.5)).is_empty()); + assert!(matches!( + changes(&mut s, &state(0.6))[..], + [ + play::Clientbound::SetEntityMotion(_), + play::Clientbound::MoveEntityPos(_) + ] + )); + } + #[test] fn fireballs_bundle_their_motion_with_their_power() { let mut e = state(0.0); diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index bf66f979b..060ec7193 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1547,6 +1547,7 @@ impl Hub { equipment, attributes, precise: false, + needs_sync: false, } } diff --git a/crates/horizon-client/src/mobs.rs b/crates/horizon-client/src/mobs.rs index db2ebeb60..76f622d9c 100644 --- a/crates/horizon-client/src/mobs.rs +++ b/crates/horizon-client/src/mobs.rs @@ -792,6 +792,9 @@ pub struct Mob { pub on_ground: bool, pub horizontal_collision: bool, pub vertical_collision_below: bool, + /// `Entity.needsSync`: knocked back, pushed or jumped since its server + /// last sent it, so the next update goes out at once. + pub needs_sync: bool, pub health: f32, pub hurt_time: i32, pub invulnerable: i32, @@ -1361,8 +1364,10 @@ impl PendingPushes { } /// Adds the pending pushes aimed at `m` to its velocity, in push order. pub fn apply_mob_pushes(m: &mut Mob, pending: &mut PendingPushes) { + // Entity.push(x, y, z) sets needsSync. let mut add = |v: Vec3| { m.velocity = Vec3::new(m.velocity.x + v.x, m.velocity.y + v.y, m.velocity.z + v.z); + m.needs_sync = true; }; if !pending.list.is_empty() { pending.list.retain(|(id, v)| { @@ -1529,6 +1534,7 @@ impl Mob { on_ground: false, horizontal_collision: false, vertical_collision_below: false, + needs_sync: false, hurt_time: 0, invulnerable: 0, death_time: 0, @@ -1769,6 +1775,7 @@ impl Mob { if power <= 0.0 { return; } + self.needs_sync = true; while xd * xd + zd * zd < f64::from(1.0e-5_f32) { xd = (self.random.next_double() - self.random.next_double()) * 0.01; zd = (self.random.next_double() - self.random.next_double()) * 0.01; diff --git a/crates/horizon-client/src/mobs/living.rs b/crates/horizon-client/src/mobs/living.rs index ebbfabf6b..d5b3c7091 100644 --- a/crates/horizon-client/src/mobs/living.rs +++ b/crates/horizon-client/src/mobs/living.rs @@ -832,6 +832,9 @@ impl Mob { }; } pub fn jump_from_ground(&mut self, w: &MobWorld) { + // LivingEntity.jumpFromGround (and the rabbit's and cubes' own, + // which call it or do the same): needsSync. + self.needs_sync = true; if self.species() == super::Species::Rabbit { super::rabbit::jump(self, w); return; diff --git a/crates/horizon-client/tests/lan_bot.rs b/crates/horizon-client/tests/lan_bot.rs index bd22d6e0a..4f5f17a3b 100644 --- a/crates/horizon-client/tests/lan_bot.rs +++ b/crates/horizon-client/tests/lan_bot.rs @@ -433,6 +433,7 @@ fn guests_see_each_other_and_the_world_entities() { equipment: Vec::new(), attributes: Vec::new(), precise: false, + needs_sync: false, }; let first = thread::spawn(move || { let mut bot = connect(port, "Alex", false)?; diff --git a/crates/horizon-world/examples/stepped_interpolation_fixture.rs b/crates/horizon-world/examples/stepped_interpolation_fixture.rs new file mode 100644 index 000000000..d2228d8b5 --- /dev/null +++ b/crates/horizon-world/examples/stepped_interpolation_fixture.rs @@ -0,0 +1,93 @@ +//! The Rust side of tools/test_stepped_interpolation_parity.py: replays the +//! harness's events (VanillaSteppedInterpolationOracle's input format) on +//! `SteppedInterpolation` and writes the entity after each one. +use horizon_world::stepped_interpolation::{Path, PosRot, Start, SteppedInterpolation}; +use std::fmt::Write as _; + +/// `Entity.setRot`: yaw and pitch wrapped, pitch clamped (`setXRot`). +fn set_rot(entity: &mut PosRot, yaw: f32, pitch: f32) { + entity.yaw = yaw % 360.0; + entity.pitch = (pitch % 360.0 % 360.0).clamp(-90.0, 90.0); +} + +fn main() { + let args: Vec = std::env::args().collect(); + let input = std::fs::read_to_string(&args[1]).expect("input"); + let mut out = String::new(); + let mut handler = SteppedInterpolation::new(0); + let mut entity = PosRot::default(); + for line in input.lines() { + let f: Vec<&str> = line.split_whitespace().collect(); + let num = |i: usize| f[i].parse::().expect("number"); + let what = match f[0] { + "case" => { + handler = SteppedInterpolation::new(f[1].parse().expect("steps")); + entity = PosRot { + position: [num(2), num(3), num(4)], + yaw: f[5].parse().expect("yaw"), + pitch: f[6].parse().expect("pitch"), + }; + "c" + } + "move" => { + let mut i = 2; + let path = match f[1] { + "L" => { + i += 3; + Some(Path::Linear([num(2), num(3), num(4)])) + } + "S" => { + let n: usize = f[2].parse().expect("count"); + i = 3; + let mut steps = Vec::new(); + for _ in 0..n { + steps.push(( + [num(i), num(i + 1), num(i + 2)], + f[i + 3].parse().expect("ticks"), + )); + i += 4; + } + Some(Path::Stepped(steps)) + } + _ => None, + }; + let rotation = (f[i] == "R").then(|| { + ( + f[i + 1].parse().expect("yaw"), + f[i + 2].parse().expect("pitch"), + ) + }); + match handler.interpolate_to(entity, path, rotation) { + Start::Snap(at) => { + entity.position = at.position; + set_rot(&mut entity, at.yaw, at.pitch); + "s" + } + Start::Unchanged => "k", + Start::Interpolating => "i", + } + } + "tick" => match handler.interpolate() { + Some(at) => { + entity.position = at.position; + set_rot(&mut entity, at.yaw, at.pitch); + "t" + } + None => "n", + }, + other => panic!("unknown event {other}"), + }; + let p = entity.position; + writeln!( + out, + "{what} {:x} {:x} {:x} {:x} {:x}", + p[0].to_bits(), + p[1].to_bits(), + p[2].to_bits(), + entity.yaw.to_bits(), + entity.pitch.to_bits() + ) + .expect("write"); + } + std::fs::write(&args[2], out).expect("output"); +} diff --git a/crates/horizon-world/src/lib.rs b/crates/horizon-world/src/lib.rs index 819262d46..b6d05ecf6 100644 --- a/crates/horizon-world/src/lib.rs +++ b/crates/horizon-world/src/lib.rs @@ -137,6 +137,7 @@ pub mod player_equipment; pub mod living_use_flags; pub mod status_effect; +pub mod stepped_interpolation; pub mod synced_values; pub mod inventory_use; diff --git a/crates/horizon-world/src/stepped_interpolation.rs b/crates/horizon-world/src/stepped_interpolation.rs new file mode 100644 index 000000000..a7157d90a --- /dev/null +++ b/crates/horizon-world/src/stepped_interpolation.rs @@ -0,0 +1,341 @@ +//! How a client moves a living entity it doesn't simulate along the +//! positions its server sends (vanilla `SteppedInterpolationHandler`, which +//! `LivingEntity.createInterpolationHandler` gives every living entity on a +//! client): each position is a step the entity walks to in the type's update +//! interval (3 ticks for most mobs), one after the other, sped up while +//! steps pile up. A punched mob's first move, sent at once (`needsSync`), +//! thus takes three ticks to show, and the rest follow it. + +use std::collections::VecDeque; + +/// A position and rotation (`PositionAndRotation`). +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct PosRot { + pub position: [f64; 3], + pub yaw: f32, + pub pitch: f32, +} + +impl PosRot { + /// `PositionAndRotation.is`: the same rotation and `Vec3.equals` + /// position (`Double.compare`, so -0.0 is not 0.0). + fn is(&self, position: [f64; 3], yaw: f32, pitch: f32) -> bool { + self.yaw == yaw && self.pitch == pitch && same_position(self.position, position) + } +} + +/// `Vec3.equals`. +fn same_position(a: [f64; 3], b: [f64; 3]) -> bool { + (0..3).all(|i| a[i].to_bits() == b[i].to_bits()) +} + +/// `PositionPath`: where a move packet puts an entity, either straight +/// there or through the positions the server stepped it through, each +/// with the ticks it took. +#[derive(Clone, Debug, PartialEq)] +pub enum Path { + Linear([f64; 3]), + Stepped(Vec<([f64; 3], i32)>), +} + +impl Path { + pub fn end(&self) -> [f64; 3] { + match self { + Path::Linear(p) => *p, + Path::Stepped(steps) => steps.last().map_or([0.0; 3], |s| s.0), + } + } +} + +/// What the client does with the entity when a packet arrives. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Start { + /// Moves on to it over the coming ticks. + Interpolating, + /// Already headed there: nothing changes. + Unchanged, + /// Is put there at once (`snapTo`): types with no update interval. + Snap(PosRot), +} + +/// `SteppedInterpolationHandler` and its `InterpolationData`. +#[derive(Clone, Debug)] +pub struct SteppedInterpolation { + /// `AbstractInterpolationHandler.interpolationSteps`: the type's + /// `updateInterval`. + steps: i32, + /// The data's own position and rotation: where the last packet said. + target: PosRot, + /// `lastStepPosRot`: where the step being walked started. + last: PosRot, + remaining: VecDeque<(PosRot, i32)>, + current_step_ticks: f32, + remaining_ticks: f32, + speed: f32, +} + +/// `Mth.wrapDegrees(float)`. +fn wrap_degrees(degrees: f32) -> f32 { + let mut wrapped = degrees % 360.0; + if wrapped >= 180.0 { + wrapped -= 360.0; + } + if wrapped < -180.0 { + wrapped += 360.0; + } + wrapped +} + +/// `Mth.lerp(float, float, float)`. +fn lerp(a: f32, from: f32, to: f32) -> f32 { + from + a * (to - from) +} + +/// `Mth.rotLerp`. +fn rot_lerp(a: f32, from: f32, to: f32) -> f32 { + from + a * wrap_degrees(to - from) +} + +impl SteppedInterpolation { + /// The handler of an entity type whose update interval is `steps` ticks. + pub fn new(steps: i32) -> Self { + Self { + steps, + target: PosRot::default(), + last: PosRot::default(), + remaining: VecDeque::new(), + current_step_ticks: 0.0, + remaining_ticks: 0.0, + speed: 1.0, + } + } + /// `hasActiveInterpolation`. + pub fn active(&self) -> bool { + !self.remaining.is_empty() + } + /// Where the entity is headed: the last packet's position and rotation + /// while interpolating (`AbstractInterpolationHandler.interpolateTo`'s + /// `current`), else where it is. + pub fn heading(&self, entity: PosRot) -> PosRot { + if self.active() { self.target } else { entity } + } + /// `AbstractInterpolationHandler.interpolateTo(path, yRot, xRot, + /// hasRotation)` for an entity at `entity`: a packet without a position + /// (`path` None) keeps the one it is headed for, one without a rotation + /// the rotation. + pub fn interpolate_to( + &mut self, + entity: PosRot, + path: Option, + rotation: Option<(f32, f32)>, + ) -> Start { + let current = self.heading(entity); + let path = path.unwrap_or(Path::Linear(current.position)); + let (yaw, pitch) = rotation.unwrap_or((current.yaw, current.pitch)); + let end = path.end(); + if self.steps == 0 { + self.cancel(); + return Start::Snap(PosRot { + position: end, + yaw, + pitch, + }); + } + if self.active() && self.target.is(end, yaw, pitch) { + return Start::Unchanged; + } + // SteppedInterpolationHandler.startInterpolating. + if !self.active() { + self.last = entity; + self.current_step_ticks = 1.0; + } + if same_position(end, self.target.position) { + self.add_step( + PosRot { + position: end, + yaw, + pitch, + }, + self.steps, + ); + } else { + self.add_steps(&path, yaw, pitch); + } + self.target = PosRot { + position: end, + yaw, + pitch, + }; + Start::Interpolating + } + /// `cancel` (`InterpolationData.reset`). + pub fn cancel(&mut self) { + self.remaining.clear(); + self.remaining_ticks = 0.0; + self.speed = 1.0; + } + /// `AbstractInterpolationHandler.interpolate`, once a client tick: where + /// the entity is put, or None when there is nothing to walk (and the + /// handler resets). The client moves the entity only this way, so the + /// target never needs the deltas `adjustInterpolationTargetFromDeltas` + /// would add. + pub fn interpolate(&mut self) -> Option { + if !self.active() { + self.cancel(); + return None; + } + // doInterpolate: getNewPositionAndRotation, then advance by the + // level's relative tick speed (1 at the normal tick rate). + let at = self.new_position(); + self.advance(1.0); + Some(at) + } + /// `InterpolationData.addStep`. + fn add_step(&mut self, step: PosRot, ticks: i32) { + self.remaining.push_back((step, ticks)); + self.remaining_ticks += ticks as f32; + } + /// `InterpolationData.addSteps`: a stepped path's steps take their own + /// ticks, turning by the share of the path each one ends. + fn add_steps(&mut self, path: &Path, yaw: f32, pitch: f32) { + match path { + Path::Linear(p) => self.add_step( + PosRot { + position: *p, + yaw, + pitch, + }, + self.steps, + ), + Path::Stepped(steps) => { + if yaw == self.target.yaw && pitch == self.target.pitch { + for (p, ticks) in steps { + self.add_step( + PosRot { + position: *p, + yaw, + pitch, + }, + *ticks, + ); + } + return; + } + let total: i32 = steps.iter().map(|s| s.1).sum(); + let mut offset = 0; + let (from_yaw, from_pitch) = (self.target.yaw, self.target.pitch); + for (p, ticks) in steps { + offset += ticks; + let a = offset as f32 / total as f32; + let step = PosRot { + position: *p, + yaw: rot_lerp(a, from_yaw, yaw), + pitch: lerp(a, from_pitch, pitch), + }; + self.add_step(step, *ticks); + } + } + } + } + /// `InterpolationData.getNewPositionAndRotation`. + fn new_position(&mut self) -> PosRot { + while let Some(&(step, ticks)) = self.remaining.front() { + if self.current_step_ticks < ticks as f32 { + let a = self.current_step_ticks / ticks as f32; + let t = f64::from(a); + let (from, to) = (self.last.position, step.position); + return PosRot { + position: [0, 1, 2].map(|i| from[i] + (to[i] - from[i]) * t), + yaw: rot_lerp(a, self.last.yaw, step.yaw), + pitch: lerp(a, self.last.pitch, step.pitch), + }; + } + self.current_step_ticks -= ticks as f32; + self.last = step; + self.remaining.pop_front(); + } + self.target + } + /// `InterpolationData.advance`: faster while more than one update's + /// worth of steps waits. + fn advance(&mut self, ticks: f32) { + let steps = self.steps as f32; + let target_speed = (self.remaining_ticks / steps).max(1.0); + self.speed = lerp(1.0 / steps, self.speed, target_speed); + let moved = if ticks * self.speed < self.remaining_ticks { + ticks * self.speed + } else { + self.speed = 1.0; + self.remaining_ticks + }; + self.current_step_ticks += moved; + self.remaining_ticks -= moved; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(x: f64) -> PosRot { + PosRot { + position: [x, 0.0, 0.0], + yaw: 0.0, + pitch: 0.0, + } + } + + #[test] + fn a_position_takes_the_update_interval_to_reach() { + let mut h = SteppedInterpolation::new(3); + let mut entity = at(0.0); + h.interpolate_to(entity, Some(Path::Linear([0.3, 0.0, 0.0])), None); + let mut seen = Vec::new(); + while let Some(p) = h.interpolate() { + entity = p; + seen.push((p.position[0] * 1000.0).round() / 1000.0); + } + assert_eq!(seen, [0.1, 0.2, 0.3]); + assert!(!h.active()); + assert_eq!(entity.position[0], 0.3); + } + + #[test] + fn steps_that_pile_up_are_walked_faster() { + let mut h = SteppedInterpolation::new(3); + let mut entity = at(0.0); + let mut ticks = 0; + for k in 1..=4 { + h.interpolate_to(entity, Some(Path::Linear([f64::from(k), 0.0, 0.0])), None); + if let Some(p) = h.interpolate() { + entity = p; + } + ticks += 1; + } + while let Some(p) = h.interpolate() { + entity = p; + ticks += 1; + } + // Four packets a tick apart: 12 ticks of steps, walked in fewer. + assert!(ticks < 12, "{ticks}"); + assert_eq!(entity.position[0], 4.0); + } + + #[test] + fn no_update_interval_snaps() { + let mut h = SteppedInterpolation::new(0); + let start = h.interpolate_to( + at(0.0), + Some(Path::Linear([2.0, 0.0, 0.0])), + Some((90.0, 0.0)), + ); + assert_eq!( + start, + Start::Snap(PosRot { + position: [2.0, 0.0, 0.0], + yaw: 90.0, + pitch: 0.0 + }) + ); + } +} diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSteppedInterpolationOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSteppedInterpolationOracle.java new file mode 100644 index 000000000..b3edd6e48 --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSteppedInterpolationOracle.java @@ -0,0 +1,148 @@ +package dev.horizonlab.parity; + +import java.io.PrintWriter; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import net.minecraft.core.PositionAndRotation; +import net.minecraft.world.entity.PositionPath; +import net.minecraft.world.entity.PositionStep; +import net.minecraft.world.phys.Vec3; + +/** + * How a client walks a living entity along the positions its server sends: the unmodified + * {@code SteppedInterpolationHandler.InterpolationData} (setStartingPoint, addStep, addSteps, + * getNewPositionAndRotation, advance, reset), driven the way + * {@code AbstractInterpolationHandler.interpolateTo} / {@code interpolate} and + * {@code SteppedInterpolationHandler.startInterpolating} / {@code doInterpolate} drive it, with the + * entity's own {@code setPos} / {@code setRot}. The handler's entity hooks are out of scope: the + * entity moves only by its interpolation (no deltas to adjust) at the normal tick rate. + * + *

Input (args[0]), one event a line: {@code case STEPS X Y Z YAW PITCH} (a fresh entity there + * with a handler of that many interpolation steps), {@code move L X Y Z} or {@code move S N + * (X Y Z TICKS)xN} or {@code move N} (no position), then {@code R YAW PITCH} or {@code -} (no + * rotation), and {@code tick}. Output (args[1]): the entity after every event. + */ +public final class VanillaSteppedInterpolationOracle { + private static Method method(Class owner, String name, Class... types) throws Exception { + Method m = owner.getDeclaredMethod(name, types); + m.setAccessible(true); + return m; + } + + private static String hex(double v) { + return Long.toHexString(Double.doubleToRawLongBits(v)); + } + + private static String hex(float v) { + return Integer.toHexString(Float.floatToRawIntBits(v)); + } + + public static void main(String[] args) throws Exception { + Class dataClass = Class.forName("net.minecraft.world.entity.SteppedInterpolationHandler$InterpolationData"); + Constructor create = dataClass.getDeclaredConstructor(); + create.setAccessible(true); + Method advance = method(dataClass, "advance", float.class, int.class); + Method addStep = method(dataClass, "addStep", Vec3.class, float.class, float.class, int.class); + Method addSteps = method(dataClass, "addSteps", PositionPath.class, float.class, float.class, int.class); + Method setStartingPoint = method(dataClass, "setStartingPoint", Vec3.class, float.class, float.class); + Method next = method(dataClass, "getNewPositionAndRotation"); + Method reset = method(dataClass, "reset"); + Field remaining = dataClass.getDeclaredField("remainingSteps"); + remaining.setAccessible(true); + + Object data = null; + int steps = 0; + Vec3 pos = Vec3.ZERO; + float yRot = 0; + float xRot = 0; + try (PrintWriter out = new PrintWriter(Files.newBufferedWriter(Path.of(args[1])))) { + for (String line : Files.readAllLines(Path.of(args[0]))) { + String[] f = line.trim().split(" "); + int i = 1; + String what; + switch (f[0]) { + case "case" -> { + data = create.newInstance(); + steps = Integer.parseInt(f[1]); + pos = new Vec3(Double.parseDouble(f[2]), Double.parseDouble(f[3]), Double.parseDouble(f[4])); + yRot = Float.parseFloat(f[5]); + xRot = Float.parseFloat(f[6]); + what = "c"; + } + case "move" -> { + PositionPath path = null; + switch (f[i++]) { + case "L" -> { + path = PositionPath.of(new Vec3(Double.parseDouble(f[i]), Double.parseDouble(f[i + 1]), Double.parseDouble(f[i + 2]))); + i += 3; + } + case "S" -> { + int n = Integer.parseInt(f[i++]); + List list = new ArrayList<>(); + for (int k = 0; k < n; k++) { + list.add(new PositionStep(new Vec3(Double.parseDouble(f[i]), Double.parseDouble(f[i + 1]), Double.parseDouble(f[i + 2])), Integer.parseInt(f[i + 3]))); + i += 4; + } + path = PositionPath.stepped(List.copyOf(list)); + } + default -> { + } + } + boolean hasRotation = f[i].equals("R"); + float y = hasRotation ? Float.parseFloat(f[i + 1]) : 0; + float x = hasRotation ? Float.parseFloat(f[i + 2]) : 0; + boolean active = !((List) remaining.get(data)).isEmpty(); + PositionAndRotation.Mutable target = (PositionAndRotation.Mutable) data; + // AbstractInterpolationHandler.interpolateTo(path, yRot, xRot, hasRotation). + PositionAndRotation current = active ? target : PositionAndRotation.of(pos, yRot, xRot); + if (path == null) path = PositionPath.of(current.position()); + if (!hasRotation) { + y = current.yRot(); + x = current.xRot(); + } + Vec3 end = path.endPosition(); + if (steps == 0) { + // snapTo, then cancel. + pos = end; + yRot = y % 360.0F; + xRot = Math.clamp(x % 360.0F % 360.0F, -90.0F, 90.0F); + reset.invoke(data); + what = "s"; + } else if (active && target.is(end, y, x)) { + what = "k"; + } else { + // SteppedInterpolationHandler.startInterpolating. + if (!active) setStartingPoint.invoke(data, pos, yRot, xRot); + if (Objects.equals(end, target.position())) addStep.invoke(data, end, y, x, steps); + else addSteps.invoke(data, path, y, x, steps); + target.set(end, y, x); + what = "i"; + } + } + case "tick" -> { + // AbstractInterpolationHandler.interpolate. + if (((List) remaining.get(data)).isEmpty()) { + reset.invoke(data); + what = "n"; + } else { + PositionAndRotation t = (PositionAndRotation) next.invoke(data); + pos = t.position(); + yRot = t.yRot() % 360.0F; + xRot = Math.clamp(t.xRot() % 360.0F % 360.0F, -90.0F, 90.0F); + advance.invoke(data, 1.0F, steps); + what = "t"; + } + } + default -> throw new IllegalArgumentException(line); + } + out.println(what + " " + hex(pos.x) + " " + hex(pos.y) + " " + hex(pos.z) + " " + hex(yRot) + " " + hex(xRot)); + } + } + } +} diff --git a/tools/test_stepped_interpolation_parity.py b/tools/test_stepped_interpolation_parity.py new file mode 100644 index 000000000..998d5a4ba --- /dev/null +++ b/tools/test_stepped_interpolation_parity.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Compare how a client walks a living entity along its server's positions with vanilla. + +Vanilla's unmodified SteppedInterpolationHandler.InterpolationData (VanillaSteppedInterpolationOracle +drives it as the handler does) and horizon-world's SteppedInterpolation replay the same packets and +client ticks: move packets with and without a position or rotation, straight or stepped paths, +repeats of the one the entity is headed for, packets a tick apart that pile up and others after it +stopped, for update intervals of 1 to 3 ticks, plus a punched pig's knockback as the server sends it +(its first move at once, `needsSync`, then every update interval). The entity's position and rotation +after every event must match bit for bit. The handler's entity hooks (deltas, passengers) are out of +scope. +""" +import os +import random +import subprocess +try: + from . import lab, test_rust_parity as base +except ImportError: + import lab + import test_rust_parity as base + +# A pig punched on flat ground, its positions on the server tick by tick (knockback 0.4 along x). +KNOCKBACK = [(0.0, 0.0), (0.3032, 0.3608), (0.4638, 0.636), (0.6049, 0.8273), (0.7279, 0.9363), (0.8345, 0.9648), + (0.926, 0.9143), (1.0037, 0.7864), (1.0688, 0.5827), (1.1223, 0.3046), (1.1654, 0.0), (1.1147, 0.0), + (0.9968, 0.0), (0.8418, 0.0), (0.6664, 0.0)] + + +def position(rng, p): + return [round(v + rng.uniform(-0.6, 0.6), rng.choice([2, 4, 17])) for v in p] + + +def rotation(rng): + return [round(rng.uniform(-400, 400), rng.choice([0, 1, 3])), round(rng.uniform(-120, 120), rng.choice([0, 2]))] + + +def cases(count, seed): + rng = random.Random(seed) + lines = [] + for interval in (1, 2, 3): + # The knockback: a packet for the hit tick's move, then every interval (each phase). + for phase in range(interval): + lines.append(f'case {interval} 10.5 64.0 0.5 90.0 0.0') + for k in range(1, len(KNOCKBACK)): + if k == 1 or (k + phase) % interval == 0: + x, y = KNOCKBACK[k] + lines.append(f'move L {10.5 + x!r} {64 + y!r} 0.5 -') + lines.append('tick') + lines += ['tick'] * 6 + for _ in range(count): + p = [rng.uniform(-100, 100), rng.uniform(-64, 300), rng.uniform(-100, 100)] + yaw, pitch = rotation(rng) + lines.append(f'case {rng.choice([1, 2, 3, 3, 3])} {p[0]!r} {p[1]!r} {p[2]!r} {yaw!r} {pitch!r}') + last = None + for _ in range(rng.randrange(1, 40)): + if rng.random() < 0.45: + lines.append('tick') + continue + if last is not None and rng.random() < 0.15: + line = last # the same packet again + else: + if rng.random() < 0.1: + line = 'move N' + elif rng.random() < 0.2: + n = rng.randrange(1, 5) + steps = [] + for _ in range(n): + p = position(rng, p) + steps.append(f'{p[0]!r} {p[1]!r} {p[2]!r} {rng.randrange(1, 4)}') + line = f'move S {n} ' + ' '.join(steps) + else: + p = position(rng, p) if rng.random() < 0.9 else p + line = f'move L {p[0]!r} {p[1]!r} {p[2]!r}' + rot = rotation(rng) if rng.random() < 0.6 else None + line += f' R {rot[0]!r} {rot[1]!r}' if rot else ' -' + last = line + lines.append(line) + lines += ['tick'] * rng.choice([0, 1, 1, 2, 3, 6]) + return lines + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / 'stepped-interpolation-parity' + build.mkdir(parents=True, exist_ok=True) + classes = build / 'classes' + classes.mkdir(exist_ok=True) + events = build / 'events.txt' + events.write_text('\n'.join(cases(3000, 1374)) + '\n', encoding='utf-8') + classpath = os.pathsep.join(map(str, [jars['client'], *libraries])) + oracle = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaSteppedInterpolationOracle.java' + subprocess.run(['javac', '--release', str(lock['minecraft']['java_major']), '-proc:none', + '-cp', classpath, '-d', str(classes), str(oracle)], check=True) + subprocess.run(['java', '-Xmx512M', '-cp', str(classes) + os.pathsep + classpath, + 'dev.horizonlab.parity.VanillaSteppedInterpolationOracle', str(events), str(build / 'vanilla.txt')], + check=True) + subprocess.run(['cargo', 'run', '--quiet', '--release', '-p', 'horizon-world', '--example', + 'stepped_interpolation_fixture', '--', str(events), str(build / 'rust.txt')], cwd=lab.ROOT, check=True) + matched = base.compare(build / 'vanilla.txt', build / 'rust.txt') + print(f'Stepped interpolation parity passed: {matched} entity positions and rotations match vanilla') + + +if __name__ == '__main__': + main()