Mobs: a punch shows smooth and slow, as on vanilla's client

The server sends a knocked-back, pushed or jumping mob's move at once
(Entity.needsSync), and the remote client walks every living entity along
the server's positions with vanilla's SteppedInterpolationHandler: each
position takes the type's update interval to reach, sped up while steps
pile up. Before, the client waited for the next due update and then
lerped there in fewer ticks, so a hit pig jumped away fast.

tools/test_stepped_interpolation_parity.py drives vanilla's unmodified
InterpolationData and the Rust port with the same packets and ticks
(random cases and a punched pig's knockback); 135236 positions and
rotations match bit for bit.

Fixes #1374

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-23 05:38:46 +02:00
parent 357dceb999
commit feeefb3a33
14 changed files with 858 additions and 30 deletions
+1
View File
@@ -2700,6 +2700,7 @@ impl Game<'_> {
equipment: Vec::new(),
attributes: Vec::new(),
precise: false,
needs_sync: false,
})
}
}
+5
View File
@@ -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 = {
@@ -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,
}
}
+116 -28
View File
@@ -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<i32, Tracked>,
lerps: HashMap<i32, Lerp>,
/// Living entities' `SteppedInterpolationHandler`s.
stepped: HashMap<i32, SteppedInterpolation>,
/// `LivingEntity.lerpHeadTo`: the head yaw and the ticks left to reach it.
head_lerps: HashMap<i32, (f32, u32)>,
/// 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<Path>,
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))
+37 -2
View File
@@ -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);
+1
View File
@@ -1547,6 +1547,7 @@ impl Hub {
equipment,
attributes,
precise: false,
needs_sync: false,
}
}
+7
View File
@@ -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;
+3
View File
@@ -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;
+1
View File
@@ -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)?;
@@ -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<String> = 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::<f64>().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");
}
+1
View File
@@ -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;
@@ -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<Path>,
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<PosRot> {
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
})
);
}
}
@@ -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.
*
* <p>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<PositionStep> 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));
}
}
}
}
+103
View File
@@ -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()