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:
JianFeeeee
2026-09-26 07:12:41 +08:00
parent a1ce560c05
commit 8b1fc9289d
4 changed files with 364 additions and 19 deletions

View File

@ -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:

View File

@ -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"

View File

@ -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
View 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)} 全部通过")