Files
agentmc/tests/test_loader_render.py
JianFeeeee 8b1fc9289d Fabric 加载器路线跑通并落地验证(launcher="fabric",6/6 绿)
上个会话把 `launcher="fabric"` 写进了后端,却**没有留下任何可重跑的验证** ——
「经加载器能起来」这件事只活在对话里。本次把它变成可复算的。

## 机制(路线 C,不是 LOADERS.md 原来写的 A/B)

A/B 都假设「游戏由 Loader 起」。但 `Knot.init()` 是 public 的:它做完
「发现 mod → 建 KnotClassLoader → 应用 mixin → invokeEntrypoints(preLaunch)」
后**返回**,不进入官方主循环(那在 GameProvider.launch())。
⇒ `knot.loadIntoTarget("McfoldRenderServer")` 把我们的服务端注入同一个
KnotClassLoader —— **mod 生态与我们的控制权同时拿到,不必把服务改写成 mod**。

## 主要改动

### ① `RenderBackend._fabric_classpath()` —— 组装从用户手里移到后端

两个变换是实测必需、且用户不该自己拼的机械操作:
  - 剔除 1.20.1 自带的 asm(9.2 与 9.6 两版并存)—— 否则 Knot.<clinit> 抛
    "duplicate ASM classes found on classpath"
  - 追加 Fabric 的 8 个库(asm×5 / sponge-mixin / intermediary / fabric-loader)

库来源改为内容根 `.mcfold/fabric/`(原在 /tmp/fab,tmpfs 且违反版本隔离)。
实测 67 条 → 去 7 asm + 加 8 fabric = 68 条。

### ② `tests/test_loader_render.py`(新增,6 项)

  ✓ 启动架构与世代正交(动态子类,不写 N 个类)
  ✓ 两个架构可枚举
  ✓ classpath 组装(67→68:去 7 asm,加 8 fabric)
  ✓ 非 asm 库原样保留(去 asm 是外科手术,不是重写)
  ✓ **Fabric 加载器真启动并渲染**(KnotClassLoader / chunks=10..25 / colors=4391..4547)
  ✓ **Fabric 拒绝不兼容 mod** ← 反向对照:缺 fabric-api 的 Explorify 被拒

最后一条是**两条路线的可判差异**:零加载器是「无脑注册」,不知道 mod 要
fabric-api;Fabric 会。没有它,"加载器真的介入了"这句话就没有反面证据。

### ③ `run_tests.sh` —— 慢套件改为列表驱动 + 全跳过不再报绿

- SLOW 列表含 test_render_bridge / test_loader_render(默认不跑)
- ★ 全部跳过的套件**不算绿**:报"跳过 N 项"而非计入通过数 ——
  否则"没测"会被读成"测过了"。

## 验证

  ./run_tests.sh               → 62 项(2 个慢套件标 ⊘)
  ./run_tests.sh --with-render → **76 项全绿**
    (62 单元 + test_render_bridge 8 + test_loader_render 6)

## 仍未做

  Iris 光影真实加载(加载器已通,未验过一次 shader pack)
  forge / neoforge 后端(gen_1_20 / gen_1_21 均待设计)
2026-09-26 07:12:41 +08:00

249 lines
10 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.

"""加载器路线联测: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)} 全部通过")