From d48b349d72deaa9617d7f4f81ffb73d1b4a7f909 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:19:06 +0500 Subject: [PATCH 1/4] Native mods: teleport, rotation, head yaw, facing, size and pushes through EntityRef (#7387) teleport_to and set_rotation move and turn any entity as /tp and /rotate do; head_yaw, set_head_yaw, direction, dimensions and push. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 1 + crates/horizon-client/src/game/commands.rs | 7 +- .../src/game/mod_api_host_generated.rs | 26 +++ .../src/game/native_entity_motion.rs | 7 +- .../src/game/native_entity_moves.rs | 151 ++++++++++++++++++ .../src/mod_api/generated_host.rs | 107 +++++++++++++ tools/native-hook-fragments/entity_moves.json | 45 ++++++ 7 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 crates/horizon-client/src/game/native_entity_moves.rs create mode 100644 tools/native-hook-fragments/entity_moves.json diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 8fe2e3f18a..7095d017da 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -205,6 +205,7 @@ mod native_criteria; mod native_data_maps; mod native_entities; mod native_entity_motion; +mod native_entity_moves; mod native_entity_refs; mod native_explosions; mod native_falling; diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 9ebea1efc3..2200b31206 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -1447,7 +1447,12 @@ impl Game<'_> { self.feedback(message); Ok(targets.len() as i32) } - fn teleport_to(&mut self, t: Target, pos: [f64; 3], rotation: Option<[f32; 2]>) { + pub(in crate::game) fn teleport_to( + &mut self, + t: Target, + pos: [f64; 3], + rotation: Option<[f32; 2]>, + ) { // An entity in another level is teleported in the level that holds it. let _ = self.in_target_level(t, |g| g.teleport_to_in_level(t, pos, rotation)); } diff --git a/crates/horizon-client/src/game/mod_api_host_generated.rs b/crates/horizon-client/src/game/mod_api_host_generated.rs index 02f8d65a4d..838d54f1bd 100644 --- a/crates/horizon-client/src/game/mod_api_host_generated.rs +++ b/crates/horizon-client/src/game/mod_api_host_generated.rs @@ -543,4 +543,30 @@ impl Host for super::Game<'_> { fn entity_distance_to_sqr(&self, entity: EntityRef, pos: [f64; 3]) -> Option { self.native_entity_distance_to_sqr(entity, pos) } + fn entity_teleport_to( + &mut self, + entity: EntityRef, + pos: [f64; 3], + rotation: Option<[f32; 2]>, + ) -> bool { + self.native_entity_teleport_to(entity, pos, rotation) + } + fn entity_set_rotation(&mut self, entity: EntityRef, yaw: f32, pitch: f32) -> bool { + self.native_entity_set_rotation(entity, yaw, pitch) + } + fn entity_head_yaw(&self, entity: EntityRef) -> Option { + self.native_entity_head_yaw(entity) + } + fn entity_set_head_yaw(&mut self, entity: EntityRef, yaw: f32) -> bool { + self.native_entity_set_head_yaw(entity, yaw) + } + fn entity_direction(&self, entity: EntityRef) -> Option<&'static str> { + self.native_entity_direction(entity) + } + fn entity_dimensions(&self, entity: EntityRef) -> Option<[f64; 2]> { + self.native_entity_dimensions(entity) + } + fn entity_push(&mut self, entity: EntityRef, delta: [f64; 3]) -> bool { + self.native_entity_push(entity, delta) + } } diff --git a/crates/horizon-client/src/game/native_entity_motion.rs b/crates/horizon-client/src/game/native_entity_motion.rs index 76469954a1..cf56e768ea 100644 --- a/crates/horizon-client/src/game/native_entity_motion.rs +++ b/crates/horizon-client/src/game/native_entity_motion.rs @@ -15,12 +15,15 @@ fn corners(b: Aabb) -> [[f64; 3]; 2] { impl Game<'_> { /// The simulated entity (one of the shared entity walk's lists) with `id`. - fn native_walk_entity(&self, id: u32) -> Option { + pub(super) fn native_walk_entity(&self, id: u32) -> Option { self.entity_handles() .find(|h| with_entity!(self, *h, e => e.id() == id)) } /// The server's copy of the player a target names. - fn native_target_guest(&self, t: Target) -> Option<&horizon_client::lan::guest::Guest> { + pub(super) fn native_target_guest( + &self, + t: Target, + ) -> Option<&horizon_client::lan::guest::Guest> { match t { Target::Player => self.command_guest(self.lan_acting?), Target::Guest(conn) => self.command_guest(conn), diff --git a/crates/horizon-client/src/game/native_entity_moves.rs b/crates/horizon-client/src/game/native_entity_moves.rs new file mode 100644 index 0000000000..6c3f0ef8aa --- /dev/null +++ b/crates/horizon-client/src/game/native_entity_moves.rs @@ -0,0 +1,151 @@ +//! Moving and turning an entity for native server mods, through an +//! `EntityRef` (docs/native-mods.md, "Entity and level handles", #7387): +//! teleport, rotation, head yaw, facing, size and pushes. Writes move the +//! entity as `/tp` and `/rotate` do, so clients see it as they see those. +//! Reads return `None`, writes false, for a stale handle or on a client. +use super::entity::{Entity as _, EntityHandle, with_entity_mut}; +use super::{Game, commands::Target}; +use horizon_client::mod_api::EntityRef; +use horizon_core::geometry::Vec3; + +/// `Level.isInSpawnableBounds` and `/tp`'s limits: 30,000,000 blocks out, +/// 20,000,000 up or down. +fn in_bounds([x, y, z]: [f64; 3]) -> bool { + [x, y, z].iter().all(|v| v.is_finite()) + && x.abs() < 30_000_000.0 + && z.abs() < 30_000_000.0 + && y.abs() < 20_000_000.0 +} + +/// `Direction.fromYRot`: the horizontal direction a yaw faces. +fn facing(yaw: f32) -> &'static str { + const BY_2D: [&str; 4] = ["south", "west", "north", "east"]; + BY_2D[((f64::from(yaw) / 90.0 + 0.5).floor() as i64 & 3) as usize] +} + +impl Game<'_> { + /// Moves the entity to `pos` in its level (`Entity.teleportTo`), as + /// `/tp` does: a player is placed there and told, a rider gets off. With + /// `rotation` (yaw, pitch in degrees) it also turns. False for a position + /// outside the world's bounds. API @API:entity_moves@. + pub(super) fn native_entity_teleport_to( + &mut self, + entity: EntityRef, + pos: [f64; 3], + rotation: Option<[f32; 2]>, + ) -> bool { + let Some(t) = self.native_entity_target(entity) else { + return false; + }; + if !in_bounds(pos) || rotation.is_some_and(|r| !r.iter().all(|v| v.is_finite())) { + return false; + } + self.teleport_to(t, pos, rotation); + true + } + /// Turns the entity to `yaw` and `pitch` in degrees (`Entity.setYRot`, + /// `setXRot`), as `/rotate` does: the pitch is clamped to -90..90, and a + /// player's client is told. API @API:entity_moves@. + pub(super) fn native_entity_set_rotation( + &mut self, + entity: EntityRef, + yaw: f32, + pitch: f32, + ) -> bool { + let Some(t) = self.native_entity_target(entity) else { + return false; + }; + let Some(pos) = self.target_position(t) else { + return false; + }; + if !yaw.is_finite() || !pitch.is_finite() { + return false; + } + self.teleport_to(t, pos, Some([yaw, pitch])); + true + } + /// The yaw of the entity's head (`Entity.getYHeadRot`): a mob's or a + /// player's own, the body's yaw for the kinds without a head. API + /// @API:entity_moves@. + pub(super) fn native_entity_head_yaw(&self, entity: EntityRef) -> Option { + let t = self.native_entity_target(entity)?; + if let Some(g) = self.native_target_guest(t) { + return Some(g.head_yaw); + } + if let Target::Mob(id) = t { + return Some(self.mobs.iter().find(|m| m.id == id)?.head_yaw); + } + Some(self.target_rotation(t)[0]) + } + /// Turns a mob's head to `yaw` (`Entity.setYHeadRot`); its body follows + /// as its AI turns it. False for the other kinds. API @API:entity_moves@. + pub(super) fn native_entity_set_head_yaw(&mut self, entity: EntityRef, yaw: f32) -> bool { + let Some(Target::Mob(id)) = self.native_entity_target(entity) else { + return false; + }; + match self.mobs.iter_mut().find(|m| m.id == id) { + Some(m) if yaw.is_finite() => { + m.head_yaw = yaw; + m.needs_sync = true; + true + } + _ => false, + } + } + /// The horizontal direction the entity faces (`Entity.getDirection`): + /// `north`, `south`, `east` or `west`. API @API:entity_moves@. + pub(super) fn native_entity_direction(&self, entity: EntityRef) -> Option<&'static str> { + let t = self.native_entity_target(entity)?; + Some(facing(self.target_rotation(t)[0])) + } + /// The entity's width and height (`Entity.getBbWidth`, `getBbHeight`): + /// its box's, so a baby's or a scaled entity's own. API + /// @API:entity_moves@. + pub(super) fn native_entity_dimensions(&self, entity: EntityRef) -> Option<[f64; 2]> { + let [min, max] = self.native_entity_bounding_box(entity)?; + Some([max[0] - min[0], max[1] - min[1]]) + } + /// Adds `delta` to the entity's velocity (`Entity.push`, + /// `addDeltaMovement`). False for a player (its client moves it), an + /// entity Horizon doesn't simulate, or a value that isn't finite. API + /// @API:entity_moves@. + pub(super) fn native_entity_push(&mut self, entity: EntityRef, delta: [f64; 3]) -> bool { + if !delta.iter().all(|v| v.is_finite()) || self.native_entity_target(entity).is_none() { + return false; + } + let Some(h) = self.native_walk_entity(entity.id()) else { + return false; + }; + let [x, y, z] = delta; + with_entity_mut!(self, h, e => { + let v = e.velocity_mut(); + *v = Vec3::new(v.x + x, v.y + y, v.z + z); + }); + if let EntityHandle::Mob(i) = h { + self.mobs[i].needs_sync = true; + } + true + } +} + +#[cfg(test)] +mod tests { + #[test] + fn a_yaw_faces_vanillas_direction() { + assert_eq!(super::facing(0.0), "south"); + assert_eq!(super::facing(90.0), "west"); + assert_eq!(super::facing(180.0), "north"); + assert_eq!(super::facing(-90.0), "east"); + assert_eq!(super::facing(-45.0), "south"); + assert_eq!(super::facing(-46.0), "east"); + assert_eq!(super::facing(720.0 + 170.0), "north"); + } + + #[test] + fn positions_stay_inside_the_worlds_bounds() { + assert!(super::in_bounds([29_999_999.0, -64.0, 0.0])); + assert!(!super::in_bounds([30_000_000.0, 64.0, 0.0])); + assert!(!super::in_bounds([0.0, 20_000_001.0, 0.0])); + assert!(!super::in_bounds([f64::NAN, 64.0, 0.0])); + } +} diff --git a/crates/horizon-client/src/mod_api/generated_host.rs b/crates/horizon-client/src/mod_api/generated_host.rs index ae3104c8cd..3131e95fe5 100644 --- a/crates/horizon-client/src/mod_api/generated_host.rs +++ b/crates/horizon-client/src/mod_api/generated_host.rs @@ -1318,6 +1318,66 @@ pub trait Host { let _ = pos; unimplemented!("Host::entity_distance_to_sqr") } + /// Moves the entity to `pos` in its level (`Entity.teleportTo`), as + /// `/tp` does: a player is placed there and told, a rider gets off. With + /// `rotation` (yaw, pitch in degrees) it also turns. False for a position + /// outside the world's bounds. API @API:entity_moves@. + fn entity_teleport_to( + &mut self, + entity: EntityRef, + pos: [f64; 3], + rotation: Option<[f32; 2]>, + ) -> bool { + let _ = entity; + let _ = pos; + let _ = rotation; + unimplemented!("Host::entity_teleport_to") + } + /// Turns the entity to `yaw` and `pitch` in degrees (`Entity.setYRot`, + /// `setXRot`), as `/rotate` does: the pitch is clamped to -90..90, and a + /// player's client is told. API @API:entity_moves@. + fn entity_set_rotation(&mut self, entity: EntityRef, yaw: f32, pitch: f32) -> bool { + let _ = entity; + let _ = yaw; + let _ = pitch; + unimplemented!("Host::entity_set_rotation") + } + /// The yaw of the entity's head (`Entity.getYHeadRot`): a mob's or a + /// player's own, the body's yaw for the kinds without a head. API + /// @API:entity_moves@. + fn entity_head_yaw(&self, entity: EntityRef) -> Option { + let _ = entity; + unimplemented!("Host::entity_head_yaw") + } + /// Turns a mob's head to `yaw` (`Entity.setYHeadRot`); its body follows + /// as its AI turns it. False for the other kinds. API @API:entity_moves@. + fn entity_set_head_yaw(&mut self, entity: EntityRef, yaw: f32) -> bool { + let _ = entity; + let _ = yaw; + unimplemented!("Host::entity_set_head_yaw") + } + /// The horizontal direction the entity faces (`Entity.getDirection`): + /// `north`, `south`, `east` or `west`. API @API:entity_moves@. + fn entity_direction(&self, entity: EntityRef) -> Option<&'static str> { + let _ = entity; + unimplemented!("Host::entity_direction") + } + /// The entity's width and height (`Entity.getBbWidth`, `getBbHeight`): + /// its box's, so a baby's or a scaled entity's own. API + /// @API:entity_moves@. + fn entity_dimensions(&self, entity: EntityRef) -> Option<[f64; 2]> { + let _ = entity; + unimplemented!("Host::entity_dimensions") + } + /// Adds `delta` to the entity's velocity (`Entity.push`, + /// `addDeltaMovement`). False for a player (its client moves it), an + /// entity Horizon doesn't simulate, or a value that isn't finite. API + /// @API:entity_moves@. + fn entity_push(&mut self, entity: EntityRef, delta: [f64; 3]) -> bool { + let _ = entity; + let _ = delta; + unimplemented!("Host::entity_push") + } } impl EntityRef { /// The entity's position (`Entity.position`): its feet, the middle of @@ -1395,4 +1455,51 @@ impl EntityRef { pub fn distance_to_sqr(self, host: &dyn Host, pos: [f64; 3]) -> Option { host.entity_distance_to_sqr(self, pos) } + /// Moves the entity to `pos` in its level (`Entity.teleportTo`), as + /// `/tp` does: a player is placed there and told, a rider gets off. With + /// `rotation` (yaw, pitch in degrees) it also turns. False for a position + /// outside the world's bounds. API @API:entity_moves@. + pub fn teleport_to( + self, + host: &mut dyn Host, + pos: [f64; 3], + rotation: Option<[f32; 2]>, + ) -> bool { + host.entity_teleport_to(self, pos, rotation) + } + /// Turns the entity to `yaw` and `pitch` in degrees (`Entity.setYRot`, + /// `setXRot`), as `/rotate` does: the pitch is clamped to -90..90, and a + /// player's client is told. API @API:entity_moves@. + pub fn set_rotation(self, host: &mut dyn Host, yaw: f32, pitch: f32) -> bool { + host.entity_set_rotation(self, yaw, pitch) + } + /// The yaw of the entity's head (`Entity.getYHeadRot`): a mob's or a + /// player's own, the body's yaw for the kinds without a head. API + /// @API:entity_moves@. + pub fn head_yaw(self, host: &dyn Host) -> Option { + host.entity_head_yaw(self) + } + /// Turns a mob's head to `yaw` (`Entity.setYHeadRot`); its body follows + /// as its AI turns it. False for the other kinds. API @API:entity_moves@. + pub fn set_head_yaw(self, host: &mut dyn Host, yaw: f32) -> bool { + host.entity_set_head_yaw(self, yaw) + } + /// The horizontal direction the entity faces (`Entity.getDirection`): + /// `north`, `south`, `east` or `west`. API @API:entity_moves@. + pub fn direction(self, host: &dyn Host) -> Option<&'static str> { + host.entity_direction(self) + } + /// The entity's width and height (`Entity.getBbWidth`, `getBbHeight`): + /// its box's, so a baby's or a scaled entity's own. API + /// @API:entity_moves@. + pub fn dimensions(self, host: &dyn Host) -> Option<[f64; 2]> { + host.entity_dimensions(self) + } + /// Adds `delta` to the entity's velocity (`Entity.push`, + /// `addDeltaMovement`). False for a player (its client moves it), an + /// entity Horizon doesn't simulate, or a value that isn't finite. API + /// @API:entity_moves@. + pub fn push(self, host: &mut dyn Host, delta: [f64; 3]) -> bool { + host.entity_push(self, delta) + } } diff --git a/tools/native-hook-fragments/entity_moves.json b/tools/native-hook-fragments/entity_moves.json new file mode 100644 index 0000000000..bd7549fe80 --- /dev/null +++ b/tools/native-hook-fragments/entity_moves.json @@ -0,0 +1,45 @@ +{ + "hooks": [], + "helpers": [ + { + "name": "entity_teleport_to", + "target": "horizon_client::game::native_entity_moves::Game<'_>::native_entity_teleport_to", + "receiver": "entity" + }, + { + "name": "entity_set_rotation", + "target": "horizon_client::game::native_entity_moves::Game<'_>::native_entity_set_rotation", + "receiver": "entity" + }, + { + "name": "entity_head_yaw", + "target": "horizon_client::game::native_entity_moves::Game<'_>::native_entity_head_yaw", + "receiver": "entity" + }, + { + "name": "entity_set_head_yaw", + "target": "horizon_client::game::native_entity_moves::Game<'_>::native_entity_set_head_yaw", + "receiver": "entity" + }, + { + "name": "entity_direction", + "target": "horizon_client::game::native_entity_moves::Game<'_>::native_entity_direction", + "receiver": "entity" + }, + { + "name": "entity_dimensions", + "target": "horizon_client::game::native_entity_moves::Game<'_>::native_entity_dimensions", + "receiver": "entity" + }, + { + "name": "entity_push", + "target": "horizon_client::game::native_entity_moves::Game<'_>::native_entity_push", + "receiver": "entity" + } + ], + "rust_summary": "moving and turning an entity through an `EntityRef`: teleport, rotation, head yaw, facing, size and pushes.", + "doc_summary": "`entity.teleport_to(host, pos, rotation)` and `set_rotation(host, yaw, pitch)` (as `/tp` and `/rotate` move and turn it), `head_yaw`, `set_head_yaw`, `direction` (`north`...), `dimensions` (width, height) and `push(host, delta)`. Bundles need \"hooks\": @API:entity_moves@.", + "comments": [ + "crates/horizon-client/src/game/native_entity_moves.rs" + ] +} From eba31f178cf33178033cc1214a8b03061fb6f8d9 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:23:11 +0500 Subject: [PATCH 2/4] Keep native rotation on the rotation-only command path Reuse the current forceSetRotation path so turning an entity preserves its mount, position and velocity. Extend the existing resource-backed rider regression. Co-Authored-By: Codex --- crates/horizon-client/src/game/commands.rs | 15 +++++++++++++++ .../src/game/native_entity_moves.rs | 7 ++----- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 602c570663..f204407d2b 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -4504,6 +4504,21 @@ mod teleport_rider_tests { rider2.yaw, 80.0, "a vehicle turned by `facing` leaves its rider" ); + // Native rotation preserves a rider's mount, position and motion. + game.native_lifecycle + .begin_open(horizon_client::mod_api::NativeAuthority::IntegratedServer); + let rider = game.mobs.iter_mut().find(|m| m.id == 51).unwrap(); + rider.velocity = Vec3::new(0.2, 0.3, 0.4); + let position = rider.position; + let velocity = rider.velocity; + let handle = game.native_entity(51).unwrap(); + assert!(game.native_entity_set_rotation(handle, -135.0, 120.0)); + let rider = game.mobs.iter().find(|m| m.id == 51).unwrap(); + assert_eq!(rider.extra.mount, Some(50)); + assert_eq!(rider.position, position); + assert_eq!(rider.velocity, velocity); + assert_eq!((rider.yaw, rider.head_pitch), (-135.0, 90.0)); + assert!(!game.native_entity_set_rotation(handle, f32::NAN, 0.0)); } } diff --git a/crates/horizon-client/src/game/native_entity_moves.rs b/crates/horizon-client/src/game/native_entity_moves.rs index 831f91f204..a32438005f 100644 --- a/crates/horizon-client/src/game/native_entity_moves.rs +++ b/crates/horizon-client/src/game/native_entity_moves.rs @@ -55,13 +55,10 @@ impl Game<'_> { let Some(t) = self.native_entity_target(entity) else { return false; }; - let Some(pos) = self.target_position(t) else { - return false; - }; - if !yaw.is_finite() || !pitch.is_finite() { + if self.target_position(t).is_none() || !yaw.is_finite() || !pitch.is_finite() { return false; } - self.teleport_to(t, pos, Some([yaw, pitch]), true); + self.set_rotation(t, [yaw, pitch]); true } /// The yaw of the entity's head (`Entity.getYHeadRot`): a mob's or a From 3cb56882d9c5234fd02b0550740bf0fd5f4f2315 Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Sat, 7 Nov 2026 08:44:37 +0500 Subject: [PATCH 3/4] Match vanilla movement bounds and facing at numeric limits Reuse the native summon spawn-bounds predicate so negative endpoints stay valid, and use Java's integer width for the yaw direction lookup. Record 26 independent outcomes from both pinned vanilla jars and add a differential harness for the actual native calculations. Co-Authored-By: Codex --- .../src/game/native_entity_moves.rs | 62 ++++++++++---- crates/horizon-client/src/game/native_mobs.rs | 2 +- .../VanillaNativeEntityMovesOracle.java | 29 +++++++ tests/fixtures/native_entity_moves.jsonl | 26 ++++++ .../native_entity_moves_expected.jsonl | 26 ++++++ tools/test_native_entity_moves_parity.py | 85 +++++++++++++++++++ 6 files changed, 213 insertions(+), 17 deletions(-) create mode 100644 mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNativeEntityMovesOracle.java create mode 100644 tests/fixtures/native_entity_moves.jsonl create mode 100644 tests/fixtures/native_entity_moves_expected.jsonl create mode 100644 tools/test_native_entity_moves_parity.py diff --git a/crates/horizon-client/src/game/native_entity_moves.rs b/crates/horizon-client/src/game/native_entity_moves.rs index a32438005f..f00d15f6a4 100644 --- a/crates/horizon-client/src/game/native_entity_moves.rs +++ b/crates/horizon-client/src/game/native_entity_moves.rs @@ -4,23 +4,14 @@ //! entity as `/tp` and `/rotate` do, so clients see it as they see those. //! Reads return `None`, writes false, for a stale handle or on a client. use super::entity::{Entity as _, EntityHandle, with_entity_mut}; -use super::{Game, commands::Target}; +use super::{Game, commands::Target, native_mobs::spawnable}; use horizon_client::mod_api::EntityRef; use horizon_core::geometry::Vec3; -/// `Level.isInSpawnableBounds` and `/tp`'s limits: 30,000,000 blocks out, -/// 20,000,000 up or down. -fn in_bounds([x, y, z]: [f64; 3]) -> bool { - [x, y, z].iter().all(|v| v.is_finite()) - && x.abs() < 30_000_000.0 - && z.abs() < 30_000_000.0 - && y.abs() < 20_000_000.0 -} - /// `Direction.fromYRot`: the horizontal direction a yaw faces. fn facing(yaw: f32) -> &'static str { const BY_2D: [&str; 4] = ["south", "west", "north", "east"]; - BY_2D[((f64::from(yaw) / 90.0 + 0.5).floor() as i64 & 3) as usize] + BY_2D[((f64::from(yaw) / 90.0 + 0.5).floor() as i32 & 3) as usize] } impl Game<'_> { @@ -37,7 +28,7 @@ impl Game<'_> { let Some(t) = self.native_entity_target(entity) else { return false; }; - if !in_bounds(pos) || rotation.is_some_and(|r| !r.iter().all(|v| v.is_finite())) { + if !spawnable(pos) || rotation.is_some_and(|r| !r.iter().all(|v| v.is_finite())) { return false; } self.teleport_to(t, pos, rotation, true); @@ -140,9 +131,48 @@ mod tests { #[test] fn positions_stay_inside_the_worlds_bounds() { - assert!(super::in_bounds([29_999_999.0, -64.0, 0.0])); - assert!(!super::in_bounds([30_000_000.0, 64.0, 0.0])); - assert!(!super::in_bounds([0.0, 20_000_001.0, 0.0])); - assert!(!super::in_bounds([f64::NAN, 64.0, 0.0])); + assert!(super::spawnable([29_999_999.0, -64.0, 0.0])); + assert!(!super::spawnable([30_000_000.0, 64.0, 0.0])); + assert!(!super::spawnable([0.0, 20_000_001.0, 0.0])); + assert!(!super::spawnable([f64::NAN, 64.0, 0.0])); + } + /// Independent vanilla outcomes for spawn bounds and the yaw-to-direction conversion. + #[test] + fn move_boundaries_match_vanilla() { + use serde_json::Value; + let cases = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/native_entity_moves.jsonl" + )); + let expected = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/native_entity_moves_expected.jsonl" + )); + let rows = |text: &str| { + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).unwrap()) + .collect::>() + }; + let (cases, expected) = (rows(cases), rows(expected)); + assert_eq!(cases.len(), expected.len()); + assert!(cases.len() >= 20); + for (input, expected) in cases.iter().zip(&expected) { + assert_eq!(input["name"], expected["name"]); + let pos = std::array::from_fn(|i| input["pos"][i].as_f64().unwrap()); + let yaw = input["yaw"].as_f64().unwrap() as f32; + assert_eq!( + super::spawnable(pos), + expected["spawnable"].as_bool().unwrap(), + "{}", + input["name"] + ); + assert_eq!( + super::facing(yaw), + expected["direction"].as_str().unwrap(), + "{}", + input["name"] + ); + } } } diff --git a/crates/horizon-client/src/game/native_mobs.rs b/crates/horizon-client/src/game/native_mobs.rs index c46ab2400b..9d627e4eb0 100644 --- a/crates/horizon-client/src/game/native_mobs.rs +++ b/crates/horizon-client/src/game/native_mobs.rs @@ -142,7 +142,7 @@ fn finite(v: [f64; 3]) -> bool { } /// `/summon`'s world bounds (`Level.isInSpawnableBounds`). -fn spawnable(pos: [f64; 3]) -> bool { +pub(super) fn spawnable(pos: [f64; 3]) -> bool { finite(pos) && (-30_000_000.0..30_000_000.0).contains(&pos[0]) && (-30_000_000.0..30_000_000.0).contains(&pos[2]) diff --git a/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNativeEntityMovesOracle.java b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNativeEntityMovesOracle.java new file mode 100644 index 0000000000..9b72ab936e --- /dev/null +++ b/mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNativeEntityMovesOracle.java @@ -0,0 +1,29 @@ +package dev.horizonlab.parity; + +import com.google.gson.*; +import java.nio.file.*; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.world.level.Level; + +/** Vanilla bounds and facing used by the native EntityRef movement methods. */ +public final class VanillaNativeEntityMovesOracle { + public static void main(String[] args) throws Exception { + ParityData.init(); + try (var output = Files.newBufferedWriter(Path.of(args[1]))) { + for (String line : Files.readAllLines(Path.of(args[0]))) { + if (line.isBlank()) continue; + var input = JsonParser.parseString(line).getAsJsonObject(); + var pos = input.getAsJsonArray("pos"); + var point = BlockPos.containing(pos.get(0).getAsDouble(), pos.get(1).getAsDouble(), pos.get(2).getAsDouble()); + var result = new JsonObject(); + result.add("name", input.get("name")); + result.addProperty("spawnable", Level.isInSpawnableBounds(point)); + // Entity.getDirection widens the entity's float yaw to Direction's double input. + result.addProperty("direction", Direction.fromYRot((double) input.get("yaw").getAsFloat()).getSerializedName()); + output.write(result.toString()); + output.newLine(); + } + } + } +} diff --git a/tests/fixtures/native_entity_moves.jsonl b/tests/fixtures/native_entity_moves.jsonl new file mode 100644 index 0000000000..945ade44e8 --- /dev/null +++ b/tests/fixtures/native_entity_moves.jsonl @@ -0,0 +1,26 @@ +{"name":"origin","pos":[0,64,0],"yaw":0} +{"name":"minimum-x","pos":[-30000000,64,0],"yaw":0} +{"name":"minimum-z","pos":[0,64,-30000000],"yaw":0} +{"name":"minimum-y","pos":[0,-20000000,0],"yaw":0} +{"name":"below-x","pos":[-30000000.01,64,0],"yaw":0} +{"name":"below-z","pos":[0,64,-30000000.01],"yaw":0} +{"name":"below-y","pos":[0,-20000000.01,0],"yaw":0} +{"name":"maximum-x","pos":[30000000,64,0],"yaw":0} +{"name":"maximum-z","pos":[0,64,30000000],"yaw":0} +{"name":"maximum-y","pos":[0,20000000,0],"yaw":0} +{"name":"inside-positive","pos":[29999999.99,19999999.99,29999999.99],"yaw":0} +{"name":"yaw-0","pos":[0,64,0],"yaw":0} +{"name":"yaw-45","pos":[0,64,0],"yaw":45} +{"name":"yaw-90","pos":[0,64,0],"yaw":90} +{"name":"yaw-135","pos":[0,64,0],"yaw":135} +{"name":"yaw-180","pos":[0,64,0],"yaw":180} +{"name":"yaw-225","pos":[0,64,0],"yaw":225} +{"name":"yaw-270","pos":[0,64,0],"yaw":270} +{"name":"yaw--45","pos":[0,64,0],"yaw":-45} +{"name":"yaw--46","pos":[0,64,0],"yaw":-46} +{"name":"yaw--90","pos":[0,64,0],"yaw":-90} +{"name":"yaw-890","pos":[0,64,0],"yaw":890} +{"name":"yaw-1000000000000.0","pos":[0,64,0],"yaw":1000000000000.0} +{"name":"yaw--1000000000000.0","pos":[0,64,0],"yaw":-1000000000000.0} +{"name":"yaw-3.4028234663852886e+38","pos":[0,64,0],"yaw":3.4028234663852886e+38} +{"name":"yaw--3.4028234663852886e+38","pos":[0,64,0],"yaw":-3.4028234663852886e+38} diff --git a/tests/fixtures/native_entity_moves_expected.jsonl b/tests/fixtures/native_entity_moves_expected.jsonl new file mode 100644 index 0000000000..adad122473 --- /dev/null +++ b/tests/fixtures/native_entity_moves_expected.jsonl @@ -0,0 +1,26 @@ +{"name":"origin","spawnable":true,"direction":"south"} +{"name":"minimum-x","spawnable":true,"direction":"south"} +{"name":"minimum-z","spawnable":true,"direction":"south"} +{"name":"minimum-y","spawnable":true,"direction":"south"} +{"name":"below-x","spawnable":false,"direction":"south"} +{"name":"below-z","spawnable":false,"direction":"south"} +{"name":"below-y","spawnable":false,"direction":"south"} +{"name":"maximum-x","spawnable":false,"direction":"south"} +{"name":"maximum-z","spawnable":false,"direction":"south"} +{"name":"maximum-y","spawnable":false,"direction":"south"} +{"name":"inside-positive","spawnable":true,"direction":"south"} +{"name":"yaw-0","spawnable":true,"direction":"south"} +{"name":"yaw-45","spawnable":true,"direction":"west"} +{"name":"yaw-90","spawnable":true,"direction":"west"} +{"name":"yaw-135","spawnable":true,"direction":"north"} +{"name":"yaw-180","spawnable":true,"direction":"north"} +{"name":"yaw-225","spawnable":true,"direction":"east"} +{"name":"yaw-270","spawnable":true,"direction":"east"} +{"name":"yaw--45","spawnable":true,"direction":"south"} +{"name":"yaw--46","spawnable":true,"direction":"east"} +{"name":"yaw--90","spawnable":true,"direction":"east"} +{"name":"yaw-890","spawnable":true,"direction":"north"} +{"name":"yaw-1000000000000.0","spawnable":true,"direction":"east"} +{"name":"yaw--1000000000000.0","spawnable":true,"direction":"south"} +{"name":"yaw-3.4028234663852886e+38","spawnable":true,"direction":"east"} +{"name":"yaw--3.4028234663852886e+38","spawnable":true,"direction":"south"} diff --git a/tools/test_native_entity_moves_parity.py b/tools/test_native_entity_moves_parity.py new file mode 100644 index 0000000000..26fc84e142 --- /dev/null +++ b/tools/test_native_entity_moves_parity.py @@ -0,0 +1,85 @@ +#!/usr/bin/env python3 +"""Check native entity movement bounds/facing against both pinned vanilla jars.""" +import json +import os +from pathlib import Path +import re +import subprocess +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 rows(path): + return [json.loads(line) for line in path.read_text().splitlines() if line.strip()] + + +def main(): + lock, jars, libraries = base.verify_artifacts() + build = lab.LOCAL / 'native-entity-moves' + build.mkdir(parents=True, exist_ok=True) + report = build / 'report.json' + report.unlink(missing_ok=True) + fixture = lab.ROOT / 'tests/fixtures/native_entity_moves.jsonl' + golden = fixture.with_name('native_entity_moves_expected.jsonl') + sources = [fixture, golden, Path(__file__).resolve(), lab.ROOT / 'Cargo.toml', + lab.ROOT / 'Cargo.lock', lab.ROOT / 'tools/oracle_harness.py', + lab.ROOT / 'tools/test_rust_parity.py', lab.ROOT / 'tools/lab.py', + lab.ROOT / 'tools/integration_env.py', + lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/VanillaNativeEntityMovesOracle.java', + lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity/ParityData.java'] + sources += sorted((lab.ROOT / 'crates').rglob('*.rs')) + hashes = base.source_hashes(sources) + expected = rows(golden) + assert len(expected) == len(rows(fixture)) + classes, _, _ = harness.compile_oracle(build, 'VanillaNativeEntityMovesOracle', + jars, libraries, lock['minecraft']['java_major']) + for side in ('server', 'client'): + output = build / (side + '-expected.jsonl') + dependencies = os.pathsep.join(map(str, [jars[side], *libraries])) + harness.java_run(build, classes, dependencies, 'VanillaNativeEntityMovesOracle', + [fixture, output], side) + assert rows(output) == expected, f'{side}: committed outcomes differ from vanilla' + harness.run_logged(['cargo', 'test', '--locked', '-p', 'horizon-client', '--bin', + 'horizon-client', '--no-run', '--message-format=json'], + build, 'compile-rust', timeout=3600) + artifacts = [json.loads(line) for line in (build / 'compile-rust.log').read_text().splitlines() + if line.startswith('{')] + executables = {record['executable'] for record in artifacts + if record.get('reason') == 'compiler-artifact' and record.get('executable') + and record.get('profile', {}).get('test') + and record['target']['name'] == 'horizon-client' + and 'bin' in record['target']['kind']} + assert len(executables) == 1, executables + executable = executables.pop() + with tempfile.TemporaryDirectory(prefix='horizon-move-properties-') as scratch: + env = integration_env.game_environment(os.environ, scratch) + for key in ('XDG_CACHE_HOME', 'TMPDIR', 'HORIZON_LAUNCHER_TRASH', + 'HORIZON_LAUNCHER_CLIPBOARD', 'HORIZON_LAUNCHER_DESKTOP'): + directory = Path(scratch) / key + directory.mkdir() + env[key] = str(directory) + with (build / 'rust.log').open('w') as output: + subprocess.run([executable, + 'game::native_entity_moves::tests::move_boundaries_match_vanilla', + '--exact', '--test-threads=1', '--nocapture'], cwd=scratch, env=env, + stdout=output, stderr=subprocess.STDOUT, check=True, timeout=900) + text = (build / 'rust.log').read_text() + assert re.search(r'^test result: ok\. 1 passed;', text, re.MULTILINE), text + base.ensure_sources_unchanged(sources, hashes) + lab.write_json(report, dict(schema=1, minecraft=lock['minecraft']['version'], + sources_sha256=hashes, cases=len(expected), + input_sha256=lab.digest(fixture, 'sha256'), + expected_sha256=lab.digest(golden, 'sha256'), + jars_sha256={side: lab.digest(jars[side], 'sha256') for side in ('client', 'server')}, + rust_test_executable_sha256=lab.digest(Path(executable), 'sha256'), + scope='Native entity spawn-bound and facing calculations, compared with Level and Direction. ' + 'Does not exercise teleport networking, rider state or a game tick loop.')) + print(f'{len(expected)} native entity movement cases match both vanilla jars: {report}', flush=True) + + +if __name__ == '__main__': + main() From 1301820f0de43ebd2945c2d1522518883d16390a Mon Sep 17 00:00:00 2001 From: levzzz <86072227+levzzz5154@users.noreply.github.com> Date: Sat, 7 Nov 2026 09:29:40 +0500 Subject: [PATCH 4/4] Run movement parity through the gameplay library tests Select the library artifact that owns Game, retaining the exact one-test assertion so a thin executable with no tests cannot count as a pass. Co-Authored-By: Codex --- tools/test_native_entity_moves_parity.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tools/test_native_entity_moves_parity.py b/tools/test_native_entity_moves_parity.py index 26fc84e142..5231080f1b 100644 --- a/tools/test_native_entity_moves_parity.py +++ b/tools/test_native_entity_moves_parity.py @@ -43,16 +43,15 @@ def main(): harness.java_run(build, classes, dependencies, 'VanillaNativeEntityMovesOracle', [fixture, output], side) assert rows(output) == expected, f'{side}: committed outcomes differ from vanilla' - harness.run_logged(['cargo', 'test', '--locked', '-p', 'horizon-client', '--bin', - 'horizon-client', '--no-run', '--message-format=json'], + harness.run_logged(['cargo', 'test', '--locked', '-p', 'horizon-client', '--lib', '--no-run', '--message-format=json'], build, 'compile-rust', timeout=3600) artifacts = [json.loads(line) for line in (build / 'compile-rust.log').read_text().splitlines() if line.startswith('{')] executables = {record['executable'] for record in artifacts if record.get('reason') == 'compiler-artifact' and record.get('executable') and record.get('profile', {}).get('test') - and record['target']['name'] == 'horizon-client' - and 'bin' in record['target']['kind']} + and record['target']['name'] == 'horizon_client' + and 'lib' in record['target']['kind']} assert len(executables) == 1, executables executable = executables.pop() with tempfile.TemporaryDirectory(prefix='horizon-move-properties-') as scratch: