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