ZYLPD2-Filter-Lonelywolf/build/lint_filter.py

618 lines
31 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.

#!/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())