618 lines
31 KiB
Python
618 lines
31 KiB
Python
#!/usr/bin/env python3
|
||
"""BadWolf PD2 BH Loot Filter 静态语法与一致性检查器 (Linter) & 模块同步工具.
|
||
|
||
功能清单:
|
||
1. [SYNC] 检查 build/00..39-*.txt 拼接结果是否与根目录 BadWolf-LootFilter.txt 逐字节完全一致
|
||
(支持 --sync-build 反向拆分主文件回写 build/*.txt,或 --merge-build 正向合并)
|
||
2. [SYNTAX] 检查指令头 (ItemDisplay / Alias / Formula / ItemDisplayFilterName)、括号配对与编码格式
|
||
3. [GUI] 检查 BH 界面开关 (@按键名@分组名@) 是否重名、Alias/Formula 是否重复定义或内容完全克隆
|
||
4. [COND] 完整模拟 BH.dll Condition::Parse 词法与等优先级 RPN 编译器:
|
||
- 捕获粘连括号/运算符 (如 ')SUFFIX='、'SK224=3OR')
|
||
- 捕获非法条件比较符 (如 'PREFIX=~'、'==')
|
||
- 捕获 BH 等优先级陷阱 (同一括号层级混写隐式 AND 与 OR)
|
||
5. [EXPR] 检查 Formula[...] 与 $f(...) 表达式括号平衡、AND(...,0) 恒零笔误、STAT350 误写、
|
||
以及同一公式同号 OR(...) 组内或组间重复计入的 STAT360 腐化词缀 ID
|
||
6. [MACRO] 按照 BH.dll 真实宏表检查输出文本中的残缺/粘连颜色宏 (如 %%CL%、%%LIGHT_GRAY%、CL%%)
|
||
7. [TIER] 检查第 8 章 (符文之语底材) 同一小节内星级顺序是否严格从高星到低星 (4★->3★->2★->1★->0★)
|
||
|
||
用法:
|
||
python build/lint_filter.py # 静态检查主文件与 build/ 分片同步状态
|
||
python build/lint_filter.py --sync-build # 将 BadWolf-LootFilter.txt 反向同步拆分回 build/00..39-*.txt 并检查
|
||
python build/lint_filter.py --merge-build # 将 build/00..39-*.txt 合并生成 BadWolf-LootFilter.txt 并检查
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
import re
|
||
import sys
|
||
|
||
VALID_SIMPLE_MACROS = {
|
||
"NAME", "BASENAME", "CL", "NL", "CONTINUE", "MAP",
|
||
"WHITE", "RED", "GOLD", "GRAY", "GREEN", "BLUE", "ORANGE", "YELLOW",
|
||
"BLACK", "TAN", "CORAL", "SAGE", "PURPLE", "DARK_GREEN", "TEAL", "LIGHT_GRAY",
|
||
"FULL_TRANS", "THREE_FOURTHS_TRANS", "HALF_TRANS", "QUARTER_TRANS",
|
||
"ILVL", "ALVL", "REROLLALVL", "CRAFTALVL", "SOCKETS", "CODE", "RUNENUM", "RUNENAME",
|
||
"GEMTYPE", "GEMLEVEL", "LVLREQ", "WPNSPD", "RANGE", "RBRACE", "LBRACE",
|
||
"SELLPRICE", "PRICE", "PERCENT", "BUYPRICE", "ED", "CS", "OS", "QTY", "RES",
|
||
"REQSTR", "REQDEX", "REQLVL", "BASEMAXONEH", "BASEMINTWOH", "BASEMINONEH",
|
||
"BASEMAXTHROW", "BASEMINKICK", "BASEMAXTWOH", "BASEMINTHROW", "BASEMAXSMITE",
|
||
"BASEBLOCK", "BASEMAXKICK", "BASEMINSMITE", "PLUSDEF", "UPSTR", "ALLATTRIB",
|
||
"MAXRES", "MAXSOCKETS", "MINDMG", "UPDEX", "UPLVL", "EDAM", "DEF", "MAXDMG",
|
||
"EDEF", "LRES", "PRES", "FRES", "CRES", "FHR", "FBR", "IAS", "FCR", "ARPER",
|
||
"MFIND", "LIFE", "MANA", "DEX", "FRW", "GFIND", "STR", "MAEK", "REPLIFE", "AR",
|
||
"DTM", "REPQUANT", "REPAIR", "ALLSK", "HEIGHT", "WIDTH", "AREA",
|
||
"ALLDISCOVERED", "ANYDISCOVERED", "ALLOWNED", "ANYOWNED", "TUCAO", "NONE",
|
||
}
|
||
|
||
COLOR_AND_CTRL_MACROS = {
|
||
"NAME", "BASENAME", "CL", "NL", "CONTINUE", "MAP", "RBRACE", "LBRACE", "PERCENT", "NONE",
|
||
"WHITE", "RED", "GOLD", "GRAY", "GREEN", "BLUE", "ORANGE", "YELLOW",
|
||
"BLACK", "TAN", "CORAL", "SAGE", "PURPLE", "DARK_GREEN", "TEAL", "LIGHT_GRAY",
|
||
"FULL_TRANS", "THREE_FOURTHS_TRANS", "HALF_TRANS", "QUARTER_TRANS",
|
||
}
|
||
NUMERIC_VALUE_MACROS = VALID_SIMPLE_MACROS - COLOR_AND_CTRL_MACROS
|
||
|
||
SHARD_ANCHORS: list[tuple[str, list[str]]] = [
|
||
("00-版本及过滤等级设置.txt", [
|
||
"//╬╬╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╤╬╬",
|
||
"//║║ BadWolf Filter For PD2-S14 ║║",
|
||
]),
|
||
("01-物品集短名称定义.txt", ["// +---------------+", "// | 4,物品集定义|", "// +---------------+"]),
|
||
("02-名称修正.txt", ["//╔════════════╗", "//║ 二、物品额外信息显示 ║", "//╚════════════╝"]),
|
||
("03-通用额外提示信息.txt", ["// ++++++ 1.2,额外提示信息 ++++++"]),
|
||
("04-显示底材ED和抗性.txt", ["// +------------+", "// | 2,基础底材 |", "// +------------+"]),
|
||
("05-显示底材STAFFMOD.txt", ["// ++++++ 2.2,显示底材STAFFMOD ++++++"]),
|
||
("06-显示底材可用符文之语.txt", ["// ++++++ 2.3,显示底材可用符文之语 ++++++"]),
|
||
("07-显示底材可能孔数.txt", ["// ++++++ 2.4,显示底材可能孔数 ++++++"]),
|
||
("08-无法冰冻提示.txt", ["// +------------+", "// | 3,其它装备 |", "// +------------+"]),
|
||
("09-装备附带技能效果提示.txt", ["// ++++++ 3.2,附带技能效果提示 ++++++"]),
|
||
("10-非底材可能孔数提示.txt", ["// ++++++ 3.3,非底材装备可能孔数提示 ++++++"]),
|
||
("11-装备升级提示.txt", ["// ++++++ 3.4,装备升级提示 ++++++"]),
|
||
("12-武器基础攻速和伤害范围提示.txt", ["// ++++++ 3.5,武器基础攻速和伤害范围提示 ++++++"]),
|
||
("13-绿色套装鞋子踢击伤害提示.txt", ["// ++++++ 3.6,套装鞋子踢击伤害提示 ++++++"]),
|
||
("14-显示盾牌格挡所需敏捷.txt", ["// ++++++ 3.7,盾牌标注75%格挡所需敏捷提示 ++++++"]),
|
||
("15-PD2新增杂物说明.txt", ["// +----------------+", "// | 4, PD2新增物品 |", "// +----------------+"]),
|
||
("16-秘境地图说明.txt", ["// ++++++ 4.2,秘境地图 ++++++"]),
|
||
("17-超级BOSS门票说明.txt", ["// ++++++ 4.3,超级BOSS门票 ++++++"]),
|
||
("18-精华和徽章说明.txt", ["// ++++++ 4.4,精华和徽章 ++++++"]),
|
||
("19-手工模组说明.txt", ["// ++++++ 4.5,手工模组 ++++++"]),
|
||
("20-商店购物提示.txt", ["// +----------------+", "// | 5,其它辅助功能 |", "// +----------------+"]),
|
||
("21-T图基德事件赌博提示.txt", ["// ++++++ 5.2,T图基德赌博提示 ++++++"]),
|
||
("22-暗金装备价值提示.txt", ["// ++++++ 5.3,暗金装备价值评测 ++++++"]),
|
||
("23-绿色装备价值提示.txt", ["// ++++++ 5.4,绿色套装价值评测 ++++++"]),
|
||
("24-亮金装备价值提示.txt", ["// ++++++ 5.5,亮金/手工装备价值提示 ++++++"]),
|
||
("25-魔法装备价值提示.txt", ["// ++++++ 5.6,魔法装备价值提示 ++++++"]),
|
||
("26-符文之语价值提示.txt", ["// ++++++ 5.7,符文之语价值提示 ++++++"]),
|
||
("27-洗珠宝提示.txt", ["// ++++++ 5.8,洗珠宝提示 ++++++"]),
|
||
("28-材料当前手工词缀等级提示.txt", ["// ++++++ 5.9,材料当前手工词缀等级提示 ++++++"]),
|
||
("29-杂物过滤设置.txt", ["//╔═════════════╗", "//║ 三、物品过滤设置 ║", "//╚═════════════╝"]),
|
||
("30-镶嵌物过滤设置.txt", ["// +-----------+", "// | 2,镶嵌物 |", "// +-----------+"]),
|
||
("31-护身符过滤设置.txt", ["// +---------+", "// | 3,饰品 |", "// +---------+"]),
|
||
("32-暗金装备过滤设置.txt", ["// +-------------+", "// | 4,暗金装备 |", "// +-------------+"]),
|
||
("33-绿色套装过滤设置.txt", ["// +-------------+", "// | 5,绿色套装 |", "// +-------------+"]),
|
||
("34-亮金装备过滤设置.txt", ["// +-------------+", "// | 6,亮金装备 |", "// +-------------+"]),
|
||
("35-魔法装备过滤设置.txt", ["// +-------------+", "// | 7,魔法装备 |", "// +-------------+"]),
|
||
("36-符文之语底材过滤设置.txt", ["// +------------------+", "// | 8,符文之语底材 |", "// +------------------+"]),
|
||
("37-注入底材过滤设置.txt", ["// +-------------+", "// | 9,注入底材 |", "// +-------------+"]),
|
||
("38-开荒装备配置.txt", ["//╔═════════════╗", "//║ 10,开荒过滤配置 ║", "//╚═════════════╝"]),
|
||
("39-通用收尾规则.txt", ["// +--------------+", "// | 11,通用规则 |", "// +--------------+"]),
|
||
]
|
||
|
||
|
||
@dataclass
|
||
class Diagnostic:
|
||
severity: str # "ERROR" | "WARN"
|
||
category: str
|
||
lineno: int
|
||
message: str
|
||
|
||
|
||
def split_main_to_shards(main_text: str) -> list[tuple[str, str]]:
|
||
"""按 40 个章节锚点将主文件无损切分为 build/00..39-*.txt 内容."""
|
||
offsets: list[tuple[str, int]] = []
|
||
search_pos = 0
|
||
for shard_name, anchor_lines in SHARD_ANCHORS:
|
||
anchor = "\n".join(anchor_lines)
|
||
idx = main_text.find(anchor, search_pos)
|
||
if idx == -1:
|
||
raise ValueError(f"未能在主文件中找到分片 {shard_name} 的章节锚点: {anchor_lines[0]!r}")
|
||
offsets.append((shard_name, idx))
|
||
search_pos = idx + len(anchor)
|
||
|
||
result: list[tuple[str, str]] = []
|
||
for i, (shard_name, start) in enumerate(offsets):
|
||
end = offsets[i + 1][1] if i + 1 < len(offsets) else len(main_text)
|
||
result.append((shard_name, main_text[start:end]))
|
||
return result
|
||
|
||
|
||
def sync_main_to_build(filter_path: Path, build_dir: Path) -> None:
|
||
raw = filter_path.read_bytes()
|
||
text = raw.decode("utf-8").replace("\r\n", "\n")
|
||
if text.startswith("\ufeff"):
|
||
text = text.lstrip("\ufeff")
|
||
filter_path.write_bytes(text.encode("utf-8"))
|
||
shards = split_main_to_shards(text)
|
||
for shard_name, shard_content in shards:
|
||
(build_dir / shard_name).write_bytes(shard_content.encode("utf-8"))
|
||
|
||
|
||
def merge_build_to_main(filter_path: Path, build_dir: Path) -> None:
|
||
chunks: list[bytes] = []
|
||
for shard_name, _ in SHARD_ANCHORS:
|
||
shard_file = build_dir / shard_name
|
||
if not shard_file.exists():
|
||
raise FileNotFoundError(f"缺少构建分片: {shard_file}")
|
||
txt = shard_file.read_text(encoding="utf-8").lstrip("\ufeff").replace("\r\n", "\n")
|
||
chunks.append(txt.encode("utf-8"))
|
||
filter_path.write_bytes(b"".join(chunks))
|
||
|
||
|
||
def check_build_sync(filter_path: Path, build_dir: Path, diags: list[Diagnostic]) -> None:
|
||
main_bytes = filter_path.read_bytes()
|
||
if main_bytes.startswith(b"\xef\xbb\xbf"):
|
||
diags.append(Diagnostic("ERROR", "SYNC", 1, "主文件包含 UTF-8 BOM 头,建议使用无 BOM UTF-8"))
|
||
|
||
shard_bytes_list: list[bytes] = []
|
||
for shard_name, _ in SHARD_ANCHORS:
|
||
p = build_dir / shard_name
|
||
if not p.exists():
|
||
diags.append(Diagnostic("ERROR", "SYNC", 0, f"缺少模块分片文件: build/{shard_name}"))
|
||
return
|
||
b = p.read_bytes()
|
||
if b.startswith(b"\xef\xbb\xbf"):
|
||
diags.append(Diagnostic("ERROR", "SYNC", 0, f"分片 build/{shard_name} 包含 UTF-8 BOM 头"))
|
||
shard_bytes_list.append(b)
|
||
|
||
merged = b"".join(shard_bytes_list)
|
||
if merged != main_bytes:
|
||
main_lines = main_bytes.decode("utf-8", errors="replace").splitlines()
|
||
merged_lines = merged.decode("utf-8", errors="replace").splitlines()
|
||
diff_line = 1
|
||
for idx, (ml, bl) in enumerate(zip(main_lines, merged_lines), 1):
|
||
if ml != bl:
|
||
diff_line = idx
|
||
break
|
||
else:
|
||
diff_line = min(len(main_lines), len(merged_lines)) + 1
|
||
diags.append(Diagnostic(
|
||
"ERROR",
|
||
"SYNC",
|
||
diff_line,
|
||
f"build/00..39-*.txt 拼接结果 ({len(merged)} 字节) 与 {filter_path.name} ({len(main_bytes)} 字节) 不一致 "
|
||
f"(首个差异在第 {diff_line} 行;可运行 --sync-build 同步)",
|
||
))
|
||
|
||
|
||
def check_condition(cond_str: str, lineno: int, ctx: str, diags: list[Diagnostic]) -> None:
|
||
for m in re.finditer(r"\)[A-Za-z0-9_!]|[A-Za-z0-9_]\(", cond_str):
|
||
diags.append(Diagnostic("ERROR", "COND", lineno, f"{ctx} 括号与条件词之间缺少空格: '{m.group(0)}'"))
|
||
|
||
for m in re.finditer(r"[0-9]OR\b|\bOR[A-Za-z0-9_!]", cond_str):
|
||
diags.append(Diagnostic("ERROR", "COND", lineno, f"{ctx} OR 运算符粘连缺少空格: '{m.group(0)}'"))
|
||
|
||
for m in re.finditer(r"=~|==|!=|>=|<=", cond_str):
|
||
diags.append(Diagnostic("ERROR", "COND", lineno, f"{ctx} 使用了非法的条件比较符 '{m.group(0)}' (BH 条件块仅支持 =, <, >, ~)"))
|
||
|
||
tokens: list[str] = []
|
||
i = 0
|
||
n = len(cond_str)
|
||
while i < n:
|
||
c = cond_str[i]
|
||
if c.isspace():
|
||
i += 1
|
||
continue
|
||
if c in "()!":
|
||
tokens.append(c)
|
||
i += 1
|
||
continue
|
||
j = i
|
||
while j < n and not cond_str[j].isspace() and cond_str[j] not in "()":
|
||
j += 1
|
||
tokens.append(cond_str[i:j])
|
||
i = j
|
||
|
||
stack: list[dict[str, object]] = [{"has_and": False, "has_or": False, "atoms": []}]
|
||
prev_operand = False
|
||
for tok in tokens:
|
||
if tok == "!":
|
||
if prev_operand:
|
||
stack[-1]["has_and"] = True
|
||
prev_operand = False
|
||
elif tok == "(":
|
||
if prev_operand:
|
||
stack[-1]["has_and"] = True
|
||
stack.append({"has_and": False, "has_or": False, "atoms": []})
|
||
prev_operand = False
|
||
elif tok == ")":
|
||
if len(stack) == 1:
|
||
diags.append(Diagnostic("ERROR", "COND", lineno, f"{ctx} 存在多余的右括号 ')'"))
|
||
return
|
||
top = stack.pop()
|
||
if top["has_and"] and top["has_or"]:
|
||
sample = " ".join(str(x) for x in list(top["atoms"])[:4])
|
||
diags.append(Diagnostic(
|
||
"ERROR",
|
||
"PREC",
|
||
lineno,
|
||
f"{ctx} 同一括号层级同时混写隐式 AND 与 OR (BH 引擎两者等优先级从左到右求值): ({sample} ...)",
|
||
))
|
||
prev_operand = True
|
||
elif tok in ("OR", "AND"):
|
||
if not prev_operand:
|
||
diags.append(Diagnostic("ERROR", "COND", lineno, f"{ctx} 二元运算符 '{tok}' 缺少左操作数"))
|
||
if tok == "OR":
|
||
stack[-1]["has_or"] = True
|
||
else:
|
||
stack[-1]["has_and"] = True
|
||
prev_operand = False
|
||
else:
|
||
if prev_operand:
|
||
stack[-1]["has_and"] = True
|
||
cast_atoms: list[str] = stack[-1]["atoms"] # type: ignore[assignment]
|
||
cast_atoms.append(tok)
|
||
prev_operand = True
|
||
|
||
if len(stack) != 1:
|
||
diags.append(Diagnostic("ERROR", "COND", lineno, f"{ctx} 存在未闭合的左括号 '('"))
|
||
return
|
||
if tokens and not prev_operand:
|
||
diags.append(Diagnostic("ERROR", "COND", lineno, f"{ctx} 条件末尾存在悬空运算符 '{tokens[-1]}'"))
|
||
if stack[0]["has_and"] and stack[0]["has_or"]:
|
||
diags.append(Diagnostic(
|
||
"ERROR",
|
||
"PREC",
|
||
lineno,
|
||
f"{ctx} 顶层条件同时混写隐式 AND 与 OR (BH 引擎两者等优先级从左到右求值,请加括号明确分组)",
|
||
))
|
||
|
||
|
||
def check_formula_expr(
|
||
expr: str,
|
||
lineno: int,
|
||
ctx: str,
|
||
defined_formulas: set[str],
|
||
diags: list[Diagnostic],
|
||
) -> None:
|
||
depth = 0
|
||
for ch in expr:
|
||
if ch == "(":
|
||
depth += 1
|
||
elif ch == ")":
|
||
depth -= 1
|
||
if depth < 0:
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, f"{ctx} 存在多余的右括号 ')'"))
|
||
return
|
||
if depth != 0:
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, f"{ctx} 存在未闭合的左括号 '('"))
|
||
return
|
||
|
||
for m in re.finditer(r"\bAND\s*\(([^()]*)\)", expr, re.IGNORECASE):
|
||
args = [a.strip() for a in m.group(1).split(",")]
|
||
if "0" in args:
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, f"{ctx} 调用 '{m.group(0)}' 含常量 0 结果恒为 0 (疑似误将 IF 写为 AND)"))
|
||
|
||
for m in re.finditer(r"\bSTAT350\s*==", expr):
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, f"{ctx} 疑似将腐化属性 STAT360 误写为 STAT350"))
|
||
|
||
for m in re.finditer(r"\bFORMULA([A-Za-z0-9_]+)\b", expr):
|
||
fname = m.group(1)
|
||
if fname not in defined_formulas:
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, f"{ctx} 引用了未定义的公式 FORMULA{fname}"))
|
||
|
||
or_groups: list[tuple[str, set[str]]] = []
|
||
for m in re.finditer(r"([+-]?)\s*OR\s*\(([^()]*)\)", expr, re.IGNORECASE):
|
||
sign = m.group(1) or "+"
|
||
ids = re.findall(r"\bSTAT360==(\d+)\b", m.group(2))
|
||
if len(ids) != len(set(ids)):
|
||
dups = sorted({x for x in ids if ids.count(x) > 1})
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, f"{ctx} 单个 OR(...) 内部重复写入了相同的 STAT360 ID: {', '.join(dups)}"))
|
||
if ids:
|
||
or_groups.append((sign, set(ids)))
|
||
|
||
for idx1 in range(len(or_groups)):
|
||
for idx2 in range(idx1 + 1, len(or_groups)):
|
||
s1, set1 = or_groups[idx1]
|
||
s2, set2 = or_groups[idx2]
|
||
overlap = sorted(set1 & set2)
|
||
if overlap and s1 == s2:
|
||
diags.append(Diagnostic(
|
||
"ERROR",
|
||
"EXPR",
|
||
lineno,
|
||
f"{ctx} 多个同号 OR(...) 权重组之间重复计入了 STAT360=={','.join(overlap)} 导致双重加/扣分",
|
||
))
|
||
|
||
|
||
def check_action_macros(
|
||
action: str,
|
||
lineno: int,
|
||
defined_formulas: set[str],
|
||
diags: list[Diagnostic],
|
||
) -> None:
|
||
brace_depth = 0
|
||
brace_blocks = 0
|
||
for ch in action:
|
||
if ch == "{":
|
||
brace_depth += 1
|
||
if brace_depth == 1:
|
||
brace_blocks += 1
|
||
else:
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, "ItemDisplay 描述块 '{...}' 不支持嵌套"))
|
||
break
|
||
elif ch == "}":
|
||
brace_depth -= 1
|
||
if brace_depth < 0:
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, "ItemDisplay 存在未配对的 '}'"))
|
||
break
|
||
if brace_depth > 0:
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, "ItemDisplay 存在未闭合的 '{'"))
|
||
if brace_blocks > 1:
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, "单条 ItemDisplay 最多只能包含一个 '{...}' 描述块"))
|
||
|
||
pos = 0
|
||
while True:
|
||
k = action.find("$f(", pos)
|
||
if k == -1:
|
||
break
|
||
depth = 0
|
||
end = k + 2
|
||
while end < len(action):
|
||
if action[end] == "(":
|
||
depth += 1
|
||
elif action[end] == ")":
|
||
depth -= 1
|
||
if depth == 0:
|
||
break
|
||
end += 1
|
||
if depth != 0:
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, "ItemDisplay 内联公式 $f(...) 缺少闭合右括号 ')'"))
|
||
break
|
||
check_formula_expr(action[k + 3:end], lineno, "$f(...)", defined_formulas, diags)
|
||
pos = end + 1
|
||
|
||
for m in re.finditer(r"%FORMULA([A-Za-z0-9_]+)%", action):
|
||
fname = m.group(1)
|
||
if fname not in defined_formulas:
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, f"输出宏 %FORMULA{fname}% 引用了未定义的 Formula[{fname}]"))
|
||
|
||
# Tokenize/replace valid %...% macros left-to-right (matching BH.dll's regex behavior)
|
||
# \x01 = color/control/name macro, \x02 = numeric value macro (which may legitimately precede literal '%')
|
||
ctrl_macro_re = re.compile(
|
||
r"%(?:"
|
||
r"(?:MAP|DOT|PX|BORDER|LINE|NOTIFY)-[0-9A-Fa-f]{1,4}"
|
||
r"|TIER-[0-9]"
|
||
r"|SOUNDID-[0-9]{1,5}"
|
||
r"|" + "|".join(sorted(COLOR_AND_CTRL_MACROS, key=len, reverse=True)) +
|
||
r")%"
|
||
)
|
||
num_macro_re = re.compile(
|
||
r"%(?:"
|
||
r"FORMULA[A-Za-z0-9_]+"
|
||
r"|(?:STAT|CHARSTAT|TABSK|CLSK|CHSK|SK)[0-9]{1,9}"
|
||
r"|MULTI[0-9]{1,9},[0-9]{1,9}"
|
||
r"|" + "|".join(sorted(NUMERIC_VALUE_MACROS, key=len, reverse=True)) +
|
||
r")%"
|
||
)
|
||
stripped = num_macro_re.sub("\x02", action)
|
||
stripped = ctrl_macro_re.sub("\x01", stripped)
|
||
|
||
# 1. Missing opening '%' before 'CL%' (e.g. '神蛇之羽CL%%ORANGE%')
|
||
for m in re.finditer(r"[^\x01\x02%A-Z_]CL%", stripped):
|
||
diags.append(Diagnostic("ERROR", "MACRO", lineno, f"换行宏 %CL% 缺少起始 '%': '{m.group(0)}'"))
|
||
stripped_no_broken_cl = re.sub(r"([^\x01\x02%A-Z_])CL%", lambda m: m.group(1) + "\x01", stripped)
|
||
|
||
# 2. Stray '%' immediately before a control/color macro when NOT preceded by digit, ']', or numeric macro \x02
|
||
for m in re.finditer(r"(?<![0-9\]\x02])%\x01", stripped_no_broken_cl):
|
||
pos_idx = m.start()
|
||
diags.append(Diagnostic("ERROR", "MACRO", lineno, f"颜色/换行宏前多写了 '%' 导致字面量残留 (偏移 {pos_idx})"))
|
||
|
||
# 3. Stray '%Word' after stripping valid macros (e.g. '{%GRAY%%Steel(')
|
||
clean_stripped = stripped_no_broken_cl.replace("\x01", "").replace("\x02", "")
|
||
for m in re.finditer(r"(?<![0-9+\-\]])%[A-Za-z]{2,}", clean_stripped):
|
||
tok = m.group(0)
|
||
if tok.upper() not in {"%ED", "%MF", "%GF", "%AR", "%DS", "%CB", "%PLR", "%DTM", "%DTU", "%DTD", "%FCR", "%IAS", "%FHR", "%FRW", "%DR"}:
|
||
diags.append(Diagnostic("ERROR", "MACRO", lineno, f"疑似未知或多余 '%' 前缀的文本宏: '{tok}'"))
|
||
|
||
|
||
def lint_filter(filter_path: Path, build_dir: Path | None, check_sync: bool = True) -> list[Diagnostic]:
|
||
diags: list[Diagnostic] = []
|
||
if check_sync and build_dir is not None:
|
||
check_build_sync(filter_path, build_dir, diags)
|
||
|
||
text = filter_path.read_text(encoding="utf-8")
|
||
lines = text.splitlines()
|
||
|
||
defined_aliases: dict[str, int] = {}
|
||
alias_bodies: dict[str, tuple[int, str]] = {}
|
||
defined_formulas: set[str] = set()
|
||
formula_lines: dict[str, int] = {}
|
||
|
||
cur_gui_group = ""
|
||
gui_labels_global: dict[str, tuple[int, str, str]] = {}
|
||
|
||
in_sec8 = False
|
||
sec8_sub_header = ""
|
||
sec8_prev_tier: int | None = None
|
||
|
||
for lineno, raw_line in enumerate(lines, 1):
|
||
stripped = raw_line.strip()
|
||
if not stripped:
|
||
continue
|
||
|
||
# Track Section 8 star-tier ordering (8.1 ~ 8.4.11)
|
||
if "8,符文之语底材" in stripped:
|
||
in_sec8 = True
|
||
elif "9,注入底材" in stripped:
|
||
in_sec8 = False
|
||
|
||
if in_sec8 and stripped.startswith("//"):
|
||
if "++++++" in stripped or "====>" in stripped:
|
||
sec8_sub_header = stripped.strip("/ =+><\t")
|
||
sec8_prev_tier = None
|
||
m_star = re.match(r"^//\s*([0-4])星\s*$", stripped)
|
||
if m_star:
|
||
star = int(m_star.group(1))
|
||
if sec8_prev_tier is not None and star >= sec8_prev_tier:
|
||
diags.append(Diagnostic(
|
||
"ERROR",
|
||
"TIER",
|
||
lineno,
|
||
f"第 8 章 [{sec8_sub_header}] 星级顺序错误: //{star}星 出现在 //{sec8_prev_tier}星 之后 "
|
||
f"(低星/同星规则在前会截胡高星底材,应严格按 高星->低星 递减排列)",
|
||
))
|
||
sec8_prev_tier = star
|
||
|
||
if stripped.startswith("//"):
|
||
continue
|
||
|
||
# 1. Alias[...]
|
||
m_alias = re.match(r"^Alias\[([^\]]+)\]\s*:\s*(.*)$", stripped)
|
||
if m_alias:
|
||
name, rest = m_alias.group(1).strip(), m_alias.group(2)
|
||
if not re.match(r"^[A-Za-z0-9_]+$", name):
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, f"Alias 名称包含非法字符: {name!r}"))
|
||
if name in defined_aliases:
|
||
diags.append(Diagnostic("ERROR", "ALIAS", lineno, f"重复定义 Alias[{name}] (首次定义于第 {defined_aliases[name]} 行)"))
|
||
defined_aliases[name] = lineno
|
||
|
||
# Check GUI switch comment //@按键名@分组名@
|
||
if "//" in rest:
|
||
body_part, comment_part = rest.split("//", 1)
|
||
m_gui = re.match(r"^\s*@([^@]+)@(?:([^@]+)@)?", comment_part)
|
||
if m_gui:
|
||
lbl, grp = m_gui.group(1).strip(), (m_gui.group(2) or "").strip()
|
||
if grp:
|
||
cur_gui_group = grp
|
||
if set(lbl) != {"#"} and not name.startswith("JUST_TAG"):
|
||
if lbl in gui_labels_global:
|
||
prev_line, prev_alias, prev_grp = gui_labels_global[lbl]
|
||
diags.append(Diagnostic(
|
||
"ERROR",
|
||
"GUI",
|
||
lineno,
|
||
f"BH 面板开关名称 '@{lbl}@' ({name}, 分组:{cur_gui_group}) 与第 {prev_line} 行 "
|
||
f"({prev_alias}, 分组:{prev_grp}) 重名",
|
||
))
|
||
gui_labels_global[lbl] = (lineno, name, cur_gui_group)
|
||
else:
|
||
body_part = rest
|
||
|
||
body = body_part.strip()
|
||
if body not in ("TRUE", "FALSE"):
|
||
check_condition(body, lineno, f"Alias[{name}]", diags)
|
||
norm_body = re.sub(r"\s+", " ", body)
|
||
if norm_body in alias_bodies:
|
||
prev_line, prev_name = alias_bodies[norm_body]
|
||
diags.append(Diagnostic(
|
||
"ERROR",
|
||
"ALIAS",
|
||
lineno,
|
||
f"Alias[{name}] 的条件体与第 {prev_line} 行 Alias[{prev_name}] 完全一致 (疑似复制后漏改词缀/星级条件)",
|
||
))
|
||
else:
|
||
alias_bodies[norm_body] = (lineno, name)
|
||
continue
|
||
|
||
# 2. Formula[...]
|
||
m_formula = re.match(r"^Formula\[([^\]]+)\]\s*:\s*(.*)$", stripped)
|
||
if m_formula:
|
||
fname, rest = m_formula.group(1).strip(), m_formula.group(2)
|
||
if not re.match(r"^[A-Za-z0-9_]+$", fname):
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, f"Formula 名称包含非法字符: {fname!r}"))
|
||
if fname in formula_lines:
|
||
diags.append(Diagnostic("ERROR", "ALIAS", lineno, f"重复定义 Formula[{fname}] (首次定义于第 {formula_lines[fname]} 行)"))
|
||
formula_lines[fname] = lineno
|
||
defined_formulas.add(fname)
|
||
expr = rest.split("//", 1)[0].strip()
|
||
check_formula_expr(expr, lineno, f"Formula[{fname}]", defined_formulas, diags)
|
||
continue
|
||
|
||
# 3. ItemDisplayFilterName[...]
|
||
if stripped.startswith("ItemDisplayFilterName["):
|
||
if not re.match(r"^ItemDisplayFilterName\[\s*\]\s*:\s*\S+", stripped):
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, "ItemDisplayFilterName 格式错误"))
|
||
continue
|
||
|
||
# 4. ItemDisplay[...]
|
||
if stripped.startswith("ItemDisplay["):
|
||
# Locate matching ']:' outside nested brackets
|
||
close_idx = stripped.find("]:")
|
||
if close_idx == -1:
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, "ItemDisplay 缺少 ']:' 分隔符"))
|
||
continue
|
||
cond = stripped[len("ItemDisplay["):close_idx].strip()
|
||
action_raw = stripped[close_idx + 2:]
|
||
# Strip trailing // comment outside {...}
|
||
action = _strip_trailing_comment(action_raw)
|
||
if cond:
|
||
check_condition(cond, lineno, "ItemDisplay", diags)
|
||
for m_fref in re.finditer(r"\bFORMULA([A-Za-z0-9_]+)\b", cond):
|
||
fname = m_fref.group(1)
|
||
if fname not in defined_formulas:
|
||
diags.append(Diagnostic("ERROR", "EXPR", lineno, f"条件引用了未定义的公式 FORMULA{fname}"))
|
||
check_action_macros(action, lineno, defined_formulas, diags)
|
||
continue
|
||
|
||
diags.append(Diagnostic("ERROR", "SYNTAX", lineno, f"无法识别的过滤器指令行: {stripped[:60]}"))
|
||
|
||
return diags
|
||
|
||
|
||
def _strip_trailing_comment(action_raw: str) -> str:
|
||
idx = action_raw.find("//")
|
||
if idx == -1:
|
||
return action_raw
|
||
return action_raw[:idx]
|
||
|
||
|
||
def main() -> int:
|
||
script_dir = Path(__file__).resolve().parent
|
||
repo_root = script_dir.parent if script_dir.name == "build" else script_dir
|
||
default_filter = repo_root / "BadWolf-LootFilter.txt"
|
||
default_build = repo_root / "build"
|
||
|
||
parser = argparse.ArgumentParser(description="BadWolf PD2 BH Loot Filter 静态检查与模块同步工具")
|
||
parser.add_argument("--filter", type=Path, default=default_filter, help="主过滤器文件路径 (默认: BadWolf-LootFilter.txt)")
|
||
parser.add_argument("--build-dir", type=Path, default=default_build, help="build 分片目录路径 (默认: build/)")
|
||
parser.add_argument("--no-build-sync", action="store_true", help="跳过 build/00..39-*.txt 与主文件的字节同步检查")
|
||
mode = parser.add_mutually_exclusive_group()
|
||
mode.add_argument("--sync-build", action="store_true", help="将主文件反向拆分回写至 build/00..39-*.txt 后执行检查")
|
||
mode.add_argument("--merge-build", action="store_true", help="将 build/00..39-*.txt 合并写入主文件后执行检查")
|
||
args = parser.parse_args()
|
||
|
||
if args.sync_build:
|
||
sync_main_to_build(args.filter, args.build_dir)
|
||
print(f"[SYNC] 已将 {args.filter.name} 反向同步至 {args.build_dir}/00..39-*.txt (共 {len(SHARD_ANCHORS)} 个模块)")
|
||
elif args.merge_build:
|
||
merge_build_to_main(args.filter, args.build_dir)
|
||
print(f"[SYNC] 已从 {args.build_dir}/00..39-*.txt 合并生成 {args.filter.name}")
|
||
|
||
diags = lint_filter(
|
||
args.filter,
|
||
args.build_dir if args.build_dir.exists() else None,
|
||
check_sync=not args.no_build_sync,
|
||
)
|
||
|
||
errors = [d for d in diags if d.severity == "ERROR"]
|
||
warns = [d for d in diags if d.severity == "WARN"]
|
||
|
||
for d in diags:
|
||
loc = f"L{d.lineno:<4d}" if d.lineno > 0 else "GLOBAL"
|
||
print(f"[{d.severity}] [{d.category:<6s}] {loc} {d.message}")
|
||
|
||
if errors or warns:
|
||
print(f"\n检查完成: 发现 {len(errors)} 个错误, {len(warns)} 个警告。")
|
||
else:
|
||
total_lines = len(args.filter.read_text(encoding="utf-8").splitlines())
|
||
print(f"[OK] 检查通过! {args.filter.name} ({total_lines} 行) 与 build/ 分片全部通过静态语法、优先级与一致性校验 (0 错误, 0 警告)。")
|
||
|
||
return 1 if errors else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|