"""加载器路线联测:Fabric 加载器下的渲染(不写我们的 mod)。 用户定向(2026-09-25): 「不写 mod」+「用 mod 加载器」⇒ 用 Fabric 的 Knot,但不写自己的 mod。 两个轴**正交**: 世代(1.20.1 / 1.21.1) × 启动架构(裸启动 / fabric) 本文件专测第二个轴 —— `launcher="fabric"`。 ★ 为什么必须有这个文件 上一个会话把 `launcher="fabric"` 写进了后端,却**没有留下任何可重跑的验证** —— 「经加载器能起来」这件事只活在对话里。这个文件把它变成可复算的。 需要(缺任一项则跳过,不误报失败): - MCFOLD_RENDER_CLASSPATH(含 client-named.jar 与编译产物) - 与 client jar 同级的 `.mcfold/fabric/`(Fabric 8 库;后端自动组装) - MCFOLD_NATIVES_DIR、DISPLAY """ import os import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from mcfold.backends import backend_class, list_launchers from mcfold.backends.base import BackendError from mcfold.render import SECTION_VOLUME STORE = Path("/home/newqqagent/mcview/store/vanilla-1.20.1") CLIENT_JAR = STORE / ".mcfold" / "client-named.jar" INSTANCE = Path("/home/newqqagent/mcview/instances/exp-loader-1201") def _fabric_available() -> tuple[bool, str]: cp = os.environ.get("MCFOLD_RENDER_CLASSPATH") if not cp: return False, "未设 MCFOLD_RENDER_CLASSPATH" if not any("client-named" in p for p in cp.split(os.pathsep)): return False, "classpath 缺 client-named.jar" if not (STORE / ".mcfold" / "fabric").is_dir(): return False, "缺 .mcfold/fabric/(Fabric 8 库)" if not (STORE / ".mcfold" / "classes" / "LoaderBoot.class").is_file(): return False, "LoaderBoot.class 未编译" if not os.environ.get("DISPLAY"): return False, "无 DISPLAY" return True, "" def _open_fabric(mods_dir: str): br = backend_class(CLIENT_JAR, launcher="fabric")( game_dir=str(INSTANCE), assets=str(STORE / "assets"), asset_index="1.20.1", mods_dir=mods_dir, ) return br # ---------------------------------------------------------------- 组装(不启动 JVM,快) def test_launcher_is_orthogonal_to_generation(): """启动架构与世代正交 ⇒ 动态子类,不写 N 个类。""" a = backend_class(CLIENT_JAR) b = backend_class(CLIENT_JAR, launcher="fabric") assert a is not b, "launcher='fabric' 应给出不同的后端类" assert b.launcher == "fabric" assert a.launcher == "", "裸启动的 launcher 应为空" # 同一个动态子类应被复用(不每次新建) assert backend_class(CLIENT_JAR, launcher="fabric") is b # 世代属性必须继承下来(正交 ⇒ 不覆盖世代的判定) assert b.generation == a.generation, f"加载器不应改动世代:{b.generation} vs {a.generation}" print(f" ✓ 正交({a.__name__} vs {b.__name__},世代={b.generation})") def test_launchers_are_listed(): """两个启动架构都要能被列出(GUI/文档的枚举入口)。""" ls = {k: (n, d) for k, n, d in list_launchers()} assert "" in ls, "裸启动必须在列表里" assert "fabric" in ls, "fabric 加载器必须在列表里" assert "Iris" in ls["fabric"][1] or "fabric" in ls["fabric"][1].lower() print(f" ✓ 启动架构枚举({[k or '(裸)' for k in ls]})") def test_fabric_classpath_composition(): """★ 组装必须自动:去 asm(否则 Knot 抛 duplicate ASM classes)+ 加 Fabric 8 库。 这是把「用户不该自己拼」的机械操作放回后端的判据 —— 实测 67 条 → 去 7 条 asm + 加 8 条 fabric = 68 条。 """ br = _open_fabric("/tmp/empty-mods") cp = br.classpath composed = br._fabric_classpath(cp) parts = composed.split(os.pathsep) n_before = len([p for p in cp.split(os.pathsep) if p]) n_asm = len([p for p in cp.split(os.pathsep) if "/org/ow2/asm/" in p]) n_fab = len([p for p in parts if "/fabric/" in p]) assert n_asm > 0, f"前提不成立:classpath 里本应有 asm(实测 1.20.1 有 7 条),得到 {n_asm}" assert not any("/org/ow2/asm/" in p for p in parts), \ f"asm 必须全部剔除(否则 duplicate ASM classes):{n_asm} 条残留" assert n_fab == 8, f"应追加 Fabric 的 8 个库,得到 {n_fab}" assert len(parts) == n_before - n_asm + 8, \ f"条目数 {len(parts)} != {n_before} - {n_asm} + 8" names = sorted(Path(p).name for p in parts if "/fabric/" in p) assert any("fabric-loader" in n for n in names), names assert any("sponge-mixin" in n for n in names), names assert any("intermediary" in n for n in names), names print(f" ✓ classpath 组装({n_before} → {len(parts)}:去 {n_asm} asm,加 {n_fab} fabric)") def test_fabric_classpath_leaves_others_intact(): """组装不得动非 asm 的普通库(去 asm 是外科手术,不是重写)。""" br = _open_fabric("/tmp/empty-mods") before = [p for p in br.classpath.split(os.pathsep) if p and "/org/ow2/asm/" not in p] after = [p for p in br._fabric_classpath(br.classpath).split(os.pathsep) if p] assert after[: len(before)] == before, "普通库的顺序与内容都必须保留(只应尾部追加)" print(f" ✓ 非 asm 库原样保留({len(before)} 条)") # ---------------------------------------------------------------- 真启动(慢) def test_fabric_launcher_starts_and_renders(): """★ 核心判据:经 Fabric 加载器(Knot)真的起得来,且渲染服务同时就绪。 断言三件事: ① KnotClassLoader 真的建了(不是退回裸启动) ② mod 依赖解析真的在跑(fabricloader/minecraft 被识别) ③ 渲染能力可用(capacity 有值、能渲出内容) """ ok, why = _fabric_available() if not ok: print(f" - 跳过({why})") return os.makedirs("/tmp/mcfold-fab-empty", exist_ok=True) br = _open_fabric("/tmp/mcfold-fab-empty") try: br.start() except BackendError as e: print(f" - 跳过(Fabric 启动失败:{str(e)[:160]})") return try: stderr = "".join(br._stderr_tail) # ① 加载器真的介入 assert "KnotClassLoader" in stderr, \ f"未见 KnotClassLoader —— 可能退回裸启动了:\n{stderr[-500:]}" # ② 我们的服务被注入同一个 classloader assert "注入 McfoldRenderServer" in stderr, f"未见注入日志:\n{stderr[-500:]}" # ③ mod 解析在跑 assert "mod fabricloader" in stderr or "mod minecraft" in stderr, \ f"mod 列表未见 —— Fabric 未真正加载:\n{stderr[-500:]}" cap = br._request({"cmd": "capacity"}) assert cap["sectionsCount"] > 0, cap stone, grass = br.resolve_blocks([ "minecraft:stone", "minecraft:grass_block[snowy=false]", ]) assert stone > 0 and grass > 0, "加载器下注册表应可用" br.set_viewport(0, 0, radius=12) secs = {} for idx in range(cap["sectionsCount"]): v = [0] * SECTION_VOLUME if idx < 4: v = [stone] * SECTION_VOLUME elif idx == 4: v = [grass] * SECTION_VOLUME secs[idx] = v for cx in range(-3, 4): for cz in range(-3, 4): assert br.set_blocks(cx, cz, secs)["ok"] br.set_camera(8.0, 72.0, 8.0, yaw=45.0, pitch=30.0) res = br.render(frames=80) assert res.chunks_rendered > 0, f"加载器下没有区块被渲染: {res.raw}" assert res.distinct_colors > 400, f"加载器下画面过单调: {res.distinct_colors}" print(f" ✓ Fabric 加载器渲染(KnotClassLoader / chunks={res.chunks_rendered}, " f"colors={res.distinct_colors})") finally: br.stop() def test_fabric_rejects_incompatible_mod(): """★ 加载器**真的在工作**的反证:硬依赖缺失的 mod 必须被拒绝。 裸启动(零加载器)路线是"无脑注册"——它不会知道 mod 要求 fabric-api。 Fabric 会 ⇒ 这条正是两条路线的**可判差异**。 """ ok, why = _fabric_available() if not ok: print(f" - 跳过({why})") return # 找一个带硬依赖的 fabric mod(本机 explorify 依赖 fabric-api) cand = Path("/home/minecraft/mods/Explorify v1.6.4 f15-88.mod.jar") if not cand.is_file(): print(" - 跳过(无探针 mod)") return d = "/tmp/mcfold-fab-badmod" os.makedirs(d, exist_ok=True) for f in os.listdir(d): os.remove(os.path.join(d, f)) # 用 copy 而不是 link:/home 与 /tmp 常在不同文件系统(实测 EXDEV) import shutil shutil.copy2(cand, os.path.join(d, cand.name)) br = _open_fabric(d) try: br.start() except BackendError as e: msg = str(e) assert "incompatible" in msg.lower() or "requires" in msg.lower() \ or "HARD_DEP" in msg, f"拒绝理由应是依赖缺失,得到:{msg[:300]}" print(" ✓ Fabric 拒绝不兼容 mod(依赖解析真的在跑)") return finally: try: br.stop() except Exception: pass raise AssertionError("缺 fabric-api 的 mod 竟被接受了 —— 加载器可能没真正介入") if __name__ == "__main__": tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")] passed = 0 failed = [] for t in tests: try: t() passed += 1 except AssertionError as e: failed.append(f"{t.__name__}: {e}") print(f" ✗ {t.__name__}: {e}") except Exception as e: failed.append(f"{t.__name__}: {e!r}") print(f" ✗ {t.__name__}: {e!r}") print() if failed: print(f"加载器联测: {passed}/{len(tests)} 通过") for f in failed: print(f" - {f}") sys.exit(1) print(f"加载器联测: {passed}/{len(tests)} 全部通过")