2e3063d821
# Conflicts: # crates/horizon-client/examples/entity_anim_fixture.rs # crates/horizon-client/examples/mob_ai_probe.rs # crates/horizon-client/src/game/creatures.rs # crates/horizon-client/src/mobs.rs # crates/horizon-client/src/mobs/ai.rs # mods/horizon/src/test/java/dev/horizonlab/parity/VanillaMobAiOracle.java # tools/test_mob_ai_parity.py
350 lines
19 KiB
Python
350 lines
19 KiB
Python
#!/usr/bin/env python3
|
|
"""Differential mob AI harness.
|
|
|
|
The Java oracle (VanillaMobAiOracle) runs vanilla mobs in a real, headless, flat vanilla server
|
|
level: each scenario builds a scene, places a target player and mobs with seeded random sources
|
|
and ticks the mobs the way ServerLevel.tick does. The Rust probe (mob_ai_probe) replays the same
|
|
scenarios on Horizon's mob AI. Both print every mob's AI-visible state after every tick (position,
|
|
motion, rotations, running goals and target goals, attack target, navigation path, move and look
|
|
controls, movement input, brain activities and memories) and the sounds played; this harness
|
|
reports, per scenario and mob, how many ticks match before the first divergence and what differs.
|
|
|
|
python3 tools/test_mob_ai_parity.py [--only NAME ...] [--rust-only] [--java-only] [--report-only] [--trace]
|
|
|
|
The oracle traces every mob random draw with its call stack; --trace builds the probe with
|
|
the rng-trace feature and line tables so the report shows Horizon's draws next to vanilla's.
|
|
"""
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import struct
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from . import lab, oracle_harness, test_rust_parity as base, test_model_baking_parity as baking
|
|
except ImportError:
|
|
import lab
|
|
import oracle_harness
|
|
import test_rust_parity as base
|
|
import test_model_baking_parity as baking
|
|
|
|
FLOOR = -60 # top of the flat preset's grass layer + 1
|
|
|
|
|
|
def ring(x0, z0, size, state, y=FLOOR):
|
|
return [f'fill {x0 - size} {y} {z0 - size} {x0 + size} {y} {z0 + size} {state}']
|
|
|
|
|
|
# Each scenario is (name, ticks, options, lines); positions are relative to the scenario origin
|
|
# and translated by the generator so scenarios never share chunks.
|
|
def scenarios():
|
|
s = []
|
|
|
|
def add(name, ticks, lines, opts=''):
|
|
s.append((name, ticks, opts, lines))
|
|
|
|
# Passive and hostile land mobs on open grass with a survival player nearby.
|
|
for species, seed in [('zombie', 1), ('skeleton', 2), ('creeper', 3), ('spider', 4), ('husk', 5),
|
|
('cow', 6), ('pig', 7), ('chicken', 8), ('sheep', 9), ('wandering_trader', 10),
|
|
('villager', 11), ('drowned', 12), ('enderman', 13), ('witch', 14)]:
|
|
add(f'{species}_player', 200, [
|
|
'player 6.5 -60 0.5',
|
|
f'mob minecraft:{species} 0.5 -60 0.5 0 {seed} persistent',
|
|
])
|
|
add(f'{species}_alone', 200, [
|
|
f'mob minecraft:{species} 0.5 -60 0.5 90 {seed * 7 + 1} persistent',
|
|
])
|
|
# Drowned in a pool with the player on the shore and in the water.
|
|
add('drowned_pool', 200, ['fill -6 -64 -6 6 -61 6 minecraft:water',
|
|
'player 9.5 -60 0.5',
|
|
'mob minecraft:drowned 0.5 -63 0.5 0 21 persistent'])
|
|
add('drowned_pool_swimmer', 200, ['fill -6 -64 -6 6 -61 6 minecraft:water',
|
|
'player 3.5 -62 0.5',
|
|
'mob minecraft:drowned -3.5 -63 0.5 0 22 persistent'])
|
|
# Night: undead do not burn, monsters seek darkness.
|
|
add('zombie_night', 200, ['player 10.5 -60 0.5', 'mob minecraft:zombie 0.5 -60 0.5 0 31 persistent'], 'time=18000')
|
|
add('skeleton_day_burn', 120, ['mob minecraft:skeleton 0.5 -60 0.5 0 32 persistent'])
|
|
# The wither (summoned, not built: no invulnerable spawn phase).
|
|
add('wither_player', 200, ['player 10.5 -60 0.5', 'mob minecraft:wither 0.5 -58 0.5 0 41 persistent'])
|
|
# A villager with a job site, a bed and a bell.
|
|
add('villager_village', 300, [
|
|
'block 4 -60 0 minecraft:composter',
|
|
'block -4 -60 2 minecraft:red_bed[facing=north,part=foot]',
|
|
'block -4 -60 1 minecraft:red_bed[facing=north,part=head]',
|
|
'block 0 -60 -6 minecraft:bell',
|
|
'player 12.5 -60 0.5',
|
|
'mob minecraft:villager 0.5 -60 0.5 0 51 persistent',
|
|
])
|
|
# Breezes: idle, and fighting a player near, in, and beyond the inner jump circle.
|
|
add('breeze_alone', 200, ['mob minecraft:breeze 0.5 -60 0.5 90 61 persistent'])
|
|
add('breeze_player', 200, ['player 8.5 -60 0.5', 'mob minecraft:breeze 0.5 -60 0.5 0 62 persistent'])
|
|
add('breeze_close', 200, ['player 2.5 -60 1.5', 'mob minecraft:breeze 0.5 -60 0.5 0 63 persistent'])
|
|
add('breeze_far', 200, ['player 13.5 -60 6.5', 'mob minecraft:breeze 0.5 -60 0.5 0 64 persistent'])
|
|
# Creakings: the player faces +z, so it watches one and turns its back on the other.
|
|
add('creaking_alone', 200, ['mob minecraft:creaking 0.5 -60 0.5 90 71 persistent'])
|
|
add('creaking_watched', 200, ['player 0.5 -60 -7.5', 'mob minecraft:creaking 0.5 -60 0.5 180 72 persistent'])
|
|
add('creaking_behind', 200, ['player 0.5 -60 8.5', 'mob minecraft:creaking 0.5 -60 0.5 0 73 persistent'])
|
|
# Woken while watched, then free to hunt and hit once the player turns away.
|
|
add('creaking_attack', 240, ['player 0.5 -60 -7.5 turn=40,180', 'mob minecraft:creaking 0.5 -60 0.5 180 74 persistent'])
|
|
# A creaking heart in a pale oak trunk: by night it spawns its creaking near a player,
|
|
# by day it sleeps, without logs it is uprooted, with no player near it waits.
|
|
TRUNK = ['block 3 -60 3 minecraft:pale_oak_log[axis=y]', 'block 3 -58 3 minecraft:pale_oak_log[axis=y]']
|
|
add('heart_night', 300, TRUNK + ['heart 3 -59 3 minecraft:creaking_heart[axis=y,creaking_heart_state=awake,natural=true]', 'player 0.5 -60 -6.5'], 'time=18000 spawning')
|
|
add('heart_day', 120, TRUNK + ['heart 3 -59 3 minecraft:creaking_heart[axis=y,creaking_heart_state=awake,natural=true]', 'player 0.5 -60 -6.5'], 'spawning')
|
|
add('heart_uprooted', 60, ['heart 3 -59 3 minecraft:creaking_heart[axis=y,creaking_heart_state=awake,natural=true]', 'player 0.5 -60 -6.5'], 'time=18000 spawning')
|
|
# A player hits the heart's creaking: no damage, but the heart bleeds resin and trails.
|
|
add('heart_hurt', 260, TRUNK + ['heart 3 -59 3 minecraft:creaking_heart[axis=y,creaking_heart_state=awake,natural=true]', 'player 0.5 -60 -6.5',
|
|
'hurt 60 0', 'hurt 64 0', 'hurt 200 0'], 'time=18000 spawning')
|
|
add('heart_far', 120, TRUNK + ['heart 3 -59 3 minecraft:creaking_heart[axis=y,creaking_heart_state=awake,natural=true]', 'player 40.5 -60 0.5'], 'time=18000 spawning')
|
|
# Llamas and trader llamas: strolling, looking at the player, a baby following its parent and a
|
|
# llama hunting a wolf (LlamaAttackWolfGoal) and spitting at it.
|
|
for species, seed in [('llama', 61), ('trader_llama', 62)]:
|
|
add(f'{species}_player', 200, ['player 6.5 -60 0.5', f'mob minecraft:{species} 0.5 -60 0.5 0 {seed} persistent'])
|
|
add(f'{species}_alone', 200, [f'mob minecraft:{species} 0.5 -60 0.5 90 {seed * 7 + 1} persistent'])
|
|
add('llama_family', 200, ['mob minecraft:llama 0.5 -60 0.5 0 63 persistent',
|
|
'mob minecraft:llama 6.5 -60 2.5 0 64 persistent baby'])
|
|
add('llama_wolf', 200, ['mob minecraft:llama 0.5 -60 0.5 0 65 persistent',
|
|
'mob minecraft:wolf 8.5 -60 0.5 0 66 persistent'])
|
|
# Sniffers on diggable grass: scenting, sniffing, searching and digging.
|
|
add('sniffer_player', 400, ['player 8.5 -60 0.5', 'mob minecraft:sniffer 0.5 -60 0.5 0 71 persistent'])
|
|
for i, seed in enumerate([72, 73, 74]):
|
|
add(f'sniffer_alone_{i}', 600, [f'mob minecraft:sniffer 0.5 -60 0.5 {90 * i} {seed} persistent'])
|
|
add('sniffer_baby', 300, ['mob minecraft:sniffer 0.5 -60 0.5 0 75 persistent baby'])
|
|
# Happy ghasts float above the grass; ghastlings follow players and adults.
|
|
add('happy_ghast_player', 200, ['player 6.5 -60 0.5', 'mob minecraft:happy_ghast 0.5 -55 0.5 0 81 persistent'])
|
|
add('happy_ghast_alone', 200, ['mob minecraft:happy_ghast 0.5 -55 0.5 90 82 persistent'])
|
|
add('ghastling_player', 200, ['player 8.5 -60 0.5', 'mob minecraft:happy_ghast 0.5 -57 0.5 0 83 persistent baby'])
|
|
add('ghastling_alone', 200, ['mob minecraft:happy_ghast 0.5 -57 0.5 0 84 persistent baby'])
|
|
add('ghastling_adult', 200, ['mob minecraft:happy_ghast 10.5 -55 0.5 0 85 persistent',
|
|
'mob minecraft:happy_ghast 0.5 -57 0.5 0 86 persistent baby'])
|
|
# Breeding, temptation and growing up.
|
|
add('sniffer_love', 300, ['mob minecraft:sniffer 0.5 -60 0.5 0 91 persistent inlove',
|
|
'mob minecraft:sniffer 4.5 -60 0.5 0 92 persistent inlove'])
|
|
add('sniffer_tempted', 200, ['player 7.5 -60 0.5 hand=minecraft:torchflower_seeds',
|
|
'mob minecraft:sniffer 0.5 -60 0.5 0 93 persistent'])
|
|
add('llama_love', 300, ['mob minecraft:llama 0.5 -60 0.5 0 94 persistent tame inlove strength=3',
|
|
'mob minecraft:llama 4.5 -60 0.5 0 95 persistent tame inlove strength=2'])
|
|
add('llama_tempted', 200, ['player 7.5 -60 0.5 hand=minecraft:hay_block',
|
|
'mob minecraft:llama 0.5 -60 0.5 0 96 persistent'])
|
|
add('ghastling_tempted', 200, ['player 8.5 -60 0.5 hand=minecraft:snowball',
|
|
'mob minecraft:happy_ghast 0.5 -57 0.5 0 97 persistent baby'])
|
|
add('happy_ghast_tempted', 200, ['player 12.5 -60 0.5 hand=minecraft:snowball',
|
|
'mob minecraft:happy_ghast 0.5 -55 0.5 0 98 persistent'])
|
|
add('ghastling_grows', 200, ['player 8.5 -60 0.5',
|
|
'mob minecraft:happy_ghast 0.5 -57 0.5 0 99 persistent age=-40'])
|
|
# Llama caravans: two llamas fall in behind one on the player's lead; a wandering trader's two
|
|
# llamas on its leads.
|
|
add('llama_caravan', 300, ['player 0.5 -60 0.5',
|
|
'mob minecraft:llama 3.5 -60 0.5 0 101 persistent leashed strength=3',
|
|
'mob minecraft:llama 9.5 -60 2.5 0 102 persistent strength=2',
|
|
'mob minecraft:llama 13.5 -60 -1.5 0 103 persistent strength=1'])
|
|
add('trader_llamas', 300, ['player 14.5 -60 0.5',
|
|
'mob minecraft:wandering_trader 0.5 -60 0.5 0 104 persistent',
|
|
'mob minecraft:trader_llama 3.5 -60 1.5 0 105 persistent leashto=0 strength=2',
|
|
'mob minecraft:trader_llama 3.5 -60 -1.5 0 106 persistent leashto=0 strength=4'])
|
|
return s
|
|
|
|
|
|
def write_scenarios(path, chosen):
|
|
lines = []
|
|
for i, (name, ticks, opts, body) in chosen:
|
|
ox, oz = 512 * (i + 1), 0
|
|
lines.append(f'scenario {name} ticks={ticks} {opts}'.rstrip())
|
|
lines.append(f'origin {ox} {oz}')
|
|
for line in body:
|
|
f = line.split()
|
|
if f[0] == 'player':
|
|
f[1] = repr(float(f[1]) + ox)
|
|
f[3] = repr(float(f[3]) + oz)
|
|
elif f[0] == 'mob':
|
|
f[2] = repr(float(f[2]) + ox)
|
|
f[4] = repr(float(f[4]) + oz)
|
|
elif f[0] in ('block', 'heart'):
|
|
f[1] = str(int(f[1]) + ox)
|
|
f[3] = str(int(f[3]) + oz)
|
|
elif f[0] == 'fill':
|
|
for k in (1, 4):
|
|
f[k] = str(int(f[k]) + ox)
|
|
for k in (3, 6):
|
|
f[k] = str(int(f[k]) + oz)
|
|
lines.append(' '.join(f))
|
|
lines.append('end')
|
|
path.write_text('\n'.join(lines) + '\n')
|
|
|
|
|
|
def parse(path):
|
|
"""{scenario: {tick: {mob: {field: value}}, sounds: {tick: [..]}}}"""
|
|
result, cur = {}, None
|
|
for line in path.read_text().splitlines():
|
|
if line.startswith('== '):
|
|
cur = result.setdefault(line[3:], {'ticks': {}, 'sounds': {}})
|
|
continue
|
|
head, *fields = line.split('\t')
|
|
parts = head.split(' ', 2)
|
|
t = int(parts[0])
|
|
if parts[1] == 'sound':
|
|
cur['sounds'].setdefault(t, []).append(parts[2])
|
|
continue
|
|
if parts[1] == 'trace':
|
|
cur.setdefault('trace', {}).setdefault(t, []).append(parts[2])
|
|
continue
|
|
if parts[1] == 'heart':
|
|
cur.setdefault('hearts', {}).setdefault(t, []).append(line.split(' ', 2)[2])
|
|
continue
|
|
mob = int(parts[1])
|
|
row = {}
|
|
if len(parts) > 2:
|
|
row['removed'] = parts[2]
|
|
for f in fields:
|
|
k, _, v = f.partition(' ')
|
|
row[k] = v
|
|
cur['ticks'].setdefault(t, {})[mob] = row
|
|
return result
|
|
|
|
|
|
def decode(value):
|
|
"""Hex float/double fields made readable."""
|
|
def one(x):
|
|
try:
|
|
if len(x) > 8:
|
|
return f'{struct.unpack(">d", int(x, 16).to_bytes(8, "big"))[0]!r}'
|
|
return f'{struct.unpack(">f", int(x, 16).to_bytes(4, "big"))[0]:.9g}'
|
|
except ValueError:
|
|
return x
|
|
out = []
|
|
for word in value.split(' '):
|
|
out.append(','.join(one(p) if all(c in '0123456789abcdef' for c in p) and len(p) >= 5 else p for p in word.split(',')))
|
|
return ' '.join(out)
|
|
|
|
|
|
MULT, MASK = 0x5DEECE66D, (1 << 48) - 1
|
|
|
|
|
|
def draws(before, after, limit=20000):
|
|
"""How many LegacyRandomSource steps lead from one state to the next."""
|
|
try:
|
|
s, goal = int(before, 16), int(after, 16)
|
|
except (TypeError, ValueError):
|
|
return '?'
|
|
for n in range(limit):
|
|
if s == goal:
|
|
return n
|
|
s = (s * MULT + 11) & MASK
|
|
return f'>{limit}'
|
|
|
|
|
|
def compare(vanilla, rust, fields=None):
|
|
"""Returns (report lines, totals)."""
|
|
report, totals = [], {'mob_ticks': 0, 'matched': 0, 'scenarios': 0, 'clean': 0}
|
|
for name, v in vanilla.items():
|
|
r = rust.get(name)
|
|
totals['scenarios'] += 1
|
|
if r is None:
|
|
report.append(f'{name}: missing from Horizon')
|
|
continue
|
|
mobs = sorted({m for t in v['ticks'].values() for m in t})
|
|
clean = True
|
|
for mob in mobs:
|
|
ticks = sorted(v['ticks'])
|
|
first = None
|
|
for t in ticks:
|
|
a = v['ticks'][t].get(mob)
|
|
b = r['ticks'].get(t, {}).get(mob)
|
|
totals['mob_ticks'] += 1
|
|
if a == b or (b is not None and fields and all(a.get(k) == b.get(k) for k in fields)):
|
|
if first is None:
|
|
totals['matched'] += 1
|
|
continue
|
|
if first is None:
|
|
first = t
|
|
diffs = []
|
|
for k in (a or {}):
|
|
if b is None or a.get(k) != b.get(k):
|
|
show = (lambda x: x) if k == 'rng' else decode
|
|
diffs.append(f'{k}: vanilla {show(a.get(k, "?"))} | horizon {show((b or {}).get(k, "?"))}')
|
|
for k in (b or {}):
|
|
if a is None or k not in a:
|
|
diffs.append(f'{k}: vanilla ? | horizon {decode(b[k])}')
|
|
prev_v = v['ticks'].get(t - 1, {}).get(mob, {}).get('rng')
|
|
prev_r = r['ticks'].get(t - 1, {}).get(mob, {}).get('rng')
|
|
report.append(f'{name} mob {mob}: first divergence at tick {t} of {len(ticks)} '
|
|
f'(random draws this tick: vanilla {draws(prev_v, (a or {}).get("rng"))}, '
|
|
f'horizon {draws(prev_r, (b or {}).get("rng"))})')
|
|
for line in [x for x in v.get('trace', {}).get(t, []) if x.split(' ', 1)[0] == str(mob)][:40]:
|
|
report.append(' vanilla ' + line)
|
|
# Present when the probe is built with the rng-trace feature (see --trace).
|
|
for line in [x for x in r.get('trace', {}).get(t, []) if x.split(' ', 1)[0] == str(mob)][:40]:
|
|
report.append(' horizon ' + line)
|
|
report.extend(' ' + d for d in diffs[:12])
|
|
if first is not None:
|
|
clean = False
|
|
# Creaking hearts (state, block entity, level random): first differing tick.
|
|
for t in sorted(set(v.get('hearts', {})) | set(r.get('hearts', {}))):
|
|
if v.get('hearts', {}).get(t) != r.get('hearts', {}).get(t):
|
|
report.append(f'{name}: hearts differ at tick {t}: vanilla {v.get("hearts", {}).get(t)} | horizon {r.get("hearts", {}).get(t)}')
|
|
clean = False
|
|
break
|
|
for t in sorted(set(v['sounds']) | set(r['sounds'])):
|
|
if v['sounds'].get(t, []) != r['sounds'].get(t, []):
|
|
report.append(f'{name}: sounds differ at tick {t}: vanilla {v["sounds"].get(t, [])} | horizon {r["sounds"].get(t, [])}')
|
|
clean = False
|
|
break
|
|
totals['clean'] += clean
|
|
if clean:
|
|
report.append(f'{name}: all {len(v["ticks"])} ticks match')
|
|
return report, totals
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument('--only', nargs='*')
|
|
parser.add_argument('--java-only', action='store_true')
|
|
parser.add_argument('--rust-only', action='store_true', help='reuse the last vanilla output')
|
|
parser.add_argument('--report-only', action='store_true', help='print divergences without failing')
|
|
parser.add_argument('--trace', action='store_true',
|
|
help="also trace Horizon's mob random draws (rng-trace probe with line tables)")
|
|
args = parser.parse_args()
|
|
build = lab.LOCAL / 'mob-ai-parity'
|
|
build.mkdir(parents=True, exist_ok=True)
|
|
chosen = [(i, s) for i, s in enumerate(scenarios()) if not args.only or any(o in s[0] for o in args.only)]
|
|
inputs = build / 'scenarios.txt'
|
|
write_scenarios(inputs, chosen)
|
|
lock, jars, _ = base.verify_artifacts()
|
|
if not args.rust_only:
|
|
classes, dependencies, _ = oracle_harness.compile_oracle(build, 'VanillaMobAiOracle', jars, baking.client_libraries(),
|
|
lock['minecraft']['java_major'])
|
|
# The oracle's server world: cleared before each run, never left in /tmp.
|
|
world = build / 'world'
|
|
shutil.rmtree(world, ignore_errors=True)
|
|
oracle_harness.run_logged(['java', '-Xmx3g', '-Djava.awt.headless=true', '-cp', str(classes) + os.pathsep + dependencies,
|
|
'dev.horizonlab.parity.VanillaMobAiOracle', inputs, build / 'vanilla.txt', world],
|
|
build, 'vanilla', timeout=3600)
|
|
if not args.java_only:
|
|
command = ['cargo', 'build', '--locked', '--release', '-p', 'horizon-client', '--example', 'mob_ai_probe']
|
|
target, env = lab.cargo_target(), None
|
|
if args.trace:
|
|
target = lab.LOCAL / 'mob-ai-trace-target'
|
|
command += ['--features', 'rng-trace']
|
|
env = dict(os.environ, CARGO_TARGET_DIR=str(target), CARGO_PROFILE_RELEASE_DEBUG='line-tables-only',
|
|
CARGO_PROFILE_RELEASE_LTO='false', CARGO_PROFILE_RELEASE_CODEGEN_UNITS='16')
|
|
oracle_harness.run_logged(command, build, 'compile-rust', env=env)
|
|
binary = target / 'release/examples/mob_ai_probe'
|
|
oracle_harness.run_logged([binary, lab.ROOT / '.local/horizon-client/26.3', inputs, build / 'horizon.txt'], build, 'horizon')
|
|
if args.java_only:
|
|
return
|
|
names = {s[0] for _, s in chosen}
|
|
vanilla = {k: v for k, v in parse(build / 'vanilla.txt').items() if k in names}
|
|
report, totals = compare(vanilla, parse(build / 'horizon.txt'))
|
|
(build / 'report.txt').write_text('\n'.join(report) + '\n')
|
|
print('\n'.join(report))
|
|
print(f"{totals['clean']}/{totals['scenarios']} scenarios match entirely; "
|
|
f"{totals['matched']}/{totals['mob_ticks']} mob-ticks match before each mob's first divergence")
|
|
if not args.report_only and totals['clean'] != totals['scenarios']:
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|