Files
horizon-airdef/tools/test_entity_particles_parity.py
T
Ludovic Marie 383fc116c9 Entity particle bursts: as many particles as vanilla, about the body
Villagers showed one angry, happy or heart particle where vanilla's
AbstractVillager.addParticlesAroundSelf shows five, spread over the body
with Gaussian motion. Taming and love bursts were spread about the middle
of the body rather than over it, and drew from the world random.

particles::around_self places a burst as the client handlers do: per
particle three Gaussian speeds, then getRandomX(1), getRandomY() + y,
getRandomZ(1). Burst holds the counts, speeds and heights vanilla uses,
and entity_event_burst maps entity events to them. The client side uses
the particle engine's random, so no server draw moves.

- villager hurt by a player, failed breeding (13), breeding hearts and
  the child's (12), trades, job sites and beds (14): five particles about
  the villager, not one 1.8 above its feet.
- wolf, cat, parrot, nautilus and horse taming (7/6): seven hearts or
  smoke about the body.
- allay duplication (18): three hearts about the allay.
- llama growth from food: one particle at rest about the body.
- animals in love: the heart moves at the Gaussian speeds already drawn,
  exactly where vanilla puts it; forced growth's particle sits where it
  was placed (sendParticles with no spread) instead of being jittered.

New harness tools/test_entity_particles_parity.py: the real client
handleEntityEvent of Villager, Animal, TamableAnimal, AbstractHorse,
Ocelot, Dolphin and Allay against entity_event_burst and around_self.

Part of #63

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:13:38 +02:00

113 lines
5.4 KiB
Python

#!/usr/bin/env python3
"""Compare the particle bursts entity events show on the client (villagers'
hearts, anger and happiness, love hearts, taming and trusting hearts and smoke,
the allay's and dolphin's) with the real handleEntityEvent of each entity:
particle, count, positions about the body, motion and every random draw."""
import json
import platform
import random
import struct
import subprocess
try:
from . import lab, oracle_harness as harness, test_rust_parity as base, test_model_baking_parity as baking
except ImportError:
import lab, oracle_harness as harness, test_rust_parity as base, test_model_baking_parity as baking
EVENTS = {
'villager': [12, 13, 14, 42],
'cow': [18], 'wolf': [6, 7, 18], 'cat': [6, 7, 18], 'parrot': [6, 7], 'nautilus': [6, 7, 18],
'horse': [6, 7, 18], 'llama': [6, 7, 18], 'camel': [6, 7, 18],
'ocelot': [40, 41, 18], 'dolphin': [38], 'allay': [18],
}
# Dyadic sizes and positions: serde_json's decimal parsing may round others a unit off.
SIZES = [(0.6, 1.95), (0.9, 1.4), (0.3, 0.85), (1.3964844, 1.6), (0.35, 0.6), (0.875, 0.9375)]
def cases(rng):
for entity, events in EVENTS.items():
for event in events:
for _ in range(40):
w, h = rng.choice(SIZES)
feet = [rng.randrange(-4096, 4096) / 64 for _ in range(3)]
yield dict(entity=entity, event=event, feet=feet, width=w, height=h,
seed=rng.randrange(-2**63, 2**63))
def make_cases(build):
path = build / 'cases.jsonl'
count = 0
with path.open('w') as out:
for case in cases(random.Random(0x454e5450)):
out.write(json.dumps(case, separators=(',', ':')) + '\n')
count += 1
return path, count
def close(a, b):
"""Lines equal but for doubles within 1e-12 relative error. The motion is
Gaussian, and MarsagliaPolarGaussian calls Math.log, which HotSpot's x86-64
intrinsic rounds a unit apart from the platform libm horizon_core::jmath
uses for a small fraction of arguments."""
ta, tb = a.split(), b.split()
if len(ta) != len(tb):
return False
for x, y in zip(ta, tb):
if x == y:
continue
if len(x) != 16 or len(y) != 16:
return False
fx, fy = (struct.unpack('>d', bytes.fromhex(t))[0] for t in (x, y))
if not abs(fx - fy) <= 1e-12 * max(1.0, abs(fx)):
return False
return True
def main():
lock, jars, _ = base.verify_artifacts()
name, oracle, probe = 'rust-entity-particles-parity', 'VanillaEntityParticlesOracle', 'entity_particles_probe'
build = lab.LOCAL / name
build.mkdir(exist_ok=True)
report = build / 'report.json'
report.unlink(missing_ok=True)
sources = [lab.ROOT / p for p in [
'tools/test_entity_particles_parity.py', 'tools/oracle_harness.py', 'tools/test_rust_parity.py', 'tools/lab.py',
f'mods/horizon/src/test/java/dev/horizonlab/parity/{oracle}.java', 'Cargo.toml', 'Cargo.lock',
'crates/horizon-client/Cargo.toml', 'crates/horizon-client/src/particles.rs',
f'crates/horizon-client/examples/{probe}.rs', 'crates/horizon-core/src/random.rs']]
tested = base.source_hashes(sources)
cases_path, units = make_cases(build)
classes, dependencies, _ = harness.compile_oracle(build, oracle, jars, baking.client_libraries(),
lock['minecraft']['java_major'])
binary = harness.build_probe(build, probe)
harness.run_logged([binary, cases_path, build / 'rust.txt'], build, 'rust')
harness.java_run(build, classes, dependencies, oracle, [cases_path, build / 'client.txt'], 'client')
vanilla = (build / 'client.txt').read_text().splitlines()
rust = (build / 'rust.txt').read_text().splitlines()
assert len(vanilla) == len(rust), f'{len(vanilla)} vanilla lines, {len(rust)} Rust lines'
tolerated = 0
for line, (a, b) in enumerate(zip(vanilla, rust), 1):
if a != b:
assert close(a, b), f'Parity mismatch at output line {line}: vanilla={a!r}, Rust={b!r}'
tolerated += 1
particles = sum(line.startswith('+ ') for line in vanilla)
assert particles > units, 'too few particles to establish parity'
base.ensure_sources_unchanged(sources, tested)
lab.write_json(report, dict(
schema=1, minecraft=lock['minecraft']['version'], platform=platform.platform(),
rustc=subprocess.check_output(['rustc', '--version'], text=True).strip(), units=units,
sources_sha256=tested, input_sha256=lab.digest(cases_path, 'sha256'),
client_jar_sha256=lab.digest(jars['client'], 'sha256'), matched_output_lines=len(vanilla),
libm_tolerated_lines=tolerated, output_sha256=lab.digest(build / 'rust.txt', 'sha256'),
scope=('The real client handleEntityEvent of Villager (12, 13, 14, 42), Animal (18, love hearts), TamableAnimal '
'and AbstractHorse (6, 7, taming), Ocelot (40, 41, trusting), Dolphin (38) and Allay (18), on entities '
'allocated with a stand-in level, a position, dimensions and a seeded random: the particle type, '
'count, positions, motion and draws on the entity random. Which server code fires each event, and '
'the particles themselves once spawned, are covered elsewhere.')))
print(f'{units:,} entity events, {particles:,} particles match the pinned client ({tolerated} lines within the '
f'Math.log tolerance): {report}', flush=True)
if __name__ == '__main__':
main()