diff --git a/mcfold/backends/base.py b/mcfold/backends/base.py index 5339ba1..8724e81 100644 --- a/mcfold/backends/base.py +++ b/mcfold/backends/base.py @@ -126,6 +126,37 @@ class RenderBackend: self._lock = threading.Lock() self._stderr_tail: list[str] = [] + # ---------------- 启动架构(加载器) ---------------- + + def _fabric_classpath(self, cp: str) -> str: + """把普通 classpath 变成 **Fabric 加载器** 可用的那条。 + + ★ 两个变换都是实测必需,且都是**用户不该自己拼**的机械操作: + + ① 剔除 1.20.1 自带的 asm(9.2 与 9.6 **两版并存**) + 否则 Knot. 抛 + "duplicate ASM classes found on classpath" + (Fabric 自带 asm 9.10.1,必须只留它一份) + ② 追加 Fabric 的 8 个库(asm ×5 / sponge-mixin / intermediary / fabric-loader) + + 库来源:与 client jar 同级的 `.mcfold/fabric/`(内容根,随版本隔离)。 + 找不到就**原样返回**(让用户自己拼,并会在 Knot 报错里看到原因)。 + """ + parts = [p for p in cp.split(os.pathsep) if p] + # ① 去 asm + kept = [p for p in parts if "/org/ow2/asm/" not in p.replace("\\", "/")] + # ② 找 Fabric 库目录 + fab_dir = None + for p in kept: + cand = Path(p).parent / "fabric" + if cand.is_dir(): + fab_dir = cand + break + if fab_dir is None: + return os.pathsep.join(kept) + fab = sorted(str(j) for j in fab_dir.glob("*.jar")) + return os.pathsep.join(kept + fab) + # ---------------- 生命周期 ---------------- @property @@ -146,12 +177,15 @@ class RenderBackend: # fabric.gameMappingNamespace=named → 游戏类名保持 official 名 # fabric.modsFolder= → mod 目录 main_class = self.main_class + classpath = self.classpath if self.launcher == "fabric": main_class = "LoaderBoot" + # ★ classpath 组装由后端负责(已实测必需的两步:去 asm + 加 Fabric 8 库) + classpath = self._fabric_classpath(classpath) java_args.append("-Dfabric.gameMappingNamespace=named") if self.mods_dir: java_args.append(f"-Dfabric.modsFolder={self.mods_dir}") - cmd = [self.java, *java_args, "-cp", self.classpath, main_class] + cmd = [self.java, *java_args, "-cp", classpath, main_class] if self.game_dir: cmd += ["--gameDir", self.game_dir] if self.assets: diff --git a/run_tests.sh b/run_tests.sh index dea1970..ab0c467 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -2,29 +2,41 @@ # 统一测试入口。 # # ./run_tests.sh 纯 Python 单元测试(秒级) -# ./run_tests.sh --with-render 额外跑渲染桥联测(需 Java + Xvfb,约 4 分钟) +# ./run_tests.sh --with-render 额外跑渲染联测(需 Java + Xvfb,约 6 分钟) # # 渲染联测需要: # MCFOLD_RENDER_CLASSPATH 含 client-named.jar 与 McfoldRenderServer.class -# MCFOLD_NATIVES_DIR lwjgl natives(.so) +# MCFOLD_NATIVES_DIR lwjgl natives(**.so 必须平铺**,不能带包路径) # DISPLAY 指向可用的 X(Xvfb 即可) +# (加载器联测另需 .mcfold/fabric/ 与已编译的 LoaderBoot.class) # 缺任一项会**跳过而非失败**(不误报)。 cd "$(dirname "$0")" WITH_RENDER=0 [ "${1:-}" = "--with-render" ] && WITH_RENDER=1 +# 需要 JVM/Xvfb 的套件(默认不跑,跑一次约数分钟) +SLOW="test_render_bridge.py test_loader_render.py" + total=0; fail=0 for t in tests/test_*.py; do name=$(basename "$t") - if [ "$name" = "test_render_bridge.py" ] && [ $WITH_RENDER -eq 0 ]; then + if [ $WITH_RENDER -eq 0 ] && [[ " $SLOW " == *" $name "* ]]; then printf ' %-24s ⊘ 跳过(用 --with-render 启用)\n' "$name" continue fi out=$(python3 "$t" 2>&1) if echo "$out" | grep -qE "全部通过|通过$"; then n=$(echo "$out" | grep -c "^ ✓") + skipped=$(echo "$out" | grep -c "^ - 跳过") total=$((total+n)) - printf ' %-24s ✓ %2d 项\n' "$name" "$n" + # ★ 全跳过的套件不算绿:它对环境一无所知,报绿等于把"没测"说成"测过了" + if [ "$n" -eq 0 ] && [ "$skipped" -gt 0 ]; then + printf ' %-24s ⊘ 全部跳过(%d 项;环境未就绪)\n' "$name" "$skipped" + elif [ "$skipped" -gt 0 ]; then + printf ' %-24s ✓ %2d 项(%d 项跳过)\n' "$name" "$n" "$skipped" + else + printf ' %-24s ✓ %2d 项\n' "$name" "$n" + fi else fail=$((fail+1)) printf ' %-24s ✗ 失败\n' "$name" diff --git a/scaffold/LOADERS.md b/scaffold/LOADERS.md index 0a81dbb..c7963f3 100644 --- a/scaffold/LOADERS.md +++ b/scaffold/LOADERS.md @@ -55,25 +55,76 @@ forge: gen_1_21 待设计 ``` -## Fabric 下的两条可行路线(都待实测) +## ✅ 已实测落地:路线 C(`Knot.init()` + `loadIntoTarget()`) -### 路线 A:作为 Fabric mod 被加载 -把我们的服务端打包成 fabric mod(`fabric.mod.json` + entrypoint), -由 Loader 在游戏初始化后调用我们。**问题**:Loader 会继续跑主循环, -我们要在 entrypoint 里接管渲染循环(或让它跑着、我们在 client tick 里注入数据)。 -改造量大,但能得到完整的 mod 生态(Iris 也在内)。 +**上面的 A/B 都不是实际采用的 —— 两者都假设了「游戏由 Loader 起」, +而 `Knot.init()` 是 public 的,它做完 mod 加载 + mixin 后**返回**, +根本不需要进入官方主循环。** -### 路线 B:只借 Fabric 的**注册表与资源包**,不跑其主循环 -用 Knot 启动(它会加载我们的 mod 并注册方块),但在我们的 mod entrypoint -里**阻止进入主菜单**(替换 Screen),然后按 vanilla 那套手动喂数据 + 渲染。 -—— 即保留我们已验证的渲染循环,只从 Fabric 借「mod 加载 + 注册表」。 +``` +LoaderBoot.main: + ① Knot knot = new Knot(EnvType.CLIENT) + ② ClassLoader cl = knot.init(args) ← 发现 mod / 建 KnotClassLoader / 应用 mixin + / invokeEntrypoints(preLaunch) + **不**启动官方主循环(那在 GameProvider.launch()) + ③ knot.loadIntoTarget("McfoldRenderServer") + → 把我们的服务端注入**同一个** KnotClassLoader(与 mod 共享 classloader 与 mixin) + ④ 调用它的 main → 我们的渲染服务照常跑 +``` -路线 B 更贴近现有架构(渲染循环不用重写),且已验证的部分(反混淆后的 -渲染调用、setblocks、RLE 路由)能复用。 +★ 关键:**mod 生态与我们的控制权可以同时拿到** —— 不需要把服务改写成 mod。 -## 当前已具备的零加载器能力(不依赖上面两条) +### 实测结果(`tests/test_loader_render.py`,6/6 绿) -`cmd="mods"`(已在两个 vanilla 后端实现,编译通过、待实测): +``` +[loaderboot] KnotClassLoader = net.fabricmc.loader.impl.launch.knot.KnotClassLoader +[loaderboot] mod fabricloader 0.19.5 / java 21 / minecraft 1.20.1 / mixinextras 0.5.5 +[loaderboot] 注入 McfoldRenderServer 到 KnotClassLoader +{"event":"ready"} {"ok":true,"sectionsCount":24,…} +渲染: chunks=10..25 colors=4391..4547 +``` + +### 两个必需条件(缺一则我们的 named 代码在加载器下跑不起来) + +1. classpath 放**反混淆后的** client jar(`client-named.jar`),不是官方 obf jar +2. `-Dfabric.gameMappingNamespace=named` + +否则游戏被 remap 成 intermediary,堆栈里是 `enn.`, +我们的代码抛 `NoClassDefFoundError: net/minecraft/client/Minecraft`(实测)。 + +### ★ 第三个必需条件:asm 必须只留一份 + +1.20.1 自带 asm **9.2 与 9.6 两版并存**,与 Fabric 的 9.10.1 同时在场会让 +`Knot.` 抛 `duplicate ASM classes found on classpath`。 + +⇒ 这两步(去 asm + 加 Fabric 8 库)已**从用户手里移到后端**: +`RenderBackend._fabric_classpath()` 自动完成(67 → 68 条:去 7 asm,加 8 fabric)。 +Fabric 库放在内容根 `.mcfold/fabric/`(随版本隔离,不再散在 /tmp)。 + +### 可判差异:加载器 vs 零加载器 + +两条路线不是「高级/低级」,而是**能做的事不同**: + +| | 零加载器(`cmd=mods`) | Fabric 加载器 | +|---|---|---| +| 方块外观 / state id | ✅ | ✅ | +| mod 的硬依赖校验 | ❌ 无脑注册 | ✅ **会拒绝** | +| mixin 变换 | ❌ | ✅ | +| Iris 光影 | ❌ | 待验 | + +The negative control proves this: `Explorify`(依赖 `fabric-api`,缺失)在 Fabric 下被**拒绝** —— +而 vanilla 那条「无脑注册」路线根本无从知道这件事。 + +## 仍未做 + +``` +Iris 光影加载 —— 加载器已通,但未验过一次真实 shader pack +forge / neoforge —— gen_1_20 / gen_1_21 均待设计 +``` + +## 当前已具备的零加载器能力(不依赖加载器) + +`cmd="mods"`(已在两个 vanilla 后端实现,实测通过): Java 侧自己扫 mods 目录 → 读 jar 的 `assets//blockstates/*.json` → 解冻 BLOCK 注册表 → 注册占位方块 + state → jar 挂为资源包。 diff --git a/tests/test_loader_render.py b/tests/test_loader_render.py new file mode 100644 index 0000000..0ab1383 --- /dev/null +++ b/tests/test_loader_render.py @@ -0,0 +1,248 @@ +"""加载器路线联测: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)} 全部通过")