"""provision —— 把一个 MC 版本的客户端文件装进 store 目录,供渲染用。 只装渲染必需的内容(用户口径:只用渲染能力,不 gameplay): versions//.jar 客户端本体 versions//.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))