Files
agentmc/mcfold/modjar.py

235 lines
9.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)",
}