diff --git a/README.md b/README.md index 5b1bed2..a58aed7 100644 --- a/README.md +++ b/README.md @@ -50,3 +50,18 @@ - [x] **P4** 区块解析(`mcfold/chunk.py`,11 项回归全绿) 位打包 / 单值·本地·全局调色板 / 生物群系 section / 世界边界 **关键行为**:越界 state id ⇒ `unknown(id)`(对照 mineflayer 的静默错配) +- [x] **P5** mod jar 解析(`mcfold/modjar.py`,8 项回归全绿) + 三种加载器真实样本验证(Forge / NeoForge / Fabric)+ 多加载器 jar 诊断 + 可提取:mod id / 名称 / 版本 / **方块名清单** / 物品名清单 + **诚实边界**:拿不到 state id(已在摘要里明说,见附录 D) + +## 累计测试 + +| 套件 | 项数 | +|---|---| +| 版本解析 | 6 | +| protodef | 6 | +| 区块解析 | 11 | +| mod jar | 8 | +| 跨实现互操作 | 2 | +| **合计** | **33** | diff --git a/mcfold/__pycache__/modjar.cpython-312.pyc b/mcfold/__pycache__/modjar.cpython-312.pyc new file mode 100644 index 0000000..4a6cf56 Binary files /dev/null and b/mcfold/__pycache__/modjar.cpython-312.pyc differ diff --git a/mcfold/modjar.py b/mcfold/modjar.py new file mode 100644 index 0000000..ad984e6 --- /dev/null +++ b/mcfold/modjar.py @@ -0,0 +1,224 @@ +"""mod jar 解析:从 jar 里读出加载器元数据与**方块名清单**。 + +实测边界(2026-09-25,文档附录 D/E)——必须说清的: + ✅ 能拿到:mod id / 名称 / 版本 / 加载器 / 方块名清单 / 物品名清单 + ❌ 拿不到:**state id 映射** + +为什么拿不到 state id:state id 是服务端加载 mod 时**动态分配**的, +jar 里不存在;而 1.20.1 又不在网络上传方块注册表(见附录 D)。 +1.20.2+ 会在 configuration 阶段发 registry_data,那条路另议。 + +所以本模块的定位是: + 1. 让 agent 知道「本实例装了什么、有哪些方块名」 + 2. 用方块名做**指令校验**(避免让 agent 去操作一个这服没有的方块) + 3. 配合 world 模块把「本机注册表没有的 state id」报告为 unknown 并归因到 mod +""" + +from __future__ import annotations + +import json +import re +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# 加载器元数据文件 → 加载器类型 / 解析方式 +META_FILES: tuple[tuple[str, str, str], ...] = ( + ("META-INF/neoforge.mods.toml", "neoforge", "toml"), + ("META-INF/mods.toml", "forge", "toml"), + ("fabric.mod.json", "fabric", "json"), + ("quilt.mod.json", "quilt", "json"), +) + + +@dataclass +class ModInfo: + """一个 mod jar 的解析结果。""" + path: Path + loader: str # forge / neoforge / fabric / quilt / unknown + mod_id: str = "" + name: str = "" + version: str = "" + # 方块名(不含命名空间前缀),来自 assets//blockstates/*.json + block_names: list[str] = field(default_factory=list) + item_names: list[str] = field(default_factory=list) + namespaces: list[str] = field(default_factory=list) + depends: dict[str, str] = field(default_factory=dict) + loaders_present: list[str] = field(default_factory=list) # 多加载器 jar 时列出全部 + error: str | None = None + + @property + def ok(self) -> bool: + return self.error is None + + def qualified_blocks(self) -> list[str]: + """带命名空间的方块名,形如 expandeddelight:cask。""" + ns = self.mod_id or (self.namespaces[0] if self.namespaces else "") + return [f"{ns}:{n}" for n in self.block_names] if ns else list(self.block_names) + + +# ---------------- 元数据解析 ---------------- + +def _parse_toml(text: str) -> dict[str, Any]: + """极简 TOML 读取:只取 mods.toml 用到的形式(够了,不引入依赖)。 + + 支持:key = "value"、[[mods]] 分段;其余分段(如 [dependencies.*])忽略。 + + 踩过的坑(实测):判断 [[mods]] 不能用 `line.rstrip("]")` —— + "[[mods]]".rstrip("]") 得到 "[[mods",并不以 "mods" 结尾,条件永远为假。 + """ + out: dict[str, Any] = {"mods": []} + cur: dict[str, Any] | None = None + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + # 数组表头 [[mods]](可能带行内注释:"[[mods]] #mandatory") + if line.startswith("[["): + mh = re.match(r"^\[\[([^\]]+)\]\]", line) + header = mh.group(1).strip() if mh else "" + cur = {} if header == "mods" else None + if cur is not None: + out["mods"].append(cur) + continue + if line.startswith("["): + cur = None # 进入其它分段,忽略 + continue + m = re.match(r'^([A-Za-z_][\w]*)\s*=\s*(.+?)\s*$', line) + if not m: + continue + key, val = m.group(1), m.group(2) + val = re.sub(r"\s*#.*$", "", val).strip().rstrip(",") + if val.startswith('"') and val.endswith('"'): + val = val[1:-1] + elif val.startswith("'") and val.endswith("'"): + val = val[1:-1] + if cur is not None: + cur.setdefault(key, val) # 取首个(后续多为依赖声明重名) + else: + out.setdefault(key, val) + return out + + +def detect_loaders(zf: zipfile.ZipFile) -> list[str]: + """列出 jar 里出现的**全部**加载器元数据类型(多加载器 jar 诊断用)。""" + names = set(zf.namelist()) + return [loader for fname, loader, _ in META_FILES if fname in names] + + +def _read_meta(zf: zipfile.ZipFile) -> tuple[str, dict[str, Any], str]: + """从 jar 读出加载器元数据。返回 (loader, meta, kind)。 + + 注意:很多 mod 是**多加载器 jar**(实测 Explorify 同时含 mods.toml、 + neoforge.mods.toml、fabric.mod.json)。此时按 META_FILES 的优先级取第一个, + 但要记录全部候选,便于诊断。 + """ + names = set(zf.namelist()) + for fname, loader, kind in META_FILES: + if fname in names: + raw = zf.read(fname).decode("utf-8", errors="replace") + meta = json.loads(raw) if kind == "json" else _parse_toml(raw) + return loader, meta, kind + return "unknown", {}, "none" + + +def _extract_identity(loader: str, meta: dict[str, Any]) -> tuple[str, str, str, dict[str, str]]: + """取 (mod_id, name, version, depends)。""" + if loader in ("forge", "neoforge"): + mods = meta.get("mods") or [{}] + first = mods[0] if mods else {} + mid = first.get("modId") or first.get("modid") or "" + return mid, first.get("displayName", "") or mid, first.get("version", ""), {} + # fabric / quilt + mid = meta.get("id", "") + depends = {} + for k, v in (meta.get("depends") or {}).items(): + depends[k] = ", ".join(v) if isinstance(v, list) else str(v) + return mid, meta.get("name", "") or mid, str(meta.get("version", "")), depends + + +def _scan_names(zf: zipfile.ZipFile) -> tuple[list[str], list[str], list[str]]: + """扫 jar 里的资源目录,得到方块名 / 物品名 / 命名空间。 + + 权威依据(实测):方块名在 assets//blockstates/.json, + 物品名在 assets//models/item/.json。 + """ + blocks: set[str] = set() + items: set[str] = set() + namespaces: set[str] = set() + + for n in zf.namelist(): + m = re.match(r"^assets/([^/]+)/blockstates/(.+)\.json$", n) + if m: + namespaces.add(m.group(1)) + blocks.add(m.group(2)) + continue + m = re.match(r"^assets/([^/]+)/models/item/(.+)\.json$", n) + if m: + namespaces.add(m.group(1)) + items.add(m.group(2)) + + return sorted(blocks), sorted(items), sorted(namespaces) + + +def parse_mod(path: str | Path) -> ModInfo: + """解析单个 mod jar。失败不抛异常,而是把原因记在 error 里。""" + p = Path(path) + info = ModInfo(path=p, loader="unknown") + try: + with zipfile.ZipFile(p) as zf: + info.loaders_present = detect_loaders(zf) + loader, meta, _kind = _read_meta(zf) + info.loader = loader + if loader != "unknown": + mid, name, ver, depends = _extract_identity(loader, meta) + info.mod_id, info.name, info.version, info.depends = mid, name, ver, depends + blocks, items, ns = _scan_names(zf) + info.block_names, info.item_names, info.namespaces = blocks, items, ns + if not info.mod_id and ns: + info.mod_id = ns[0] + except zipfile.BadZipFile: + info.error = "不是有效的 zip/jar" + except Exception as e: # noqa: BLE001 - 解析失败不应中断整目录扫描 + info.error = f"{type(e).__name__}: {e}" + return info + + +def scan_mods(mods_dir: str | Path) -> list[ModInfo]: + """扫描实例的 mods/ 目录。跳过 .disabled 与目录。""" + d = Path(mods_dir) + if not d.is_dir(): + return [] + out: list[ModInfo] = [] + for f in sorted(d.iterdir()): + if f.is_dir() or f.name.startswith("."): + continue + if f.suffix.lower() not in (".jar", ".zip"): + continue + out.append(parse_mod(f)) + return out + + +def summarize(mods: list[ModInfo]) -> dict[str, Any]: + """给 agent 看的摘要(用于 mod 感知的降级提示)。""" + ok = [m for m in mods if m.ok] + bad = [m for m in mods if not m.ok] + blocks: list[str] = [] + for m in ok: + blocks.extend(m.qualified_blocks()) + return { + "count": len(mods), + "loaded": len(ok), + "failed": len(bad), + "mods": [ + {"id": m.mod_id, "name": m.name, "version": m.version, + "loader": m.loader, "blocks": len(m.block_names)} + for m in ok + ], + "failed_detail": [{"file": m.path.name, "error": m.error} for m in bad], + "block_names": sorted(set(blocks)), + "block_name_count": len(set(blocks)), + # 诚实标注:这是**方块名**,不是 state id + "note": "mod 方块名清单(state id 无法从 jar 获得,见文档附录 D)", + } diff --git a/tests/test_modjar.py b/tests/test_modjar.py new file mode 100644 index 0000000..ca7e96d --- /dev/null +++ b/tests/test_modjar.py @@ -0,0 +1,126 @@ +"""mod jar 解析回归 —— 用**真实 mod jar** 验证(三种加载器 + 边界)。""" +import sys, os, json, zipfile, io, tempfile +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from mcfold.modjar import parse_mod, scan_mods, summarize, _parse_toml, detect_loaders + +FORGE = "/home/mc-test/mods/curtain-mc1.20.1-1.3.2.jar" +NEOFORGE= "/home/minecraft/mods/bettercombat-neoforge-2.3.2+1.21.1.jar" +MULTI = "/home/minecraft/mods/Explorify v1.6.4 f15-88.mod.jar" +BLOCKS = "/home/minecraft/mods/[扩充乐事] expandeddelight-0.1.4.jar" + +def exists(p): return os.path.isfile(p) + +def test_toml_parser_comment_headers(): + """★ 踩过的坑:表头带行内注释('[[mods]] #mandatory')必须正确识别。""" + text = ''' +loaderVersion = "[47,)" +[[mods]] #mandatory +modId = "curtain" #mandatory +version = "${file.jarVersion}" +displayName = "Curtain" +[[dependencies.curtain]] +modId = "forge" +''' + m = _parse_toml(text) + assert len(m["mods"]) == 1, f"mods 段数应为 1,实际 {len(m['mods'])}" + assert m["mods"][0]["modId"] == "curtain", m["mods"][0] + assert m["mods"][0]["displayName"] == "Curtain" + # 依赖段的 modId 不应污染 mods[0] + assert m["mods"][0]["modId"] != "forge" + print(" ✓ TOML: 带行内注释的 [[mods]] 正确识别,依赖段不污染") + +def test_forge_real(): + if not exists(FORGE): + print(" - forge 样本不存在,跳过"); return + m = parse_mod(FORGE) + assert m.loader == "forge", m.loader + assert m.mod_id == "curtain", m.mod_id + assert m.name == "Curtain", m.name + # curtain 只加命令、不新增方块 —— 0 方块是正确结果 + assert m.block_names == [], m.block_names + print(f" ✓ Forge 真实: id={m.mod_id} name={m.name} 方块=0(它只加命令)") + +def test_neoforge_real(): + if not exists(NEOFORGE): + print(" - neoforge 样本不存在,跳过"); return + m = parse_mod(NEOFORGE) + assert m.loader == "neoforge", m.loader + assert m.mod_id == "bettercombat", m.mod_id + assert m.version == "2.3.2+1.21.1", m.version + print(f" ✓ NeoForge 真实: id={m.mod_id} ver={m.version}") + +def test_multi_loader_jar(): + """多加载器 jar(同时含 forge/neoforge/fabric 元数据)必须能诊断。""" + if not exists(MULTI): + print(" - 多加载器样本不存在,跳过"); return + m = parse_mod(MULTI) + assert len(m.loaders_present) >= 2, m.loaders_present + assert m.mod_id == "explorify", m.mod_id + # 结构 mod:用原版方块生成地形,自己不新增方块 ⇒ 0 方块正确 + assert m.block_names == [], m.block_names + print(f" ✓ 多加载器 jar: 含 {m.loaders_present},id={m.mod_id}(结构 mod,0 方块)") + +def test_block_extraction_real(): + """★ 核心:从真实 mod 提取方块名清单(这是 mod 支持的可见部分)。""" + if not exists(BLOCKS): + print(" - 方块 mod 样本不存在,跳过"); return + m = parse_mod(BLOCKS) + assert m.mod_id == "expandeddelight", m.mod_id + assert len(m.block_names) > 20, f"方块数偏少: {len(m.block_names)}" + # 已知方块(前面实测过) + for want in ("cask", "cheese_wheel", "asparagus_crate"): + assert want in m.block_names, f"缺方块 {want}" + q = m.qualified_blocks() + assert "expandeddelight:cask" in q, q[:5] + # 物品也应提取到 + assert len(m.item_names) > 20, len(m.item_names) + print(f" ✓ 方块提取: {len(m.block_names)} 方块 / {len(m.item_names)} 物品,含 cask/cheese_wheel") + +def test_no_state_id_claim(): + """★ 诚实性:解析结果里**不含** state id(因为拿不到),摘要必须明说。""" + m = parse_mod(BLOCKS) if exists(BLOCKS) else None + if m is None: + print(" - 跳过"); return + s = summarize([m]) + assert "state id" in s["note"], s["note"] + assert "block_names" in s and s["block_name_count"] > 0 + # 摘要里不应有任何形如 state id 的映射承诺 + assert "state_ids" not in s and "stateId" not in json.dumps(s) + print(f" ✓ 诚实标注: 摘要含 {s['block_name_count']} 方块名,并注明无 state id") + +def test_bad_jar_no_crash(): + """损坏的 jar 不能中断整目录扫描。""" + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "broken.jar") + open(p, "wb").write(b"this is not a zip") + m = parse_mod(p) + assert not m.ok and "zip" in (m.error or "").lower(), m.error + good = os.path.join(d, "ok.jar") + with zipfile.ZipFile(good, "w") as zf: + zf.writestr("fabric.mod.json", json.dumps({"id":"t","name":"T","version":"1"})) + mods = scan_mods(d) + assert len(mods) == 2, len(mods) + s = summarize(mods) + assert s["loaded"] == 1 and s["failed"] == 1, s + print(f" ✓ 损坏 jar 不中断扫描: loaded={s['loaded']} failed={s['failed']}") + +def test_json_meta_without_assets(): + """纯 JSON 元数据、无 assets 的 mod 也要能拿到 id(边界)。""" + with tempfile.TemporaryDirectory() as d: + p = os.path.join(d, "t.jar") + with zipfile.ZipFile(p, "w") as zf: + zf.writestr("fabric.mod.json", json.dumps( + {"id":"mymod","name":"My Mod","version":"2.0", + "depends":{"minecraft":">=1.20","fabric-api":"*"}})) + m = parse_mod(p) + assert m.loader == "fabric" and m.mod_id == "mymod", (m.loader, m.mod_id) + assert m.depends.get("minecraft") == ">=1.20", m.depends + assert m.block_names == [] + print(" ✓ fabric 纯元数据: id/depends 正确,无 assets 不报错") + +if __name__ == "__main__": + for fn in [test_toml_parser_comment_headers, test_forge_real, test_neoforge_real, + test_multi_loader_jar, test_block_extraction_real, test_no_state_id_claim, + test_bad_jar_no_crash, test_json_meta_without_assets]: + fn() + print("\nmod jar 解析:全部通过")