6d7f11feb2
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>
147 lines
6.2 KiB
Python
147 lines
6.2 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
|
|
library_allowed = lab.library_allowed
|
|
native_library_matches = lab.native_library_matches
|
|
|
|
|
|
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 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 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 artifact in lab.client_library_artifacts(manifest):
|
|
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()
|