"""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。""" return [f"{self.namespace}:{n}" for n in self.block_names] def qualified_blocks_for(self, block_name: str) -> list[str]: """指定裸方块名在本 mod 下的完整限定名(不在则空)。""" if block_name not in self.block_names: return [] return [f"{self.namespace}:{block_name}"] @property def namespace(self) -> str: """该 mod 的命名空间(通常 == mod_id)。""" return self.mod_id or (self.namespaces[0] if self.namespaces else "") # ---------------- 元数据解析 ---------------- 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)", }