ZYLPD2-Filter-Lonelywolf/build/build.ps1

1728 lines
87 KiB
PowerShell
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.

#requires -Version 5.1
<#
BadWolf 过滤器构建脚本 build.ps1 v1.13
------------------------------------------------------------------
用途:把模块目录下的 txt 按文件名排序拼接成 BadWolf-LootFilter.txt,
并完成一致性校验与行号换算。纯 PowerShell,无需安装任何软件。
v1.13 相对 v1.12 的变化(2026-11-01)
------------------------------------------------------------------
· 颜色白名单**收敛到官方 16 色**:BLACK WHITE GRAY LIGHT_GRAY BLUE YELLOW
GOLD GREEN DARK_GREEN TAN PURPLE RED ORANGE CORAL SAGE TEAL。
v1.12 里混进了 `LIGHT_BLUE` `VIOLET` `DARK_RED` `DARK_BLUE` `DARK_GRAY`,
这些**游戏并不认**——写上去不会报错,而是把 `%LIGHT_BLUE%` 当普通文字
原样打在物品名旁边(2026-11-01 实机踩过:符文行显示成
`✦ %LIGHT_BLUE%✦ 符文:伊司特[24]`)。收敛后同类手滑会直接报「未定义占位符」。
· 自检新增「弹匣配色作用域」→「提示」:彩虹弹匣的符号(※ ◆ ★ ◇ ◎ ✦ ○)
只允许出现在 `!SHOW_ICON`(文本版)行。`SHOW_ICON`(图标版,`00` 模块的
Alias[SHOW_ICON] 开关)行本身已用 ┣ / ┺ / ┹ 一套紧凑标记表达同样信息,
再叠弹匣符号就是两套标记打架。`■` 不在此列——它已降级为
「可做 RW / 可注入」的功能标记,跨品类复用。
v1.12 相对 v1.11 的变化(2026-11-01)
------------------------------------------------------------------
· 自检新增「星级图标矩阵」校验 → 「提示」(三条子检查,均不阻断构建)。
口径:**颜色=品类、尺寸=星级**。图标颜色必须与该品类装备的品质名色
一致(wiki 调色板 Average 列);尺寸 `BORDER > MAP > DOT > PX` 对应
`★4 > ★3 > ★2 > ★1`,`★0` 不出图标。这样小地图上「颜色认品类、
大小认档位」,两条轴互不串味。
A) 色卡:30=60/6D、32=53、33=84、34=6D/97、36=20,越界即提示。
B) 行序:32 暗金 / 33 套装 / 30 符文 都是按档位排序的优先链,图标
尺寸必须随行号单调(32·33 递减、30 符文递增)。
C) 底材:36 的档位写在别名后缀 `BASEn` 里,n → 尺寸必须一一对应
(4=BORDER 3=MAP 2=DOT 1=PX 0=无图标;无 BASEn 的 SHOW_CTABASE
按 DOT)。
· 背景:裸 `%MAP%` 会沿用当前文本色,五个品类在小地图上同色不可判,
故一律写死 2 位 hex。色码无法参数化(不能传变量),只能靠本校验兜住。
v1.11 相对 v1.10 的变化(2026-11-01)
------------------------------------------------------------------
· 自检新增「版本说明每行宽度」校验 → 「提示」。
`00` 模块的 `ItemDisplay[box]` 版本说明是整块提示文本,**靠 `%CL%` 逐行
断行**;写成 `%NL%%NL%%NAME%` 会把「BADWOLF FILTER…/ 当前过滤等级」整段
重复打印一次(2026-11-01 实测,8 段炸成 8 个块)。游戏里一行放不下不会
自动折行,而是**溢出到框外被裁掉**(实测 37 宽就开始丢字)。故按
`%CL%` / `%NL%` 切行、剔除 `%XXX%` 控制码后逐行判宽,上限 **72**
(=36 汉字宽)。
· `V2.10` 那条概要原来是一整段 312 宽,已按本规则切成 8 行(每行 ≤50 宽)。
v1.10 相对 v1.9 的变化(2026-11-01)
------------------------------------------------------------------
· 「菜单按钮文字长度」改口径为**显示宽度**:汉字/全角记 2,字母·数字·
符号各记 1;按钮 X ≤ 20(=10 汉字宽),分组名 Y ≤ 24(=12 汉字宽)。
v1.9 按「汉字个数」统计是偏松近似——ASCII 较多的按钮实际宽度越界也
查不出来。实测 108 个按钮中仅 `00` L136 那条越界(65 宽),已按裁定放行。
· 两条提示加入**已裁定放行名单**(校验本身保留,只放行具体条目):
1)「同条件近似重复」02/03 模块的 7 组(UNI/SET/RARE/MAG/CRAFT/RW 与
NMAG 那组)—— 2026-11-01 现场确认为误报;
2)「按钮文字超长」`00` 模块 29 汉字的 JUST_TAG4 说明按钮 —— 有意写长。
放行判据是「条件 + 规则所在模块」,其它模块出现同类问题照常提示。
· 物品代码白名单 `item_codes.txt` 迁到 `tools\` 子目录。业务目录
(filters\Local)顶层除 `本地过滤器存放目录.txt` 外的每个 .txt 都会被
BH 菜单当成**独立过滤器**列出,item_codes.txt 混在顶层属误识别。
脚本按 tools\ → 旧位置(同目录)顺序探测,兼容未迁移的旧布局。
· 新增「业务目录顶层杂项 txt」提示:顶层出现除合并版与
`本地过滤器存放目录.txt` 以外的 .txt 就提示,防止再次被误识别。
v1.9 相对 v1.8 的变化
------------------------------------------------------------------
· 自检新增「菜单按钮文字长度」校验 → 「提示」。
`00` 模块里 `//@X@` 的 X 是 BH 界面上的按钮文字,上限 10 个汉字;
分组写法 `//@X@Y@` 的 Y 上限 12 个汉字。超长会让菜单显示异常,而游戏
不报错、只在界面上变形,属「肉眼才能发现」的一类。
口径按**汉字数**统计,不是字符总数:`屏蔽ILVL<68亮金珠宝` 共 13 个字符
但只有 6 个汉字,合规;`显示无孔鬼魂尖枪(有形/无形均可)` 17 个汉字
才是真超标(2026-10-31 实际事故,用户手工改成 `@显示无孔鬼魂尖枪@(…)`)。
结论口径:补充说明一律写在第二个 `@` 之后,不进按钮文字。
v1.8 相对 v1.7 的变化
------------------------------------------------------------------
· 自检新增「别名互为子串」校验 → 「错误」。
硬规则:别名名不得是另一别名名的子串(必须严格不同)。引擎按子串
扫描别名表,短名会抢先命中长名,把长名切成「前缀残渣 + 短名」,
残渣是无效 token → 整条条件恒假,静默失效,原 check 完全查不出。
实例(2026-10-30 修复):裸名 CAST_4_1 / DMG_4_1 是 SHIELD_DMG_4_1、
AMU_DMG_4_1、MELEE_DMG_4_1、GLOVE_DMG_4_1 等 24 个别名的子串 ——
盔甲档位用裸名所以正常,其余部位的长名全部被吃掉。
修法:给裸名加类型前缀(CAST_4_1 → CHEST_CAST_4_1)。
v1.7 相对 v1.6 的变化
------------------------------------------------------------------
· 自检新增 3 条校验,全部来自 2026-10-30 的全库手工审计,都是
「游戏内静默失效、check 原本查不出」的类型:
A) STAT 编号白名单 —— 读 ItemStatCost.txt 的 Id 列,条件里出现
STAT<n> 而 n 不在表内 → 「错误」。写错的编号不会报错,只会让
该条件恒为 0、规则永不命中,肉眼几乎查不出来。
B) PREFIX / SUFFIX 编号越界 —— 与 MagicPrefix / MagicSuffix 的
数据行数比对(当前上界 990 / 1043)→ 「错误」。支持 = 与 ~ 两种写法。
C) 同条件 + 模板只差配色 + 都带 CONTINUE —— 「提示」。这类规则会让
同一件物品把同一段内容并排显示两遍,而既有的「完全重复规则」
(要求模板一字不差)与「死规则」(要求前一条不带 CONTINUE)
都抓不到它。★ 判据必须收在「只差配色」:模板内容不同的同条件组
绝大多数是**分段拼接**(箭袋「升级后需求:(」+ 数值 + ")",
护身符/地图的多行说明等),放开判据会立刻膨胀成 66 组噪音。
数据表位置按 PD2_EXCEL_DIR → build.ps1 同目录 excel\ → 游戏
Data\global\excel → 桌面 data\global\excel 依次探测;找不到就跳过
这两条校验并给提示,不阻断构建。
★ 不要指向 ZYLPD2\Data\global\magicprefix.txt(911 条残表)。
v1.6 相对 v1.5 的变化
------------------------------------------------------------------
· 「非法占位符」放行已定义别名:`%别名%` 不再判错误,改判「提示」。
原因:别名是**条件宏**、不是文本占位符——三个过滤器(BadWolf 479 个、
Kryszard 160 个)中 `%别名%` 形态的实际用法为 0,别名定义体也全部是
条件表达式,没有文本型别名。所以模板里写 `%别名%` 在游戏内只会原样
显示成 `%别名%`。
处理:错误降为提示,并明确写出「别名是条件宏,模板里不会被替换成文本」,
既不再误报,也不会让真正的显示错误被静默吞掉。
实现:扫描完成后统一判定($badPh 里名字若在 $aliasNames 内则移入 $aliasPh)——
别名在文件中的定义位置有先有后,必须等全量收集完再判,不能边扫边判。
v1.5 相对 v1.4 的变化
------------------------------------------------------------------
· 自检新增「别名 0 引用」检查,补上 v1.4 留下的盲区。
★ 别名与公式的引用语法完全不同:别名在条件里是**裸名**
(`!KNOWN_CODES`、`(SHIELD_SKILL_5_1 OR ...)`),只有公式才是
`%FORMULA名%`。用 `%别名名%` 去统计会全部漏检——2026-10-30 就因此
把 6 个盾牌星级别名误判为全死(实际都在 34 模块 L38/L39 使用)。
实现:复用 $tokRe 抓全大写 token 再查字典,480 个别名 × 4,470 行
只需一次线性扫描,不逐名做正则。
排除:值为裸 TRUE / FALSE 的条目(现 105 个)是设置菜单的开关与
分组标题(JUST_TAG1~4 之类),本就不需要被任何规则引用。
级别:「提示」,不阻断构建。
v1.4 相对 v1.3 的变化
------------------------------------------------------------------
· 「公式 lint」由 3 条扩到 5 条,依据 2026-10-30 游戏内四轮实测:
0) 公式名合法性 —— 名字必须匹配 ^[A-Za-z_]+$。实测 Formula[TESTA] 的
%FORMULATESTA% 正常出 7,Formula[TEST1] 的
%FORMULATEST1% 原样输出文字 → 带数字会让占位符
静默查不到值。「错误」
4) 公式体含 % —— 实测 %FORMULATESTA%*3 编译不过、该公式作废
(% 只对 ItemDisplay 模板生效)。「错误」
· 自检新增「公式交叉引用」:某条公式的表达式里出现另一个公式名
(如 Formula[ZZ]: XPA+1)直接「错误」。实测裸名引用 / %FORMULA…% 包裹 /
$f() / Alias 宏四条路全不通,且失败不报错、不连坐其它公式——只能靠构建期拦。
· 结论性事实:Formula 之间完全隔离,69 个公式必须保持自包含;
「抽公共子公式省体积」这条路不存在,不要再尝试。
v1.3 相对 v1.2 的变化
------------------------------------------------------------------
· 新增三条「公式 lint」,只作用于 Formula[...] 行,把 2026-10-30 审计
发现的问题变成自动拦截(先剥掉行尾 // 注释,注释里的中文不参与):
1) IF 元数 —— 按顶层逗号切参数,必须正好 3 个;MULTI<n>,<l> 的
双参逗号是变量自身的一部分,先掩码再切,不算分隔符。
不满足则「提示」(build 不中断)。
2) AND/OR 单参 —— 只有一个参数时等价于给表达式加括号,写法冗余;
「提示」。
3) 公式体污染 —— 出现非 ASCII / 单等号(应为 ==)/ ~(BETWEEN 只能
用在 ItemDisplay 条件)→ 直接「错误」,构建不通过。
· 第 4 节自检摘要新增一行「公式 lint 错误/提示」计数。
· build 在写盘前先跑同样的错误级检查:有 lint 错误就中止,不会把
错公式写进合并版(提示级不阻断)。
v1.2 相对 v1.1 的变化
------------------------------------------------------------------
· 自检新增:孤立 % / 未闭合 % 扫描(半截百分号会吞颜色或漏出明文)
判定:先整体屏蔽合法占位符 %NAME%,剩下的 % 只放行四种写字面百分号的情形
(数值后 40%ED / 右括号后 [4-6]%物理减伤 / 夹在两个占位符之间 /
「物理减伤%」「魔法减伤%」后的百分比示意符)
v1.1 相对 v1.0 的变化
------------------------------------------------------------------
· 模块白名单:只拼 NN-*.txt,其它 txt 一律跳过并黄字警告(不再静默混入)
· 备份轮转:build 只保留最近 2 份 .bak,可用 -KeepBackups N 调整
· 临时文件:写盘失败也会清理 .tmp
· 自检新增:物品代码白名单(item_codes.txt)/ 死规则 / Formula 引用完整性
· 版本行联动:版本行日期落后于最晚模块改动时间时提示
用法(二选一)
------------------------------------------------------------------
A. 双击 build.bat → 直接校验(窗口不关)
B. 命令行:
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1 build
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1 map 1939
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1 map 16:139
powershell -NoProfile -ExecutionPolicy Bypass -File build.ps1 export
命令
------------------------------------------------------------------
check 校验(默认,不写任何文件)
build 重建合并版(自动备份 .bak,只保留最近 2 份;-NoBackup 关闭备份)
map 行号换算: 1939(合并→模块) 或 16:139(模块→合并)
export 导出行号映射表
参数
------------------------------------------------------------------
-NoBackup build 时不备份原文件
-KeepBackups N 备份保留份数,默认 2
-Strict 有提示项也算不通过(退出码 1)
退出码:0 一致 / 1 有问题 / 2 环境问题
#>
param(
[Parameter(Position = 0)][string]$Command = 'check',
[Parameter(Position = 1)][string]$Arg = '',
[switch]$NoBackup,
[switch]$Strict,
[int]$KeepBackups = 2
)
$ErrorActionPreference = 'Stop'
# ---------------------------------------------------------------- 基础路径
$Root = $PSScriptRoot
$MergedName = 'BadWolf-LootFilter.txt'
$MergedPath = Join-Path $Root $MergedName
$MapName = [char]0x884C + [char]0x53F7 + [char]0x6620 + [char]0x5C04 + [char]0x8868 + '.md'
function Write-Line([string]$s) { Write-Host $s }
function Format-N([long]$n) { return $n.ToString('N0', [System.Globalization.CultureInfo]::InvariantCulture) }
function Get-Bar { return ('=' * 74) }
function Write-Head([string]$t) { Write-Host ""; Write-Host "[$t]"; Write-Host ('-' * 74) }
function Get-Md5Hex([byte[]]$Bytes) {
$md5 = [System.Security.Cryptography.MD5]::Create()
try { $h = $md5.ComputeHash($Bytes) } finally { $md5.Dispose() }
return (-join ($h | ForEach-Object { $_.ToString('x2') }))
}
# ---------------------------------------------------------------- 模块目录
# 只认「≥10 个 数字-xxx.txt」的目录;多个候选直接报错,绝不猜
function Get-ModuleDir {
$cands = @()
foreach ($d in @(Get-ChildItem -LiteralPath $Root -Directory -ErrorAction SilentlyContinue)) {
$n = @(Get-ChildItem -LiteralPath $d.FullName -Filter '*.txt' -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^\d{2}-' }).Count
if ($n -gt 0) { $cands += [pscustomobject]@{ Path = $d.FullName; Count = $n } }
}
$good = @($cands | Where-Object { $_.Count -ge 10 } | Sort-Object -Descending Count)
# 兼容「模块与脚本平铺在同一目录」的布局(仓库里可能没有子文件夹)
$flat = @(Get-ChildItem -LiteralPath $Root -Filter '*.txt' -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^\d{2}-' }).Count
if ($flat -ge 10) { return $Root }
if ($good.Count -eq 0) {
$tip = ''
if ($cands.Count -gt 0) {
$tip = ';候选目录(文件数不足):' + (($cands | ForEach-Object { "$($_.Path)($($_.Count)个)" }) -join ',')
}
throw "找不到模块目录:$Root 下没有包含 ≥10 个 `00-xxx.txt` 形式文件的文件夹$tip"
}
if ($good.Count -gt 1) {
$list = ($good | ForEach-Object { "$($_.Path)($($_.Count) 个)" }) -join ';'
throw "检测到多个可能的模块目录,为避免用错目录构建,已停止:$list。请把多余的移走或改名。"
}
return $good[0].Path
}
# ---------------------------------------------------------------- 载入模块
# 白名单:只认「NN-名称.txt」;名字里带 副本/备份/copy/bak/(1) 的视为复制品,同样跳过
$script:SkippedModules = @()
$CopyNameRe = '副本|备份|复制|copy|bak|backup|\(\d+\)'
function Get-Modules([string]$Dir) {
$allTxt = @(Get-ChildItem -LiteralPath $Dir -Filter '*.txt' -File)
$files = @($allTxt | Where-Object {
$_.Name -match '^\d{2}-.*\.txt$' -and $_.Name -notmatch $CopyNameRe
} | Sort-Object Name)
$script:SkippedModules = @($allTxt | Where-Object {
-not ($_.Name -match '^\d{2}-.*\.txt$') -or ($_.Name -match $CopyNameRe)
} | Sort-Object Name | ForEach-Object { $_.Name })
$list = New-Object System.Collections.ArrayList
$cursor = 1
$strict = [System.Text.UTF8Encoding]::new($false, $true)
foreach ($f in $files) {
$bytes = [System.IO.File]::ReadAllBytes($f.FullName)
$text = [System.Text.Encoding]::UTF8.GetString($bytes)
$errors = New-Object System.Collections.ArrayList
try { [void]$strict.GetString($bytes) }
catch { [void]$errors.Add('不是合法的 UTF-8 编码') }
if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) {
[void]$errors.Add('含 UTF-8 BOM')
}
$lfOnly = ([regex]::Matches($text, "(?<!\r)\n")).Count
if ($lfOnly -gt 0) { [void]$errors.Add("存在裸 LF 换行($lfOnly 处)") }
if ($bytes.Length -eq 0) { [void]$errors.Add('空文件') }
elseif ($bytes[$bytes.Length - 1] -ne 10) { [void]$errors.Add('文件末尾缺少换行') }
$lineCount = ([regex]::Matches($text, "\n")).Count
$m = [pscustomobject]@{
Name = $f.Name
Code = if ($f.Name.Length -ge 2) { $f.Name.Substring(0, 2) } else { '??' }
Path = $f.FullName
Bytes = $bytes
ByteLen = $bytes.Length
Text = $text
LineCount = $lineCount
Start = $cursor
MTime = $f.LastWriteTime
Errors = $errors
}
$m | Add-Member -MemberType ScriptProperty -Name End -Value { $this.Start + $this.LineCount - 1 }
[void]$list.Add($m)
$cursor += $lineCount
}
return , $list.ToArray()
}
function Get-Lines([string]$Text) {
$a = $Text -split "\r?\n"
if ($a.Count -gt 0 -and $a[-1] -eq '') {
if ($a.Count -eq 1) { return @() }
return $a[0..($a.Count - 2)]
}
return $a
}
function Find-ModuleOfLine($Mods, [int]$Line) {
foreach ($m in $Mods) { if ($Line -ge $m.Start -and $Line -le $m.End) { return $m } }
return $null
}
function Join-Bytes($Mods) {
$ms = New-Object System.IO.MemoryStream
try {
foreach ($m in $Mods) { $ms.Write($m.Bytes, 0, $m.ByteLen) }
return , $ms.ToArray()
} finally { $ms.Dispose() }
}
# ---------------------------------------------------------------- 白名单
# 官方色板(wiki Item_Filtering → Output Keywords → Colors,2026-11-01 核对):只有这 16 个。
# 写其它名字(%LIGHT_BLUE% %VIOLET% %DARK_RED% %DARK_BLUE% %DARK_GRAY% …)游戏不认,
# 会原样打印成字面量,且不报错——只能靠这张表兜住。
$Colors = @('BLACK','WHITE','GRAY','LIGHT_GRAY','BLUE','YELLOW','GOLD','GREEN',
'DARK_GREEN','TAN','PURPLE','RED','ORANGE','CORAL','SAGE','TEAL')
$Special = @('NAME','CL','NL','CONTINUE')
$InfoCodes = @('ED','EDAM','EDEF','RES','FRES','CRES','LRES','PRES','IAS','FCR','MFIND','LIFE',
'STR','DEX','AR','RANGE','PRICE','QTY','GEMTYPE','SOCKETS','MAXSOCKETS','BASENAME',
'BASEMINKICK','BASEMAXKICK','CRAFTALVL','REROLLALVL','UPLVL','UPSTR','UPDEX',
'WPNSPD','MAXDMG','ITEMCODE')
$PhSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($x in @($Colors + $Special + $InfoCodes)) { [void]$PhSet.Add($x) }
$NumPrefix = @('SK','STAT','CHARSTAT','TABSK','FORMULA')
$KwPrefix = @('SK','STAT','CHARSTAT','TABSK','MULTI','CLSK','FORMULA')
$IgnoreUndef = @()
$MaxLineLen = 1024
# ---------------------------------------------------------------- 已裁定放行(v1.10)
# 只放行**具体条目**,不关闭整条校验:其它模块 / 其它按钮出现同类问题照常提示。
$SameContAcceptedConds = @(
'UNI', 'SET', 'RARE', 'MAG', 'CRAFT', 'RW',
'(ARMOR OR WEAPON OR QUIVER OR MAPTIER>0) NMAG !RW !INF'
)
$SameContAcceptedMods = @('02', '03') # 只放行这两个模块里的这 7 组
$ButtonAccepted = @(
'说明:使用重置开关重新加载本地配置;点击下拉框[V]以切换至其它页面'
)
$DirPlaceholderTxt = '本地过滤器存放目录.txt' # 业务目录顶层唯一允许的占位 txt
# 显示宽度:汉字/全角记 2,字母·数字·符号各记 1(BH 菜单按此宽度排版)
function Get-Display-Width([string]$s) {
if ([string]::IsNullOrEmpty($s)) { return 0 }
$w = 0
foreach ($ch in $s.ToCharArray()) {
$cp = [int]$ch
$wide =
($cp -ge 0x1100 -and $cp -le 0x115F) -or # 韩文字母
($cp -ge 0x2E80 -and $cp -le 0xA4CF) -or # CJK 部首/汉字/假名/注音
($cp -ge 0xAC00 -and $cp -le 0xD7A3) -or # 韩文音节
($cp -ge 0xF900 -and $cp -le 0xFAFF) -or # CJK 兼容汉字
($cp -ge 0xFE30 -and $cp -le 0xFE6F) -or # CJK 兼容形式
($cp -ge 0xFF00 -and $cp -le 0xFF60) -or # 全角 ASCII
($cp -ge 0xFFE0 -and $cp -le 0xFFE6) # 全角符号
if ($wide) { $w += 2 } else { $w += 1 }
}
return $w
}
function Test-Placeholder([string]$name) {
if ($PhSet.Contains($name)) { return $true }
# 带连字符的显示宏(图标/边框/音效/层级):只认合法形态,写错即报非法占位符
if ($name -match '^(MAP|DOT|BG|BORDER|PX|PY)-[0-9A-Fa-f]{2}$') { return $true }
if ($name -match '^SOUNDID-\d+$') { return $true }
if ($name -match '^TIER-[1-5]$') { return $true }
foreach ($p in $NumPrefix) {
if (-not $name.StartsWith($p, [System.StringComparison]::Ordinal)) { continue }
$tail = $name.Substring($p.Length)
if ($tail -eq '') { return $true }
if ($tail -match '^\d+$') { return $true }
if ($p -eq 'FORMULA' -and $tail -match '^[A-Z0-9_]+$') { return $true }
}
return $false
}
function Test-KeywordToken([string]$t) {
foreach ($p in $KwPrefix) {
if (-not $t.StartsWith($p, [System.StringComparison]::Ordinal)) { continue }
$tail = $t.Substring($p.Length)
if ($tail -eq '') { return $true }
if ($tail -match '^\d+$') { return $true }
if ($p -eq 'FORMULA' -and $tail -match '^[A-Z0-9_]+$') { return $true }
}
return $false
}
# ---------------------------------------------------------------- 物品代码白名单
# item_codes.txt 由 data\global\excel 生成:[codes] 物品代码 / [types] 类型代码。
# v1.10 起优先读 tools\item_codes.txt:业务目录顶层只能有合并版与
# 本地过滤器存放目录.txt,其它 .txt 会被 BH 菜单当成独立过滤器。
# 旧位置(同目录)仍兼容,便于仓库/他人 clone 的扁平布局。
# 文件不存在则跳过该项检查(不视为错误)。
function Get-CodeWhitelist {
$p = $null
foreach ($cand in @((Join-Path $Root 'tools\item_codes.txt'),
(Join-Path $Root 'item_codes.txt'),
(Join-Path $Root 'build\item_codes.txt'))) {
if (Test-Path -LiteralPath $cand) { $p = $cand; break }
}
if (-not $p) { return $null }
$set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($l in [System.IO.File]::ReadAllLines($p, [System.Text.Encoding]::UTF8)) {
$t = $l.Trim()
if ($t -eq '') { continue }
if ($t.StartsWith('#') -or $t.StartsWith('[')) { continue }
[void]$set.Add($t)
}
if ($set.Count -eq 0) { return $null }
return , $set
}
# ---------------------------------------------------------------- 数据表读取(v1.7 新增)
# 用途:给「STAT 编号白名单」「词缀编号越界」两条校验提供权威数据。
# 为什么必须查表:编号写错时游戏内是**静默失效**——该条件恒为 0,规则永不命中,
# 不报错、不连坐,肉眼极难发现。2026-10-30 手工审计才把 139 种 STAT 全核了一遍。
# 表目录按优先级探测,都找不到就跳过这两条校验(只给提示,不阻断构建):
# 1. 环境变量 PD2_EXCEL_DIR
# 2. build.ps1 同目录下的 excel\
# 3. 游戏目录 ..\..\Data\global\excel
# 4. 桌面 data\global\excel
# ★ 不要读 ZYLPD2\Data\global\magicprefix.txt:那是 911 条残表,比正式表少 79 条。
function Get-ExcelDir {
$cands = New-Object System.Collections.ArrayList
if ($env:PD2_EXCEL_DIR) { [void]$cands.Add($env:PD2_EXCEL_DIR) }
[void]$cands.Add((Join-Path $Root 'excel'))
[void]$cands.Add((Join-Path $Root '..\..\Data\global\excel'))
$desk = [Environment]::GetFolderPath('Desktop')
if ($desk) { [void]$cands.Add((Join-Path $desk 'data\global\excel')) }
foreach ($d in $cands) {
if (-not $d) { continue }
if (Test-Path -LiteralPath (Join-Path $d 'ItemStatCost.txt')) { return $d }
}
return $null
}
function Read-ExcelRows([string]$Path) {
if (-not (Test-Path -LiteralPath $Path)) { return $null }
$txt = [System.IO.File]::ReadAllText($Path, [System.Text.Encoding]::UTF8)
$rows = New-Object System.Collections.ArrayList
foreach ($l in ($txt -split "`r?`n")) {
if ($l.Trim() -eq '') { continue }
[void]$rows.Add(($l -split "`t"))
}
if ($rows.Count -lt 2) { return $null }
return , $rows.ToArray()
}
# 返回 @{ Id = <编号哈希>; Max = <最大合法编号> }
# Id 仅在给了 -IdCol 时填(ItemStatCost 用 Id 列)
# Max = 数据行数 - 1,即编号上界(MagicPrefix 992 行含表头 → 编号 0..990)
function Get-ExcelMeta([string]$Dir, [string]$File, [string]$IdCol) {
$rows = Read-ExcelRows (Join-Path $Dir $File)
if ($null -eq $rows) { return $null }
$res = @{ Id = $null; Max = ($rows.Count - 2) }
if ($IdCol) {
$hdr = $rows[0]
$ci = -1
for ($k = 0; $k -lt $hdr.Count; $k++) { if ($hdr[$k].Trim() -eq $IdCol) { $ci = $k; break } }
if ($ci -ge 0) {
$h = @{}
for ($r = 1; $r -lt $rows.Count; $r++) {
$row = $rows[$r]
if ($row.Count -le $ci) { continue }
$v = 0
if ([int]::TryParse($row[$ci].Trim(), [ref]$v)) { $h[$v] = $true }
}
$res.Id = $h
}
}
return $res
}
# ---------------------------------------------------------------- 孤立 % / 未闭合 %
# 半截百分号不会让过滤器报错,只会吞掉颜色或把 %XXX 漏成明文,所以只作「提示」。
# 做法:先找出所有合法占位符 %NAME% 的区间,再逐个检查剩下的 %:
# 放行 前一位是数字 40%ED、/35%%TAN%、10%移动速度
# 放行 前一位是 ] ) / - [4-6]%物理减伤、+[50-80]% 增强伤害
# 放行 前一位是占位符结尾,且后一位是占位符开头 / } / ] %EDEF%%%GRAY%、%STAT258%%}
# 放行 前四位是「物理减伤」「魔法减伤」 [4-6]%物理减伤% ← 百分比减伤的专用示意符,
# 刻意与数值减伤 [2-5]物理减伤 区分(2026-10-28 用户定义,不要删、不要报)
# 报出 其余全部(%GRAY%%Steel( 缺右侧、抽动挣扎%%CL% 多一个、神蛇之羽CL% 缺左侧)
function Get-PercentFault([string[]]$Lines) {
$phMask = [regex]'%[A-Za-z][A-Za-z0-9_\-]*%'
$nextNameRe = [regex]'^([A-Za-z][A-Za-z0-9_\-]*)'
$prevNameRe = [regex]'([A-Za-z][A-Za-z0-9_\-]*)$'
$res = New-Object System.Collections.ArrayList
for ($i = 0; $i -lt $Lines.Count; $i++) {
$s = $Lines[$i]
if (-not $s.Contains('%')) { continue }
if ($s.TrimStart().StartsWith('//')) { continue }
# 行尾 // 注释里的 % 不参与显示,先切掉
$cut = $s.IndexOf('//')
$body = if ($cut -ge 0) { $s.Substring(0, $cut) } else { $s }
if (-not $body.Contains('%')) { continue }
$inPh = [System.Collections.Generic.HashSet[int]]::new()
$phStart = [System.Collections.Generic.HashSet[int]]::new()
$phEnd = [System.Collections.Generic.HashSet[int]]::new()
foreach ($mm in $phMask.Matches($body)) {
[void]$phStart.Add($mm.Index)
[void]$phEnd.Add($mm.Index + $mm.Length - 1)
for ($k = $mm.Index; $k -lt ($mm.Index + $mm.Length); $k++) { [void]$inPh.Add($k) }
}
$j = $body.IndexOf('%')
while ($j -ge 0) {
if ($inPh.Contains($j)) { $j = $body.IndexOf('%', $j + 1); continue }
$prev = if ($j -gt 0) { $body[$j - 1] } else { [char]0 }
$next = if ($j + 1 -lt $body.Length) { $body[$j + 1] } else { [char]0 }
$ok = $false
if ([char]::IsDigit($prev)) { $ok = $true }
elseif ('])/-'.IndexOf($prev) -ge 0) { $ok = $true }
elseif ($phEnd.Contains($j - 1) -and ($phStart.Contains($j + 1) -or $next -eq '}' -or $next -eq ']')) { $ok = $true }
elseif ($j -ge 4 -and ($body.Substring($j - 4, 4) -eq '物理减伤' -or $body.Substring($j - 4, 4) -eq '魔法减伤')) { $ok = $true }
if (-not $ok) {
$kind = '孤立 %'
$nw = $nextNameRe.Match($body.Substring($j + 1))
$pw = $prevNameRe.Match($body.Substring(0, $j))
if ($pw.Success -and (Test-Placeholder $pw.Groups[1].Value)) { $kind = '缺左侧 %' }
elseif ($nw.Success) { $kind = '缺右侧 %' }
elseif ($phStart.Contains($j + 1) -or $next -eq '%') { $kind = '多余 %' }
$a = [Math]::Max(0, $j - 16)
$len = [Math]::Min(38, $body.Length - $a)
$snip = $body.Substring($a, $len)
[void]$res.Add([pscustomobject]@{ Line = $i + 1; Kind = $kind; Snippet = $snip })
}
$j = $body.IndexOf('%', $j + 1)
}
}
return $res.ToArray()
}
# ---------------------------------------------------------------- 公式 lint
# 五条轻量规则,只作用于 Formula[...] 行。入口先切掉行尾 // 注释:
# 注释里写中文是合法的,代码体里写中文才是错的。
# 0 公式名 必须匹配 ^[A-Za-z_]+$(错误)
# 2026-10-30 游戏内实测:名字带数字时 %FORMULA名字% 查不到值,
# 占位符原样输出文字、不报错、不连坐其它公式——纯静默失效,
# 所以必须在构建期拦死
# 1 IF 元数 顶层逗号切参数必须正好 3 个(提示)
# MULTI<n>,<l> 的双参逗号属于变量自身,先整体掩码再切
# 2 AND/OR 单参 只有 1 个参数(提示)——等价于给表达式加括号,写法冗余
# 3 公式体污染 非 ASCII / 单等号(应为 ==)/ ~(BETWEEN 只能用在
# ItemDisplay 条件)→ 错误,构建不通过
# 4 公式体含 % 错误。同一轮实测:公式之间完全隔离,裸名引用、%FORMULA…%
# 包裹、Alias 宏四条路全不通。% 只对 ItemDisplay 模板生效,
# 写进公式体必然让该条公式编译失败
# 数「顶层逗号」得出参数个数。注意不要返回数组:PS 里 `return ,$arr` 再被 @() 包裹
# 会变成嵌套数组,Count 恒为 1(本轮踩过)。直接返回整数最省心。
function Get-TopLevelArgCount([string]$s) {
if ($s.Trim() -eq '') { return 0 }
$n = 1
$depth = 0
foreach ($ch in $s.ToCharArray()) {
if ($ch -eq '(') { $depth++ }
elseif ($ch -eq ')') { $depth-- }
elseif ($ch -eq ',' -and $depth -eq 0) { $n++ }
}
return $n
}
function Get-FormulaLint([string]$Body, [string]$Name = '') {
$res = New-Object System.Collections.ArrayList
$code = $Body
$cut = $code.IndexOf('//')
if ($cut -ge 0) { $code = $code.Substring(0, $cut) }
# ---- 规则 0:公式名只允许字母和下划线(错误)
# 实测:Formula[TESTA] 的 %FORMULATESTA% 正常,Formula[TEST1] 的 %FORMULATEST1%
# 原样输出。名字里带数字会让占位符查不到值,且完全无声。
if ($Name -ne '' -and $Name -notmatch '^[A-Za-z_]+$') {
[void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式名 '$Name' 含非法字符(只允许字母和下划线)。带数字会让 %FORMULA$Name% 静默失效" })
}
# ---- 规则 4:公式体内不能出现 % (错误)
# 实测:%FORMULATESTA%*3 这类写法编译不过,该公式作废。% 仅对 ItemDisplay 模板生效。
if ($code.Contains('%')) {
$qp = $code.IndexOf('%')
$qa = [Math]::Max(0, $qp - 16)
$ql = [Math]::Min(36, $code.Length - $qa)
[void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式体内出现 %(公式之间不能互相引用,%FORMULA…% 只在 ItemDisplay 模板里生效):$($code.Substring($qa, $ql).Trim())" })
}
# ---- 规则 3:非 ASCII / 单等号 / ~ (错误)
$nonAscii = $null
foreach ($ch in $code.ToCharArray()) { if ([int]$ch -gt 127) { $nonAscii = $ch; break } }
if ($null -ne $nonAscii) {
[void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式体内出现非 ASCII 字符 '$nonAscii'(中文请写在 // 注释里)" })
}
$eqRe = [regex]'(?<![!<>=])=(?!=)'
$em = $eqRe.Match($code)
if ($em.Success) {
$a = [Math]::Max(0, $em.Index - 16)
$l = [Math]::Min(36, $code.Length - $a)
[void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式体内出现单等号(比较应写 ==):$($code.Substring($a, $l).Trim())" })
}
if ($code.Contains('~')) {
$pp = $code.IndexOf('~')
$a = [Math]::Max(0, $pp - 16)
$l = [Math]::Min(36, $code.Length - $a)
[void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "公式体内出现 ~(BETWEEN 只用于 ItemDisplay 条件,公式请写 >= / <=):$($code.Substring($a, $l).Trim())" })
}
# ---- 规则 1 / 2:IF 元数、AND·OR 单参数(提示)
$multiRe = [regex]'(?i)MULTI\d+\s*,\s*\d+'
$callRe = [regex]'(?i)\b(IF|AND|OR)\s*\('
foreach ($m in $callRe.Matches($code)) {
$fn = $m.Groups[1].Value.ToUpperInvariant()
$op = $m.Index + $m.Length - 1
$d = 0
$cl = -1
for ($k = $op; $k -lt $code.Length; $k++) {
$c = $code[$k]
if ($c -eq '(') { $d++ }
elseif ($c -eq ')') { $d--; if ($d -eq 0) { $cl = $k; break } }
}
if ($cl -lt 0) {
[void]$res.Add([pscustomobject]@{ Level = 'E'; Msg = "$fn( 括号未闭合" })
continue
}
$inner = $code.Substring($op + 1, $cl - $op - 1)
$masked = $multiRe.Replace($inner, 'MULTI_V')
$n = Get-TopLevelArgCount $masked
$a = [Math]::Max(0, $op - 4)
$l = [Math]::Min(52, $code.Length - $a)
$snip = $code.Substring($a, $l).Trim()
if ($fn -eq 'IF') {
if ($n -ne 3) {
[void]$res.Add([pscustomobject]@{ Level = 'W'; Msg = "IF 参数数 $n(应为 3):$snip" })
}
} elseif ($n -le 1) {
[void]$res.Add([pscustomobject]@{ Level = 'W'; Msg = "$fn 只有 $n 个参数(等价于加括号,建议直接写 (...)):$snip" })
}
}
# 不要写 return , $res.ToArray():外层 @() 会得到「嵌套数组」,
# foreach 拿到的元素是空数组,反而每个公式都吐一条空提示(本轮踩过)
return $res.ToArray()
}
# ---------------------------------------------------------------- 自检
function Invoke-SelfCheck([string[]]$Lines, $Mods) {
$errors = New-Object System.Collections.ArrayList
$warns = New-Object System.Collections.ArrayList
$ruleRe = [regex]'^ItemDisplay\[([^\]]*)\]:(.*)$'
$aliasRe = [regex]'^Alias\[([^\]]+)\]:(.*)$'
$formulaRe = [regex]'^Formula\[([^\]]+)\]:(.*)$'
$phRe = [regex]'%([A-Za-z][A-Za-z0-9_\-]*)%'
$tokRe = [regex]'(?<![A-Za-z0-9_])([A-Z][A-Z0-9_]{1,})(?![A-Za-z0-9_])'
$rules = New-Object System.Collections.ArrayList
$aliasNames = New-Object System.Collections.ArrayList
$formulaNames = New-Object System.Collections.ArrayList
$formulaCount = 0
$continueCount = 0
$phSeen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
$badPh = @{}
# 以 %别名% 形式出现的名字:别名是条件宏、不是文本占位符,游戏内不会替换成文字。
# 因此不判「非法占位符」(那是错误),只作提示,避免写错别名导致界面出现裸 %名%。
$aliasPh = @{}
$filtlvl = 0
$trail = New-Object System.Collections.ArrayList
$lintErr = 0
$lintWarn = 0
$lintShown = 0
for ($i = 0; $i -lt $Lines.Count; $i++) {
$ln = $i + 1
$s = $Lines[$i]
$mr = $ruleRe.Match($s)
if ($mr.Success) {
$cond = $mr.Groups[1].Value
$out = $mr.Groups[2].Value
[void]$rules.Add([pscustomobject]@{ Line = $ln; Cond = $cond; Out = $out })
if ($cond.Contains('FILTLVL')) { $filtlvl++ }
if ($cond.Contains('(') -or $cond.Contains(')')) {
$open = ([regex]::Matches($cond, '\(')).Count
$close = ([regex]::Matches($cond, '\)')).Count
if ($open -ne $close) {
[void]$errors.Add("L$ln 条件括号不平衡(( $open / ) $close):$cond")
}
}
}
$ma = $aliasRe.Match($s)
if ($ma.Success) {
$anm = $ma.Groups[1].Value
[void]$aliasNames.Add($anm)
$abody = $ma.Groups[2].Value
$acut = $abody.IndexOf('//')
if ($acut -ge 0) { $abody = $abody.Substring(0, $acut) }
$aopen = ([regex]::Matches($abody, '\(')).Count
$aclose = ([regex]::Matches($abody, '\)')).Count
if ($aopen -ne $aclose) {
[void]$errors.Add("L$ln 别名 $anm 条件括号不平衡(( $aopen / ) $aclose):$abody")
}
if ($abody -match '\)(?=[A-Za-z0-9!])') {
[void]$errors.Add("L$ln 别名 $anm 右括号后粘连(缺空格):$abody")
}
if ($abody -match '\dOR\b') {
[void]$errors.Add("L$ln 别名 $anm 数字与 OR 粘连:$abody")
}
if ($abody -match '=~(?![0-9])') {
[void]$errors.Add("L$ln 别名 $anm 非法比较符 =~:$abody")
}
}
$mf = $formulaRe.Match($s)
if ($mf.Success) {
$formulaCount++
$fkey = $mf.Groups[1].Value
[void]$formulaNames.Add($fkey)
foreach ($q in @(Get-FormulaLint $mf.Groups[2].Value $fkey)) {
if ($q.Level -eq 'E') {
$lintErr++
[void]$errors.Add("L$ln 公式 $fkey $($q.Msg)")
} else {
$lintWarn++
if ($lintShown -lt 12) {
$lintShown++
[void]$warns.Add("L$ln 公式 $fkey $($q.Msg)")
}
}
}
}
$continueCount += ([regex]::Matches($s, '%CONTINUE%')).Count
foreach ($pm in $phRe.Matches($s)) {
$nm = $pm.Groups[1].Value
[void]$phSeen.Add($nm)
if (-not (Test-Placeholder $nm)) {
if (-not $badPh.ContainsKey($nm)) { $badPh[$nm] = New-Object System.Collections.ArrayList }
[void]$badPh[$nm].Add($ln)
}
}
if ($s -ne $s.TrimEnd()) { [void]$trail.Add($ln) }
if ($s.Length -gt $MaxLineLen) { [void]$warns.Add("L$ln 超长($($s.Length) 字符)") }
}
# 孤立 % / 未闭合 %
$pctFaults = @(Get-PercentFault $Lines)
foreach ($f in $pctFaults) {
[void]$warns.Add("$($f.Kind) L$($f.Line):$($f.Snippet)")
}
# 别名重复
$seenAlias = @{}
foreach ($n in $aliasNames) { if ($seenAlias.ContainsKey($n)) { $seenAlias[$n]++ } else { $seenAlias[$n] = 1 } }
foreach ($k in ($seenAlias.Keys | Sort-Object)) {
if ($seenAlias[$k] -gt 1) { [void]$errors.Add("别名重复定义:$k") }
}
# 非法占位符(前缀是已定义别名的不算非法:记入 $aliasPh 作提示,见下)
$aliasSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($a in $aliasNames) { [void]$aliasSet.Add($a) }
# 别名互为子串(v1.8 新增)—— 命名红线:别名名不得是另一别名名的子串
# 引擎按子串扫描别名表,短名会抢先命中长名,把长名切成「前缀残渣 + 短名」,
# 残渣是无效 token → 整条条件恒假,静默失效、原 check 完全查不出。
# 实例:裸名 DMG_4_1 是 SHIELD_DMG_4_1 / AMU_DMG_4_1 / MELEE_DMG_4_1 … 的子串。
$aliasByLen = @($aliasSet | Sort-Object -Property @{Expression={$_.Length}}, @{Expression={$_}})
for ($i = 0; $i -lt $aliasByLen.Count; $i++) {
$shortName = $aliasByLen[$i]
for ($j = $i + 1; $j -lt $aliasByLen.Count; $j++) {
$longName = $aliasByLen[$j]
if ($longName.Length -le $shortName.Length) { continue }
if ($longName.IndexOf($shortName, [System.StringComparison]::Ordinal) -ge 0) {
[void]$errors.Add("别名互为子串:$shortName 是 $longName 的一部分(短名会抢先命中,长名失效)")
}
}
}
foreach ($k in ($badPh.Keys | Sort-Object)) {
if ($aliasSet.Contains($k)) {
$aliasPh[$k] = $badPh[$k]
continue
}
$lns = ($badPh[$k] | Select-Object -First 8) -join ','
[void]$errors.Add("非法占位符 %$k%($($badPh[$k].Count) 处,行 $lns)")
}
# 别名写成 %别名% 形态:不算错误,但游戏内不会被替换成文字,只会原样显示 %别名%
foreach ($k in ($aliasPh.Keys | Sort-Object)) {
$lns = ($aliasPh[$k] | Select-Object -First 6) -join ','
[void]$warns.Add("别名以占位符形态出现 %$k%($($aliasPh[$k].Count) 处,行 $lns):别名是条件宏,模板里不会被替换成文本")
}
# 未定义标识(只判别名风格:含下划线或数字)
$defined = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($n in $aliasNames) { [void]$defined.Add($n.ToUpperInvariant()) }
$refs = @{}
foreach ($r in $rules) {
foreach ($tm in $tokRe.Matches($r.Cond)) {
$t = $tm.Groups[1].Value
if (-not $refs.ContainsKey($t)) { $refs[$t] = New-Object System.Collections.ArrayList }
[void]$refs[$t].Add($r.Line)
}
}
foreach ($t in ($refs.Keys | Sort-Object)) {
if ($defined.Contains($t)) { continue }
if ($IgnoreUndef -contains $t) { continue }
if (Test-KeywordToken $t) { continue }
if ($t -match '[_\d]') {
$lns = ($refs[$t] | Select-Object -First 4) -join ','
[void]$warns.Add("未定义标识 $t(L$lns)")
}
}
# 完全重复规则
$dupMap = @{}
foreach ($r in $rules) {
$k = (($r.Cond -replace '\s+', ' ').Trim().ToLowerInvariant()) + '|' + (($r.Out -replace '\s+', ' ').Trim())
if ($dupMap.ContainsKey($k)) { $dupMap[$k] += ",$($r.Line)" } else { $dupMap[$k] = "$($r.Line)" }
}
foreach ($k in $dupMap.Keys) {
if ($dupMap[$k] -match ',') {
$c = $k.Split('|')[0]
if ($c.Length -gt 70) { $c = $c.Substring(0, 70) }
[void]$warns.Add("完全重复规则 L$($dupMap[$k]):$c")
}
}
# 死规则:同一条件重复出现,且更早的一条没有 %CONTINUE% → 后一条永不命中
$stopAt = @{}
$deadCount = 0
foreach ($r in $rules) {
$k = ($r.Cond -replace '\s+', ' ').Trim().ToUpperInvariant()
if ($k -eq '') { continue }
if ($stopAt.ContainsKey($k)) {
$deadCount++
if ($deadCount -le 12) {
[void]$warns.Add("死规则 L$($r.Line) 永不命中(同条件 L$($stopAt[$k]) 未带 CONTINUE,已终结判定):$($r.Cond)")
}
} elseif (-not $r.Out.ToUpperInvariant().Contains('%CONTINUE%')) {
$stopAt[$k] = $r.Line
}
}
if ($deadCount -gt 12) { [void]$warns.Add("... 另有 $($deadCount - 12) 条死规则") }
# ---------------------------------------------------------------- 编号体系校验(v1.7 新增)
# 三条都是「游戏内静默失效、check 原本查不出」的类型:
# A STAT 编号不在 ItemStatCost → 条件恒为 0,规则永不命中
# B PREFIX / SUFFIX 编号越界 → 同上
# C 同条件 + 都带 CONTINUE → 一件物品并排输出多条标题
# 扫描对象是全部非注释行(不只是 ItemDisplay),这样别名体内的编号也能覆盖到。
$excelDir = Get-ExcelDir
$statIds = $null
$pfMax = -1
$sfMax = -1
if ($null -eq $excelDir) {
[void]$warns.Add('未找到 ItemStatCost.txt / MagicPrefix.txt,STAT 与词缀编号校验已跳过(可用环境变量 PD2_EXCEL_DIR 指定 excel 目录)')
} else {
$mc = Get-ExcelMeta $excelDir 'ItemStatCost.txt' 'Id'
if ($null -ne $mc -and $null -ne $mc.Id) { $statIds = $mc.Id }
$mp = Get-ExcelMeta $excelDir 'MagicPrefix.txt' ''
if ($null -ne $mp) { $pfMax = $mp.Max }
$ms = Get-ExcelMeta $excelDir 'MagicSuffix.txt' ''
if ($null -ne $ms) { $sfMax = $ms.Max }
}
$statBad = @{}
$pfBad = @{}
$sfBad = @{}
$statN = 0
if ($null -ne $statIds) { $statN = $statIds.Count }
if ($null -ne $statIds -or $pfMax -ge 0 -or $sfMax -ge 0) {
$pfRe = [regex]'\bPREFIX(?:=(\d+)|~(\d+)-(\d+))'
$sfRe = [regex]'\bSUFFIX(?:=(\d+)|~(\d+)-(\d+))'
for ($i = 0; $i -lt $Lines.Count; $i++) {
$sl = $Lines[$i]
if ($sl.TrimStart().StartsWith('//')) { continue }
if ($null -ne $statIds) {
foreach ($m in [regex]::Matches($sl, '\bSTAT(\d+)\b')) {
$v = [int]$m.Groups[1].Value
if ($statIds.ContainsKey($v)) { continue }
if (-not $statBad.ContainsKey($v)) { $statBad[$v] = New-Object System.Collections.ArrayList }
[void]$statBad[$v].Add($i + 1)
}
}
if ($pfMax -ge 0) {
foreach ($m in $pfRe.Matches($sl)) {
$vs = @()
if ($m.Groups[1].Success) { $vs = @([int]$m.Groups[1].Value) }
else { $vs = @([int]$m.Groups[2].Value, [int]$m.Groups[3].Value) }
foreach ($v in $vs) {
if ($v -le $pfMax) { continue }
if (-not $pfBad.ContainsKey($v)) { $pfBad[$v] = New-Object System.Collections.ArrayList }
[void]$pfBad[$v].Add($i + 1)
}
}
}
if ($sfMax -ge 0) {
foreach ($m in $sfRe.Matches($sl)) {
$vs = @()
if ($m.Groups[1].Success) { $vs = @([int]$m.Groups[1].Value) }
else { $vs = @([int]$m.Groups[2].Value, [int]$m.Groups[3].Value) }
foreach ($v in $vs) {
if ($v -le $sfMax) { continue }
if (-not $sfBad.ContainsKey($v)) { $sfBad[$v] = New-Object System.Collections.ArrayList }
[void]$sfBad[$v].Add($i + 1)
}
}
}
}
foreach ($v in ($statBad.Keys | Sort-Object)) {
$lns = ($statBad[$v] | Select-Object -First 4) -join ','
[void]$errors.Add("STAT$v 不在 ItemStatCost 中($($statBad[$v].Count) 处,行 $lns)")
}
foreach ($v in ($pfBad.Keys | Sort-Object)) {
$lns = ($pfBad[$v] | Select-Object -First 4) -join ','
[void]$errors.Add("PREFIX=$v 越界(MagicPrefix 最大编号 $pfMax,$($pfBad[$v].Count) 处,行 $lns)")
}
foreach ($v in ($sfBad.Keys | Sort-Object)) {
$lns = ($sfBad[$v] | Select-Object -First 4) -join ','
[void]$errors.Add("SUFFIX=$v 越界(MagicSuffix 最大编号 $sfMax,$($sfBad[$v].Count) 处,行 $lns)")
}
}
# C 同条件 + 都带 CONTINUE → 并排输出多条标题。
# 与既有两个检查正好互补、零重叠:
# 「完全重复规则」抓 条件+模板全同;「死规则」抓 同条件但前面那条不带 CONTINUE;
# 这里抓 同条件、都带 CONTINUE、模板又不完全相同的那一类(2026-10-30 审计的 02/03 段
# 7 条就是这种:模板只差一个 %GRAY%,两边都带 CONTINUE,前两个检查都漏)。
$condMap = @{}
foreach ($r in $rules) {
$k = ($r.Cond -replace '\s+', ' ').Trim().ToUpperInvariant()
if ($k -eq '') { continue }
if (-not $condMap.ContainsKey($k)) { $condMap[$k] = New-Object System.Collections.ArrayList }
[void]$condMap[$k].Add($r)
}
$sameCont = 0
$colorRe = [regex]('%(' + ($Colors -join '|') + ')%')
foreach ($k in ($condMap.Keys | Sort-Object)) {
$grp = $condMap[$k]
if ($grp.Count -lt 2) { continue }
$allCont = $true
$outs = @{}
$lns = New-Object System.Collections.ArrayList
foreach ($g in $grp) {
if (-not $g.Out.ToUpperInvariant().Contains('%CONTINUE%')) { $allCont = $false; break }
[void]$lns.Add($g.Line)
# 归一化:先剥掉配色占位符再比。判据必须是「只差颜色」——
# 模板内容不同的绝大多数是**分段拼接**(前一段以「升级后需求:(」收尾、
# 后一段接数值、末段以 ")}" 闭口;护身符三段说明、地图两行信息同理),
# 那是正常设计,一旦误报就变成 66 组噪音(首版实测)。
$no = $colorRe.Replace($g.Out, '')
$outs[($no -replace '\s+', ' ').Trim()] = $true
}
if (-not $allCont) { continue }
if ($outs.Count -ge 2) { continue }
# 已裁定放行(v1.10):条件命中名单,且组内每条规则都落在 02/03 模块
# → 这 7 组是现场确认的误报,不再提示;其它模块的同名条件照常提示。
if ($SameContAcceptedConds -contains $k) {
$allInAcceptedMods = $true
foreach ($g in $grp) {
$mm = Find-ModuleOfLine $Mods $g.Line
if ($null -eq $mm -or ($SameContAcceptedMods -notcontains $mm.Code)) {
$allInAcceptedMods = $false
break
}
}
if ($allInAcceptedMods) { continue }
}
$sameCont++
$cs = $k
if ($cs.Length -gt 70) { $cs = $cs.Substring(0, 70) }
[void]$warns.Add("同条件近似重复 L$($lns -join ',')(模板只差配色、都带 CONTINUE,同一件物品会并排显示两遍):$cs")
}
# 物品代码白名单
$wl = Get-CodeWhitelist
$wlCount = 0
if ($null -eq $wl) {
[void]$warns.Add('未找到 item_codes.txt,跳过物品代码校验')
} else {
$wlCount = $wl.Count
$codeRe = [regex]'^[a-z][a-z0-9]{1,5}$'
$badCode = @{}
foreach ($r in $rules) {
$c = [regex]::Replace($r.Cond, '%[^%]*%', ' ')
$c = $c.Replace('(', ' ').Replace(')', ' ').Replace('!', ' ')
foreach ($t in ($c -split '\s+')) {
if ($t -eq '') { continue }
if (-not $codeRe.IsMatch($t)) { continue }
if ($wl.Contains($t)) { continue }
if ($defined.Contains($t.ToUpperInvariant())) { continue }
if (-not $badCode.ContainsKey($t)) { $badCode[$t] = New-Object System.Collections.ArrayList }
[void]$badCode[$t].Add($r.Line)
}
}
foreach ($k in ($badCode.Keys | Sort-Object)) {
$lns = ($badCode[$k] | Select-Object -First 4) -join ','
[void]$errors.Add("疑似无效物品代码 $k($($badCode[$k].Count) 处,行 $lns)")
}
}
# Formula 引用完整性
$fDef = @{}
foreach ($n in $formulaNames) { $fDef[$n] = $true }
$fRef = @{}
for ($i = 0; $i -lt $Lines.Count; $i++) {
if ($formulaRe.IsMatch($Lines[$i])) { continue }
foreach ($fm in [regex]::Matches($Lines[$i], 'FORMULA([A-Za-z0-9_]+)')) {
$n = $fm.Groups[1].Value
if (-not $fRef.ContainsKey($n)) { $fRef[$n] = New-Object System.Collections.ArrayList }
[void]$fRef[$n].Add($i + 1)
}
}
foreach ($n in ($fRef.Keys | Sort-Object)) {
if ($fDef.ContainsKey($n)) { continue }
$lns = ($fRef[$n] | Select-Object -First 4) -join ','
[void]$errors.Add("Formula 引用无定义:FORMULA$n($($fRef[$n].Count) 处,行 $lns)")
}
$fUnused = @($formulaNames | Where-Object { -not $fRef.ContainsKey($_) } | Sort-Object)
if ($fUnused.Count -gt 0) {
[void]$warns.Add("Formula 定义后 0 引用:$($fUnused -join '、')")
}
# 别名 0 引用(v1.5 新增)
# ★ 别名与公式的引用语法完全不同:别名在条件里是**裸名**(如 `!KNOWN_CODES`、
# `(SHIELD_SKILL_5_1 OR ...)`),只有公式才是 `%FORMULA名%`。曾经用 `%别名名%`
# 去查别名引用,导致 6 个盾牌星级别名被误判为全死(实际都在 34 模块 L38/L39 使用)。
# 实现:借用已有的 $tokRe 抓全大写 token 再查字典,避免 480×4470 次正则的开销。
# 排除:值为裸 TRUE / FALSE 的条目是设置菜单的开关与分组标题(JUST_TAG1~4 之类),
# 本就不需要被任何规则引用。
$aliasBody = @{}
for ($i = 0; $i -lt $Lines.Count; $i++) {
$ca = $aliasRe.Match($Lines[$i])
if (-not $ca.Success) { continue }
$ab = $ca.Groups[2].Value
$ac = $ab.IndexOf('//')
if ($ac -ge 0) { $ab = $ab.Substring(0, $ac) }
$aliasBody[$ca.Groups[1].Value] = $ab.Trim()
}
$aliasRef = @{}
for ($i = 0; $i -lt $Lines.Count; $i++) {
$s2 = $Lines[$i]
if ($s2.TrimStart().StartsWith('//')) { continue }
$ca = $aliasRe.Match($s2)
$body2 = $s2
$selfName = ''
if ($ca.Success) { $body2 = $ca.Groups[2].Value; $selfName = $ca.Groups[1].Value }
$cut2 = $body2.IndexOf('//')
if ($cut2 -ge 0) { $body2 = $body2.Substring(0, $cut2) }
foreach ($tm2 in $tokRe.Matches($body2)) {
$t2 = $tm2.Groups[1].Value
if ($t2 -eq $selfName) { continue }
if ($aliasBody.ContainsKey($t2)) { $aliasRef[$t2] = $true }
}
}
# $tokRe 只认 [A-Z] 开头的 token。名字以数字或下划线开头的别名(现存 `7CR_INFUSION`)
# 永远抓不到,会被误判成 0 引用——这里对这少数几个名字单独补扫一遍。
$oddNames = @($aliasBody.Keys | Where-Object { -not ($_ -cmatch '^[A-Z][A-Z0-9_]+$') })
if ($oddNames.Count -gt 0) {
for ($i = 0; $i -lt $Lines.Count; $i++) {
$s3 = $Lines[$i]
if ($s3.TrimStart().StartsWith('//')) { continue }
$ca3 = $aliasRe.Match($s3)
$b3 = if ($ca3.Success) { $ca3.Groups[2].Value } else { $s3 }
$c3 = $b3.IndexOf('//')
if ($c3 -ge 0) { $b3 = $b3.Substring(0, $c3) }
foreach ($on in $oddNames) {
if ($ca3.Success -and $ca3.Groups[1].Value -eq $on) { continue }
$pat3 = '(?<![A-Za-z0-9_])' + [regex]::Escape($on) + '(?![A-Za-z0-9_])'
if ([regex]::IsMatch($b3, $pat3)) { $aliasRef[$on] = $true }
}
}
}
$aUnused = @()
foreach ($an in ($aliasBody.Keys | Sort-Object)) {
if ($aliasRef.ContainsKey($an)) { continue }
$av = $aliasBody[$an].ToUpperInvariant()
if ($av -eq 'TRUE' -or $av -eq 'FALSE') { continue }
$aUnused += $an
}
if ($aUnused.Count -gt 0) {
$aShow = $aUnused
if ($aShow.Count -gt 10) { $aShow = $aShow[0..9] }
$aMore = if ($aUnused.Count -gt 10) { "…(共 $($aUnused.Count) 个)" } else { '' }
[void]$warns.Add("别名定义后 0 引用:$($aShow -join '、')$aMore")
}
# 公式之间不可互相引用(2026-10-30 游戏内实测四条路全不通:裸名 / %FORMULA…% /
# $f() / Alias 宏)。失败是静默的——该条公式作废、占位符原样输出、不报错。
# 这里抓「公式体里出现另一个公式名」这种最容易犯的写法;公式名(SMA/XPA/C_HELM…)
# 与 STAT/MULTI 等变量名不重叠,误报风险极低。
for ($i = 0; $i -lt $Lines.Count; $i++) {
$cf = $formulaRe.Match($Lines[$i])
if (-not $cf.Success) { continue }
$own = $cf.Groups[1].Value
$cbody = $cf.Groups[2].Value
$ccut = $cbody.IndexOf('//')
if ($ccut -ge 0) { $cbody = $cbody.Substring(0, $ccut) }
foreach ($fn2 in $formulaNames) {
if ($fn2 -eq $own) { continue }
$pat = '(?<![A-Za-z0-9_])' + [regex]::Escape($fn2) + '(?![A-Za-z0-9_])'
if ([regex]::IsMatch($cbody, $pat)) {
[void]$errors.Add("L$($i + 1) 公式 $own 的表达式里引用了公式 $fn2(公式之间不能互相引用,游戏内会静默失效)")
break
}
}
}
# 版本行联动:版本行日期 vs 最晚模块改动
$verRe = [regex]'更新于\s*(\d{2,4})[/-](\d{1,2})[/-](\d{1,2})'
foreach ($l in $Lines) {
$mv = $verRe.Match($l)
if (-not $mv.Success) { continue }
$y = [int]$mv.Groups[1].Value
if ($y -lt 100) { $y += 2000 }
$verOk = $true
$vd = [datetime]::MinValue
try { $vd = [datetime]::new($y, [int]$mv.Groups[2].Value, [int]$mv.Groups[3].Value) }
catch { $verOk = $false }
if ($verOk -and $null -ne $Mods -and $Mods.Count -gt 0) {
$newest = $Mods | Sort-Object MTime -Descending | Select-Object -First 1
if ($vd.Date -lt $newest.MTime.Date) {
[void]$warns.Add("版本行停在 $($vd.ToString('yyyy-MM-dd')),但最晚模块改动是 $($newest.MTime.ToString('yyyy-MM-dd'))($($newest.Name))—— 记得更新版本号")
}
}
break
}
if ($trail.Count -gt 0) {
$head = ($trail | Select-Object -First 6) -join ','
[void]$warns.Add("行尾空白 $($trail.Count) 处(L$head…)")
}
# 菜单按钮文字长度(v1.9 新增,v1.10 改口径)—— `00` 的 //@X@ 里 X 是 BH
# 界面按钮文字,正则的分组名部分要求**以 @ 收尾**((?:([^@]*)@)?),否则会把
# 关闭 @ 之后的普通行尾注释当成分组名(实测踩过:`//@屏蔽ILVL<68亮金珠宝@ 说明:…`)。
# 口径 = **显示宽度**(不是汉字个数):汉字/全角记 2,字母·数字·符号各记 1;
# 按钮 X ≤ 20(=10 汉字宽),分组写法 //@X@Y@ 的 Y ≤ 24(=12 汉字宽)。
# v1.9 按汉字个数统计偏松,`显示无孔鬼魂尖枪ETH5` 这类 ASCII 多的按钮会漏报。
# 超长→菜单显示异常,游戏不报错,只能靠这里拦。
$btnRe = [regex]'//@([^@\r\n]*)@(?:([^@\r\n]*)@)?'
for ($bi = 0; $bi -lt $Lines.Count; $bi++) {
$mb = $btnRe.Match($Lines[$bi])
if (-not $mb.Success) { continue }
$btn = $mb.Groups[1].Value
$grp = $mb.Groups[2].Value
$bl = $bi + 1
# 已裁定放行(v1.10):JUST_TAG4 那条说明按钮有意写长,只放行这一个
if ($ButtonAccepted -notcontains $btn) {
$bc = Get-Display-Width $btn
if ($bc -gt 20) { [void]$warns.Add("按钮文字超长 L$bl「$btn」$bc 宽(上限 20 = 10 汉字宽,超长会让菜单显示异常)") }
}
$gc = Get-Display-Width $grp
if ($gc -gt 24) { [void]$warns.Add("分组名超长 L$bl「$grp」$gc 宽(上限 24 = 12 汉字宽)") }
}
# 版本说明每行宽度(v1.11 新增)—— `00` 模块的 `ItemDisplay[box]` 版本说明
# 断行单元的**标准写法是整条 `%NL%%NL%%NAME%`**:第一个 `%NL%` 换行,第二个
# `%NL%` 再多一个空行隔开,视觉更整齐(2026-11-01 用户裁定)。
# 单个 `%CL%` / 单个 `%NL%` 都没问题;**有 BUG 的是 `%CL%%NL%` 相邻混用**。
# 语义区别(2026-11-01 用户说明):`%CL%` = 惰性换行,后面有内容才换行;
# `%NL%` = 强制换行,不管有没有内容。所以本检查把两者都当切点(%CL% 后若
# 无内容会产生空段,已在下面跳过,不影响判宽)。
# 但 `ItemDisplay[box]` 里用 `%NL%%NL%%NAME%` 断行会**重开一个新块**,把
# 「BADWOLF FILTER…/ 当前过滤等级」整段再打一遍(实测一屏炸成 8 个块),
# 所以版本说明正文段间改用 `%CL%%CORAL%`(已进游戏验证显示正常)。
# 两种切法这里都认:先把整条 `%NL%%NL%%NAME%` 换成哨兵,再按 `%CL%`/`%NL%` 切。
# 游戏里不自动折行:一行超宽会溢出框外被裁掉(实测 37 宽即丢字)。
# 故逐行剔掉 `%XXX%` 控制码后按显示宽度判,每行 ≤ 72(=36 汉字宽)。
$ctlRe = [regex]'%[A-Za-z0-9_]+%'
$brkTok = '%NL%%NL%%NAME%'
$brkRe = [regex]'%CL%|%NL%'
for ($vi = 0; $vi -lt $Lines.Count; $vi++) {
$vl = $Lines[$vi]
if (-not $vl.StartsWith('ItemDisplay[box]')) { continue }
if ($brkRe.IsMatch($vl) -eq $false) { continue }
$norm = $vl.Replace($brkTok, [string][char]1)
$segs = [regex]::Split($norm, ('[' + [char]1 + ']|%CL%|%NL%'))
$vline = $vi + 1
for ($si = 0; $si -lt $segs.Count; $si++) {
$txt = $ctlRe.Replace($segs[$si], '').Trim()
# 首段带着 `ItemDisplay[条件]:` 前缀,前缀不显示、不计入宽度
if ($si -eq 0) { $txt = [regex]::Replace($txt, '^ItemDisplay\[[^\]]*\]:\s*', '').Trim() }
if ($txt.Length -eq 0) { continue }
$vw = Get-Display-Width $txt
if ($vw -gt 72) { [void]$warns.Add("版本说明/提示行超宽 L$vline「$txt」$vw 宽(上限 72 = 36 汉字宽,超出会被框裁掉,需断行)") }
}
}
# 业务目录顶层杂项 txt(v1.10 新增)—— BH 菜单把 filters\Local 顶层的每个
# .txt 当成一个**独立过滤器**列出,只有 `本地过滤器存放目录.txt` 被忽略。
# 所以顶层除合并版与该占位文件外不应再有 .txt;工具文件一律放 tools\ 子目录。
# 在仓库里跑时顶层只有合并版,本检查自然为空。
$strayTxt = @(Get-ChildItem -LiteralPath $Root -Filter '*.txt' -File -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne $MergedName -and $_.Name -ne $DirPlaceholderTxt })
if ($strayTxt.Count -gt 0) {
[void]$warns.Add("业务目录顶层有 $($strayTxt.Count) 个多余 .txt(会被 BH 菜单当成独立过滤器):$(($strayTxt | ForEach-Object Name) -join ', ') —— 建议移到 tools\ 子目录")
}
# 星级图标矩阵(v1.12 新增)—— 颜色=品类,尺寸=星级。
# 背景:裸 `%MAP%` 会沿用当前文本色,暗金/套装/亮金/底材/符文在小地图上会长得
# 一模一样,无法判定,所以每个图标都必须写死 2 位 hex。而 hex 无法参数化
# (没有语法能把变量塞进 `%MAP-xx%`),维护只能靠这里的三条校验兜住:
# A) 色卡:模块内出现的图标色必须落在该模块的品类色内(防手滑写错 hex)
# B) 行序:32 / 33 / 30 符文 是按档位排序的优先链,图标尺寸须随行号单调
# C) 底材:36 的档位写在别名后缀 BASEn 里,n 与尺寸必须一一对应
# 全部只报「提示」,不阻断构建。
$iconRe = [regex]'%(BORDER|MAP|DOT|PX)-([0-9A-Fa-f]{2})%'
$sizeRank = @{ 'BORDER' = 4; 'MAP' = 3; 'DOT' = 2; 'PX' = 1 }
$iconPal = @{
'30' = @('60', '6D') # 符文橙 / 珠宝黄(珠宝并入亮金)
'32' = @('53') # 暗金金
'33' = @('84') # 套装绿
'34' = @('6D', '97') # 亮金黄(97 = 魔法珠宝标记,沿用旧码)
'36' = @('20') # 底材白
}
$baseSize = @{ '4' = 'BORDER'; '3' = 'MAP'; '2' = 'DOT'; '1' = 'PX'; '0' = '' }
if ($Mods) {
foreach ($im in $Mods) {
$code = $im.Code
if (-not $code) { continue }
$pal = $iconPal[$code]
$chain = New-Object System.Collections.ArrayList
for ($li = $im.Start; $li -le $im.End; $li++) {
$s2 = $Lines[$li - 1]
$mr2 = $ruleRe.Match($s2)
if (-not $mr2.Success) { continue }
$cond2 = $mr2.Groups[1].Value
$out2 = $mr2.Groups[2].Value
$icons = @($iconRe.Matches($out2))
if ($pal) {
foreach ($ic in $icons) {
if ($pal -notcontains $ic.Groups[2].Value.ToUpper()) {
[void]$warns.Add("L$li 模块 $code 图标 $($ic.Value) 颜色不在品类色卡内(应为 $($pal -join '/'))")
}
}
}
if ($code -eq '36') {
$bt = @([regex]::Matches($cond2, 'BASE([0-4])') | ForEach-Object { $_.Groups[1].Value })
# 注意:单元素管道会退化成标量,标量再下标 [0] 得到的是 [char],
# 拿 [char] 去查以字符串为键的哈希表会静默取到 $null。
# 故先 @() 强制成数组、再显式转 [string]。
if ($bt.Count -gt 0) { $mx = [string](@($bt | Sort-Object -Descending)[0]); $want = [string]$baseSize[$mx] } else { $want = 'DOT' }
if ($icons.Count -gt 0) { $got = [string]$icons[0].Groups[1].Value.ToUpper() } else { $got = '' }
if ($got -ne $want) {
$wantTxt = if ($want) { $want } else { '无图标' }
$gotTxt = if ($got) { $got } else { '无图标' }
[void]$warns.Add("L$li 底材档位与图标尺寸不符:BASEn 决定应为 $wantTxt,实为 $gotTxt")
}
}
if (($code -eq '32') -or ($code -eq '33') -or (($code -eq '30') -and ($cond2 -match '^RUNE>'))) {
if ($icons.Count -gt 0) { $r = $sizeRank[$icons[0].Groups[1].Value.ToUpper()] } else { $r = 0 }
[void]$chain.Add([pscustomobject]@{ Line = $li; Rank = $r })
}
}
for ($k = 1; $k -lt $chain.Count; $k++) {
$pa = $chain[$k - 1]; $pb = $chain[$k]
if ($code -eq '30') { $bad = ($pb.Rank -le $pa.Rank) } else { $bad = ($pb.Rank -gt $pa.Rank) }
if ($bad) {
[void]$warns.Add("L$($pb.Line) 模块 $code 图标档位未随行号单调(L$($pa.Line)=$($pa.Rank) → L$($pb.Line)=$($pb.Rank)),请检查该档位排错")
}
}
}
}
# 弹匣配色作用域(v1.13 新增)—— 用户裁定:彩虹弹匣的符号与梯度色**只能**写在
# `!SHOW_ICON`(文本版)行。`SHOW_ICON`(图标版)行已有 ┣ / ┺ / ┹ 这套紧凑标记
# 表达同一件事,再叠弹匣符号就是两套标记互相打架。
# `■` 刻意不在集合内:它已是「可做 RW / 可注入」的功能标记,跨品类复用。
$magSym = [regex]'[※◆★◇◎✦○]'
if ($Mods) {
foreach ($qm in $Mods) {
for ($li = $qm.Start; $li -le $qm.End; $li++) {
$s3 = $Lines[$li - 1]
$mr3 = $ruleRe.Match($s3)
if (-not $mr3.Success) { continue }
$cond3 = $mr3.Groups[1].Value
$out3 = $mr3.Groups[2].Value
if ($cond3 -match '(?<!!)SHOW_ICON' -and $magSym.IsMatch($out3)) {
[void]$warns.Add("L$li 图标版行(SHOW_ICON)出现弹匣符号 $($magSym.Match($out3).Value):弹匣配色只允许用于 !SHOW_ICON 行")
}
}
}
}
return [pscustomobject]@{
Errors = $errors
Warns = $warns
Rules = $rules.Count
Aliases = $aliasNames.Count
Formulas = $formulaCount
Continue = $continueCount
Lines = $Lines.Count
PhKinds = $phSeen.Count
FiltLvl = $filtlvl
WlCount = $wlCount
Dead = $deadCount
FRef = $fRef.Count
FUnused = $fUnused.Count
ARef = $aliasRef.Count
AUnused = $aUnused.Count
Pct = $pctFaults.Count
LintE = $lintErr
LintW = $lintWarn
Excel = $excelDir
StatN = $statN
PfMax = $pfMax
SfMax = $sfMax
SameCont = $sameCont
}
}
# ---------------------------------------------------------------- 差异定位
function Get-DiffBlocks([string[]]$A, [string[]]$B) {
$blocks = New-Object System.Collections.ArrayList
if ($A.Count -eq 0 -or $B.Count -eq 0) { return , $blocks.ToArray() }
if ($A.Count -eq $B.Count) {
# 行数相同:逐行比对,合并连续差异
$cur = $null
for ($i = 0; $i -lt $A.Count; $i++) {
if ($A[$i] -cne $B[$i]) {
if ($null -eq $cur) {
$cur = [pscustomobject]@{
AFrom = $i + 1; ATo = $i + 1
BFrom = $i + 1; BTo = $i + 1
A = @(); B = @()
}
}
$cur.ATo = $i + 1
$cur.BTo = $i + 1
$cur.A += $A[$i]
$cur.B += $B[$i]
} elseif ($null -ne $cur) {
[void]$blocks.Add($cur); $cur = $null
}
}
if ($null -ne $cur) { [void]$blocks.Add($cur) }
return , $blocks.ToArray()
}
# 行数不同:掐掉公共前后缀,报中间
$min = [Math]::Min($A.Count, $B.Count)
$p = 0
while ($p -lt $min -and $A[$p] -ceq $B[$p]) { $p++ }
$s = 0
while ($s -lt ($min - $p) -and $A[$A.Count - 1 - $s] -ceq $B[$B.Count - 1 - $s]) { $s++ }
$blk = [pscustomobject]@{
AFrom = $p + 1
ATo = $A.Count - $s
BFrom = $p + 1
BTo = $B.Count - $s
A = @($A[$p..([Math]::Max($p, $A.Count - $s - 1))])
B = @($B[$p..([Math]::Max($p, $B.Count - $s - 1))])
}
[void]$blocks.Add($blk)
return , $blocks.ToArray()
}
# ---------------------------------------------------------------- check
function Invoke-Check($Mods) {
Write-Line (Get-Bar)
Write-Line " BadWolf 过滤器 构建校验"
Write-Line (Get-Bar)
$mergedRaw = @()
if (Test-Path -LiteralPath $MergedPath) {
$mergedRaw = [System.IO.File]::ReadAllBytes($MergedPath)
}
$mergedText = [System.Text.Encoding]::UTF8.GetString($mergedRaw)
$mergedLines = Get-Lines $mergedText
$cat = Join-Bytes $Mods
$catText = [System.Text.Encoding]::UTF8.GetString($cat)
$catLines = Get-Lines $catText
Write-Line " 模块数 $($Mods.Count)"
Write-Line " 拼接 $(Format-N $cat.Length) B $(Format-N $catLines.Count) 行 MD5 $(Get-Md5Hex $cat)"
Write-Line " 合并 $(Format-N $mergedRaw.Length) B $(Format-N $mergedLines.Count) 行 MD5 $(Get-Md5Hex $mergedRaw)"
# ---- 1 完整性
Write-Head '1/5 模块完整性'
$badMods = 0
foreach ($m in $Mods) {
if ($m.Errors.Count -eq 0) { $flag = 'OK ' } else { $flag = 'ERR'; $badMods++ }
$line = " $flag [{0}] {1,5} 行 {2,10} B {3}" -f $m.Code, $m.LineCount, (Format-N $m.ByteLen), $m.Name
Write-Line $line
foreach ($e in $m.Errors) { Write-Line " ! $e" }
}
# ---- 2 比对
Write-Head '2/5 拼接与比对'
$same = $false
if ($mergedRaw.Length -eq 0) {
Write-Line " 状态 合并版不存在(首次构建?),无基准可比 → 运行 build 生成"
$same = $false
} elseif ($cat.Length -eq $mergedRaw.Length) {
$same = $true
for ($i = 0; $i -lt $cat.Length; $i++) {
if ($cat[$i] -ne $mergedRaw[$i]) { $same = $false; break }
}
} else { $same = $false }
if ($mergedRaw.Length -eq 0) {
# 没有基准文件,跳过差异定位
} elseif ($same) {
Write-Line " 状态 一致(逐字节相同)"
} else {
$delta = $cat.Length - $mergedRaw.Length
$sign = ''
if ($delta -ge 0) { $sign = '+' }
$blocks = Get-DiffBlocks $mergedLines $catLines
Write-Line " 状态 不一致(拼接比合并版 $sign$delta B,$($blocks.Count) 个差异块)"
foreach ($b in $blocks) {
$mod = Find-ModuleOfLine $Mods $b.BFrom
$head = " [差异] 合并版 L$($b.AFrom)-$($b.ATo)"
if ($null -ne $mod) {
$ml = $b.BFrom - $mod.Start + 1
$mh = $b.BTo - $mod.Start + 1
if ($ml -eq $mh) { $head += " ← $($mod.Name) 第 $ml 行" }
else { $head += " ← $($mod.Name) 第 $ml-$mh 行" }
}
Write-Line $head
$n = [Math]::Min($b.A.Count, 3)
for ($i = 0; $i -lt $n; $i++) {
Write-Line " 合并: $($b.A[$i])"
Write-Line " 模块: $($b.B[$i])"
}
if ($b.A.Count -ne $b.B.Count) {
Write-Line " (行数 $($b.A.Count) → $($b.B.Count))"
}
}
}
# ---- 3 行号表
Write-Head '3/5 行号映射表(模块 → 合并版)'
Write-Line " 模块 合并版区间 行数 字节 文件名"
foreach ($m in $Mods) {
$line = " [{0}] {1,5}-{2,-5} {3,8} {4,12} {5}" -f $m.Code, $m.Start, $m.End, $m.LineCount, (Format-N $m.ByteLen), $m.Name
Write-Line $line
}
$last = $Mods[$Mods.Count - 1]
Write-Line (" 合计 1-{0,-9} {1,8} {2,12} ({3} 个模块)" -f $last.End, $last.End, (Format-N $cat.Length), $Mods.Count)
# ---- 4 自检
Write-Head '4/5 静态自检'
$chk = Invoke-SelfCheck $catLines $Mods
Write-Line " 规则 $($chk.Rules) 条 | 别名 $($chk.Aliases) 个 | 公式 $($chk.Formulas) 个 | CONTINUE $($chk.Continue) 处 | FILTLVL $($chk.FiltLvl) 条"
Write-Line " 行 $(Format-N $chk.Lines) | 字节 $(Format-N $cat.Length) | 占位符 $($chk.PhKinds) 种"
$wlTxt = if ($chk.WlCount -gt 0) { "白名单 $($chk.WlCount) 码" } else { '白名单 未加载' }
Write-Line " 语义 $wlTxt | 死规则 $($chk.Dead) | 公式引用 $($chk.FRef)/$($chk.Formulas)(未引用 $($chk.FUnused))"
Write-Line " 引用 别名 $($chk.ARef)/$($chk.Aliases)(未引用 $($chk.AUnused),不含 TRUE/FALSE 开关项)"
Write-Line " 符号 孤立 % / 未闭合 % $($chk.Pct) 处"
if ($null -ne $chk.Excel) {
Write-Line " 编号 STAT 白名单 $($chk.StatN) 个 | PREFIX <= $($chk.PfMax) | SUFFIX <= $($chk.SfMax) | 同条件近似重复 $($chk.SameCont) 组"
} else {
Write-Line " 编号 数据表未加载,STAT / 词缀编号校验已跳过"
}
$lintFlag = 'OK'
if ($chk.LintE -gt 0) { $lintFlag = 'x' } elseif ($chk.LintW -gt 0) { $lintFlag = '!' }
Write-Line " 公式 lint $lintFlag 错误 $($chk.LintE) / 提示 $($chk.LintW)(公式名 / 公式体 % / IF 元数 / AND·OR 单参 / 非 ASCII·单等号·~)"
if ($chk.Errors.Count -gt 0) {
Write-Line ""
Write-Line " 错误 $($chk.Errors.Count) 项:"
foreach ($e in ($chk.Errors | Select-Object -First 30)) { Write-Line " x $e" }
} else {
Write-Line ""
Write-Line " 无错误(括号 / 占位符 / 别名重复 / 物品代码 / 公式引用 / 别名引用 / 公式 lint)"
}
if ($chk.Warns.Count -gt 0) {
Write-Line ""
Write-Line " 提示 $($chk.Warns.Count) 项(不阻断):"
foreach ($w in ($chk.Warns | Select-Object -First 20)) { Write-Line " - $w" }
if ($chk.Warns.Count -gt 20) { Write-Line " ... 另有 $($chk.Warns.Count - 20) 项" }
} else {
Write-Line " 无提示项"
}
# ---- 5 结论
Write-Head '5/5 结论'
$ok = $same -and ($chk.Errors.Count -eq 0) -and ($badMods -eq 0)
if ($ok) {
Write-Line " 通过:合并版与模块一致,自检无错误。"
if ($chk.Warns.Count -gt 0) { Write-Line " (有 $($chk.Warns.Count) 项提示,非阻断)" }
} else {
if (-not $same) { Write-Line " x 模块已改动但合并版未更新 → 运行 build(或双击 build.bat 选 build)" }
if ($badMods -gt 0) { Write-Line " x $badMods 个模块有编码/换行问题,先修复再构建" }
if ($chk.Errors.Count -gt 0) { Write-Line " x 自检发现 $($chk.Errors.Count) 项错误" }
}
Write-Line ""
if ($Strict) { if ($ok -and $chk.Warns.Count -eq 0) { return 0 } else { return 1 } }
if ($ok) { return 0 } else { return 1 }
}
# ---------------------------------------------------------------- 备份轮转
# 只保留最近 $Keep 份 BadWolf-LootFilter.txt.<时间戳>.bak(文件名时间戳可直接按名倒序)
function Remove-OldBackup([int]$Keep) {
$dir = Split-Path -Parent $MergedPath
$pat = (Split-Path -Leaf $MergedPath) + '.*.bak'
$all = @(Get-ChildItem -LiteralPath $dir -Filter $pat -File -ErrorAction SilentlyContinue |
Sort-Object Name -Descending)
if ($all.Count -le $Keep) { return @() }
$gone = @($all | Select-Object -Skip $Keep)
foreach ($g in $gone) { Remove-Item -LiteralPath $g.FullName -Force -ErrorAction SilentlyContinue }
return $gone
}
# ---------------------------------------------------------------- build
function Invoke-Build($Mods) {
Write-Line (Get-Bar)
Write-Line " BadWolf 过滤器 构建"
Write-Line (Get-Bar)
$bad = @($Mods | Where-Object { $_.Errors.Count -gt 0 })
if ($bad.Count -gt 0) {
Write-Line " [中止] 以下模块存在编码/换行问题:"
foreach ($m in $bad) {
Write-Line " $($m.Name)"
foreach ($e in $m.Errors) { Write-Line " ! $e" }
}
return 1
}
$cat = Join-Bytes $Mods
# 写盘前先拦公式 lint 错误(非 ASCII / 单等号 / ~):过了才生成合并版,
# 避免把明知有错的公式写进 BadWolf-LootFilter.txt
$preLines = Get-Lines ([System.Text.Encoding]::UTF8.GetString($cat))
$preFormulaRe = [regex]'^Formula\[([^\]]+)\]:(.*)$'
$blockers = New-Object System.Collections.ArrayList
for ($i = 0; $i -lt $preLines.Count; $i++) {
$pf = $preFormulaRe.Match($preLines[$i])
if (-not $pf.Success) { continue }
foreach ($q in @(Get-FormulaLint $pf.Groups[2].Value $pf.Groups[1].Value)) {
if ($q.Level -eq 'E') { [void]$blockers.Add("L$($i + 1) 公式 $($pf.Groups[1].Value) $($q.Msg)") }
}
}
if ($blockers.Count -gt 0) {
Write-Line " [中止] 公式 lint 发现 $($blockers.Count) 项错误,未写入合并版:"
foreach ($b in ($blockers | Select-Object -First 20)) { Write-Line " x $b" }
return 1
}
$old = @()
if (Test-Path -LiteralPath $MergedPath) { $old = [System.IO.File]::ReadAllBytes($MergedPath) }
$same = $false
if ($old.Length -eq $cat.Length) {
$same = $true
for ($i = 0; $i -lt $cat.Length; $i++) { if ($cat[$i] -ne $old[$i]) { $same = $false; break } }
}
if ($same) {
Write-Line " 合并版已是最新,无需重建。"
Write-Line ""
return Invoke-Check $Mods
}
if (-not $NoBackup -and (Test-Path -LiteralPath $MergedPath)) {
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$bak = "$MergedPath.$stamp.bak"
Copy-Item -LiteralPath $MergedPath -Destination $bak -Force
Write-Line " 已备份 → $(Split-Path -Leaf $bak)"
if ($KeepBackups -gt 0) {
$gone = @(Remove-OldBackup $KeepBackups)
if ($gone.Count -gt 0) {
Write-Line " 轮转 删除旧备份 $($gone.Count) 份(保留最近 $KeepBackups 份):$(($gone | ForEach-Object { $_.Name }) -join ', ')"
} else {
Write-Line " 轮转 现有备份未超过 $KeepBackups 份,无需删除"
}
}
}
$tmp = "$MergedPath.tmp"
try {
[System.IO.File]::WriteAllBytes($tmp, $cat)
Move-Item -LiteralPath $tmp -Destination $MergedPath -Force
} finally {
if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
}
$new = [System.IO.File]::ReadAllBytes($MergedPath)
$lineN = ([regex]::Matches([System.Text.Encoding]::UTF8.GetString($new), "\n")).Count
Write-Line " 已写入 $(Format-N $new.Length) B / $(Format-N $lineN) 行 MD5 $(Get-Md5Hex $new)"
$okWrite = $false
if ($new.Length -eq $cat.Length) {
$okWrite = $true
for ($i = 0; $i -lt $cat.Length; $i++) { if ($new[$i] -ne $cat[$i]) { $okWrite = $false; break } }
}
if ($okWrite) { Write-Line " 写入校验通过(逐字节一致)" } else { Write-Line " [错误] 写入校验失败"; return 1 }
Write-Line ""
return Invoke-Check $Mods
}
# ---------------------------------------------------------------- map
function Invoke-Map($Mods, [string]$Q) {
if ($Q -match ':') {
$parts = $Q.Split(':')
$code = $parts[0]; $ln = 0
if (-not [int]::TryParse($parts[1], [ref]$ln)) { Write-Line "无法解析行号:$Q"; return 1 }
$m = $Mods | Where-Object { $_.Code -eq $code -or $_.Name.StartsWith($code) } | Select-Object -First 1
if ($null -eq $m) { Write-Line "找不到模块:$code"; return 1 }
if ($ln -lt 1 -or $ln -gt $m.LineCount) { Write-Line "$($m.Name) 只有 $($m.LineCount) 行"; return 1 }
$merged = $m.Start + $ln - 1
Write-Line "$($m.Name) 第 $ln 行 → 合并版 L$merged"
$lines = Get-Lines $m.Text
if ($ln -le $lines.Count) { Write-Line " $($lines[$ln - 1])" }
return 0
}
$ln = 0
if (-not [int]::TryParse($Q, [ref]$ln)) { Write-Line "无法解析行号:$Q"; return 1 }
$m = Find-ModuleOfLine $Mods $ln
if ($null -eq $m) { Write-Line "L$ln 超出范围(1-$($Mods[$Mods.Count-1].End))"; return 1 }
Write-Line "合并版 L$ln → $($m.Name) 第 $($ln - $m.Start + 1) 行"
return 0
}
# ---------------------------------------------------------------- export
function Invoke-Export($Mods) {
$cat = Join-Bytes $Mods
$end = $Mods[$Mods.Count - 1].End
$sb = New-Object System.Text.StringBuilder
[void]$sb.AppendLine('# BadWolf 过滤器行号映射表')
[void]$sb.AppendLine('')
[void]$sb.AppendLine("- 生成时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')")
[void]$sb.AppendLine("- 模块数:$($Mods.Count)")
[void]$sb.AppendLine("- 模块拼接:$(Format-N $cat.Length) 字节 / $(Format-N $end) 行 / MD5 ``$(Get-Md5Hex $cat)``")
[void]$sb.AppendLine('- 换算:`合并行 = 模块起始行 + 模块行 - 1`')
[void]$sb.AppendLine('')
[void]$sb.AppendLine('| 模块 | 合并版起 | 合并版止 | 行数 | 字节 |')
[void]$sb.AppendLine('|---|---:|---:|---:|---:|')
foreach ($m in $Mods) {
[void]$sb.AppendLine("| $($m.Name) | $($m.Start) | $($m.End) | $($m.LineCount) | $(Format-N $m.ByteLen) |")
}
[void]$sb.AppendLine("| **合计** | 1 | $end | $end | $(Format-N $cat.Length) |")
[void]$sb.AppendLine('')
[void]$sb.AppendLine('## 常用锚点')
[void]$sb.AppendLine('')
foreach ($m in $Mods) {
if (@('00','09','16','22','28','37','39') -contains $m.Code) {
[void]$sb.AppendLine("- ``$($m.Code)`` $($m.Name) → L$($m.Start)–L$($m.End)")
}
}
$path = Join-Path $Root $MapName
[System.IO.File]::WriteAllText($path, $sb.ToString(), (New-Object System.Text.UTF8Encoding($false)))
Write-Line "已导出 $path"
return 0
}
# ---------------------------------------------------------------- 主流程
try {
$moduleDir = Get-ModuleDir
} catch {
Write-Line "[错误] $($_.Exception.Message)"
exit 2
}
Write-Line (Get-Bar)
Write-Line " BadWolf 过滤器构建工具(PowerShell 版)"
Write-Line (Get-Bar)
Write-Line " 脚本位置 $PSCommandPath"
Write-Line " 模块目录 $moduleDir"
Write-Line " 合并版 $MergedPath"
$mods = Get-Modules $moduleDir
if ($mods.Count -eq 0) { Write-Line "[错误] 模块目录里没有 NN-*.txt 模块文件"; exit 2 }
if ($script:SkippedModules.Count -gt 0) {
Write-Host ""
Write-Host " [警告] 以下 txt 不符合「NN-名称.txt」,已跳过(不会进入合并版):" -ForegroundColor Yellow
foreach ($n in $script:SkippedModules) { Write-Host " - $n" -ForegroundColor Yellow }
Write-Host " 若是正式模块,请改名成 40-xxx.txt 这种形式。" -ForegroundColor Yellow
}
# 编号必须唯一:两个 16-xxx.txt 会让合并版多出一整段,且顺序取决于文件名,必须人工处理
$dupCodes = @($mods | Group-Object Code | Where-Object { $_.Count -gt 1 })
if ($dupCodes.Count -gt 0) {
Write-Line ""
Write-Line "[错误] 模块编号重复,已停止(避免把两份内容都拼进合并版):"
foreach ($g in $dupCodes) {
$names = ($g.Group | ForEach-Object { $_.Name }) -join ' / '
Write-Line (" [$($g.Name)] $names")
}
Write-Line " 处理办法:把多余的那份移出模块目录,或改成未占用的编号。"
exit 2
}
switch ($Command.ToLowerInvariant()) {
'build' { exit (Invoke-Build $mods) }
'map' { if ([string]::IsNullOrWhiteSpace($Arg)) { Write-Line "用法:build.bat map 1939 或 build.bat map 16:139"; exit 2 }; exit (Invoke-Map $mods $Arg) }
'export' { exit (Invoke-Export $mods) }
'check' { exit (Invoke-Check $mods) }
default { Write-Line "[错误] 未知命令:$Command(可用 check / build / map / export)"; exit 2 }
}