Merge pull request #7690 from levzzz5154/modapi/entity-moves

Native mods: teleport, rotation, head yaw, facing, size and pushes through EntityRef (#7387)
This commit is contained in:
rdbt authored and GitHub committed 2026-11-07 12:35:28 +00:00
commit 30ee97bbc7
10 files changed
+411 -4

No files matched your search

+1
View File
@@ -241,6 +241,7 @@ mod native_diagnostics;
mod native_entities;
mod native_entity_data;
mod native_entity_motion;
mod native_entity_moves;
mod native_entity_refs;
mod native_entity_state;
mod native_explosions;
+16 -1
View File
@@ -1518,7 +1518,7 @@ impl Game<'_> {
self.feedback(message);
Ok(targets.len() as i32)
}
fn teleport_to(
pub(in crate::game) fn teleport_to(
&mut self,
t: Target,
pos: [f64; 3],
@@ -4586,6 +4586,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));
}
}
@@ -16,7 +16,7 @@ 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<EntityHandle> {
pub(super) fn native_walk_entity(&self, id: u32) -> Option<EntityHandle> {
self.entity_handles()
.find(|h| with_entity!(self, *h, e => e.id() == id))
}
@@ -27,7 +27,10 @@ impl Game<'_> {
self.player.as_ref()
}
/// 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),
@@ -0,0 +1,178 @@
//! 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, native_mobs::spawnable};
use horizon_client::mod_api::EntityRef;
use horizon_core::geometry::Vec3;
/// `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 i32 & 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 !spawnable(pos) || rotation.is_some_and(|r| !r.iter().all(|v| v.is_finite())) {
return false;
}
self.teleport_to(t, pos, rotation, true);
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;
};
if self.target_position(t).is_none() || !yaw.is_finite() || !pitch.is_finite() {
return false;
}
self.set_rotation(t, [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<f32> {
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::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::<Value>(line).unwrap())
.collect::<Vec<_>>()
};
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"]
);
}
}
}
@@ -140,7 +140,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])
@@ -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();
}
}
}
}
+26
View File
@@ -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}
+26
View File
@@ -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"}
@@ -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"
]
}
+84
View File
@@ -0,0 +1,84 @@
#!/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', '--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 'lib' 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()