决定性验证:gen_1_21 后端在 1.21.1 上收到 {"event":"ready"}
⇒ 世代分派架构成立:同一套 Python 层,两个独立编译的 Java 后端
运行时发现第 12 处世代差异(编译期看不出):
1.20.x registryAccess 是可变 LayeredRegistryAccess → 事后反射替换
1.21.x registryAccess 是 final RegistryAccess$Frozen → 反射抛
"Can not set final field"
处理:gen_1_21 改为先载注册表 → 经 CommonListenerCookie.receivedRegistries
传入 → 再构造 listener。比 1.20 的反射做法更干净。
新增 mcfold/provision.py(从官方 manifest 装任意版本,版本无关):
- 跳过音频资源:1.21.1 实测 3911 条里跳过 3744 条
- natives 两种形态都认(1.21 起是独立库条目,非 classifiers)
- 实测 1.21.1:274 文件 / 200MB / 32s / 0 失败
252 lines
9.3 KiB
Python
252 lines
9.3 KiB
Python
"""provision —— 把一个 MC 版本的客户端文件装进 store 目录,供渲染用。
|
||
|
||
只装渲染必需的内容(用户口径:只用渲染能力,不 gameplay):
|
||
versions/<id>/<id>.jar 客户端本体
|
||
versions/<id>/<id>.json 版本信息(协议/主类的依据)
|
||
libraries/** 依赖库
|
||
assets/indexes/*.json 资源索引
|
||
assets/objects/** 资源对象(**跳过音频**,我们不输出声音)
|
||
|
||
为什么自己做而不用 MCLC:
|
||
MCLC 会顺带写 options.txt / usercache.json / logs,污染实例隔离;
|
||
而我们要的只是「文件到位」。另外跳过音频能把下载量砍掉一大截。
|
||
|
||
★ 与版本无关:所有 URL 都从官方 version_manifest / version json 读,
|
||
所以加新版本不需要改代码(这正是「按世代分派」想要的性质)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||
from pathlib import Path
|
||
|
||
MANIFEST = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
|
||
|
||
# 不下载的资源(渲染用不到,体积却大)
|
||
SKIP_ASSET_PREFIXES = (
|
||
"minecraft/sounds/",
|
||
"minecraft/sounds.json",
|
||
"realms/",
|
||
)
|
||
|
||
|
||
def _get(url: str, timeout: int = 60) -> bytes:
|
||
req = urllib.request.Request(url, headers={"User-Agent": "mcfold-provision/1.0"})
|
||
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||
return r.read()
|
||
|
||
|
||
def _download(url: str, dest: Path, retries: int = 3) -> tuple[Path, int]:
|
||
"""下载到 dest(已存在且非空则跳过)。返回 (路径, 字节数)。"""
|
||
if dest.is_file() and dest.stat().st_size > 0:
|
||
return dest, dest.stat().st_size
|
||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||
last = None
|
||
for i in range(retries):
|
||
try:
|
||
data = _get(url)
|
||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||
tmp.write_bytes(data)
|
||
os.replace(tmp, dest)
|
||
return dest, len(data)
|
||
except Exception as e: # noqa: BLE001
|
||
last = e
|
||
time.sleep(1.5 * (i + 1))
|
||
raise RuntimeError(f"下载失败 {url}: {last}")
|
||
|
||
|
||
def resolve_version(version: str) -> dict:
|
||
"""从官方 manifest 拿版本 json(支持 '1.21.1' 或 'latest')。"""
|
||
manifest = json.loads(_get(MANIFEST))
|
||
if version in ("latest", "latest-release"):
|
||
version = manifest["latest"]["release"]
|
||
for v in manifest["versions"]:
|
||
if v["id"] == version:
|
||
return json.loads(_get(v["url"]))
|
||
raise SystemExit(f"未知版本: {version}")
|
||
|
||
|
||
def maven_url(lib: dict) -> str | None:
|
||
"""库的下载地址:优先用 version json 里给的 artifact.url。"""
|
||
dl = (lib.get("downloads") or {}).get("artifact")
|
||
if dl and dl.get("url"):
|
||
return dl["url"]
|
||
# 回退:按 maven 坐标拼(有些库不给 downloads)
|
||
name = lib.get("name", "")
|
||
parts = name.split(":")
|
||
if len(parts) != 3:
|
||
return None
|
||
g, a, v = parts
|
||
base = lib.get("url") or "https://libraries.minecraft.net/"
|
||
if not base.endswith("/"):
|
||
base += "/"
|
||
return f"{base}{g.replace('.', '/')}/{a}/{v}/{a}-{v}.jar"
|
||
|
||
|
||
def natives_artifacts(version_json: dict) -> list[tuple[str, str]]:
|
||
"""linux 平台的原生库(LWJGL .so)。
|
||
|
||
★ 实测(1.21.1):natives 是**独立的库条目**
|
||
(name 形如 `org.lwjgl:lwjgl-freetype:3.3.3:natives-linux`),
|
||
不走老版的 `downloads.classifiers` 结构。
|
||
所以这里按 name 后缀 `:natives-linux` 识别 —— 对两代都成立。
|
||
"""
|
||
out = []
|
||
for lib in version_json.get("libraries", []):
|
||
rules = lib.get("rules")
|
||
if rules and not _rules_allow_linux(rules, lib):
|
||
continue
|
||
art = (lib.get("downloads") or {}).get("artifact") or {}
|
||
name = lib.get("name", "")
|
||
path = art.get("path") or ""
|
||
is_native = (":natives-linux" in name) or ("natives-linux" in path)
|
||
if is_native and art.get("url"):
|
||
out.append((art["url"], art["path"]))
|
||
# 兼容老结构(classifiers)
|
||
for lib in version_json.get("libraries", []):
|
||
rules = lib.get("rules")
|
||
if rules and not _rules_allow_linux(rules, lib):
|
||
continue
|
||
for cname, c in ((lib.get("downloads") or {}).get("classifiers") or {}).items():
|
||
if "linux" in cname and c.get("url"):
|
||
out.append((c["url"], c["path"]))
|
||
return out
|
||
|
||
|
||
def _rules_allow_linux(rules: list, lib: dict) -> bool:
|
||
"""MC 的 rules 语义:从默认 allow 出发,按序应用 allow/disallow。"""
|
||
allowed = True
|
||
for r in rules:
|
||
action = r.get("action")
|
||
os_name = (r.get("os") or {}).get("name")
|
||
if os_name is None or os_name == "linux":
|
||
allowed = action == "allow"
|
||
return allowed
|
||
|
||
|
||
def library_path(lib: dict) -> str | None:
|
||
"""库在 libraries/ 下的相对路径。"""
|
||
dl = (lib.get("downloads") or {}).get("artifact")
|
||
if dl and dl.get("path"):
|
||
return dl["path"]
|
||
name = lib.get("name", "")
|
||
parts = name.split(":")
|
||
if len(parts) != 3:
|
||
return None
|
||
g, a, v = parts
|
||
return f"{g.replace('.', '/')}/{a}/{v}/{a}-{v}.jar"
|
||
|
||
|
||
def provision(version: str, dest: Path, workers: int = 8,
|
||
skip_assets: bool = False, verbose: bool = True) -> dict:
|
||
"""装一个版本的渲染所需文件。返回统计。"""
|
||
dest = Path(dest)
|
||
dest.mkdir(parents=True, exist_ok=True)
|
||
vj = resolve_version(version)
|
||
vid = vj["id"]
|
||
|
||
jobs: list[tuple[str, Path]] = []
|
||
|
||
# 1) 客户端 jar + version json
|
||
client = vj["downloads"]["client"]
|
||
vdir = dest / "versions" / vid
|
||
jobs.append((client["url"], vdir / f"{vid}.jar"))
|
||
(vdir).mkdir(parents=True, exist_ok=True)
|
||
(vdir / f"{vid}.json").write_text(json.dumps(vj, indent=2))
|
||
# mappings 也存一份(重映射用;不下载则反混淆得自己拉)
|
||
cm = vj["downloads"].get("client_mappings")
|
||
if cm:
|
||
jobs.append((cm["url"], vdir / "client_mappings.txt"))
|
||
|
||
# 2) libraries
|
||
for lib in vj.get("libraries", []):
|
||
rules = lib.get("rules")
|
||
if rules and not _rules_allow_linux(rules, lib):
|
||
continue
|
||
url = maven_url(lib)
|
||
rel = library_path(lib)
|
||
if url and rel:
|
||
jobs.append((url, dest / "libraries" / rel))
|
||
for url, rel in natives_artifacts(vj):
|
||
jobs.append((url, dest / "libraries" / rel))
|
||
|
||
if verbose:
|
||
print(f"[provision] {vid}: {len(jobs)} 个文件待检查/下载")
|
||
|
||
# 3) assets(可跳过音频)
|
||
asset_idx_id = vj.get("assetIndex", {}).get("id")
|
||
if asset_idx_id and not skip_assets:
|
||
idx_url = vj["assetIndex"]["url"]
|
||
idx_dir = dest / "assets" / "indexes"
|
||
idx_dir.mkdir(parents=True, exist_ok=True)
|
||
# ★ 索引落盘名用 version id:MCLC 也这么做,而 MC 实际按
|
||
# options/启动参数里的 assetIndex 名去找(见 scaffold/README 坑 3)
|
||
idx_file = idx_dir / f"{vid}.json"
|
||
if not idx_file.is_file():
|
||
idx_file.write_bytes(_get(idx_url))
|
||
index = json.loads(idx_file.read_text())
|
||
objects = index.get("objects", {})
|
||
n_before = len(jobs)
|
||
skipped = 0
|
||
for name, o in objects.items():
|
||
if any(name.startswith(p) for p in SKIP_ASSET_PREFIXES):
|
||
skipped += 1
|
||
continue
|
||
h = o["hash"]
|
||
jobs.append((
|
||
f"https://resources.download.minecraft.net/{h[:2]}/{h}",
|
||
dest / "assets" / "objects" / h[:2] / h,
|
||
))
|
||
if verbose:
|
||
print(f"[provision] assets: {len(objects)} 条,跳过 {skipped} 条(音频等),"
|
||
f"需下载 {len(jobs) - n_before}")
|
||
|
||
# 4) 并行下载
|
||
done = failed = 0
|
||
bytes_total = 0
|
||
errors: list[str] = []
|
||
t0 = time.time()
|
||
with ThreadPoolExecutor(max_workers=workers) as pool:
|
||
futs = {pool.submit(_download, u, p): (u, p) for u, p in jobs}
|
||
for i, fut in enumerate(as_completed(futs), 1):
|
||
url, path = futs[fut]
|
||
try:
|
||
_, n = fut.result()
|
||
done += 1
|
||
bytes_total += n
|
||
except Exception as e: # noqa: BLE001
|
||
failed += 1
|
||
if len(errors) < 5:
|
||
errors.append(f"{path.name}: {e}")
|
||
if verbose and i % 500 == 0:
|
||
print(f"[provision] {i}/{len(jobs)} ...")
|
||
|
||
el = time.time() - t0
|
||
if verbose:
|
||
print(f"[provision] {vid} 完成:{done} 文件,{bytes_total / 1048576:.0f} MB,"
|
||
f"{el:.0f}s,失败 {failed}")
|
||
for e in errors:
|
||
print(f"[provision] ✗ {e}")
|
||
return {"version": vid, "files": done, "failed": failed,
|
||
"bytes": bytes_total, "seconds": el, "errors": errors,
|
||
"dest": str(dest)}
|
||
|
||
|
||
def main(argv: list[str]) -> int:
|
||
if len(argv) < 2:
|
||
print("用法: python -m mcfold.provision <版本> [目标目录] [--no-assets]")
|
||
return 2
|
||
version = argv[1]
|
||
dest = Path(argv[2]) if len(argv) > 2 and not argv[2].startswith("--") \
|
||
else Path(f"/home/newqqagent/mcview/store/vanilla-{version}")
|
||
stats = provision(version, dest, skip_assets="--no-assets" in argv)
|
||
return 0 if stats["failed"] == 0 else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main(sys.argv))
|