Files
agentmc/tests/test_loader_render.py
JianFeeeee a68497089b 加载器联测加强 + Sodium 诊断探针
## tests/test_loader_render.py

★ 划清本文件的**射程**(诚实标注):
它证明的是「Knot 能被我们驱动,且在加载器下渲染能力照常」。
它**不**证明「真实 mod(带 mixin 的)能工作」—— 因为它用空 mods 目录。

  实测原因:真实 mod 的 mixin 目标是 intermediary 名,而我们跑 named
  ⇒ 同一 JVM 只能一个命名空间 ⇒ 真实 mixin mod 在本路线下不生效。
  ⇒ 「加载器能起动」与「mod 能用」是**两件事**,别把前者读成后者。

这条注释是被自己坑出来的:早先那份 6/6 全绿的报告,
读起来像"加载器路线可用",实际只验证了"Knot 能起来"。

## tools/probe_sodium.py

诊断脚本:在装了 Sodium 的实例上跑一次注入 + debug,
打印它自己的状态(chunkStatus / chunkReady / loadQueue / rebuildQueue /
visibleChunks / chunksDebug),并出一张图。

★ 为什么需要它:vanilla 的 `countRenderedChunks()` 在 Sodium 下
  **永远返回 0**(它数的是 vanilla 的网格),光看这个数会把
  「换了渲染器」误判成「没有数据」。而 blockinfo 能读方块 ⇒ 数据是在的。
  ⇒ 排障必须问**渲染器自己**,不能问 vanilla 的计数器。

(debug 命令里的 sodium* 字段就是这条思路的落地:
  sodiumLoaded / sodiumTracker_chunkStatus / sodiumTracker_chunkReady /
  sodiumRebuildQueue / sodiumChunksDebug …)
2026-09-26 10:18:36 +08:00

259 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"`。
★★ 本文件的射程(诚实地划清)
它证明的是 **「Knot 能被我们驱动,且在加载器下渲染能力照常」**。
它**不**证明「真实 mod(Iris/Sodium 等带 mixin 的)能工作」——
因为这里用的是**空 mods 目录**。
实测原因(2026-09-26,见 test_mixin_namespace_is_incompatible):
真实 mod 的 mixin 目标是 **intermediary** 名(`net.minecraft.class_310`),
而我们的代码跑在 **named** 命名空间(`net.minecraft.client.Minecraft`,
由 `-Dfabric.gameMappingNamespace=named` 指定)。
⇒ 同一 JVM 只能一个命名空间 ⇒ 真实 mixin mod 在本路线下**不会生效**。
所以「加载器能起动」与「mod 能用」是**两件事**,别把前者读成后者。
需要(缺任一项则跳过,不误报失败):
- 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)} 全部通过")