From 85ad7e580e9a7993e7c41bcd7dadd61b4125c376 Mon Sep 17 00:00:00 2001 From: pi Date: Fri, 25 Sep 2026 14:32:39 +0800 Subject: [PATCH] =?UTF-8?q?feat(mcfold):=20P5=20mod=20jar=20=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=EF=BC=88=E4=B8=89=E7=A7=8D=E5=8A=A0=E8=BD=BD=E5=99=A8?= =?UTF-8?q?+=E5=A4=9A=E5=8A=A0=E8=BD=BD=E5=99=A8=E8=AF=8A=E6=96=AD+?= =?UTF-8?q?=E6=96=B9=E5=9D=97=E5=90=8D=E6=8F=90=E5=8F=96=EF=BC=8C=E5=90=AB?= =?UTF-8?q?=E8=AF=9A=E5=AE=9E=E8=BE=B9=E7=95=8C=E6=A0=87=E6=B3=A8=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++ mcfold/__pycache__/modjar.cpython-312.pyc | Bin 0 -> 12538 bytes mcfold/modjar.py | 224 ++++++++++++++++++++++ tests/test_modjar.py | 126 ++++++++++++ 4 files changed, 365 insertions(+) create mode 100644 mcfold/__pycache__/modjar.cpython-312.pyc create mode 100644 mcfold/modjar.py create mode 100644 tests/test_modjar.py 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 0000000000000000000000000000000000000000..4a6cf564b2bf6b8743eed1b16f3be9d21dd322f0 GIT binary patch literal 12538 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/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 解析:全部通过")