From 22cdee35e3e643752e34b9668a7e2abaf92e9f4f Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 25 Sep 2026 14:06:55 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcfold):=20P1=20=E6=8C=89=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E6=96=87=E4=BB=B6=E5=A4=B9=E8=A7=A3=E6=9E=90=E7=89=88?= =?UTF-8?q?=E6=9C=AC=EF=BC=88inheritsFrom=20=E5=BD=92=E4=B8=80=E5=8C=96=20?= =?UTF-8?q?+=20=E5=8A=A0=E8=BD=BD=E5=99=A8=E5=88=A4=E5=AE=9A=EF=BC=89+=20?= =?UTF-8?q?=E5=9B=9E=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 +++++ mcfold/__pycache__/version.cpython-312.pyc | Bin 0 -> 6607 bytes mcfold/version.py | 150 +++++++++++++++++++++ tests/test_version.py | 75 +++++++++++ 4 files changed, 256 insertions(+) create mode 100644 README.md create mode 100644 mcfold/__pycache__/version.cpython-312.pyc create mode 100644 mcfold/version.py create mode 100644 tests/test_version.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..c42e9d2 --- /dev/null +++ b/README.md @@ -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//.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 项回归全绿) diff --git a/mcfold/__pycache__/version.cpython-312.pyc b/mcfold/__pycache__/version.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..76a6478f5152f558df83e024639a9b9d9118bd4e GIT binary patch literal 6607 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 literal 0 HcmV?d00001 diff --git a/mcfold/version.py b/mcfold/version.py new file mode 100644 index 0000000..78182c1 --- /dev/null +++ b/mcfold/version.py @@ -0,0 +1,150 @@ +"""版本解析:从实例文件夹读出「该用哪个协议」。 + +设计依据(2026-09-25 实测): + * 实例文件夹 = 一个完整的 MC 客户端安装(与官方启动器一致) + * 版本信息在 versions//.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//.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//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) diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..14d0173 --- /dev/null +++ b/tests/test_version.py @@ -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 版本解析:全部通过")