From 253146cffeb8d28f16cfa0501119dcbf48a40a10 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Fri, 25 Sep 2026 18:35:28 +0800 Subject: [PATCH] =?UTF-8?q?gen=5F1=5F21=20=E5=AE=9E=E6=B5=8B=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E6=88=90=E5=8A=9F=20+=20provision=20=E5=B7=A5?= =?UTF-8?q?=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 决定性验证: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 失败 --- mcfold/__pycache__/provision.cpython-312.pyc | Bin 0 -> 13826 bytes mcfold/__pycache__/render.cpython-312.pyc | Bin 21852 -> 21866 bytes mcfold/backends/__init__.py | 10 +- .../__pycache__/__init__.cpython-312.pyc | Bin 4804 -> 4891 bytes mcfold/provision.py | 251 ++++++++++++++++++ mcfold/render.py | 6 +- scaffold/java/VERSION-DIFFS.md | 25 ++ .../java/gen_1_21/McfoldRenderServer.java | 64 ++--- 8 files changed, 316 insertions(+), 40 deletions(-) create mode 100644 mcfold/__pycache__/provision.cpython-312.pyc create mode 100644 mcfold/provision.py diff --git a/mcfold/__pycache__/provision.cpython-312.pyc b/mcfold/__pycache__/provision.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..418fc6a12b242406aa26a2636bbab83fe269ffa0 GIT binary patch literal 13826 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 OeOmV9l0?XDX!{o}R(IV1 delta 1499 zcmaJ=YfPJE6#mZFi(E!acOe@BE#s1va@m-a=*A_XLCU4$CK#8+bo7(UD#f?O3HjI- z6QeQyFrFVvl<5AMZ6suC&@PK6i;I~$KofspV-zyY0-B}6i4zm?oVVB|{_y?ryyraU z@}BRU^Q~Qi?=OMntE{XHh0e#`k4DbaU9l9G)F`T=E4oQH=xKVoo-t#&Y+?^ZnX*cY zRfo&!Hj}LzMYe7ovEq%gJXOz*zE-ls05*K*K(|G_O-^z3v@>xicVRL% z@~y@DhT%KgZtN@j0)9b^#!nsnGFR{ z6D?~{jhVmmE6*})U~Q8J`cQ9jWd1?K@YWWwOZZ{aarhez9;bbi-3&mmkEJsNaYI-e z5L2w)#5PZrx}~7*u|r#Q+H)IhF*1ZHKyiqC_F2J+=KU}boo+q>Su4aijiQAO7Bsi` zVJJG(@;Kz(V8+YP%E0ZpsLDx*&+!BAo?K?)2BEza=BYaPB=g_F1+N`O@t*hRBb+5} z3TP+C2gM$$LHxIvy+ls#>JN!vpj#UaXhIW}(tR8)ZFgY;H`?YP7iZe@&32A=;9`3n zOkq~XER^CW9d39PA9ReCIobFwzh*oc3I)Y^5=A@1dkoS%EhT)*XD@bQF4?8c z3}XHz610lIMZnEkBWGJ%(xwqli1{eT^xVw5~>aL#~p{A@@m2MX7B6|AOjUJxZCWFLLK)KTA0_ z`PD30*`6c16$Nj17In>$Dy}hHXZVz1o?)F~m4SXNC7w1WLSveUOo$ccc511@J%z*FlA Iqp*|hA9)aU82|tP diff --git a/mcfold/backends/__init__.py b/mcfold/backends/__init__.py index ce9ad7d..6e21c57 100644 --- a/mcfold/backends/__init__.py +++ b/mcfold/backends/__init__.py @@ -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 diff --git a/mcfold/backends/__pycache__/__init__.cpython-312.pyc b/mcfold/backends/__pycache__/__init__.cpython-312.pyc index a9c88e77267d31b8de333c01230e9842f167a9bc..ccb2b54e6cfc23c0300112aae358a4080dbf309e 100644 GIT binary patch delta 1186 zcmY*XL2TSa6rHix-d%gW_HMG-G-)=jLX#SyG$|F8ny9pt5QV}Uix0~%dE_d$k;M{lM>)& zRN1n8ixP!|8mm08_9-?Z;4c$a-6PE2SMtbyZiruny;bJ08dy6lGv^d!j_V-qJgDz$ z5L$xOqrSt2!WtW11dole#$Lg%vvD>8cBo9sgq?;p!J2!TKg?Qe6ygmw$xeYCxubgS zfkIcb9j}~~mcuxh4=&7|u=z}dSY|FSwU^ee-CS91uV*zDreQY?dSM*$PP*BTPeL2K zzZE2DpNEa-XHK-4yjq_OW@6FS7l-~pX*|mH+!Voxul3s+ zFCqI!e_|k!-W%d+(R+Fz;!+f#DH^Cc-EJN;oLv$)CcNKI3KZ5B$<^ zlunBC#%X#+d|+Ip?~8lJM^`bIsh#+atoI6;(Tz64I1O%fct!^q?eS^Yk`w@3*S*Tm z0O2?w65csDYYKWEeNWA_To4s%`5c526#%O8A`^=cR>h0TyW=jf?tkL|ln~quiyplv zZd!BWdU}#vCn=sUzjZiB{Ak@>aln;Ycq+G$qlUSj3ZDg@886>0!CuEdQo)S~pTJ69 z7Z0mbl|#Gvm3?Rq?XGfHJgM%yiS0clzbUE;9Gm9%dbOK88(~9yYtK+u{A6!aN4(`+ zrndOf8EZ{qnFJS^6tId6Ar_CERtXbcfzOIVXL|f1<}M>FAmFTfO1mxd6Sd~lB?t_R zB<1d1x809JOyI_sCBx$5+Kk%d6XIU2LC=YAYOn1SgP;>dK_DkQ`0(1=jit49E=9x{ zluG6j;^1Xky8T5I%F*F1%*!X2PblS+x0bi~|IM<};#Qw+N8uHI9m;UZlZOCeqCPtH zr>>Z@yTxY)sW1HMEIfAV&o$LncFh+RLMMMGt>?N}sdvT7fK6rR~1d%f%Ru1$g+K$anpWGRFMXe$z+1re+WVIV;qjL>%RY)pcG(CoSr zR#2iUhhEW~s&YWws&I?cV-KjeUN|5WtrVfE5{MH&X(f)lw@w7b(!MwI&G+8S``+`f zLw_E~{AHRtfuBD8`$Ayd&lKqM`@`QZu|%EL2}?eZs+vT6m1&Pts}zZZNTf`dzCxI> zD)o@no+H&1a8oSJOz^s|vGf3(Y{vnfewj$5`D?5AE_R(ooyh?m0P9$%ukzDp$;_%jirz%r3XRc0Ps?4s@ z)H7P#2@FS$q|vd6HY2s=HT@uv0W6i4c|mOI$LU!yVqB|W<&o^Q?#AgICDLn+dA}99 z3m%VXH==c31WKU9v8C^M0Rytk5B*x`*8P^xy>PzW;tt>wYkwGmp28(ut_ES7`;iQp z_y|B-_m}>kVve_)Zp`FIfK(RC=DFRdMQy8Vup|VZ4ja%%)rBKakeTmw{z93h8G0<8=zc4+L(Bg8O&{HL+e)lXKO>* zw{GlMzQpsQXe}MXs~jnoF>W%f@y4EVC`GB4o*zdP%@5+KHB9s3gVmu)(XuDb$AhEd z;C$6J7rY<6Bkt?PXOzd_}eaO$ikvUGCM>Mi_z@BBtEB` z4MJX8EM4}(Ti_~f=>OM<>pdKSEv@(j;CvE|A%4mZ59D|sY$Dxt=Q?4B`>xBet-K_j zWcM%O47(T4%v_k7nT`Jyks5aHG<=Q|%KOpaT*X5YOWG3xU;JfV+R0{{bsGL@eilx# z^T8u9Z)Hm9d#Rr){~aQ9@E?-dAbU4R;jKn;lh#vva^23oQ)Nq9Hr}Ph!<@G(P5ai1 TBbze#&7w;C|Fu<`;3&;UuW$r= diff --git a/mcfold/provision.py b/mcfold/provision.py new file mode 100644 index 0000000..afb3e53 --- /dev/null +++ b/mcfold/provision.py @@ -0,0 +1,251 @@ +"""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)) diff --git a/mcfold/render.py b/mcfold/render.py index 25d304a..6aad5dc 100644 --- a/mcfold/render.py +++ b/mcfold/render.py @@ -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, ) diff --git a/scaffold/java/VERSION-DIFFS.md b/scaffold/java/VERSION-DIFFS.md index 6f669b5..7f7a5da 100644 --- a/scaffold/java/VERSION-DIFFS.md +++ b/scaffold/java/VERSION-DIFFS.md @@ -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 失败。 diff --git a/scaffold/java/gen_1_21/McfoldRenderServer.java b/scaffold/java/gen_1_21/McfoldRenderServer.java index 12263eb..1e01211 100644 --- a/scaffold/java/gen_1_21/McfoldRenderServer.java +++ b/scaffold/java/gen_1_21/McfoldRenderServer.java @@ -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>(); 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 dimHolder = loaded.registryOrThrow(Registries.DIMENSION_TYPE) .getHolderOrThrow(ResourceKey.create(Registries.DIMENSION_TYPE, ResourceLocation.parse("minecraft:overworld")));