Files
horizon-airdef/tools/test_subtitle_parity.py
Ludovic Marie a891037c9f parity: harnesses build horizon-client in the fast profile only
Under parity.py the release and fast profiles have identical settings
(PROFILE: no LTO, 16 codegen units, incremental), yet 38 harnesses built
horizon-client in release and 16 others in fast: every run where the client
changed compiled it twice. They all use fast now. Release stays for
horizon-world's probes, which no fast harness builds.

Measured on playhorizon (4 CPUs), all 13 probe builds after 78 merges:
1578 s, of which the release client (bin + client_probe) took 485 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 23:24:54 +01:00

173 lines
8.1 KiB
Python

#!/usr/bin/env python3
"""Compare Horizon's subtitle overlay with the vanilla 26.3 client's SubtitleOverlay.
The Java oracle (VanillaSubtitleOracle) runs vanilla's own SubtitleOverlay with a
stand-in client: sounds the sound engine reports to its listeners (subtitles from
sounds.json, unknown keys and sounds without one; positions; ranges, infinite for
relative and unattenuated sounds; looping) at controlled times of Util.getMillis,
and frames that render it for a listener transform (position, forward and up
vectors), the subtitle option turned on or off (it starts and stops listening as
it renders), the notification display time, the text background opacity and its
"chat only" setting, and a GUI size. Each frame records the fills and texts in
order with the pose: which subtitles show, their order, merging of repeats,
expiry, the fade by age (looping sounds stay bright longer), the direction
arrows and the box width. `examples/probe/subtitle_fixture` replays the cases through
`horizon_client::subtitles`; the frames must agree exactly.
test_subtitle_parity.py [--count N] [--detail N]
"""
import argparse
import json
import math
import os
import random
import subprocess
import sys
from pathlib import Path
try:
from . import lab, test_rust_parity as base, test_model_baking_parity as baking
except ImportError:
import lab
import test_rust_parity as base
import test_model_baking_parity as baking
PARITY = lab.ROOT / 'mods/horizon/src/test/java/dev/horizonlab/parity'
def subtitle_keys(resources):
sounds = json.loads((resources / 'assets/minecraft/sounds.json').read_text())
keys = sorted({v['subtitle'] for v in sounds.values() if isinstance(v, dict) and 'subtitle' in v})
return keys
def unit(rng):
while True:
v = [rng.uniform(-1, 1) for _ in range(3)]
n = math.sqrt(sum(x * x for x in v))
if n > 0.1:
return [x / n for x in v]
def listener(rng, pos):
yaw, pitch = rng.uniform(-180, 180), rng.uniform(-90, 90)
y, p = math.radians(yaw), math.radians(pitch)
forward = [-math.sin(y) * math.cos(p), -math.sin(p), math.cos(y) * math.cos(p)]
up = [-math.sin(y) * -math.sin(p), math.cos(p), math.cos(y) * -math.sin(p)]
if rng.random() < 0.2:
forward, up = rng.choice([([0.0, 0.0, -1.0], [0.0, 1.0, 0.0]), ([1.0, 0.0, 0.0], [0.0, 1.0, 0.0]),
([0.0, -1.0, 0.0], [0.0, 0.0, 1.0])])
return {'pos': pos, 'forward': forward, 'up': up}
def cases(count, keys):
rng = random.Random(0x5355425449544c45)
out = []
for n in range(count):
pool = rng.sample(keys, rng.choice([1, 3, 8, 20])) + ['subtitles.horizon.unknown']
t = rng.randint(0, 10_000_000)
pos = [rng.uniform(-100, 100), rng.uniform(-60, 300), rng.uniform(-100, 100)]
here = listener(rng, pos)
mult = rng.choice([0.5, 1.0, 1.0, 1.5, 2.0, 3.5, 10.0])
show = rng.random() < 0.9
opacity, chat_only = rng.choice([0.5, 0.0, 1.0, 0.25]), rng.random() < 0.6
size = rng.choice([(427, 240), (320, 240), (480, 270), (961, 501)])
ops = [{'op': 'time', 'ms': t, 'sub': rng.randint(0, 999999)}]
locations = [[pos[i] + rng.uniform(-30, 30) for i in range(3)] for _ in range(6)]
def frame():
nonlocal here
if rng.random() < 0.3:
here = listener(rng, [p + rng.uniform(-2, 2) for p in here['pos']])
return {'op': 'frame', **here, 'show': show, 'mult': mult, 'opacity': opacity, 'chat_only': chat_only,
'w': size[0], 'h': size[1]}
ops.append(frame())
for _ in range(rng.choice([5, 20, 60])):
r = rng.random()
if r < 0.45:
at = rng.choice(locations) if rng.random() < 0.6 else [here['pos'][i] + rng.uniform(-20, 20) for i in range(3)]
if rng.random() < 0.05:
at = list(here['pos'])
key = rng.choice(pool) if rng.random() < 0.9 else None
rng_range = rng.choice([16.0, 16.0, 8.0, 48.0, 2.5, 100.0, None])
ops.append({'op': 'play', 'key': key, 'at': at, 'range': rng_range, 'looping': rng.random() < 0.15})
elif r < 0.75:
t += rng.choice([0, 1, 16, 50, 499, 500, 501, 1000, 2999, 3000, 3001, rng.randint(0, 8000)])
ops.append({'op': 'time', 'ms': t, 'sub': rng.randint(0, 999999)})
else:
if rng.random() < 0.08:
show = not show
if rng.random() < 0.05:
mult = rng.choice([0.5, 1.0, 2.0, 10.0])
ops.append(frame())
ops.append(frame())
out.append({'case': n, 'ops': ops})
return out
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--count', type=int, default=1500)
parser.add_argument('--detail', type=int, default=3)
args = parser.parse_args()
lock, jars, _ = base.verify_artifacts()
libraries = baking.client_libraries()
resources = lab.RESOURCES
build = lab.LOCAL / 'subtitle-parity'
build.mkdir(parents=True, exist_ok=True)
classes = build / 'classes'
classes.mkdir(exist_ok=True)
dependencies = os.pathsep.join(map(str, [jars['client'], *libraries]))
def run(command, name):
with (build / (name + '.log')).open('w') as log:
result = subprocess.run(list(map(str, command)), cwd=lab.ROOT, timeout=3600, stdout=log, stderr=subprocess.STDOUT)
if result.returncode:
print((build / (name + '.log')).read_text()[-4000:])
sys.exit(f'{name} failed')
chosen = cases(args.count, subtitle_keys(resources))
inputs = build / 'inputs.jsonl'
inputs.write_text(''.join(json.dumps(c) + '\n' for c in chosen))
run(['javac', '--release', lock['minecraft']['java_major'], '-proc:none', '-nowarn', '-cp', dependencies, '-d', classes,
*(PARITY / f'{n}.java' for n in ('ParityFont', 'VanillaSubtitleOracle'))], 'compile-java')
run(['java', '-Xmx1G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaSubtitleOracle',
inputs, build / 'vanilla.jsonl'], 'vanilla')
run(['cargo', 'build', '--profile', 'fast', '-p', 'horizon-client', '--example', 'client_probe'], 'compile-rust')
metadata = json.loads(subprocess.check_output(['cargo', 'metadata', '--no-deps', '--format-version', '1'], cwd=lab.ROOT, text=True))
binary = Path(metadata['target_directory']) / 'fast/examples' / ('client_probe.exe' if os.name=='nt' else 'client_probe')
run([binary, 'subtitle_fixture',resources, inputs, build / 'rust.jsonl'], 'rust')
vanilla = [json.loads(l) for l in (build / 'vanilla.jsonl').read_text(encoding='utf-8').splitlines()]
rust = [json.loads(l) for l in (build / 'rust.jsonl').read_text(encoding='utf-8').splitlines()]
assert len(vanilla) == len(rust) == len(chosen)
errors = [v for v in vanilla if 'error' in v]
bad, frames, drawn = [], 0, 0
for c, v, r in zip(chosen, vanilla, rust):
if 'error' in v:
continue
frames += len(v['frames'])
drawn += sum(1 for f in v['frames'] if f)
for i, (a, b) in enumerate(zip(v['frames'], r['frames'])):
if a != b:
bad.append((c, i, a, b))
break
else:
if len(v['frames']) != len(r['frames']):
bad.append((c, len(r['frames']), v['frames'][len(r['frames']):][:1], []))
for v in errors[:3]:
print('ORACLE ERROR', v['case'], v['error'])
for c, i, a, b in bad[:args.detail]:
first = next((j for j, (x, y) in enumerate(zip(a, b)) if x != y), min(len(a), len(b)))
print(f'MISMATCH case {c["case"]} frame {i} at draw {first} ({len(a)} vanilla, {len(b)} Horizon draws)')
print(' vanilla', json.dumps(a[first:first + 2], ensure_ascii=False))
print(' horizon', json.dumps(b[first:first + 2], ensure_ascii=False))
print(f'{len(chosen) - len(bad) - len(errors)}/{len(chosen)} subtitle cases match '
f'({frames} frames, {drawn} with subtitles); {len(errors)} oracle errors')
if bad or errors:
sys.exit(1)
print('subtitle parity ok')
if __name__ == '__main__':
main()