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 失败
This commit is contained in:
JianFeeeee
2026-09-25 18:35:28 +08:00
parent 8c7d5b9d38
commit 253146cffe
8 changed files with 316 additions and 40 deletions

Binary file not shown.

View File

@ -27,12 +27,20 @@ from dataclasses import dataclass
from pathlib import Path
# 世代的权威判据:这个类出现在哪个 version,就是那个世代
__all__ = ["Gen_1_20", "Gen_1_21", "Gen", "GENERATIONS",
"detect_generation", "detect_from_store", "backend_class",
"backend_for", "list_generations"]
GEN_MARKERS = {
"gen_1_21": ["net/minecraft/client/DeltaTracker.class"],
"gen_1_20": [], # 兜底(无判据类 ⇒ 最老的世代)
}
from .gen_1_20 import Gen_1_20 # noqa: E402
from .gen_1_21 import Gen_1_21 # noqa: E402
@dataclass(frozen=True)
class Gen:
"""一个 API 世代。"""
@ -87,9 +95,7 @@ def backend_class(client_jar: str | Path | None = None,
if generation is None:
generation = backend_for(client_jar) if client_jar else "gen_1_20"
if generation == "gen_1_21":
from .gen_1_21 import Gen_1_21
return Gen_1_21
from .gen_1_20 import Gen_1_20
return Gen_1_20

251
mcfold/provision.py Normal file
View File

@ -0,0 +1,251 @@
"""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))

View File

@ -222,8 +222,10 @@ class WorldRenderer:
# ★ Python 层 switch:按 client jar 的世代选后端
# (1.21+ 的 DeltaTracker 在 1.20.x jar 中不存在 ⇒ 必须独立编译的后端)
cls = backend_class(client_jar=client_jar, generation=generation)
bridge = cls(
# ★ 变量名不能叫 cls —— 会覆盖 classmethod 的 cls,
# 导致下面 cls(bridge, instance) 构造出后端而非 WorldRenderer(实测踩过)
backend_cls = backend_class(client_jar=client_jar, generation=generation)
bridge = backend_cls(
java=java, classpath=classpath,
game_dir=game_dir, assets=assets, asset_index=asset_index,
)

View File

@ -54,3 +54,28 @@ Java 编译期绑定签名。实测:
⚠️ 教训(本轮踩过):`Reason.LEVEL_LOAD`、`DirectoryValidator.NONE`
都是凭印象写出来的、实际不存在的 API;`loadEffect` 的可见性也没验证。
**改之前先 `javap`。**
## 补充:1.21.1 实际运行验证(比编译更强的证据)
`gen_1_21` 已实测启动成功(stdout 收到 `{"event":"ready"}`)。
运行期又暴露 1 处「编译期看不出」的差异:
| # | API | 1.20.x | 1.21+ | 处理 |
|---|---|---|---|---|
| 12 | `ClientPacketListener.registryAccess` | 可变 `LayeredRegistryAccess`,可事后反射替换 | **`final RegistryAccess$Frozen`**,反射抛 `Can not set final field` | gen_1_21 改为**先载注册表 → 经 `CommonListenerCookie.receivedRegistries` 传入 → 再构造 listener** |
★ 这条是本轮最有价值的发现:1.21 的 cookie 机制让注册表注入**比 1.20 的反射更干净**。
两代的启动顺序因此不同:
```
1.20.x: 构造 listener → 反射替换 registryAccess 字段
1.21.x: 先加载注册表 → 构造 cookie(含注册表) → 构造 listener(final 字段一次到位)
```
## provision 的实测发现
- natives 在 1.21.1 是**独立库条目**(`name` 含 `:natives-linux`),
不走老版 `downloads.classifiers` 结构 ⇒ 两种形态都要认。
- 跳过音频资源可省 **3744/3911 条**(仅 167 条实际下载),
实测 1.21.1 全量 274 文件 / 200MB / 32s,0 失败。

View File

@ -524,35 +524,13 @@ public class McfoldRenderServer {
reloadResources(mc);
System.err.println("[mcfold] resources reloaded");
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
// 初始注册表:官方静态层(1.21 的 cookie 需要它,随后会被真实注册表替换)
net.minecraft.core.RegistryAccess.Frozen listenerRegistryAccess =
net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
.compositeAccess();
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
// ★ 世代差异(1.21+):ClientPacketListener(Minecraft, Connection, CommonListenerCookie)
// 1.20.x 是 (Minecraft, Screen, Connection, ServerData, GameProfile, TelemetryManager)。
// ★★ 这是本世代最大的一处变化 —— 参数个数与语义都不同。
var cookie = new net.minecraft.client.multiplayer.CommonListenerCookie(
profile,
telemetry,
listenerRegistryAccess, // 后面注册表加载后会被替换
net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS,
null, // serverBrand
null, // serverData
null, // postDisconnectScreen
java.util.Map.of(), // serverCookies
null, // chatState
false, // strictErrorHandling
java.util.Map.of(), // customReportDetails
net.minecraft.server.ServerLinks.EMPTY);
ClientPacketListener listener = new ClientPacketListener(mc, conn, cookie);
// ★ 世代差异(1.21+):createPackRepository(Path, DirectoryValidator)
// DirectoryValidator 没有 NONE 常量,构造要 PathMatcher;
// 传一个「全部拒绝」的 matcher 最安全(我们不加载外来符号链接数据包)
// ★★ 世代差异(1.21+):注册表经 CommonListenerCookie.receivedRegistries 传入,
// 并在 ClientPacketListener 构造时赋给 **final** 字段
// (实测:1.20.x 是可变 LayeredRegistryAccess,可事后反射替换;
// 1.21 是 final RegistryAccess$Frozen,反射会抛
// "Can not set final ... field")。
// ⇒ 本世代的正确顺序:**先加载注册表,再构造 cookie + listener**。
// 这比 1.20 的反射注入更干净。
var packs = net.minecraft.server.packs.repository.ServerPacksSource.createPackRepository(
new File(runDir, "datapacks").toPath(),
new net.minecraft.world.level.validation.DirectoryValidator(p -> false));
@ -563,13 +541,27 @@ public class McfoldRenderServer {
var all = new java.util.ArrayList<RegistryDataLoader.RegistryData<?>>();
all.addAll(RegistryDataLoader.WORLDGEN_REGISTRIES);
all.addAll(RegistryDataLoader.DIMENSION_REGISTRIES);
RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, listener.registryAccess(), all);
var layered = net.minecraft.client.multiplayer.ClientRegistryLayer.createRegistryAccess()
.replaceFrom(net.minecraft.client.multiplayer.ClientRegistryLayer.REMOTE,
new RegistryAccess.Frozen[]{loaded});
var rf = ClientPacketListener.class.getDeclaredField("registryAccess");
rf.setAccessible(true);
rf.set(listener, layered);
RegistryAccess.Frozen base = net.minecraft.client.multiplayer.ClientRegistryLayer
.createRegistryAccess().compositeAccess();
RegistryAccess.Frozen loaded = RegistryDataLoader.load(dataRm, base, all);
System.err.println("[mcfold] registries loaded: dimension_type="
+ loaded.registryOrThrow(Registries.DIMENSION_TYPE).size());
Connection conn = new Connection(PacketFlow.CLIENTBOUND);
GameProfile profile = new GameProfile(UUID.randomUUID(), "mcfold_cam");
WorldSessionTelemetryManager telemetry = new WorldSessionTelemetryManager(
TelemetryEventSender.DISABLED, false, Duration.ZERO, "");
var cookie = new net.minecraft.client.multiplayer.CommonListenerCookie(
profile,
telemetry,
loaded, // ← 注册表从这里进
net.minecraft.world.flag.FeatureFlags.DEFAULT_FLAGS,
null, null, null,
java.util.Map.of(), null, false, java.util.Map.of(),
net.minecraft.server.ServerLinks.EMPTY);
ClientPacketListener listener = new ClientPacketListener(mc, conn, cookie);
System.err.println("[mcfold] listener ok, registryAccess dims="
+ listener.registryAccess().registryOrThrow(Registries.DIMENSION_TYPE).size());
Holder<DimensionType> dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE)
.getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, ResourceLocation.parse("minecraft:overworld")));