diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index fbb26468f8..656344ed55 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -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; diff --git a/crates/horizon-client/src/game/commands.rs b/crates/horizon-client/src/game/commands.rs index 7d7427d857..5c73fbbfa9 100644 --- a/crates/horizon-client/src/game/commands.rs +++ b/crates/horizon-client/src/game/commands.rs @@ -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)); } } diff --git a/crates/horizon-client/src/game/native_entity_motion.rs b/crates/horizon-client/src/game/native_entity_motion.rs index 6b953353f4..6dc713a9b1 100644 --- a/crates/horizon-client/src/game/native_entity_motion.rs +++ b/crates/horizon-client/src/game/native_entity_motion.rs @@ -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 { + pub(super) fn native_walk_entity(&self, id: u32) -> Option { 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), 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..f00d15f6a4 --- /dev/null +++ b/crates/horizon-client/src/game/native_entity_moves.rs @@ -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 { + 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::(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 4e5cd6e65d..91f2c75c42 100644 --- a/crates/horizon-client/src/game/native_mobs.rs +++ b/crates/horizon-client/src/game/native_mobs.rs @@ -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]) 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/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" + ] +} diff --git a/tools/test_native_entity_moves_parity.py b/tools/test_native_entity_moves_parity.py new file mode 100644 index 0000000000..5231080f1b --- /dev/null +++ b/tools/test_native_entity_moves_parity.py @@ -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()