From 1686cd755a187b9431d1c8759a038f402f434e48 Mon Sep 17 00:00:00 2001 From: Florian ERNST <30944236+TheMrZZ@users.noreply.github.com> Date: Wed, 4 Nov 2026 22:47:11 +0100 Subject: [PATCH] Dispatch eligible mob leashes through quad physics Use the live holder eligibility and preserve the float lookup, yaw and angular-friction boundaries needed by quad attachments. Keep single-spring arithmetic unchanged. Co-Authored-By: Codex --- crates/horizon-client/src/mobs/leash.rs | 100 ++++++++++++++++++++++-- tools/mob_quad_leash_corpus.py | 76 ++++++++++++++++++ tools/port_map/game-006.tsv | 2 +- tools/test_command_exec_parity.py | 11 ++- tools/test_mob_quad_leash_parity.py | 29 +++++++ 5 files changed, 211 insertions(+), 7 deletions(-) create mode 100644 tools/mob_quad_leash_corpus.py create mode 100644 tools/test_mob_quad_leash_parity.py diff --git a/crates/horizon-client/src/mobs/leash.rs b/crates/horizon-client/src/mobs/leash.rs index d2581331d4..9e1ae335be 100644 --- a/crates/horizon-client/src/mobs/leash.rs +++ b/crates/horizon-client/src/mobs/leash.rs @@ -201,6 +201,16 @@ pub fn elastic_pull( } else { (&OWN, &THEIRS) }; + // Vec3.yRot takes a float angle and widens Mth's float lookup results. + // Bound the repair to quad attachment physics; retain the single spring. + let rotate = |v: [f64; 3], angle: f64| { + if quad { + let v = Vec3::new(v[0], v[1], v[2]).rotate_y(angle as f32); + [v.x, v.y, v.z] + } else { + rot_y(v, angle) + } + }; let (w, h) = size; let own_yaw = f64::from(yaw.to_radians()); let holder_yaw = f64::from(holder.yaw.to_radians()); @@ -208,13 +218,13 @@ pub fn elastic_pull( let mut torque = 0.0; let mut any = false; for (mine, other) in own.iter().zip(theirs) { - let lever = rot_y([mine[0] * w, mine[1] * h, mine[2] * w], -own_yaw); + let lever = rotate([mine[0] * w, mine[1] * h, mine[2] * w], -own_yaw); let object = [ position.x + lever[0], position.y + lever[1], position.z + lever[2], ]; - let arm = rot_y( + let arm = rotate( [ other[0] * holder.width, other[1] * holder.height, @@ -375,13 +385,20 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { return; } let stretched = distance > ELASTIC - state.width - f64::from(m.width()); + // Leashable.checkElasticInteractions: both ends must support the shared + // quad attachment points. The live holder was resolved and checked above; + // these physics points are independent of the rendered rope offsets. + let quad = supports_quad_leash(m.species()) + && matches!(holder, Holder::Mob(id) if w.seen(id).is_some_and(|s| + matches!(s.species, Species::Ghast | Species::HappyGhast))); let pulled = stretched - && spring( + && elastic_pull( m.position, m.yaw, (f64::from(m.width()), f64::from(m.height())), &state, m.velocity, + quad, ) .is_some_and(|(force, torque)| { m.extra.leash_spin += 10.0 * torque; @@ -402,7 +419,12 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { if !pulled { close_range(m, w, &state); } - m.yaw -= m.extra.leash_spin as f32; + // tickLeash widens the float yaw before subtracting double momentum. + if quad { + m.yaw = (f64::from(m.yaw) - m.extra.leash_spin) as f32; + } else { + m.yaw -= m.extra.leash_spin as f32; + } let friction = if m.on_ground { 0.6 * 0.91 } else if m.is_in_liquid() { @@ -410,7 +432,20 @@ pub fn tick(m: &mut Mob, w: &MobWorld, events: &mut Vec) { } else { 0.91 }; - m.extra.leash_spin *= friction; + if quad { + // angularFriction returns a float, including the ground product. + let friction = if m.on_ground { + let [x, y, z] = m.on_pos(0.500_001); + w.facts.get(w.world.block(x, y, z)).friction * 0.91_f32 + } else if m.is_in_liquid() { + 0.8_f32 + } else { + 0.91_f32 + }; + m.extra.leash_spin *= f64::from(friction); + } else { + m.extra.leash_spin *= friction; + } } /// The lead comes off (`Mob.onLeashRemoved`): no more home. pub fn remove(m: &mut Mob) -> Option { @@ -619,6 +654,61 @@ pub fn quad_holder_offsets(m: &Mob) -> Option<[[f64; 3]; 4]> { mod tests { use super::*; + #[test] + fn mob_tick_selects_quad_only_when_both_live_ends_support_it() { + assert!(crate::mobs::world::with_test_world(&[], |world, types| { + for (kind, holder_kind, quad) in [ + ("horse", "happy_ghast", true), + ("horse", "ghast", true), + ("horse", "cow", false), + ("llama", "happy_ghast", false), + ] { + let new = |id, kind: &str, position, yaw| { + Mob::new( + id, + std::sync::Arc::new(types.types[&format!("minecraft:{kind}")].clone()), + position, + yaw, + ) + }; + let mut mob = new(1, kind, Vec3::new(-7.5, 100.0, -4.5), 90.0); + let holder = new(2, holder_kind, Vec3::new(0.5, 104.0, 0.5), 30.0); + let others = [holder.seen(None)]; + let mut w = world.clone(); + w.others = &others; + mob.extra.leash = Some(Holder::Mob(holder.id)); + let state = holder_state(&w, Holder::Mob(holder.id)).unwrap(); + let (force, torque) = elastic_pull( + mob.position, + mob.yaw, + (f64::from(mob.width()), f64::from(mob.height())), + &state, + Vec3::ZERO, + quad, + ) + .unwrap(); + let mut events = Vec::new(); + tick(&mut mob, &w, &mut events); + assert_eq!( + mob.velocity, + Vec3::new(force[0] * 0.8, force[1] * 0.2, force[2] * 0.8), + "{kind} held by {holder_kind}" + ); + let yaw = if quad { + (90.0 - 10.0 * torque) as f32 + } else { + 90.0 - (10.0 * torque) as f32 + }; + assert_eq!(mob.yaw, yaw); + assert!( + !events + .iter() + .any(|e| matches!(e, MobEvent::LeashBreak { .. })) + ); + } + })); + } + #[test] fn a_players_leash_does_not_pull_toward_a_different_player() { crate::mobs::world::with_test_world(&[], |world, types| { diff --git a/tools/mob_quad_leash_corpus.py b/tools/mob_quad_leash_corpus.py new file mode 100644 index 0000000000..d8dc9f0b48 --- /dev/null +++ b/tools/mob_quad_leash_corpus.py @@ -0,0 +1,76 @@ +"""Guarded live mob leash dispatch; all numeric feedback compares strictly.""" +try: + from .command_exec_corpus import cmd, setup +except ImportError: + from command_exec_corpus import cmd, setup + + +def mob_quad_leash(raw=False): + out = setup('/tick freeze', '/gamerule minecraft:spawn_mobs false', + '/gamerule minecraft:advance_time false', '/gamemode spectator', + '/tp @s 0.5 115 0.5 0 0') + out += [cmd('/forceload add -32 -32 31 31', expect_result=16), {'op': 'settle'}] + for x in [-32, -16, 0, 16]: + for z in [-32, -16, 0, 16]: + out.append(cmd(f'/execute if loaded {x} 100 {z}', expect_result=1)) + out.append(cmd('/fill -32 99 -32 31 99 31 ice', expect_result=4096)) + # Each valid slab is below32768, unlike the historical53248-block clear. + for lo, hi, count in [(100, 106, 28672), (107, 112, 24576)]: + out += [cmd(f'/fill -32 {lo} -32 31 {hi} 31 stone', expect_result=count), + cmd(f'/fill -32 {lo} -32 31 {hi} 31 air', expect_result=count)] + out += setup('/scoreboard objectives add leash_audit dummy', + '/data modify storage h302:observer uuid set from entity @s UUID') + out.append(cmd('/data get entity @s UUID', expect_result=4)) + for scene, holder_type in enumerate(['happy_ghast', 'cow'], 1): + holder_health = 20 if holder_type == 'happy_ghast' else 10 + holder_pos = '0.5 104 0.5' if scene == 1 else '-5.5 100 -4.5' + holder_nbt_pos = '[0.5d,104d,0.5d]' if scene == 1 else '[-5.5d,100d,-4.5d]' + holder = f'00000001-0000-0002-0000-00030000000{scene}' + horse = f'0000012e-0000-0000-0000-00000000000{scene}' + out += setup('/kill @e[type=!minecraft:player]') + out += [cmd(f'/summon minecraft:{holder_type} {holder_pos} ' + f'{{UUID:[I;1,2,3,{scene}],Tags:["h302_holder"],NoAI:1b,NoGravity:1b,' + f'Health:{holder_health}f,Rotation:[30f,0f],PersistenceRequired:1b}}', expect_result=1), + cmd('/summon minecraft:horse -7.5 100 -4.5 ' + f'{{UUID:[I;302,0,0,{scene}],Tags:["h302_horse"],NoAI:1b,Health:53f,attributes:[{{id:"minecraft:max_health",base:53d}}],' + f'Rotation:[90f,0f],PersistenceRequired:1b,leash:{{UUID:[I;1,2,3,{scene}]}}}}', expect_result=1)] + def guards(): + return [cmd('/data get entity @s playerGameType', expect_result=3), + cmd('/execute if entity @s[type=player,nbt={playerGameType:3,Health:20f,Pos:[0.5d,115d,0.5d]}]', expect_result=1), + cmd(f'/data get entity {holder} UUID', expect_result=4), + cmd(f'/execute as {holder} if entity @s[type=minecraft:{holder_type},tag=h302_holder,nbt={{Health:{holder_health}f,NoAI:1b,NoGravity:1b,Pos:{holder_nbt_pos},Rotation:[30f,0f]}}]', expect_result=1), + cmd(f'/data get entity {horse} UUID', expect_result=4), + cmd(f'/execute as {horse} if entity @s[type=minecraft:horse,tag=h302_horse,nbt={{Health:53f,NoAI:1b,Pos:[-7.5d,100d,-4.5d]}}]', expect_result=1), + cmd('/execute if block -8 99 -5 minecraft:ice', expect_result=1), + cmd('/execute if block 0 104 0 minecraft:air', expect_result=1)] + out += guards() + out += [cmd(f'/data get entity {horse} leash.UUID', expect_result=4), + cmd(f'/execute as {horse} on leasher run data get entity @s UUID')] + out += setup('/execute store result score start leash_audit run time query gametime') + for at, n in [(10, 10), (20, 10), (40, 20)]: + out += [cmd(f'/tick step {n}', expect_result=1), {'op': 'tick', 'n': n}] + out += setup('/execute store result score now leash_audit run time query gametime', + '/scoreboard players operation now leash_audit -= start leash_audit') + out += [cmd('/scoreboard players get now leash_audit', expect_result=at)] + guards() + out += [cmd(f'/execute as {horse} on leasher if entity @s[nbt={{UUID:[I;1,2,3,{scene}]}}]', expect_result=1), + cmd(f'/data get entity {horse} leash.UUID', expect_result=4)] + # Exact three-decimal command contract, not a comparison tolerance. + # Each successful numeric read has a separately observed success callback. + for field in ['Rotation[0]', 'Motion[0]', 'Motion[1]', 'Motion[2]']: + out += setup('/scoreboard players set value leash_audit -99', + '/scoreboard players set success leash_audit 17', + f'/execute store success score success leash_audit store result score value leash_audit run data get entity {horse} {field} 1000') + out.append(cmd('/scoreboard players get success leash_audit', expect_result=1)) + op = cmd('/scoreboard players get value leash_audit') + op['label'] = f'h302 {holder_type}/{at}/{field} x1000' + out.append(op) + out.append(cmd(f'/data get entity {horse} Pos')) + if raw: + for field in ['Rotation[0]', 'Motion']: + op = cmd(f'/data get entity {horse} {field}') + op['label'] = f'h302 raw/{holder_type}/{at}/{field}' + out.append(op) + out += setup('/execute store success score changed leash_audit run data modify storage h302:observer uuid set from entity @s UUID') + out += [cmd('/scoreboard players get changed leash_audit', expect_result=0), + cmd('/data get entity @s UUID', expect_result=4)] + return out diff --git a/tools/port_map/game-006.tsv b/tools/port_map/game-006.tsv index d4dd2bbc5c..9ffd3d2413 100644 --- a/tools/port_map/game-006.tsv +++ b/tools/port_map/game-006.tsv @@ -11,7 +11,7 @@ Leashable.setDelayedLeashHolderId inline crates/horizon-client/src/game/remote.r Leashable.readLeashData port crates/horizon-client/src/mobs/leash.rs::pending_from_nbt holder UUID or fence knot block; kept pending until the holder exists Leashable.restoreLeashFromSave port crates/horizon-client/src/game/leads.rs::Game::tick_leads holder found by UUID or knot block; a lead whose holder never shows up drops as an item after 100 ticks Leashable.angularFriction inline crates/horizon-client/src/mobs/leash.rs::tick gap: minor: ground friction is a fixed 0.6 times 0.91, not the friction of the block under the mob (ice, slime) -Leashable.checkElasticInteractions port crates/horizon-client/src/mobs/leash.rs::tick gap: minor: one attachment pair only; no quad leash for a horse, llama or sniffer led by a happy ghast +Leashable.checkElasticInteractions port crates/horizon-client/src/mobs/leash.rs::tick live mob dispatch selects shared quad physics only when both holder and leashee support it; llama remains single Leashable.getHolderMovement port crates/horizon-client/src/mobs/leash.rs::holder_state gap: minor: the holder's velocity is always used; a NoAI mob holder should count as standing still Leashable.computeElasticInteraction port crates/horizon-client/src/mobs/leash.rs::spring gap: minor: single attachment pair (0, 0.5, 0.5 of the mob and 0, 0.5, 0 of the holder); the four-point version is missing Leashable.createQuadLeashOffsets port crates/horizon-client/src/mobs/leash.rs::Holder::create_quad_leash_offsets four attachment points around an entity, front-left first, clockwise from above diff --git a/tools/test_command_exec_parity.py b/tools/test_command_exec_parity.py index a96080f08a..ed80056e95 100644 --- a/tools/test_command_exec_parity.py +++ b/tools/test_command_exec_parity.py @@ -83,8 +83,11 @@ def main(): parser.add_argument('--binary', type=Path, help='use an already built Horizon game instead of building it') parser.add_argument('--slot-codec-diagnostic', action='store_true', help='retain the separate invalid inline slot-range codec diagnostic') + parser.add_argument('--work-dir', type=Path, help='isolated scratch output directory') + parser.add_argument('--mob-quad-fixture', action='store_true', help='guarded live mob quad-leash scene') + parser.add_argument('--mob-quad-raw-diagnostic', action='store_true', help='retain full-precision leash diagnostic') args = parser.parse_args() - build = lab.LOCAL / 'command-exec-parity' + build = args.work_dir.resolve() if args.work_dir else lab.LOCAL / 'command-exec-parity' build.mkdir(parents=True, exist_ok=True) report_path = build / 'report.json' report_path.unlink(missing_ok=True) @@ -115,6 +118,12 @@ def main(): if args.sulfur_power_load_diagnostic: groups = [('setup', corpus_module.group_setup()), ('sulfur powered-load diagnostic', corpus_module.sulfur_cube_fuse_ticks(True))] + if args.mob_quad_fixture: + try: + from .mob_quad_leash_corpus import mob_quad_leash + except ImportError: + from mob_quad_leash_corpus import mob_quad_leash + groups = [('setup', corpus_module.group_setup()), ('mob quad leash dispatch', mob_quad_leash(args.mob_quad_raw_diagnostic))] if args.only: groups = [g for g in groups if args.only in g[0] or g[0] == 'setup'] if args.slot_codec_diagnostic: diff --git a/tools/test_mob_quad_leash_parity.py b/tools/test_mob_quad_leash_parity.py new file mode 100644 index 0000000000..fd769ba76d --- /dev/null +++ b/tools/test_mob_quad_leash_parity.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +"""Compare live NoAI horse leash dispatch against pinned Java26.3.""" +import argparse +from pathlib import Path +import subprocess +import sys + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--binary', type=Path) + parser.add_argument('--prepare', action='store_true') + parser.add_argument('--raw-diagnostic', action='store_true', help='add full-precision yaw/motion records') + args = parser.parse_args() + root = Path(__file__).resolve().parents[1] + folder = 'mob-quad-leash-raw-diagnostic' if args.raw_diagnostic else 'mob-quad-leash-parity' + command = [sys.executable, str(root / 'tools/test_command_exec_parity.py'), + '--mob-quad-fixture', '--work-dir', str(root / '.local' / folder)] + if args.raw_diagnostic: + command.append('--mob-quad-raw-diagnostic') + if args.binary: + command += ['--binary', str(args.binary.resolve())] + if args.prepare: + command.append('--prepare') + return subprocess.run(command, cwd=root).returncode + + +if __name__ == '__main__': + sys.exit(main())