Files
horizon-airdef/tools/test_rust_parity.py
T
Ludovic Marie 6d7f11feb2 Prepare: require only this platform's libraries; accept republished 26.3 manifests
lab.prepare imported every client library in the version JSON, ignoring
its OS rules, so a Linux install failed on the macOS-only
java-objc-bridge the official launcher never downloads there. The rule
evaluation moves from runtime.py into lab.py and one selector,
client_library_artifacts, now serves preparation, the runtime and the
model baking harness: OS rules as the launcher applies them, and the
natives for this architecture (classifier suffixes, plain natives being
the x64 build only when an ARM variant is listed). Required libraries
are still checksummed and a missing one still errors.

Mojang republished 26.3.json twice (702fe591, then 11f1aefe, the current
one) with only assetIndex changed: non-English translations, same jars
and libraries. The lock pins the current file and accepts both earlier
ones; preparation takes the launcher's copy so it matches the asset
index installed with it, and an unknown republish errors clearly.
Fixes #41

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

200 lines
11 KiB
Python

#!/usr/bin/env python3
"""Compare Horizon core operations with both checksum-pinned vanilla game JARs."""
import argparse
from collections import Counter
import itertools
import json
import os
from pathlib import Path
import platform
import random
import subprocess
import zipfile
try:
from . import lab
except ImportError:
import lab
def corpus():
"""Stable inputs; Java supplies expected results instead of a second port."""
rng = random.Random(0x484F52495A4F4E)
edges = [-2147483648, -33554433, -33554432, -2097152, -2049, -2048,
-1025, -1024, -513, -512, -33, -32, -17, -16, -1, 0, 1,
15, 16, 31, 32, 511, 512, 2047, 2048, 2097151, 33554431, 33554432, 2147483647]
for axis in range(3):
for edge in edges:
p = [0, 0, 0]
p[axis] = edge
yield f"position {' '.join(map(str, p))} 1 -1 2147483647"
for x, y, z in itertools.product(range(16), repeat=3):
yield f"position {x - 16} {y - 16} {z - 16} 0 0 0"
for _ in range(8192):
values = [rng.randrange(-2147483648, 2147483648) for _ in range(6)]
yield "position " + " ".join(map(str, values))
seeds = [-9223372036854775808, -281474976710656, -1, 0, 1, 281474976710656,
9223372036854775807, *[rng.randrange(-2**63, 2**63) for _ in range(16)]]
for kind in ["legacy", "xoroshiro"]:
for seed in seeds:
yield f"{kind} {seed} 2200"
for lo, hi in [(0, 0), (0, 1), (1, 0), (-1, -1), (-9223372036854775808, 9223372036854775807)]:
yield f"xoroshiro_state {lo} {hi} 2200"
for bits in range(1, 33):
per_word = 64 // bits
for length in sorted({0, 1, per_word - 1, per_word, per_word + 1, 63, 64, 65, 4096}):
yield f"storage {bits} {length} {rng.randrange(-2**63, 2**63)}"
names = [[], [0], [0xffff], [0xd800], [0xdc00], [0xd800, 0x61, 0xdc00], [0xd83d, 0xde00], [0xdbff, 0xdfff],
*[[ord(c) for c in text] for text in ["minecraft:temperature", "octave_-3", "Aa", "BB", "Horizon", "àé中"]],
*[[0x61] * n for n in [55, 56, 63, 64, 65, 119, 120, 127, 128, 129, 1024]]]
for scenario in range(2200):
name = names[scenario % len(names)] if scenario % 3 else [rng.randrange(65536) for _ in range(rng.randrange(160))]
encoded = "".join(f"{unit:04x}" for unit in name) or "-"
lo = seeds[scenario % len(seeds)] if scenario % 3 else rng.randrange(-2**63, 2**63)
hi = lo if scenario % 11 == 0 else rng.randrange(-2**63, 2**63)
position = [rng.choice(edges) if scenario % 4 else rng.randrange(-2**31, 2**31) for _ in range(3)]
seed = lo if scenario % 7 == 0 else rng.randrange(-2**63, 2**63)
for kind in ["legacy", "xoroshiro"]:
yield f"factory {kind} {scenario % 2} {lo} {hi} {' '.join(map(str, position))} {seed} {encoded} 24"
double_edges = [0, 1, 0x8000000000000000, 0x3ff0000000000000, 0xbff0000000000000,
0x7fefffffffffffff, 0x7ff0000000000000, 0xfff0000000000000, 0x7ff8000000000000]
float_edges = [0, 1, 0x80000000, 0x3f800000, 0xbf800000, 0x7f7fffff, 0x7f800000, 0xff800000, 0x7fc00000]
interval_edges = [-(2**31), -(2**31)+1, -1, 0, 1, 2, 16, 2**30+1, 2**31-2, 2**31-1]
intervals = [*itertools.product(interval_edges, repeat=2),
*[(rng.randrange(-2**31,2**31),rng.randrange(-2**31,2**31)) for _ in range(2400)]]
for scenario, (minimum, maximum) in enumerate(intervals):
d = [rng.choice(double_edges) if scenario % 3 else rng.getrandbits(64) for _ in range(2)]
f = [rng.choice(float_edges) if scenario % 3 else rng.getrandbits(32) for _ in range(2)]
for kind in ["legacy", "xoroshiro"]:
yield f"helpers {kind} {seeds[scenario % len(seeds)]} {minimum} {maximum} {d[0]:016x} {d[1]:016x} {f[0]:08x} {f[1]:08x}"
def verify_artifacts():
"""Authenticate against the lock and the bundled checksums, not generated reports."""
lock, directory = lab.config()
manifest_path = directory / "manifest.json"
if not lab.manifest_accepted(lock, lab.digest(manifest_path, "sha1")):
raise ValueError("Manifest checksum changed; run tools/lab.py prepare")
manifest = json.loads(manifest_path.read_text())
if manifest["id"] != lock["minecraft"]["version"]:
raise ValueError("Manifest version differs from the lock")
jars = {}
for side in ["client", "server"]:
jar = directory / "jars" / f"{side}.jar"
if lab.digest(jar, "sha1") != manifest["downloads"][side]["sha1"]:
raise ValueError(f"{side} artifact checksum changed; run tools/lab.py prepare")
jars[side] = jar
libraries = []
with zipfile.ZipFile(jars["server"]) as archive:
versions = list(lab.bundle_entries(archive, "META-INF/versions.list"))
if len(versions) != 1 or versions[0][1] != manifest["id"]:
raise ValueError("Unexpected server bundle")
checksum, _, _ = versions[0]
jars["server"] = directory / "jars/server-game.jar"
if lab.digest(jars["server"], "sha256") != checksum:
raise ValueError("Inner server artifact checksum changed")
for checksum, _, relative in lab.bundle_entries(archive, "META-INF/libraries.list"):
library = directory / "libraries/server" / relative
if lab.digest(library, "sha256") != checksum:
raise ValueError(f"Dependency checksum changed: {relative}")
libraries.append(library)
return lock, jars, libraries
def compare(reference, candidate):
# Text mode retains cross-platform universal-newline behavior. Equal chunks
# avoid a Python loop for every line in multi-gigabyte differential traces.
count = 0
last = ''
with reference.open(encoding='utf-8') as expected, candidate.open(encoding='utf-8') as actual:
while True:
left, right = expected.read(1024 * 1024), actual.read(1024 * 1024)
if left != right:
break
if not left:
if not last:
raise AssertionError('Empty output cannot establish parity')
return count + (last != '\n')
count += left.count('\n')
last = left[-1]
# Preserve the existing exact first-line mismatch diagnostic. Mismatches are
# exceptional; successful runs use only the bounded chunk buffers above.
with reference.open(encoding='utf-8') as expected, candidate.open(encoding='utf-8') as actual:
for line, (left, right) in enumerate(itertools.zip_longest(expected, actual), 1):
if left != right:
raise AssertionError(f'Parity mismatch at output line {line}: vanilla={left!r}, Rust={right!r}')
raise AssertionError('Parity outputs changed during comparison')
def run(command, **kwargs):
# A hang guard only: a loaded machine (14 harnesses at once on the Mac mini) can take minutes for one oracle.
subprocess.run(command, cwd=lab.ROOT, check=True, timeout=900, **kwargs)
def source_hashes(sources):
return {str(Path(path).resolve().relative_to(lab.ROOT)): lab.digest(Path(path), "sha256") for path in sources}
def ensure_sources_unchanged(sources, expected):
actual = source_hashes(sources)
changed = sorted(key for key in expected.keys() | actual.keys() if expected.get(key) != actual.get(key))
if changed:
raise RuntimeError("Sources changed during validation: " + ", ".join(changed))
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--prepare", action="store_true", help="Download and verify the pinned reference artifacts first")
args = parser.parse_args()
build = lab.LOCAL / "rust-parity"
build.mkdir(parents=True, exist_ok=True)
# Remove a previous success marker before any build or comparison can fail.
report_path = build / "report.json"
report_path.unlink(missing_ok=True)
if args.prepare:
lab.prepare()
lock, jars, libraries = verify_artifacts()
commands = list(corpus())
inputs = build / "inputs.txt"
inputs.write_text("\n".join(commands) + "\n", encoding="utf-8")
source = lab.ROOT / "mods/horizon/src/test/java/dev/horizonlab/parity/VanillaCoreOracle.java"
sources = [source, Path(__file__), *sorted((lab.ROOT / "crates/horizon-core").rglob("*.rs")),
lab.ROOT / "crates/horizon-core/Cargo.toml", lab.ROOT / "Cargo.toml", lab.ROOT / "Cargo.lock"]
tested_sources = source_hashes(sources)
classes = build / "classes"
classes.mkdir(exist_ok=True)
dependency_path = os.pathsep.join(map(str, libraries))
run(["javac", "--release", str(lock["minecraft"]["java_major"]), "-proc:none",
"-cp", os.pathsep.join([str(jars["server"]), dependency_path]), "-d", str(classes), str(source)])
run(["cargo", "build", "--release", "--bin", "horizon-parity"])
# Ask Cargo for its artifact path so custom target directories work too.
metadata = json.loads(subprocess.check_output(["cargo", "metadata", "--no-deps", "--format-version", "1"], cwd=lab.ROOT, text=True))
binary = Path(metadata["target_directory"]) / "release" / ("horizon-parity.exe" if os.name == "nt" else "horizon-parity")
candidate = build / "rust.txt"
with inputs.open("rb") as stdin, candidate.open("wb") as stdout:
run([str(binary)], stdin=stdin, stdout=stdout)
references = []
for side, jar in jars.items():
reference = build / f"vanilla-{side}.txt"
run(["java", "-Xmx512M", "-cp", os.pathsep.join([str(classes), str(jar), dependency_path]),
"dev.horizonlab.parity.VanillaCoreOracle", str(inputs), str(reference)])
matched = compare(reference, candidate)
references.append({"side": side, "jar_sha256": lab.digest(jar, "sha256"),
"output_sha256": lab.digest(reference, "sha256"), "matched_output_lines": matched})
print(f"{side}: {matched:,} output lines match the Rust implementation", flush=True)
ensure_sources_unchanged(sources, tested_sources)
report = {"schema": 1, "minecraft": lock["minecraft"]["version"], "platform": platform.platform(),
"java": subprocess.check_output(["java", "--version"], text=True).splitlines()[0],
"rustc": subprocess.check_output(["rustc", "--version"], text=True).strip(),
"manifest_sha1": lock["minecraft"]["manifest_sha1"], "commands": dict(Counter(line.split()[0] for line in commands)),
"inputs_sha256": lab.digest(inputs, "sha256"), "rust_output_sha256": lab.digest(candidate, "sha256"),
"sources_sha256": tested_sources,
"references": references,
"scope": "Selected integer positions, seeded RNG operations including inclusive/exclusive integer intervals and float/double triangular draws, positional factories, UTF-16 Java string hashing, MD5-derived seeds and packed-storage operations. Floating-point helper NaNs canonicalized; finite results and signed zeros compared by bits. No gameplay, rendering or performance claim."}
lab.write_json(report_path, report)
print(f"Scoped parity checks passed: {report_path}")
if __name__ == "__main__":
main()