This commit is contained in:
lonelywolf 2026-10-31 09:07:26 +00:00
parent 00c561a642
commit 3640c6dc42
1 changed files with 551 additions and 4 deletions

View File

@ -1,10 +1,96 @@
#requires -Version 5.1
<#
BadWolf 过滤器构建脚本 build.ps1 v1.2
BadWolf 过滤器构建脚本 build.ps1 v1.8
------------------------------------------------------------------
用途:把模块目录下的 txt 按文件名排序拼接成 BadWolf-LootFilter.txt,
并完成一致性校验与行号换算。纯 PowerShell,无需安装任何软件。
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 的变化
------------------------------------------------------------------
· 自检新增:孤立 % / 未闭合 % 扫描(半截百分号会吞颜色或漏出明文)
@ -237,6 +323,67 @@ function Get-CodeWhitelist {
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% 的区间,再逐个检查剩下的 %:
@ -300,6 +447,115 @@ function Get-PercentFault([string[]]$Lines) {
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
@ -318,8 +574,14 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) {
$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
@ -362,7 +624,23 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) {
}
}
$mf = $formulaRe.Match($s)
if ($mf.Success) { $formulaCount++; [void]$formulaNames.Add($mf.Groups[1].Value) }
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
@ -392,12 +670,40 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) {
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()) }
@ -450,6 +756,124 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) {
}
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 }
$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
@ -499,6 +923,92 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) {
[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) {
@ -538,7 +1048,16 @@ function Invoke-SelfCheck([string[]]$Lines, $Mods) {
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
}
}
@ -681,14 +1200,23 @@ function Invoke-Check($Mods) {
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 " 无错误(括号 / 占位符 / 别名重复 / 物品代码 / 公式引用)"
Write-Line " 无错误(括号 / 占位符 / 别名重复 / 物品代码 / 公式引用 / 别名引用 / 公式 lint)"
}
if ($chk.Warns.Count -gt 0) {
Write-Line ""
@ -746,6 +1274,25 @@ function Invoke-Build($Mods) {
}
$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) }