## 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 …)
259 lines
10 KiB
Python
259 lines
10 KiB
Python
"""加载器路线联测: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)} 全部通过")
|