ZYLPD2-Filter-Lonelywolf/build/build.ps1

1480 lines
71 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.9
------------------------------------------------------------------
用途:把模块目录下的 txt 按文件名排序拼接成 BadWolf-LootFilter.txt,
并完成一致性校验与行号换算。纯 PowerShell,无需安装任何软件。
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() }
}
# ---------------------------------------------------------------- 白名单
$Colors = @('BLACK','WHITE','GRAY','LIGHT_GRAY','DARK_GRAY','RED','DARK_RED','ORANGE','GOLD',
'YELLOW','GREEN','DARK_GREEN','SAGE','TEAL','BLUE','DARK_BLUE','PURPLE','CORAL',
'TAN','LIGHT_BLUE','VIOLET')
$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
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] 类型代码。
# 文件不存在则跳过该项检查(不视为错误)。
function Get-CodeWhitelist {
$p = Join-Path $Root 'item_codes.txt'
if (-not (Test-Path -LiteralPath $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 }
$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 新增)—— `00` 的 //@X@ 里 X 是 BH 界面按钮文字,
# 正则的分组名部分要求**以 @ 收尾**((?:([^@]*)@)?),否则会把关闭 @ 之后的
# 普通行尾注释当成分组名(实测踩过:`//@屏蔽ILVL<68亮金珠宝@ 说明:…`)。
# 上限 10 个汉字;分组写法 //@X@Y@ 的 Y 上限 12 个汉字。超长→菜单显示异常,
# 游戏不报错,只能靠这里拦。口径数**汉字**,不数字符总数(含 ASCII 的条目
# 如 `屏蔽ILVL<68亮金珠宝` 只有 6 个汉字,是合规的)。
$cjkRe = [regex]'[\u4e00-\u9fa5]'
$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
$bc = $cjkRe.Matches($btn).Count
$gc = $cjkRe.Matches($grp).Count
$bl = $bi + 1
if ($bc -gt 10) { [void]$warns.Add("按钮文字超长 L$bl「$btn」$bc 个汉字(上限 10,超长会让菜单显示异常)") }
if ($gc -gt 12) { [void]$warns.Add("分组名超长 L$bl「$grp」$gc 个汉字(上限 12)") }
}
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 }
}