235 lines
9.1 KiB
Python
235 lines
9.1 KiB
Python
"""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/<ns>/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/<ns>/blockstates/<name>.json,
|
||
物品名在 assets/<ns>/models/item/<name>.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)",
|
||
}
|