#!/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()