feat(mcfold): P1 按实例文件夹解析版本(inheritsFrom 归一化 + 加载器判定)+ 回归测试
This commit is contained in:
31
README.md
Normal file
31
README.md
Normal file
@ -0,0 +1,31 @@
|
||||
# mcfold — Minecraft 折叠层(净室 Python 实现)
|
||||
|
||||
按用户 2026-09-25 定向:**净室 Python 实现**,替代 Node/mineflayer 栈,
|
||||
使产物可打包分发(无 Node 运行时依赖)。
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
底层 mcfold(本工程,Python)
|
||||
├─ 按实例文件夹读版本 → 自动选协议(P1 已完成)
|
||||
├─ 声明式 protocol.json 解释器(protodef)
|
||||
├─ 世界模型(chunk/方块,含 mod 注册表)
|
||||
└─ 触发截屏 → 调脚手架 → 官方渲染 jar
|
||||
中间 可包装为不同 agent 的插件
|
||||
上层 面向 homeagent 的 Go 包装(复用现有 plugin.go 形状)
|
||||
```
|
||||
|
||||
## 关键实测事实(决定设计)
|
||||
|
||||
| 事实 | 证据 |
|
||||
|---|---|
|
||||
| 实例文件夹 = 完整 MC 客户端安装 | `versions/<id>/<id>.json` + `.jar` |
|
||||
| 加载器 id **不是**协议版本 | `minecraft-data('1.20.1-forge-47.4.0')` → FAIL,必须走 `inheritsFrom` |
|
||||
| protocol.json 是**声明式**的 | `varint`/`pstring`/`mapper`/`switch` 全是数据,非代码 |
|
||||
| 因此协议**不需重写**,只需解释器 | 111 个 play 包由同一份 JSON 驱动 |
|
||||
| 1.20.1 **不**在网络上传递方块注册表 | codec 仅 6 类,无 block ⇒ mod 方块必须另寻途径 |
|
||||
| 1.20.2+ 有 configuration 阶段 | 会发 `registry_data` ⇒ mod 方块可行的版本分界 |
|
||||
|
||||
## 进度
|
||||
|
||||
- [x] **P1** 按文件夹解析版本(`mcfold/version.py`,6 项回归全绿)
|
||||
BIN
mcfold/__pycache__/version.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/version.cpython-312.pyc
Normal file
Binary file not shown.
150
mcfold/version.py
Normal file
150
mcfold/version.py
Normal file
@ -0,0 +1,150 @@
|
||||
"""版本解析:从实例文件夹读出「该用哪个协议」。
|
||||
|
||||
设计依据(2026-09-25 实测):
|
||||
* 实例文件夹 = 一个完整的 MC 客户端安装(与官方启动器一致)
|
||||
* 版本信息在 versions/<verId>/<verId>.json
|
||||
* 加载器(forge/fabric)的 verId **不是**协议版本,必须经 inheritsFrom 归一化:
|
||||
minecraft-data('1.20.1') -> OK
|
||||
minecraft-data('1.20.1-forge-47.4.0') -> FAIL
|
||||
minecraft-data('fabric-loader-0.19.5-1.20.1') -> FAIL
|
||||
* mod 加载器会改写 mainClass,这决定「谁来渲染」(见 loaders 判定)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# 已知加载器的 mainClass 特征(用于判定实例该按哪种加载器解析 mods/)
|
||||
LOADER_MAINCLASS_HINTS: tuple[tuple[str, str], ...] = (
|
||||
("forgewrapper", "forge"),
|
||||
("forge", "forge"),
|
||||
("neoforge", "neoforge"),
|
||||
("fabric", "fabric"),
|
||||
("quilt", "quilt"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class VersionInfo:
|
||||
"""一个已安装版本的解析结果。"""
|
||||
|
||||
version_id: str # versions/ 下的目录名,如 1.20.1-forge-47.4.0
|
||||
mc_version: str # 归一化后的真实 MC 版本,如 1.20.1
|
||||
inherits_from: str | None # version.json 里的 inheritsFrom
|
||||
main_class: str # 启动主类(加载器会改写它)
|
||||
loader: str # vanilla / forge / neoforge / fabric / quilt
|
||||
json_path: Path # version.json 路径
|
||||
jar_path: Path | None # 客户端 jar(继承时可能在自己的目录里没有)
|
||||
assets: str | None = None
|
||||
libs_count: int = 0
|
||||
raw: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_modded(self) -> bool:
|
||||
return self.loader != "vanilla"
|
||||
|
||||
|
||||
def _detect_loader(main_class: str, version_id: str, inherits_from: str | None) -> str:
|
||||
"""从 mainClass / 版本 id 判定加载器类型。
|
||||
|
||||
注意:vanilla 的 version.json 里没有 inheritsFrom;加载器则一定有
|
||||
(forge: inheritsFrom=1.20.1, id=1.20.1-forge-47.4.0)。
|
||||
"""
|
||||
lc = (main_class or "").lower()
|
||||
for hint, name in LOADER_MAINCLASS_HINTS:
|
||||
if hint in lc:
|
||||
return name
|
||||
# mainClass 不可辨识时退回看 version id(如 fabric-loader-0.19.5-1.20.1)
|
||||
vid = version_id.lower()
|
||||
if inherits_from:
|
||||
for name in ("neoforge", "forge", "fabric", "quilt"):
|
||||
if name in vid:
|
||||
return name
|
||||
return "unknown-modded"
|
||||
return "vanilla"
|
||||
|
||||
|
||||
def parse_version_json(path: Path) -> VersionInfo:
|
||||
"""解析单个 versions/<id>/<id>.json。"""
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
version_id = data.get("id") or path.stem
|
||||
inherits_from = data.get("inheritsFrom")
|
||||
main_class = data.get("mainClass", "")
|
||||
loader = _detect_loader(main_class, version_id, inherits_from)
|
||||
|
||||
# mc_version 归一化:有 inheritsFrom 就取它(这才是协议版本)
|
||||
mc_version = inherits_from or version_id
|
||||
|
||||
jar = path.parent / f"{version_id}.jar"
|
||||
return VersionInfo(
|
||||
version_id=version_id,
|
||||
mc_version=mc_version,
|
||||
inherits_from=inherits_from,
|
||||
main_class=main_class,
|
||||
loader=loader,
|
||||
json_path=path,
|
||||
jar_path=jar if jar.is_file() else None,
|
||||
assets=data.get("assets"),
|
||||
libs_count=len(data.get("libraries", [])),
|
||||
raw=data,
|
||||
)
|
||||
|
||||
|
||||
def list_versions(instance_dir: str | os.PathLike[str]) -> list[VersionInfo]:
|
||||
"""列出实例文件夹里所有已安装版本(instances/<name>/versions/*/)。
|
||||
|
||||
跳过以 '.' 开头的目录,以及没有对应 json 的目录(半途安装的残留)。
|
||||
"""
|
||||
vroot = Path(instance_dir) / "versions"
|
||||
if not vroot.is_dir():
|
||||
return []
|
||||
|
||||
out: list[VersionInfo] = []
|
||||
for d in sorted(vroot.iterdir()):
|
||||
if not d.is_dir() or d.name.startswith("."):
|
||||
continue
|
||||
vj = d / f"{d.name}.json"
|
||||
if vj.is_file():
|
||||
out.append(parse_version_json(vj))
|
||||
return out
|
||||
|
||||
|
||||
def resolve_version(
|
||||
instance_dir: str | os.PathLike[str],
|
||||
prefer: str | None = None,
|
||||
) -> VersionInfo | None:
|
||||
"""从实例文件夹选出「该用哪个版本」。
|
||||
|
||||
prefer 指定优先的 version id(用户显式选择时用)。
|
||||
否则:优先 vanilla(少一层加载器,最稳),再按目录名排序取第一个。
|
||||
"""
|
||||
versions = list_versions(instance_dir)
|
||||
if not versions:
|
||||
return None
|
||||
|
||||
if prefer:
|
||||
for v in versions:
|
||||
if v.version_id == prefer:
|
||||
return v
|
||||
return None
|
||||
|
||||
vanilla = [v for v in versions if v.loader == "vanilla"]
|
||||
if vanilla:
|
||||
# 有多个原版时取版本号最高的那个
|
||||
return max(vanilla, key=lambda v: _verkey(v.mc_version))
|
||||
return versions[0]
|
||||
|
||||
|
||||
def _verkey(s: str) -> tuple[int, ...]:
|
||||
"""把 '1.20.1' 变成可比较的元组;非数字段回退为 0。"""
|
||||
parts: list[int] = []
|
||||
for seg in s.split("."):
|
||||
num = "".join(ch for ch in seg if ch.isdigit())
|
||||
parts.append(int(num) if num else 0)
|
||||
return tuple(parts)
|
||||
75
tests/test_version.py
Normal file
75
tests/test_version.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""P1 回归:按文件夹解析版本。"""
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from pathlib import Path
|
||||
from mcfold.version import list_versions, resolve_version, parse_version_json
|
||||
import json, tempfile
|
||||
|
||||
def mk(d, vid, obj):
|
||||
p = Path(d)/"versions"/vid; p.mkdir(parents=True)
|
||||
(p/f"{vid}.json").write_text(json.dumps(obj))
|
||||
|
||||
def test_vanilla():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
mk(d, "1.20.1", {"id":"1.20.1","mainClass":"net.minecraft.client.main.Main","assets":"5","libraries":[{}]*88})
|
||||
vs = list_versions(d)
|
||||
assert len(vs)==1, vs
|
||||
v = vs[0]
|
||||
assert v.mc_version=="1.20.1" and v.loader=="vanilla" and not v.is_modded
|
||||
print(" ✓ vanilla: 1.20.1 -> loader=vanilla, assets=5, libs=88")
|
||||
|
||||
def test_forge_inherits():
|
||||
"""核心用例:forge 的 id 不能当协议版本,必须走 inheritsFrom。"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
mk(d, "1.20.1-forge-47.4.0", {
|
||||
"id":"1.20.1-forge-47.4.0","inheritsFrom":"1.20.1",
|
||||
"mainClass":"io.github.zekerzhayard.forgewrapper.installer.Main"})
|
||||
v = list_versions(d)[0]
|
||||
assert v.mc_version=="1.20.1", v.mc_version # ← 归一化
|
||||
assert v.version_id=="1.20.1-forge-47.4.0"
|
||||
assert v.loader=="forge" and v.is_modded
|
||||
print(" ✓ forge: id=1.20.1-forge-47.4.0 -> mc_version=1.20.1, loader=forge")
|
||||
|
||||
def test_fabric_inherits():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
mk(d, "fabric-loader-0.19.5-1.20.1", {
|
||||
"id":"fabric-loader-0.19.5-1.20.1","inheritsFrom":"1.20.1",
|
||||
"mainClass":"net.fabricmc.loader.impl.launch.knot.KnotClient"})
|
||||
v = list_versions(d)[0]
|
||||
assert v.mc_version=="1.20.1" and v.loader=="fabric", (v.mc_version,v.loader)
|
||||
print(" ✓ fabric: -> mc_version=1.20.1, loader=fabric")
|
||||
|
||||
def test_prefer_and_vanilla_priority():
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
mk(d, "1.20.1", {"id":"1.20.1","mainClass":"net.minecraft.client.main.Main"})
|
||||
mk(d, "1.20.1-forge-47.4.0", {"id":"1.20.1-forge-47.4.0","inheritsFrom":"1.20.1",
|
||||
"mainClass":"io.github.zekerzhayard.forgewrapper.installer.Main"})
|
||||
# 不指定时 prior vanilla(最稳)
|
||||
assert resolve_version(d).loader=="vanilla"
|
||||
# 指定时按指定来
|
||||
assert resolve_version(d, "1.20.1-forge-47.4.0").loader=="forge"
|
||||
print(" ✓ 默认优先 vanilla;prefer 可显式选 forge")
|
||||
|
||||
def test_garbage_skipped():
|
||||
"""半途安装的残留目录(无 json)必须跳过,而不是崩。"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
(Path(d)/"versions"/"broken").mkdir(parents=True)
|
||||
mk(d, "1.20.1", {"id":"1.20.1","mainClass":"net.minecraft.client.main.Main"})
|
||||
vs = list_versions(d)
|
||||
assert len(vs)==1, [v.version_id for v in vs]
|
||||
assert resolve_version(d) is not None
|
||||
print(" ✓ 无 json 的残留目录被跳过")
|
||||
|
||||
def test_real_install():
|
||||
"""对真实 mcview 安装目录验证(若存在)。"""
|
||||
real = "/home/newqqagent/mcview/store/vanilla-1.20.1"
|
||||
if not Path(real).is_dir(): print(" - 真实安装目录不存在,跳过"); return
|
||||
v = resolve_version(real)
|
||||
assert v is not None
|
||||
print(f" ✓ 真实安装: {v.version_id} -> mc={v.mc_version} loader={v.loader} libs={v.libs_count} jar={'有' if v.jar_path else '无'}")
|
||||
|
||||
if __name__=="__main__":
|
||||
for fn in [test_vanilla, test_forge_inherits, test_fabric_inherits,
|
||||
test_prefer_and_vanilla_priority, test_garbage_skipped, test_real_install]:
|
||||
fn()
|
||||
print("\nP1 版本解析:全部通过")
|
||||
Reference in New Issue
Block a user