Files
agentmc/mcfold/provision.py
JianFeeeee 253146cffe gen_1_21 实测启动成功 + provision 工具
决定性验证: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 失败
2026-09-25 18:35:28 +08:00

252 lines
9.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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))