version_port.py: detect, prepare and diff Minecraft releases

detect reads Mojang's manifest; prepare downloads and verifies a version into the
version store (outside the repository), runs its data generator and reads the
jar's own version facts; diff compares facts, packets, registries, blocks, item
components, commands, data packs, assets and lang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-15 13:38:29 +02:00
parent 720e91fb8c
commit 58ad81674b
3 changed files with 703 additions and 1 deletions
+9 -1
View File
@@ -260,10 +260,18 @@ The replay of 26.2 → 26.3 below measures how much that is for one release.
## Implementation status
```sh
python3 tools/version_port.py detect --snapshots # exit 10: new release, 11: new snapshot
python3 tools/version_port.py prepare 26.3 # the pinned version: indexes .local, downloads nothing
python3 tools/version_port.py prepare 26.4 # downloads, verifies, runs the data generator (about 10 s)
python3 tools/version_port.py diff 26.3 26.4 # summary; the full diff in versions/diff-26.3-26.4/diff.json
python3 tools/version_port.py gc # keep the pinned version and the newest two others
```
| Slice | Status |
| --- | --- |
| This design | this page |
| `detect`, `prepare`, `diff` (facts, packets, registries, blocks, items, commands, data, assets) | next PR |
| `detect`, `prepare`, `diff` (facts, packets, registries, blocks, items, commands, data, assets), `gc` | done |
| Class index, class diff, `vanilla_map.json` | next PR |
| Port report (`report`), `port` | next PR |
| Changed-class decompilation, `regen`, `version_consts` refactor, candidate harness run, `publish`, launchd agent | later |
+144
View File
@@ -0,0 +1,144 @@
import json
from pathlib import Path
import tempfile
import unittest
import zipfile
from tools import version_port as port
def jar(path, files):
with zipfile.ZipFile(path, "w") as archive:
for name, value in files.items():
archive.writestr(name, value if isinstance(value, (str, bytes)) else json.dumps(value))
return path
class DetectTests(unittest.TestCase):
MANIFEST = {"latest": {"release": "26.4", "snapshot": "26.5-snapshot-1"}}
def test_new_release_and_snapshot(self):
self.assertEqual(port.news(self.MANIFEST, "26.3", {}), [("release", "26.4")])
self.assertEqual(port.news(self.MANIFEST, "26.3", {}, snapshots=True),
[("release", "26.4"), ("snapshot", "26.5-snapshot-1")])
def test_pinned_or_seen_is_not_news(self):
self.assertEqual(port.news(self.MANIFEST, "26.4", {}), [])
self.assertEqual(port.news(self.MANIFEST, "26.3", {"release": "26.4", "snapshot": "26.5-snapshot-1"},
snapshots=True), [])
def test_a_release_is_not_also_reported_as_the_latest_snapshot(self):
manifest = {"latest": {"release": "26.4", "snapshot": "26.4"}}
self.assertEqual(port.news(manifest, "26.3", {}, snapshots=True), [("release", "26.4")])
def test_unsafe_versions_are_refused(self):
for version in ["..", "26.4/../x", ""]:
with self.assertRaises(ValueError):
port.safe_version(version)
class ReportDiffTests(unittest.TestCase):
def test_json_diff_paths(self):
self.assertEqual(port.json_diff({"a": 1, "b": [1, 2]}, {"a": 2, "b": [1, 3], "c": True}),
[("/a", 1, 2), ("/b[1]", 2, 3), ("/c", None, True)])
self.assertEqual(port.json_diff([1], [1, 2]), [("/", [1], [1, 2])])
self.assertEqual(len(port.json_diff({str(i): i for i in range(9)}, {}, limit=3)), 3)
def test_packets_added_removed_renumbered(self):
old = {"play": {"serverbound": {"minecraft:swing": {"protocol_id": 0}, "minecraft:use": {"protocol_id": 1}}}}
new = {"play": {"serverbound": {"minecraft:punch": {"protocol_id": 0}, "minecraft:use": {"protocol_id": 2},
"minecraft:x": {"protocol_id": 1}}}}
self.assertEqual(port.diff_packets(old, new), {"play/serverbound": {
"added": {"minecraft:punch": 0, "minecraft:x": 1}, "removed": {"minecraft:swing": 0},
"renumbered": {"minecraft:use": [1, 2]}, "count": [2, 3]}})
self.assertEqual(port.diff_packets(old, old), {})
def test_registries(self):
def registry(*names, default=None):
return {"default": default, "entries": {n: {"protocol_id": i} for i, n in enumerate(names)}}
old = {"minecraft:item": registry("air", "stone"), "minecraft:gone": registry("a")}
new = {"minecraft:item": registry("air", "dirt", "stone"), "minecraft:new": registry("b")}
self.assertEqual(port.diff_registries(old, new), {
"added": {"minecraft:new": ["b"]}, "removed": ["minecraft:gone"],
"changed": {"minecraft:item": {"added": ["dirt"], "ids_shifted": 1}}})
def test_blocks_state_ids_and_format(self):
def block(first, *values, default=0, **extra):
states = [{"id": first + i, "properties": {"p": v}} for i, v in enumerate(values)]
states[default]["default"] = True
return {"properties": {"p": list(values)}, "states": states, **extra}
old = {"a": block(0, "x", "y"), "b": block(2, "x")}
new = {"a": block(0, "x", "y", "z", default=1, definition={}),
"b": block(3, "x", definition={}), "c": block(4, "x", definition={})}
result = port.diff_blocks(old, new)
self.assertEqual(result["added"], ["c"])
self.assertEqual(result["format"], ["definition"])
self.assertEqual(set(result["changed"]), {"a"})
self.assertEqual(result["changed"]["a"]["states"], [2, 3])
self.assertEqual(result["changed"]["a"]["default"], [{"p": "x"}, {"p": "y"}])
self.assertEqual((result["state_ids_shifted"], result["first_shifted"]), (1, "b"))
def test_items_by_component(self):
old = {"stone": {"swing": {}, "name": "Stone"}, "dirt": {"swing": {}}}
new = {"stone": {"attack": {}, "name": "Rock"}, "dirt": {"attack": {}}, "mud": {}}
result = port.diff_items(old, new)
self.assertEqual(result["added"], ["mud"])
self.assertEqual(result["components"], {"attack": {"added": 2, "removed": 0, "changed": 0},
"swing": {"added": 0, "removed": 2, "changed": 0},
"name": {"added": 0, "removed": 0, "changed": 1}})
def test_command_paths(self):
def literal(**children):
return {"type": "literal", "children": children} if children else {"type": "literal", "executable": True}
old = {"type": "root", "children": {"data": literal(get=literal(block=literal(targetPos=literal())))}}
new = {"type": "root", "children": {"data": literal(get=literal(block=literal(pos=literal()))),
"compute": literal()}}
result = port.diff_commands(old, new)
self.assertEqual(result["added"], ["compute", "data get block pos"])
self.assertEqual(result["removed"], ["data get block targetPos"])
class JarDiffTests(unittest.TestCase):
def test_contents_lang_and_facts(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
common = {"data/minecraft/tags/block/logs.json": {"values": ["oak"]}}
old = jar(root / "old.jar", {**common,
"data/minecraft/recipe/a.json": {"result": "a", "count": 1},
"data/minecraft/recipe/gone.json": {},
"assets/minecraft/lang/en_us.json": {"k": "K", "same": "S"}})
new = jar(root / "new.jar", {**common,
"data/minecraft/recipe/a.json": {"result": "a", "count": 2},
"data/minecraft/worldgen/biome/new.json": {},
"assets/minecraft/lang/en_us.json": {"k": "K2", "same": "S", "n": "N"},
"version.json": {"id": "26.4", "world_version": 5100,
"protocol_version": 778, "java_version": 25,
"pack_version": {"resource_major": 98, "resource_minor": 0,
"data_major": 122, "data_minor": 0}}})
data = port.diff_contents(old, new, "data/minecraft/")
self.assertEqual(data["counts"], {"added": 1, "removed": 1, "changed": 1})
self.assertEqual(data["kinds"]["recipe"], {"added": [], "removed": ["data/minecraft/recipe/gone.json"],
"changed": ["data/minecraft/recipe/a.json"]})
self.assertEqual(data["kinds"]["worldgen/biome"]["added"], ["data/minecraft/worldgen/biome/new.json"])
self.assertEqual(data["json"], {"data/minecraft/recipe/a.json": [("/count", 1, 2)]})
self.assertEqual(port.diff_lang(old, new), {"added": ["n"], "removed": [], "changed": ["k"]})
facts = port.jar_facts(new)
self.assertEqual((facts["world_version"], facts["protocol_version"], facts["data_pack"]),
(5100, 778, "122.0"))
self.assertEqual(port.diff_facts({"id": "a", "protocol_version": 777}, {"id": "b", "protocol_version": 778}),
{"protocol_version": [777, 778]})
class GcTests(unittest.TestCase):
def test_keeps_pinned_newest_two_and_their_diffs(self):
entries = [("26.1", 1, True), ("26.2", 2, True), ("26.3", 3, True), ("26.4-snapshot-1", 4, True),
("26.4-snapshot-2", 5, True), ("diff-26.2-26.3", 6, False),
("diff-26.3-26.4-snapshot-2", 7, False), (".datagen-x", 8, False), ("detect-state.json", 9, False)]
self.assertEqual(port.to_remove(entries, "26.3", set()),
[".datagen-x", "26.1", "26.2", "diff-26.2-26.3"])
self.assertEqual(port.to_remove(entries, "26.3", {"26.1"}),
[".datagen-x", "26.2", "diff-26.2-26.3"])
if __name__ == "__main__":
unittest.main()
+550
View File
@@ -0,0 +1,550 @@
#!/usr/bin/env python3
"""Port a new Minecraft release to Horizon: detect it, prepare it, diff it against the pinned one
(docs/version-ports.md).
version_port.py detect [--snapshots] what Mojang released that Horizon doesn't pin
version_port.py prepare VERSION download, verify, run the data generator, read its facts
version_port.py diff OLD NEW facts, packets, registries, blocks, items, commands,
data packs, assets and lang: diff-OLD-NEW/diff.json
version_port.py gc [--keep VERSION...] keep the pinned, the newest two others and --keep
The downloads are for development only: they go to the version store (~/code/horizon-parity/versions,
HORIZON_VERSIONS overrides it), never into the repository, and nothing from them is shipped. The pinned
version is not downloaded: its files are read where tools/lab.py prepared them (.local/).
detect exits 0 when nothing is new, 10 for a new release and 11 for a new snapshot (with --snapshots).
"""
import argparse
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import zipfile
try:
from . import lab
except ImportError:
import lab
MANIFEST_URL = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
STORE = Path(os.environ.get("HORIZON_VERSIONS", Path.home() / "code" / "horizon-parity" / "versions"))
STATES = ["handshake", "status", "login", "configuration", "play"]
# How many changed JSON paths one changed file keeps in diff.json.
PATH_LIMIT = 50
def safe_version(version):
if not re.fullmatch(r"[A-Za-z0-9_.-]+", version) or version in {".", ".."}:
raise ValueError(f"Unsafe version identifier: {version!r}")
return version
def fetch_json(url, sha1=None):
request = urllib.request.Request(url, headers={"User-Agent": "horizon/0.1"})
with urllib.request.urlopen(request, timeout=60) as response:
data = response.read()
if sha1 is not None and hashlib.sha1(data).hexdigest() != sha1:
raise ValueError(f"{url}: sha1 mismatch")
return data
def pinned():
return json.loads(lab.LOCK.read_text(encoding="utf-8"))["minecraft"]["version"]
# --- detect ------------------------------------------------------------------------------------------------
def news(manifest, pin, seen, snapshots=False):
"""[(kind, version)] Mojang lists that are neither pinned nor seen before, newest kind first."""
found = []
for kind in ["release"] + (["snapshot"] if snapshots else []):
latest = manifest["latest"][kind]
if latest != pin and latest != seen.get(kind):
# The latest snapshot is the latest release right after a release: report it once.
if kind == "snapshot" and latest == manifest["latest"]["release"]:
continue
found.append((kind, latest))
return found
def detect(args):
manifest = json.loads(fetch_json(MANIFEST_URL))
state_path = STORE / "detect-state.json"
seen = json.loads(state_path.read_text()) if state_path.exists() else {}
found = news(manifest, pinned(), seen, args.snapshots)
if not found:
print(f"Nothing new: latest release {manifest['latest']['release']}, Horizon pins {pinned()}")
return 0
for kind, version in found:
print(f"New {kind}: {version} (Horizon pins {pinned()})")
seen[kind] = version
if not args.dry_run:
lab.write_json(state_path, seen)
return 10 if found[0][0] == "release" else 11
# --- prepare -----------------------------------------------------------------------------------------------
def version_dir(version):
return STORE / safe_version(version)
def paths(version):
"""The prepared files of a version: {client, server, server_game, reports, version_json}."""
record = version_dir(version) / "prepared.json"
if not record.exists():
raise SystemExit(f"{version} is not prepared; run version_port.py prepare {version}")
return {key: Path(value) for key, value in json.loads(record.read_text())["paths"].items()}
def jar_facts(client_jar):
"""The facts the game states about itself in the client jar's version.json."""
with zipfile.ZipFile(client_jar) as archive:
info = json.loads(archive.read("version.json"))
pack = info.get("pack_version", {})
return {
"id": info["id"],
"world_version": info["world_version"],
"protocol_version": info["protocol_version"],
"resource_pack": f"{pack.get('resource_major')}.{pack.get('resource_minor')}",
"data_pack": f"{pack.get('data_major')}.{pack.get('data_minor')}",
"java_version": info["java_version"],
"stable": info.get("stable"),
}
def unpack_game(server, destination, version):
"""The server download is a bundler: take its game jar only (the libraries stay inside it)."""
with zipfile.ZipFile(server) as archive:
if "META-INF/versions.list" not in archive.namelist():
shutil.copyfile(server, destination)
return destination
entries = list(lab.bundle_entries(archive, "META-INF/versions.list"))
if len(entries) != 1 or entries[0][1] != version:
raise ValueError(f"Expected one bundled server for {version}")
checksum, _, relative = entries[0]
lab.extract_verified(archive, f"META-INF/versions/{relative}", destination, checksum)
return destination
def java_command(major):
java = Path(os.environ["JAVA_HOME"]) / "bin" / "java" if os.environ.get("JAVA_HOME") else shutil.which("java")
if not java:
raise SystemExit("Java not found: set JAVA_HOME or put java on the PATH")
result = subprocess.run([str(java), "-version"], capture_output=True, text=True)
found = re.search(r'version "(\d+)', result.stderr + result.stdout)
if not found or int(found[1]) < major:
raise SystemExit(f"This version needs Java {major}; {java} is {found[1] if found else 'unknown'}. "
f"Install JDK {major} (a fleet toolchain change) and rerun.")
return str(java)
def run_datagen(server, reports, major):
"""vanilla's data generator, --reports only. The bundler unpacks its libraries into a scratch folder."""
scratch = Path(tempfile.mkdtemp(prefix=".datagen-", dir=STORE))
out = scratch / "out"
try:
log = scratch / "datagen.log"
with log.open("w") as stream:
result = subprocess.run([java_command(major), "-DbundlerMainClass=net.minecraft.data.Main", "-jar",
str(server), "--reports", "--output", str(out)],
cwd=scratch, stdout=stream, stderr=subprocess.STDOUT)
if result.returncode:
raise SystemExit(f"The data generator failed:\n{log.read_text()[-3000:]}")
if reports.exists():
shutil.rmtree(reports)
shutil.move(str(out / "reports"), reports)
finally:
shutil.rmtree(scratch, ignore_errors=True)
def prepare_pinned(version, directory):
"""The pinned version's files are already in .local: record where, don't copy them."""
game = lab.LOCAL / "minecraft" / version
found = {
"client": game / "jars" / "client.jar",
"server": game / "jars" / "server.jar",
"server_game": game / "jars" / "server-game.jar",
"reports": lab.LOCAL / "datagen" / "out" / "reports",
"version_json": game / "manifest.json",
}
missing = [str(p) for p in found.values() if not p.exists()]
if missing:
raise SystemExit("The pinned version is not prepared in .local (tools/lab.py prepare, horizon-prepare): "
+ ", ".join(missing))
return {key: path.resolve() for key, path in found.items()}
def prepare_download(version, directory):
manifest = json.loads(fetch_json(MANIFEST_URL))
entry = next((v for v in manifest["versions"] if v["id"] == version), None)
if entry is None:
raise SystemExit(f"Mojang's manifest has no version {version}")
version_json = directory / "version.json"
if not (version_json.exists() and lab.digest(version_json, "sha1") == entry["sha1"]):
version_json.write_bytes(fetch_json(entry["url"], entry["sha1"]))
meta = json.loads(version_json.read_text())
if "client_mappings" in meta["downloads"]:
raise SystemExit(f"{version} is obfuscated; the pipeline handles unobfuscated releases (26.1 and later)")
jars = directory / "jars"
found = {"version_json": version_json}
for side in ["client", "server"]:
download = meta["downloads"][side]
print(f"Fetching the {version} {side} jar ({download['size'] // 1_000_000} MB)", flush=True)
found[side] = lab.download(download["url"], jars / f"{side}.jar", download["sha1"])
found["server_game"] = unpack_game(found["server"], jars / "server-game.jar", version)
found["reports"] = directory / "datagen" / "reports"
if not found["reports"].exists():
print(f"Running the {version} data generator", flush=True)
run_datagen(found["server"], found["reports"], meta["javaVersion"]["majorVersion"])
return found
def prepare(args):
version = safe_version(args.version)
directory = version_dir(version)
directory.mkdir(parents=True, exist_ok=True)
found = (prepare_pinned if version == pinned() and not args.download else prepare_download)(version, directory)
facts = jar_facts(found["client"])
if facts["id"] != version:
raise ValueError(f"The client jar says it is {facts['id']}, not {version}")
lab.write_json(directory / "facts.json", facts)
lab.write_json(directory / "prepared.json", {
"version": version, "pinned": version == pinned() and not args.download,
"paths": {key: str(path) for key, path in found.items()},
})
print(json.dumps(facts, indent=2))
return 0
# --- diff --------------------------------------------------------------------------------------------------
def clip(value, limit=200):
text = json.dumps(value, sort_keys=True)
return value if len(text) <= limit else text[:limit] + "..."
def json_diff(old, new, path="", limit=PATH_LIMIT):
"""[(path, old, new)] where two JSON values differ; lists of different lengths count as one change."""
changes = []
def walk(a, b, at):
if len(changes) >= limit or a == b:
return
if isinstance(a, dict) and isinstance(b, dict):
for key in sorted(a.keys() | b.keys()):
walk(a.get(key), b.get(key), f"{at}/{key}")
elif isinstance(a, list) and isinstance(b, list) and len(a) == len(b):
for index, (x, y) in enumerate(zip(a, b)):
walk(x, y, f"{at}[{index}]")
else:
changes.append((at or "/", clip(a), clip(b)))
walk(old, new, path)
return changes
def keyed_diff(old, new):
"""Added, removed and common keys of two mappings."""
return sorted(new.keys() - old.keys()), sorted(old.keys() - new.keys()), sorted(old.keys() & new.keys())
def diff_facts(old, new):
return {key: [old.get(key), new.get(key)] for key in sorted(old.keys() | new.keys())
if key != "id" and old.get(key) != new.get(key)}
def diff_packets(old, new):
"""Per state and direction: packets added, removed and renumbered (the ids are on the wire)."""
result = {}
for state in sorted(old.keys() | new.keys(), key=lambda s: (STATES.index(s) if s in STATES else 99, s)):
for direction in sorted(old.get(state, {}).keys() | new.get(state, {}).keys()):
a = {name: e["protocol_id"] for name, e in old.get(state, {}).get(direction, {}).items()}
b = {name: e["protocol_id"] for name, e in new.get(state, {}).get(direction, {}).items()}
added, removed, common = keyed_diff(a, b)
renumbered = {name: [a[name], b[name]] for name in common if a[name] != b[name]}
if added or removed or renumbered:
result[f"{state}/{direction}"] = {
"added": {name: b[name] for name in added}, "removed": {name: a[name] for name in removed},
"renumbered": renumbered, "count": [len(a), len(b)]}
return result
def diff_registries(old, new):
"""Registries added and removed; per registry its entries added and removed, and how many common
entries changed protocol id (the network sends those ids)."""
added, removed, common = keyed_diff(old, new)
result = {"added": {name: sorted(new[name].get("entries", {})) for name in added}, "removed": removed,
"changed": {}}
for name in common:
a, b = old[name].get("entries", {}), new[name].get("entries", {})
entries_added, entries_removed, shared = keyed_diff(a, b)
shifted = [e for e in shared if a[e].get("protocol_id") != b[e].get("protocol_id")]
change = {}
if entries_added:
change["added"] = entries_added
if entries_removed:
change["removed"] = entries_removed
if shifted:
change["ids_shifted"] = len(shifted)
if old[name].get("default") != new[name].get("default"):
change["default"] = [old[name].get("default"), new[name].get("default")]
if change:
result["changed"][name] = change
return result
def diff_blocks(old, new):
"""Blocks added and removed; per block property changes, state counts and default state; and the
global block-state ids (the network and the chunk palette use them)."""
added, removed, common = keyed_diff(old, new)
# A field one report has for no block at all is a change of the report's format, not of every block.
fields = [{key for block in side.values() for key in block} for side in (old, new)]
format_change = sorted(fields[0] ^ fields[1])
def default(block):
return next((s.get("properties", {}) for s in block["states"] if s.get("default")), {})
def first_id(block):
return min(s["id"] for s in block["states"])
changed = {}
for name in common:
a, b = old[name], new[name]
change = {}
if a.get("properties", {}) != b.get("properties", {}):
change["properties"] = json_diff(a.get("properties", {}), b.get("properties", {}))
if len(a["states"]) != len(b["states"]):
change["states"] = [len(a["states"]), len(b["states"])]
if default(a) != default(b):
change["default"] = [default(a), default(b)]
if a.get("definition") != b.get("definition") and "definition" not in format_change:
change["definition"] = json_diff(a.get("definition"), b.get("definition"))
if change:
changed[name] = change
shifted = [name for name in common if first_id(old[name]) != first_id(new[name])]
first = min(shifted, key=lambda n: first_id(new[n]), default=None)
return {"added": added, "removed": removed, "changed": changed, "format": format_change,
"state_count": [sum(len(b["states"]) for b in old.values()), sum(len(b["states"]) for b in new.values())],
"state_ids_shifted": len(shifted), "first_shifted": first}
def diff_items(old, new):
"""Items added and removed; per item the default components added, removed and changed."""
added, removed, common = keyed_diff(old, new)
changed = {}
for name in common:
a, b = old[name], new[name]
components_added, components_removed, shared = keyed_diff(a, b)
values = {c: json_diff(a[c], b[c], limit=10) for c in shared if a[c] != b[c]}
if components_added or components_removed or values:
changed[name] = {"added": components_added, "removed": components_removed, "changed": values}
components = {}
for change in changed.values():
for label in ("added", "removed", "changed"):
for component in change[label]:
components.setdefault(component, {"added": 0, "removed": 0, "changed": 0})[label] += 1
return {"added": added, "removed": removed, "changed": changed, "components": components}
def command_nodes(tree):
"""Every node of a commands report by its path ("execute as targets"), with what defines it."""
nodes = {}
def walk(node, path):
for name, child in node.get("children", {}).items():
at = f"{path} {name}".strip()
nodes[at] = {k: child[k] for k in ("type", "parser", "properties", "executable", "redirect", "permissions")
if k in child}
walk(child, at)
walk(tree, "")
return nodes
def diff_commands(old, new):
a, b = command_nodes(old), command_nodes(new)
added, removed, common = keyed_diff(a, b)
return {"added": added, "removed": removed,
"changed": {path: json_diff(a[path], b[path], limit=10) for path in common if a[path] != b[path]}}
def jar_entries(jar, prefix):
"""{path: crc} of a jar's files under prefix."""
with zipfile.ZipFile(jar) as archive:
return {i.filename: i.CRC for i in archive.infolist() if i.filename.startswith(prefix) and not i.is_dir()}
def content_kind(path):
"""data/minecraft/worldgen/biome/x.json -> worldgen/biome; assets/minecraft/models/block/x.json -> models/block."""
parts = path.split("/")[2:-1]
if parts and parts[0] in {"worldgen", "tags"} and len(parts) > 1:
return "/".join(parts[:2])
return parts[0] if parts else "(root)"
def diff_contents(old_jar, new_jar, prefix):
"""Files under prefix added, removed and changed (by CRC), by kind; changed JSON files with their paths."""
a, b = jar_entries(old_jar, prefix), jar_entries(new_jar, prefix)
added, removed, common = keyed_diff(a, b)
changed = [p for p in common if a[p] != b[p]]
kinds = {}
for label, names in [("added", added), ("removed", removed), ("changed", changed)]:
for name in names:
kinds.setdefault(content_kind(name), {"added": [], "removed": [], "changed": []})[label].append(name)
details = {}
with zipfile.ZipFile(old_jar) as left, zipfile.ZipFile(new_jar) as right:
for name in changed:
if name.endswith(".json") and not name.endswith("/lang/en_us.json"):
try:
details[name] = json_diff(json.loads(left.read(name)), json.loads(right.read(name)))
except ValueError:
pass
return {"kinds": dict(sorted(kinds.items())), "json": details,
"counts": {"added": len(added), "removed": len(removed), "changed": len(changed)}}
def diff_lang(old_jar, new_jar, name="assets/minecraft/lang/en_us.json"):
with zipfile.ZipFile(old_jar) as left, zipfile.ZipFile(new_jar) as right:
a, b = json.loads(left.read(name)), json.loads(right.read(name))
added, removed, common = keyed_diff(a, b)
return {"added": added, "removed": removed, "changed": [k for k in common if a[k] != b[k]]}
def read_reports(reports):
items = {}
for path in sorted((reports / "minecraft" / "components" / "item").glob("*.json")):
items[f"minecraft:{path.stem}"] = json.loads(path.read_text())["components"]
return {
"packets": json.loads((reports / "packets.json").read_text()),
"registries": json.loads((reports / "registries.json").read_text()),
"blocks": json.loads((reports / "blocks.json").read_text()),
"commands": json.loads((reports / "commands.json").read_text()),
"items": items,
}
def diff_versions(old_paths, new_paths, old_facts, new_facts):
a, b = read_reports(old_paths["reports"]), read_reports(new_paths["reports"])
return {
"facts": diff_facts(old_facts, new_facts),
"packets": diff_packets(a["packets"], b["packets"]),
"registries": diff_registries(a["registries"], b["registries"]),
"blocks": diff_blocks(a["blocks"], b["blocks"]),
"items": diff_items(a["items"], b["items"]),
"commands": diff_commands(a["commands"], b["commands"]),
"data": diff_contents(old_paths["client"], new_paths["client"], "data/minecraft/"),
"assets": diff_contents(old_paths["client"], new_paths["client"], "assets/minecraft/"),
"lang": diff_lang(old_paths["client"], new_paths["client"]),
}
def summary(old, new, result):
"""A few lines a person reads first."""
lines = [f"Minecraft {old} -> {new}"]
for key, (a, b) in result["facts"].items():
lines.append(f" {key}: {a} -> {b}")
for flow, change in result["packets"].items():
lines.append(f" packets {flow}: {change['count'][0]} -> {change['count'][1]}, +{len(change['added'])} "
f"-{len(change['removed'])}, {len(change['renumbered'])} renumbered")
registries = result["registries"]
lines.append(f" registries: +{len(registries['added'])} -{len(registries['removed'])}, "
f"{len(registries['changed'])} changed")
for name, change in registries["changed"].items():
lines.append(f" {name}: +{len(change.get('added', []))} -{len(change.get('removed', []))}"
+ (f", {change['ids_shifted']} ids shifted" if change.get("ids_shifted") else ""))
blocks = result["blocks"]
lines.append(f" blocks: +{len(blocks['added'])} -{len(blocks['removed'])}, {len(blocks['changed'])} changed, "
f"states {blocks['state_count'][0]} -> {blocks['state_count'][1]}, "
f"{blocks['state_ids_shifted']} blocks' state ids shifted")
if blocks["format"]:
lines.append(f" report format: fields {', '.join(blocks['format'])} appear or disappear")
items = result["items"]
lines.append(f" items: +{len(items['added'])} -{len(items['removed'])}, {len(items['changed'])} with "
f"changed default components")
ranked = sorted(items["components"].items(), key=lambda kv: -sum(kv[1].values()))
for component, counts in ranked[:10]:
lines.append(f" {component}: " + ", ".join(f"{k} on {n}" for k, n in counts.items() if n))
commands = result["commands"]
lines.append(f" command nodes: +{len(commands['added'])} -{len(commands['removed'])}, "
f"{len(commands['changed'])} changed")
for key in ["data", "assets"]:
counts = result[key]["counts"]
lines.append(f" {key} files: +{counts['added']} -{counts['removed']} ~{counts['changed']}")
lang = result["lang"]
lines.append(f" en_us keys: +{len(lang['added'])} -{len(lang['removed'])} ~{len(lang['changed'])}")
return "\n".join(lines)
def diff_dir(old, new):
return STORE / f"diff-{safe_version(old)}-{safe_version(new)}"
def diff(args):
facts = {v: json.loads((version_dir(v) / "facts.json").read_text()) for v in (args.old, args.new)}
result = diff_versions(paths(args.old), paths(args.new), facts[args.old], facts[args.new])
directory = diff_dir(args.old, args.new)
lab.write_json(directory / "diff.json", result)
print(summary(args.old, args.new, result))
print(f"\n{directory / 'diff.json'}")
return 0
# --- gc ----------------------------------------------------------------------------------------------------
def to_remove(entries, pin, keep):
"""Version store entries to delete: every version but the pinned one, --keep and the newest two others;
diffs whose versions are gone; scratch folders. entries: [(name, mtime, is_version)]."""
versions = sorted(((m, n) for n, m, is_version in entries if is_version and n not in keep | {pin}), reverse=True)
kept = keep | {pin} | {n for _, n in versions[:2]}
remove = []
for name, _, is_version in entries:
if name.startswith("."):
remove.append(name)
elif is_version and name not in kept:
remove.append(name)
elif name.startswith("diff-") and not any(name == f"diff-{a}-{b}" for a in kept for b in kept):
remove.append(name)
return sorted(remove)
def gc(args):
if not STORE.exists():
return 0
entries = [(p.name, p.stat().st_mtime, (p / "prepared.json").exists()) for p in STORE.iterdir() if p.is_dir()]
for name in to_remove(entries, pinned(), set(args.keep)):
print(f"Removing {STORE / name}")
if not args.dry_run:
shutil.rmtree(STORE / name)
return 0
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
commands = parser.add_subparsers(dest="command", required=True)
command = commands.add_parser("detect", help="what Mojang released that Horizon doesn't pin")
command.add_argument("--snapshots", action="store_true")
command.add_argument("--dry-run", action="store_true", help="don't remember what was found")
command = commands.add_parser("prepare", help="download, verify and run the data generator")
command.add_argument("version")
command.add_argument("--download", action="store_true", help="download even the pinned version")
command = commands.add_parser("diff", help="diff two prepared versions")
command.add_argument("old")
command.add_argument("new")
command = commands.add_parser("gc", help="delete versions and scratch the store doesn't need")
command.add_argument("--keep", nargs="*", default=[])
command.add_argument("--dry-run", action="store_true")
args = parser.parse_args()
STORE.mkdir(parents=True, exist_ok=True)
return {"detect": detect, "prepare": prepare, "diff": diff, "gc": gc}[args.command](args)
if __name__ == "__main__":
sys.exit(main())