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 <[email protected]>
This commit is contained in:
Florian ERNSTandCodex committed 2026-11-04 22:47:11 +01:00
1 parent 63eccfc355
commit 1686cd755a
5 files changed
+211 -7

No files matched your search

+95 -5
View File
@@ -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<MobEvent>) {
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<MobEvent>) {
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<MobEvent>) {
} 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<Holder> {
@@ -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| {
+76
View File
@@ -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
+1 -1
View File
@@ -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
1 Interaction.defineSynchedData port crates/horizon-client/src/game/interactions.rs::RemoteInteraction::new width 1.0, height 1.0, response false
11 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
12 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
13 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)
14 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 live mob dispatch selects shared quad physics only when both holder and leashee support it; llama remains single
15 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
16 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
17 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
+10 -1
View File
@@ -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:
+29
View File
@@ -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())