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 均待设计)
This commit is contained in:
@ -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.<clinit> 抛
|
||||
"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=<dir> → 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:
|
||||
|
||||
20
run_tests.sh
20
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"
|
||||
|
||||
@ -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.<init>`,
|
||||
我们的代码抛 `NoClassDefFoundError: net/minecraft/client/Minecraft`(实测)。
|
||||
|
||||
### ★ 第三个必需条件:asm 必须只留一份
|
||||
|
||||
1.20.1 自带 asm **9.2 与 9.6 两版并存**,与 Fabric 的 9.10.1 同时在场会让
|
||||
`Knot.<clinit>` 抛 `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/<ns>/blockstates/*.json`
|
||||
→ 解冻 BLOCK 注册表 → 注册占位方块 + state → jar 挂为资源包。
|
||||
|
||||
|
||||
248
tests/test_loader_render.py
Normal file
248
tests/test_loader_render.py
Normal file
@ -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)} 全部通过")
|
||||
Reference in New Issue
Block a user