37898e856b
Horizon no longer fetches anything of Minecraft from Mojang's servers. The client jar, version JSON, libraries and assets come from the user's official launcher install, and the server jar is one they downloaded themselves (--server-jar or HORIZON_SERVER_JAR). Every file is checked against checksums pinned in research-lock.json, and the prepared .local/ layout is byte-identical to the old downloads, so the harnesses and the native client are unchanged. CI keeps the offline checks on GitHub's runners; the vanilla comparisons and the mod build need the game, so they move to a manual job on a self-hosted runner that holds the maintainer's own copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
7.5 KiB
Python
181 lines
7.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Prepare an isolated game runtime from your own launcher install; local smoke tests use demo mode."""
|
|
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import urllib.error
|
|
import zipfile
|
|
|
|
try:
|
|
from . import lab
|
|
except ImportError:
|
|
import lab
|
|
|
|
ROOT = lab.ROOT
|
|
|
|
|
|
def install_original_mod(source, mod_dir, filename=None):
|
|
"""Replace our own previous versions, retaining backups outside Fabric's mod scan."""
|
|
mod_dir.mkdir(parents=True, exist_ok=True)
|
|
filename = filename or source.name
|
|
# Retire both current and pre-rebrand filenames to avoid duplicate mod IDs.
|
|
candidates = sorted({*mod_dir.glob("horizon-*.jar"), *mod_dir.glob("horizon-lab-*.jar")})
|
|
for old in candidates:
|
|
if old.name == filename:
|
|
continue
|
|
with zipfile.ZipFile(old) as archive:
|
|
if json.loads(archive.read("fabric.mod.json"))["id"] != "horizonlab":
|
|
raise ValueError(f"Refusing to replace another mod: {old}")
|
|
backup = mod_dir.parent / ".horizon-mod-backups"
|
|
backup.mkdir(exist_ok=True)
|
|
destination = backup / f"{old.stem}-{lab.digest(old, 'sha256')[:12]}.jar"
|
|
shutil.move(str(old), str(destination))
|
|
if source.resolve() != (mod_dir / filename).resolve():
|
|
shutil.copyfile(source, mod_dir / filename)
|
|
CACHE = ROOT / ".local" / "runtime"
|
|
|
|
|
|
def jvm_options(heap, compact_headers=False, collector="default"):
|
|
options = ["-Xms512M", f"-Xmx{heap}"]
|
|
if compact_headers:
|
|
options.append("-XX:+UseCompactObjectHeaders")
|
|
if collector != "default":
|
|
options.append({"g1": "-XX:+UseG1GC", "zgc": "-XX:+UseZGC"}[collector])
|
|
return options
|
|
|
|
|
|
def fetch(url, path, checksum, algorithm="sha1"):
|
|
for attempt in range(3):
|
|
try:
|
|
return lab.download(url, path, checksum, algorithm)
|
|
except (OSError, urllib.error.URLError):
|
|
if attempt == 2:
|
|
raise
|
|
time.sleep(attempt + 1)
|
|
|
|
|
|
def fabric_libraries():
|
|
profile = json.loads((ROOT / "packs/fabric.lock.json").read_text())
|
|
jars = []
|
|
for library in profile["libraries"]:
|
|
group, artifact, version = library["name"].split(":")
|
|
relative = f"{group.replace('.', '/')}/{artifact}/{version}/{artifact}-{version}.jar"
|
|
jars.append(fetch(library["url"] + relative, CACHE / "fabric" / relative,
|
|
library["sha256"], "sha256"))
|
|
return jars
|
|
|
|
|
|
def library_allowed(library, system=None, architecture=None):
|
|
system = system or {"Darwin": "osx", "Linux": "linux", "Windows": "windows"}[platform.system()]
|
|
architecture = (architecture or platform.machine()).lower()
|
|
rules = library.get("rules", [])
|
|
allowed = not rules
|
|
for rule in rules:
|
|
operating_system = rule.get("os", {})
|
|
if operating_system.get("name", system) != system:
|
|
continue
|
|
if "arch" in operating_system and not re.fullmatch(operating_system["arch"], architecture):
|
|
continue
|
|
if "version" in operating_system and not re.search(operating_system["version"], platform.release()):
|
|
continue
|
|
if rule.get("features"):
|
|
continue
|
|
allowed = rule["action"] == "allow"
|
|
return allowed
|
|
|
|
|
|
def conditional_client_jvm_options(manifest, system=None, architecture=None):
|
|
"""Use official OS/architecture flags alongside our managed JVM/classpath options."""
|
|
options = []
|
|
for argument in manifest.get("arguments", {}).get("jvm", []):
|
|
if not isinstance(argument, dict) or not library_allowed(argument, system, architecture):
|
|
continue
|
|
value = argument["value"]
|
|
values = [value] if isinstance(value, str) else value
|
|
if not isinstance(values, list) or any(not isinstance(item, str) or "${" in item for item in values):
|
|
raise ValueError("Unsupported conditional client JVM argument")
|
|
options.extend(values)
|
|
return options
|
|
|
|
|
|
def native_library_matches(library, architecture=None):
|
|
"""Modern manifests list both x64 and ARM natives with OS-only rules."""
|
|
architecture = (architecture or platform.machine()).lower()
|
|
name = library["name"]
|
|
if ":natives-" not in name:
|
|
return True
|
|
arm = architecture in {"arm64", "aarch64"}
|
|
if "-arm64" in name or "-aarch64" in name:
|
|
return arm
|
|
return not arm
|
|
|
|
|
|
def prepare_client(assets=True):
|
|
"""Classpath and natives from the user's own launcher install; nothing of the game is downloaded."""
|
|
lock, directory = lab.config()
|
|
manifest, client = lab.import_client(lock, directory)
|
|
jars = [client]
|
|
natives = CACHE / "natives"
|
|
natives.mkdir(parents=True, exist_ok=True)
|
|
for library in manifest["libraries"]:
|
|
if not library_allowed(library) or not native_library_matches(library):
|
|
continue
|
|
artifact = library.get("downloads", {}).get("artifact")
|
|
if not artifact:
|
|
continue
|
|
jar = lab.import_library(artifact, directory / "libraries/client")
|
|
jars.append(jar)
|
|
with zipfile.ZipFile(jar) as archive:
|
|
for name in archive.namelist():
|
|
if name.endswith((".so", ".dll", ".dylib", ".jnilib")):
|
|
(natives / Path(name).name).write_bytes(archive.read(name))
|
|
jars.extend(fabric_libraries())
|
|
if assets:
|
|
store, index = lab.launcher_assets(manifest)
|
|
unique = {v["hash"] for v in json.loads(index.read_text())["objects"].values()}
|
|
print(f"Verifying {len(unique)} game assets in {store}...", flush=True)
|
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
|
list(pool.map(lambda checksum: lab.launcher_object(store, checksum), sorted(unique)))
|
|
return manifest, jars, natives
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("command", choices=["prepare", "demo"])
|
|
parser.add_argument("--instance", type=Path, default=CACHE / "demo")
|
|
parser.add_argument("--heap", default="4G")
|
|
parser.add_argument("--compact-headers", action="store_true", help="Enable Java 25 compact object headers for this demo process")
|
|
parser.add_argument("--gc", choices=["default", "g1", "zgc"], default="default", help="Select a collector for this process; default leaves JVM policy in use")
|
|
args = parser.parse_args()
|
|
if not re.fullmatch(r"[1-9][0-9]*[MGmg]", args.heap):
|
|
parser.error("--heap must look like 4G")
|
|
manifest, jars, natives = prepare_client()
|
|
if args.command == "prepare":
|
|
return
|
|
instance = args.instance.resolve()
|
|
instance.mkdir(parents=True, exist_ok=True)
|
|
command = ["java", *jvm_options(args.heap, args.compact_headers, args.gc),
|
|
*conditional_client_jvm_options(manifest),
|
|
"--enable-native-access=ALL-UNNAMED",
|
|
f"-Djava.library.path={natives}", f"-Dorg.lwjgl.system.SharedLibraryExtractPath={natives}",
|
|
"-Dfabric.log.disableAnsi=true", "-cp", os.pathsep.join(map(str, jars)),
|
|
"net.fabricmc.loader.impl.launch.knot.KnotClient",
|
|
"--username", "HorizonDemo", "--version", manifest["id"], "--gameDir", str(instance),
|
|
"--assetsDir", str(lab.minecraft_dir() / "assets"), "--assetIndex", manifest["assetIndex"]["id"],
|
|
"--accessToken", "0", "--uuid", "00000000000000000000000000000000", "--demo",
|
|
"--width", "1280", "--height", "800"]
|
|
subprocess.run(command, cwd=instance, check=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|