Reset mob fall distance before travel

This commit is contained in:
voidsip committed 2026-11-05 21:42:45 -06:00
1 parent 69fab597a6
commit 8181ce0222
4 files changed
+243 -5

No files matched your search

+82
View File
@@ -681,6 +681,7 @@ pub fn tick(
super::villager_brain::woken(m, w.game_time);
}
if m.no_ai {
reset_effect_fall_distance(m);
// Mob.aiStep still runs without AI (only serverAiStep is skipped),
// so undead still burn in daylight and the endermite's life keeps
// counting.
@@ -1541,6 +1542,14 @@ fn ageable_step(
}
}
/// `LivingEntity.aiStep`: both flight effects discard accumulated fall before
/// travel, even when NoAI skips the server AI step.
fn reset_effect_fall_distance(m: &mut Mob) {
if m.has_effect("minecraft:slow_falling") || m.has_effect("minecraft:levitation") {
m.fall_distance = 0.0;
}
}
/// LivingEntity.aiStep (and Mob/AgeableMob/Animal additions).
fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &mut Vec<MobEvent>) {
if m.species() == Species::Parrot {
@@ -1602,6 +1611,7 @@ fn ai_step(m: &mut Mob, w: &MobWorld, level_random: &mut LegacyRandom, events: &
f64::from(m.ai.yya),
f64::from(m.ai.zza),
);
reset_effect_fall_distance(m);
if super::equine::moved_by_client(m) {
// LivingEntity.travelRidden: the rider's client moves it
// (handleMoveVehicle), so the server only stops it.
@@ -4969,3 +4979,75 @@ mod air_tests {
assert_eq!(air, 300);
}
}
#[cfg(test)]
mod flight_effect_fall_tests {
use super::*;
use std::sync::Arc;
#[test]
#[ignore = "needs prepared resources in HORIZON_TEST_RESOURCES"]
fn flight_effects_reset_accumulated_fall_distance_even_without_ai() {
let root = std::path::PathBuf::from(std::env::var_os("HORIZON_TEST_RESOURCES").unwrap());
let ran = crate::mobs::world::with_resource_test_world(
&root,
&[],
true,
true,
|world, _| {
world
.chunks
.insert((0, 0), Arc::new(crate::chunk::Chunk::empty(0, 0)));
},
|world, types| {
let mut failures = Vec::new();
let mut rows = Vec::new();
for effect in [
None,
Some("minecraft:levitation"),
Some("minecraft:slow_falling"),
] {
for no_ai in [false, true] {
for velocity_y in [0.0, 0.2, -0.2] {
let kind = Arc::new(types.types["minecraft:cow"].clone());
let mut mob = Mob::new(1, kind, Vec3::new(2.5, 64.0, 2.5), 0.0);
mob.no_ai = no_ai;
mob.no_gravity = true;
mob.persistent = true;
mob.fall_distance = 12.0;
mob.velocity.y = velocity_y;
if let Some(effect) = effect {
mob.add_effect(effect, 100, 0);
}
let mut random = LegacyRandom::new(0);
assert!(tick(&mut mob, world, &mut random, &mut Vec::new()));
// The pinned Java aiStep resets before travel. A
// downward move then adds 0.2F of NEW fall distance;
// NoAI skips movement but must still erase the old fall.
let expected = match (effect.is_some(), no_ai, velocity_y < 0.0) {
(false, false, true) => 12.200000002980232,
(true, false, true) => 0.20000000298023224,
(false, _, _) => 12.0,
(true, _, _) => 0.0,
};
let name = effect.unwrap_or("none").trim_start_matches("minecraft:");
rows.push(format!(
"{name} {no_ai} {:016x} {:016x}",
velocity_y.to_bits(),
mob.fall_distance.to_bits()
));
if mob.fall_distance != expected {
failures.push((effect, no_ai, velocity_y, mob.fall_distance));
}
}
}
}
if let Some(path) = std::env::var_os("HORIZON_MOB_FALL_RESET_OUTPUT") {
std::fs::write(path, rows.join("\n") + "\n").unwrap();
}
assert!(failures.is_empty(), "fall distance differs: {failures:?}");
},
);
assert!(ran, "prepared resources must run the integration fixture");
}
}
-5
View File
@@ -1382,11 +1382,6 @@ impl Mob {
{
y += (0.05 * f64::from(amp + 1) - movement.y) * 0.2;
} else {
// LivingEntity.travelInAir: a Slow Falling mob on its way down lands without
// having fallen.
if self.velocity.y <= 0.0 && self.has_effect("minecraft:slow_falling") {
self.fall_distance = 0.0;
}
y -= self.effective_gravity();
}
// LivingEntity.shouldDiscardFriction (a breeze's, a frog's or a goat's
@@ -0,0 +1,86 @@
package dev.horizonlab.parity;
import com.google.gson.JsonParser;
import java.io.PrintWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import net.minecraft.core.Holder;
import net.minecraft.world.effect.MobEffect;
import net.minecraft.world.effect.MobEffectInstance;
import net.minecraft.world.effect.MobEffects;
import net.minecraft.world.entity.EntityTypes;
import net.minecraft.world.entity.animal.cow.Cow;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Blocks;
/** Real Cow.aiStep through travel and movement, stopped before block contacts.
* The scene is dry air and gravity is disabled to isolate accumulated fall distance.
* Active-effect lookups are controlled; the NoAI flag and movement are vanilla's.
*/
public final class VanillaMobFallResetOracle {
static final class Finished extends RuntimeException {}
static final class Actor extends Cow {
String effect;
boolean reachedTravelEnd;
Actor(Level level) { super(EntityTypes.COW, level); }
@Override public boolean hasEffect(Holder<MobEffect> query) {
return "levitation".equals(effect) && query == MobEffects.LEVITATION
|| "slow_falling".equals(effect) && query == MobEffects.SLOW_FALLING;
}
@Override public MobEffectInstance getEffect(Holder<MobEffect> query) {
return hasEffect(query) ? new MobEffectInstance(query, 100, 0) : null;
}
// The fixture contains only air; no fluid interaction is possible.
@Override protected boolean updateFluidInteraction() { return false; }
@Override protected void applyEffectsFromBlocks() {
reachedTravelEnd = true;
throw new Finished();
}
}
public static void main(String[] args) throws Exception {
if (args.length != 1) throw new IllegalArgumentException("expected output path");
ParityData.init();
VanillaBlockUpdateOracle.AIR = Blocks.AIR.defaultBlockState();
VanillaBlockUpdateOracle.OVERWORLD_TYPE = VanillaBlockUpdateOracle.loadOverworld();
var allocator = Class.forName("sun.misc.Unsafe");
var field = allocator.getDeclaredField("theUnsafe");
field.setAccessible(true);
VanillaBlockUpdateOracle.unsafe = field.get(null);
VanillaBlockUpdateOracle.allocate = allocator.getMethod("allocateInstance", Class.class);
try (var out = new PrintWriter(Files.newBufferedWriter(Path.of(args[0])))) {
for (String effect : List.of("none", "levitation", "slow_falling")) {
for (boolean noAI : List.of(false, true)) {
for (double vy : new double[]{0.0, 0.2, -0.2}) {
var world = VanillaBlockUpdateOracle.build(
JsonParser.parseString("{\"cells\":[],\"seed\":0}").getAsJsonObject());
world.entities = new ArrayList<>();
world.registries = ParityData.access;
var actor = new Actor(world);
actor.effect = effect;
actor.setNoAi(noAI);
actor.fallDistance = 12.0;
actor.setNoGravity(true);
actor.setPos(2.5, 64.0, 2.5);
actor.setDeltaMovement(0, vy, 0);
try { actor.aiStep(); } catch (Finished done) { }
if (!actor.reachedTravelEnd) {
throw new AssertionError("did not reach the post-travel boundary");
}
out.printf(Locale.ROOT, "%s %s %016x %016x%n", effect, noAI,
Double.doubleToLongBits(vy), Double.doubleToLongBits(actor.fallDistance));
}
}
}
}
}
}
+75
View File
@@ -0,0 +1,75 @@
#!/usr/bin/env python3
"""Compare mob flight-effect fall distance with the pinned server's actual aiStep.
The Java cow runs through real travel/movement, stopping before inside-block effects.
Its fluid query is fixed to dry air; active effects are controlled and NoAI is real.
Horizon runs its complete production mob tick in an empty resource-backed world.
Gravity is disabled. No effect, Levitation and Slow Falling are compared for both
NoAI states and zero/upward/downward movement. Positions, damage and landing are
outside this scoped comparison.
"""
import argparse
import os
from pathlib import Path
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 main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--resources', type=Path,
default=Path(os.environ.get('HORIZON_TEST_RESOURCES', lab.RESOURCES)))
args = parser.parse_args()
resources = args.resources.resolve()
if not (resources / 'entity-types.json').is_file():
raise SystemExit('prepared resources required; pass --resources PATH')
lock, jars, libraries = base.verify_artifacts()
build = lab.LOCAL / 'rust-mob-fall-reset-parity'
build.mkdir(parents=True, exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
java_root = lab.ROOT / 'mods/horizon/src/test/java'
oracle = java_root / 'dev/horizonlab/parity/VanillaMobFallResetOracle.java'
sources = [Path(__file__), oracle, lab.ROOT / 'tools/lab.py',
lab.ROOT / 'tools/oracle_harness.py', lab.ROOT / 'tools/test_rust_parity.py',
lab.ROOT / 'tools/integration_env.py', lab.ROOT / 'Cargo.toml', lab.ROOT / 'Cargo.lock',
lab.ROOT / 'crates/horizon-client/src/mobs.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/ai.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/living.rs',
lab.ROOT / 'crates/horizon-client/src/mobs/world.rs',
java_root / 'dev/horizonlab/parity/ParityData.java',
java_root / 'dev/horizonlab/parity/VanillaBlockUpdateOracle.java']
hashes = base.source_hashes(sources)
classes = build / 'classes'
classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['server'], *libraries]))
harness.run_logged(['javac', '--release', lock['minecraft']['java_major'], '-proc:none',
'-sourcepath', java_root, '-cp', dependencies, '-d', classes, oracle],
build, 'compile-java')
harness.run_logged(['java', '-Xmx512M', '-Djava.awt.headless=true', '-cp',
str(classes) + os.pathsep + dependencies,
'dev.horizonlab.parity.VanillaMobFallResetOracle', build / 'server.txt'],
build, 'server')
with tempfile.TemporaryDirectory(prefix='horizon-mob-fall-reset-') as scratch:
env = integration_env.game_environment(os.environ, scratch)
env.update(HORIZON_TEST_RESOURCES=str(resources),
HORIZON_MOB_FALL_RESET_OUTPUT=str(build / 'rust.txt'))
env.setdefault('CARGO_BUILD_JOBS', '1')
env.setdefault('RUST_TEST_THREADS', '2')
harness.run_logged(['cargo', 'test', '--locked', '-p', 'horizon-client-lib', '--lib',
'mobs::ai::flight_effect_fall_tests::flight_effects_reset_accumulated_fall_distance_even_without_ai',
'--', '--exact', '--ignored'], build, 'rust', env=env)
matched = harness.compare(build / 'server.txt', build / 'rust.txt', 18)
base.ensure_sources_unchanged(sources, hashes)
lab.write_json(report, dict(schema=1, minecraft=lock['minecraft']['version'],
sources_sha256=hashes, server_jar_sha256=lab.digest(jars['server'], 'sha256'),
matched_output_lines=matched, output_sha256=lab.digest(build / 'rust.txt', 'sha256'),
scope=__doc__))
print(f'{matched} mob fall-distance cases match the pinned server: {report}', flush=True)
if __name__ == '__main__':
main()