feat(mcfold): P5 mod jar 解析(三种加载器+多加载器诊断+方块名提取,含诚实边界标注)
This commit is contained in:
15
README.md
15
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** |
|
||||
|
||||
BIN
mcfold/__pycache__/modjar.cpython-312.pyc
Normal file
BIN
mcfold/__pycache__/modjar.cpython-312.pyc
Normal file
Binary file not shown.
224
mcfold/modjar.py
Normal file
224
mcfold/modjar.py
Normal file
@ -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/<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。"""
|
||||
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/<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)",
|
||||
}
|
||||
126
tests/test_modjar.py
Normal file
126
tests/test_modjar.py
Normal file
@ -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 解析:全部通过")
|
||||
Reference in New Issue
Block a user