DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# 115CN 兼容性检查
检查日期:2026-10-31。样本位于本地 `testdata/115CN`,共 6,251,369 个文件。
本报告验证内核、GUI 数据读取和源码编辑逻辑,不代表已逐项完成桌面窗口交互验收。
## 容器
- 独立识别为 `paged115cn` / `115CN`,不冒用 `115US` 身份。
- 配套客户端参数和 `sk.dat` 封装在 `internal/pvf/assets/clients/115cn.bin`。
日常打开无需同目录的 exe 或 `sk.dat`,也无需执行客户端程序。
- 字符串池为 UTF-16,保留分页容器、字符串引用和 indexhash 约定。
- 无配套文件、错误外部 `sk.dat`、内存解析均已验证;未修改保存通过 SHA-256 一致性验证。
- 客户端参数不输出到日志,不在源码中展开;真实归档、exe 和原始配套文件不提交。
## GUI 结果
| 工具 | 样本数量 | 结果和限制 |
| --- | ---: | --- |
| 商店 | 1,491 | 已适配嵌套商品记录、直接商品列表和根级分页。读取无错误,剩余 16 个没有可展示的分页;529 个文件存在结构、缺失引用或成本提示,不等于全部可编辑。 |
| 基础掉率 | 4 | 均为 9 列分组;已放开前后端入口。修改后的四份 payload 可重新解码,额外阈值保持不变。 |
| 全局掉落 | 1 | 数据读取通过。 |
| 强化/增幅 | 2 | 普通表和安全表读取通过,无格式提示。 |
| 锻造 | 1 | 已适配每级 6 列,成功率/材料倍率映射至正确源位置;两项附加系数只读。4/6 列无法唯一识别时拒绝 GUI 编辑。 |
| 技能 | 4,981 | 合并远端根级参数适配后读取无错误;36 个带有重复段、异常等级宽度或额外描述绑定等保护提示。1,004 个没有可展示的根级或 dungeon/pvp 参数模式。 |
| 任务 | 14,128 | 14,108 个通过前端解析;14,107 个验证了整数编辑只改目标 token。20 个闭合结构无法安全识别,保留 DSL 编辑。 |
任务嵌套区块整体只读,子字段不会覆盖顶层名称、条件或奖励;正常顶层字段仍可编辑。
2,668 个可解析任务带有嵌套、重复或特殊类型字段提示,不意味着整份任务都可由表单覆盖。
没有强行接受那 20 个结构不明的任务,也没有清理或重写原始样本。
## 商店结构
旧结构在 `[item list]` 内直接列出物品 ID。115CN 改为:
```text
[sell item list]
[item]
0
[index]
12345
[/item]
[/sell item list]
```
`[item]` 的直接整数是记录键,`[index]` 才是物品 ID;示例 ID 仅用于说明。
正常样本有五种布局:`sell info/tab` 下直接或按分类存放列表、
`sell info` 下直接或按分类存放列表,以及根级 `tab`。
- 正常闭合结构共解析 27,977 条非空商品记录,全部核对了预览 ID 与二进制编辑定位;
`[index] -1` 作为空位保留,不展示成商品。
- 支持新增、替换、删除商品;替换只修改 `index`,删除移除整条 `item`,
不合并重复物品,不丢弃同记录的限购、购买数量、退款等字段。
新增记录使用高于当前文件已有键的记录键,不复制其他商品的局部配置。
- 显式分页支持增删改;无分页布局只投影成一个 GUI 页,不擅自重组原文件,
其分页管理仍需文本模式。
- 记录内的 `price`、`need material` 按对应成本类型覆盖物品文件中的共享成本。
局部成本目前只读,带局部成本的记录可替换物品或删除;
单条成本修改及包含这些记录的批量成本修改会被拒绝,避免误改共享物品。
- 46 个文件带有结构提示,包含缺少分类 ID、未声明分类类型或不可识别的嵌套列表等;
声明分类但直接列出商品的记录可以通过“未分类”入口查看,仍保留保护提示。
另有缺失物品/材料引用的提示,合计 529 个商店带提示。
6 个文件有二进制区块闭合警告,拒绝 GUI 编辑。
- 五种真实布局均通过新增、另存临时 PVF、重开及删除新增记录验证;
删除后对应商店 payload 与原始内容逐字节一致。未覆盖原始样本。
## 排版与标注
- `section.basis of rarity dicision` 及其 `.hell` 规则为 115CN 每组显示 9 项。
- `etc.forge.table` 为 115CN 每行显示 6 项,其他版本保留原行宽。
- `etc.forge.115cn.table.0` 至 `.5` 独立标注六个字段;旧四列标注不应用于 115CN。
- 规则版本校验、编辑入口和归档版本标签均增加 115CN。
- 已运行的应用需要重新构建并打开;仅修改仓库 JSON 不会保证旧进程自动加载新配置。
## 复现
在仓库根目录使用 PowerShell 7:
```powershell
$env:PVF_TESTFILE = (Resolve-Path testdata/115CN/Script.pvf).Path
$env:PVF_TEST_CLIENT_VERSION = '115CN'
go test ./internal/pvf -run '^TestReal(BundledPagedClient|PagedClientCompatibility|PagedClientEditRoundTrip)$' -count=1 -v -timeout 20m
$env:PVF_GUI_TESTFILE = $env:PVF_TESTFILE
$env:PVF_GUI_QUEST_CORPUS = Join-Path ([IO.Path]::GetTempPath()) 'pvfine-115cn-quest-corpus.json'
go test ./services -run '^TestGUIRealArchiveCompatibility$' -count=1 -v -timeout 20m
$env:PVF_SHOP_TESTFILE = $env:PVF_TESTFILE
go test ./services -run '^TestShopReal115CN' -count=1 -v -timeout 20m
$env:PVF_GUI_QUEST_EXPECTED_REJECTIONS = '20'
Push-Location frontend
npm test
npm run build
Pop-Location
```
任务语料仅写入指定的本地临时文件,不加入仓库。`PVF_GUI_QUEST_EXPECTED_REJECTIONS`
只记录这份样本的预期拒绝数,不会放宽应用解析或编辑限制。
运行普通 `go test ./...` 前应清除这些真实样本环境变量,避免触发其他版本专用测试。
+34
View File
@@ -0,0 +1,34 @@
# 60ACT 归档读写
本地 A1、A4、A5、A12 样本均已通过目录与全部文件 CRC 校验。它们不使用分页保护,不需要客户端 EXE 或 sk.dat。
| 变体 | 文件数 | 文件头 | 目录项顺序 | 文件 CRC 初始值 |
| --- | ---: | --- | --- | --- |
| A1 | 48,326 | `DNF_SCRIPT_PACK`(15 字节) | 编号、偏移、长度、CRC、路径长度、路径 | 文件偏移 |
| A4 | 71,019 | UUID 长度、UUID、版本 | 编号、路径长度、路径、长度、CRC、偏移 | 文件编号 |
| A5 | 126,696 | 同 A4 | 同 A4 | 文件编号 |
| A12 | 145,184 | 同 A4 | 同 A4 | 文件编号 |
A1 的签名之后,或其余版本的版本号之后,均为三个小端 uint32:目录密文长度、目录 CRC、文件数。目录后紧接文件数据区,目录中的文件偏移相对于此数据区。
目录和文件均按四字节对齐。逐 uint32 解密为 `ROR32(cipher XOR CRC, 6)`。目录 CRC 为 `crc32.Update(文件数, IEEE, 对齐后的目录明文)`;文件 CRC 使用上表初始值,对齐后的文件明文参与计算,读取时再截断到声明长度。现有 TW 容器的额外异或常量 `0x81A79011` 不适用于这四个样本。
文本以国服 GBK 解码,保留注释和排版;有 UTF-8 BOM 时按 UTF-8 读取。语言后缀不用于推断编码。路径按既有 CP949 路径约定读取。带 NUL 的原生资源保留为二进制,不尝试解释成五字节脚本令牌。A5/A12 大多数 ANI 为二进制,可通过通用 ANI 模块解码为只读文本并预览,原始载荷保持不变。
支持读取、搜索文本、修改、增删文件及保存。磁盘格式没有字符串池或 `stringtable.bin`;打开时为文本脚本建立编辑器内部的令牌视图和字符串池,统一作为 `TypeScript` 使用,兼容名称、稀有度、图标、列表编号、标注、GUI 和批量脚本解析。`.str`、`.txt`、`.nut` 等普通文本仍为 `TypeUnicode`;含 NUL 的二进制资源保持原样。源码支持正负号数字、嵌套 Section、反引号字符串、`//`/`/* */` 注释和 `<table::key>` 本地化引用,兼容 ACT60 根目录的 `string.lst` 字符串表映射。未修改的归档逐字节原样导出;修改后重建目录与文件数据区,保留原始签名、UUID、版本和已有文件编号。未修改文件不经过字符解码再编码,保持原始载荷和对齐字节。A1 文件移动时以新偏移重新计算 CRC 并加密,其他版本沿用未修改文件的原始密文。
文本修改按原编码保存(默认 GBK,原有 UTF-8 BOM 文件继续使用 UTF-8 BOM),不能用 GBK 表示的字符会报错并保留待保存修改。新增文件路径按 CP949 编码。导入的令牌脚本在保存时转为文本源码,重新打开后继续提供 `TypeScript` 语义视图。直接文本编辑保留原始排版与注释,仅修改注释或空白也会被记录;GUI/令牌编辑按脚本渲染规则输出源码,内部块字符串标记不会写入原生 ACT60 文本。二进制 ANI 可原样保留或替换原始载荷,支持只读文本解码与预览,尚不支持文本重编译。保存前重新解析并校验所有文件,写入失败时不清除内存修改。
实现位于 `internal/pvf/act60.go` 和 `internal/pvf/act60_save.go`,合成回归覆盖两种目录布局、GBK 文本、二进制、空文件、克隆、比较、原样导出、编辑、增删、连续保存、UTF-8 BOM 保留、编码/写入失败重试与损坏/截断数据。真实样本验证使用:
```sh
PVF_TESTFILE=/path/to/Script.pvf go test ./internal/pvf -run '^TestRealACT60(Parse|EditRoundTrip)$' -v -count=1
```
## pvfUtility 对照
核对版本 `53dc8e48bba0f7871ca96bcf10bfe6d2028d0582`:其容器读取固定使用 TW 异或常量,没有上述 ACT60 容器分支,因此无法直接打开这些样本。但其 `BinaryAniCompiler` 支持二进制 ANI,后续可参考其格式分析:
- [容器读取](https://github.com/Lancarus/pvfUtility/blob/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/SourceLibraries/PvfCode.Services/PvfCode/PvfGroup.cs#L188)
- [加解密算法](https://github.com/Lancarus/pvfUtility/blob/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/SourceLibraries/PvfCode.Models/PvfCode/Models/Pvf/PvfAlgorithmHelper.cs#L49)
- [二进制 ANI](https://github.com/Lancarus/pvfUtility/blob/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/SourceLibraries/PvfCode.Services/PvfCode/Services/BinaryAniCompiler.cs#L166)
+312
View File
@@ -0,0 +1,312 @@
# 90US 格式规格
## 总体布局
```
偏移 大小 内容
0x00 0x30 (48B) Header —— "HeaD" 加密(可选 Guard)
0x30 FileCount × 24B FileTable(明文)
... HashTableSize HashTable —— "HASH" 加密
... NameTableSize NameTable —— 内含 sTrA/sTrW 两个加密 zlib 段
... GroupCount × 8B GRPI(分块索引)—— "GRPI" 加密
... BodySize Body:GroupCount 个 zlib 分块,整块 "BodY" 加密
```
## 1. Header(解密后 48 字节,pack=1)
| 字段 | 类型 | 说明 |
|---|---|---|
| Signature | u32 | 明文恒为 `0x69706B6E`(LE 字节 `6E 6B 70 69`) |
| Guid | 20B | 本文件全 0 |
| FileCount | i32 | 本文件 1,008,171 |
| Padding | i32 | — |
| BodySize | i32 | 54,090,116 |
| GroupCount | i32 | 8,019 |
| HashTableSize | i32 | 9,868,348 |
| NameTableSize | i32 | 12,703,586 |
校验:`0x30 + FileCount*24 + HashTableSize + NameTableSize + GroupCount*8 + BodySize == 文件大小`。
## 2. 解密算法(LCG 流 XOR,即 MSVC `rand()` 参数)
四种 key:`"HeaD"`(头部)、`"HASH"`、`"GRPI"`、`"BodY"`(整块)用 magic `0x269EC3`;
`"sTrA"` / `"sTrW"`(Decrypt2)用 magic `0x269EC9`。
```python
def decrypt(data, key, magic=0x269EC3):
k = key.encode() # 恰好 4 字节
seed = (0x76826701*k[0] + 0x1C1*(k[3] + 0x1C1*(k[2] + 0x1C1*k[1]))) & 0xFFFFFFFF
for each 4-byte block:
t1 = (0x343FD*seed + magic) & 0xFFFFFFFF
seed = (0x343FD*t1 + magic) & 0xFFFFFFFF
xor = (seed & 0xFFFF0000) | (t1 >> 16) # 即 C# 的 (uint)((seed>>16)&0xFFFF + t1&0xFFFF0000)
block ^= xor # little-endian u32
tail(0-3 字节): t1,t2 再各迭代一步,
finalKey = (t1 & 0xFFFF0000) | (t2 >> 16),按字节 XOR
```
注意所有运算都是 C# `int` 溢出语义 = mod 2³²;`(seed>>16)&0xFFFF` 因子掩码,算术/逻辑移位等价。
**Guard 变体**:解密头部前先做 `header[24..28] ^= 0x55`。本文件使用 Guard=True。
两种都试,Signature 匹配者胜。
**种子变体**:另一批归档沿用同一 LCG(乘数 `0x343FD`、magic `0x269EC3`/`0x269EC9`),
但分段种子不再由 `"HeaD"/"HASH"/…` 这组固定字符串推出,标准密钥解不开头部。
这些种子是**该变体的固定常量,而非每个归档各不相同**:同版本先后两份归档
(其中一份经第三方工具修改过)的 header/GRPI/Body/sTrA/sTrW 五个种子完全相同,
因此直接内置于 `internal/pvf/variant.go` 的 `variantKeys()`:
| 段 | seed | magic |
|---|---|---|
| Header | `0x4A454634` | `0x269EC3` |
| GRPI | `0x1FBB7078` | `0x269EC3` |
| Body | `0xDD4FF706` | `0x269EC3` |
| sTrA | `0x712A98D4` | `0x269EC9` |
| sTrW | `0x712AE776` | `0x269EC9` |
若遇到种子未知的其它变体,`recoverHeader` 会从数据反解:签名明文固定为 `nkpi`,
可限定首个密钥流 dword 的低 16 位,余下 16 位再用「各段长度之和等于文件大小」
筛出唯一解;GRPI 用「最后一块累计压缩大小等于 `BodySize`」反解;Body 与
sTrA/sTrW 则利用 zlib 头部 `78 xx` 这两个已知明文,并用解压长度必须等于
GRPI/NameTable 声明值来确认。
**HASH 段**:该变体的 HASH 种子未能反解(其内容既非偏移量也非文件表顺序,
排序列表也不呈升序,多种已知明文攻击均不成立)。由于该段只参与段偏移计算、
代码从不读取其内容,保存时**原样保留原始密文**,不再用标准密钥重写,
以免破坏变体客户端的查找表。标准变体仍按原逻辑重建。
## 3. FileTable(明文,每条 24 字节,pack=1)
```c
struct PvfFileItem {
int NameOffset; // 字符串池魔数偏移(见 §5)
int PathOffset; // 同上,存目录部分
int ChunkIndex; // 第几个 Body 分块
int DataOffset; // 文件数据在解压后分块内的偏移
int DataSize;
int DataType; // 1 = 脚本 token 流;3 = 本地化文本(UTF-16)
};
```
完整路径 = `PathOffset 解析串 + "/" + NameOffset 解析串`。
## 4. GRPI(解密后每条 8 字节)
```c
struct GrpiItem { int CompressedSize; int OriginalSize; };
```
`CompressedSize` 是**累计**值。第 i 块 Body 字节区间 =
`[bodyOffset + g[i-1].CompressedSize, bodyOffset + g[i].CompressedSize)`。
整块 `"BodY"` 解密 → 标准 zlib 解压(0x78 0x9C 头 + Adler32 尾)。
`OriginalSize` 为该块解压后大小。本文件 54.1MB → 583.6MB(10.79 倍)。
## 5. NameTable(字符串池)
布局:8 字节保留头 + 两个 section:
```
sTrA: u32 encSize ^ 0xAA74472E ; u32 rawLen ^ encSize ; encSize 字节密文
sTrW: u32 encSize ^ 0x9A82F037 ; u32 rawLen ^ encSize ; encSize 字节密文
```
密文:`Decrypt2(key)` → zlib 解压。sTrA = UTF-8 以 `\0` 分隔;sTrW = UTF-16LE 以 `\0\0` 分隔。
本文件:sTrA 63,000,914B;sTrW 7,035,304B。
**魔数偏移**(NameOffset/PathOffset/token 字符串值通用):
- 偶数 → sTrA,字节偏移 = `off >> 1`
- 奇数 → sTrW,字节偏移 = `(off >> 1) * 2`
### 5.1 写入时该放哪个池(新文本必须遵守,否则游戏里乱码)
客户端**只从 sTrW 读非 ASCII 文本**,所以新输入的中文/韩文必须写进 sTrW(奇偏移)。
实测两代归档的既有文本都严格遵循这条:
| 归档 | ASCII → sTrA(偶) | 非 ASCII → sTrW(奇) | 反例 |
|---|---|---|---|
| `90CN\Script.pvf`(Protected 变体,105 万文件) | 9,904 条 | 1,141 条 | 0 |
| `testdata/110US.pvf`(Paged110) | 0(其 sTrA 为空) | 24,821 条(ASCII 也在 sTrW) | 0 |
因此写入规则(`StringOffset` / `scriptStringOffset`):
1. 先按原样查两个池的索引,命中就沿用原偏移;
2. 未命中时:**非 ASCII → sTrW**;ASCII → 若归档的 sTrA 非空则进 sTrA,否则也进 sTrW
(110US 的 sTrA 为空,该客户端把所有字符串都放在 sTrW)。
> 旧实现把一切都 append 到 sTrA,于是新输入的中文被写成 UTF-8、用偶偏移引用,
> 客户端按自己的约定取不到这段文本 → 游戏里显示乱码。已修,并有回归测试
> (`TestStringOffsetPoolConvention`、`TestVariantChineseNameEncoding`)。
>
> 另外 90CN 的 sTrA 里本来就有 13.7 万个非 ASCII 字节(历史遗留),所以校验方式是
> 「新增文本没有让 sTrA 的非 ASCII 字节变多」,而不是「sTrA 纯 ASCII」。
## 6. DataType 1 脚本格式
Token 流,每个 token 5 字节:`u8 type + i32 value`。
| type | 含义 | 反编译表现 |
|---|---|---|
| 0 | 整数 | 直接输出数值 |
| 2 | 浮点(value 按 f32 位型解释) | 输出浮点;整值写成 `8000.0` 以区分于整数 |
| 3 | 标签串(字符串池偏移) | 独占一行,如 `[name]` |
| 5 | 块串开头 | `{5=\`...\`}` |
| 6 | 行内串 | `` `...` `` |
| 7 | 块串(另一形式) | `{7=\`...\`}` |
| 8 | 字符串池引用(Paged110) | `{8=\`...\`}`,如 `{8=\`<31::equip_name_1>\`}` |
| 10 | 字符串池引用(Paged110) | `{10=\`...\`}`,多行命令文本 |
## 7. DataType 3 本地化文本(.str 等)
UTF-16LE 明文。本文件存在韩服转制痕迹:内容实为 **EUC-KR 字节被逐字节按 CP437
字体映射进 UTF-16 码位**(如 `한국` = `C7 D1` → `╟╤`)。
还原:每个字符 `chr.encode('cp437')` 拼回字节流 → `cp949` 解码。
`pvflib.py` 已内置自动检测与还原(`_fix_korean_mojibake`)。
**写回**:编辑器读到的是还原后的可读韩文,保存时按**原载荷是否伪画**决定编码 ——
`SetText` 对 TypeUnicode 会在原载荷通过 `looksLikeCP437Painting` 时用
`EncodeKoreanMojibake` 重新伪画回写,否则写普通 UTF-16LE。这样「打开→改→保存」
不会把可读韩文变成乱码(检测基于整段载荷的前 2000 字符,短载荷可能判不出)。
## 8. HashTable(供客户端按路径查找)
解密后:`u32 count` + `count × (u32 NameOffset, u32 PathOffset)` + `u32 n` + `n × u32`
(按解析串字典序排序的偏移表,二分用)。长度满足
`hashSize = 4 + count*8 + 4 + n*4`。
密钥名:
- 标准 90US(LegacyNkpi):`keySeed("HASH")` + magic `0x269EC3`。
- Protected 变体:**`wideSeed("hash")`**(全小写)+ magic `0x269EC3`。
- Paged110:密钥名未取到,但**可由结构反解**(见下),110US 解出
`seed=0x5824C072`、magic `0x269EC3`。
**反解**:段首 dword 就是条目数(等于文件数;90US 变体族会多 19 条,故在
文件数 ±64 的窗口内搜)。已知这个明文 dword 就钉住了首个 keystream dword 的低
16 位,每个 (magic, count) 只剩 ≤4 个候选种子;再用结构验证:长度精确满足
`4+count*8+4+n*4`、抽样的每个 NameOffset/PathOffset 都能解析出池内字符串、
尾部偏移表按串严格递增。实现见 `internal/pvf/hashtable.go` 的
`recoverHashSeed`;解析时若密钥集未给出 HASH 种子会自动尝试。
## 解析结果摘要
- 文件 1,008,171 个,分块 8,019 个,全部解压成功,0 引用越界
- 扩展名分布:.ani 570,617 / .equ 148,594 / .act 77,955 / .stk 42,219 / .obj 21,974 / .ai 21,534 / .til 20,792 / .atk 18,896 / .key 16,322 / .map 15,921 …
- 顶层目录:equipment 408,432 / monster 211,668 / passiveobject 162,065 / map 79,046 / stackable 46,390 / character 40,203 …
## 9. 分页容器(110US / 115US)
新客户端在 90US 逻辑布局之上加了一层「页首保护」:
- **版本与容器分离**:`paged110` 对应 `110US`,`paged115` 对应 `115US`,
两者共享 `pagedContainer`、内容规则、解密和写回实现。版本按实际解封成功的
RSA 公钥身份识别,并在页头签名和布局校验通过后采用;不是按目录名、页数或
扫描回退路径猜测。已登记 115US 2.38.2.34 客户端的公钥指纹。
未登记身份使用 `paged`,其 `ClientVersion()` 为空,仅应用未限定版本的标注。
`IsPaged()` 用于容器能力判断;旧 `IsPaged110()` 和 JSON `paged110` 字段
保留为兼容别名,不表示客户端版本。通用错误使用 `ErrPagedKeys`、
`ErrPagedReadOnly`、`ErrPagedStructureLocked`,旧错误标识保留相同 identity。
- 每 **10 MiB 页的前 10,240 字节**用该页 **32 字节密钥做 AES-256-CBC(IV=0)**
加密;页内其余字节仍是普通 90US 数据。Guards **就地覆盖逻辑流**
(物理长度 == 逻辑长度),所以把页首块解密后,整份文件与 §3–§8 的布局一致。
- **页密钥表不在归档内**:同目录 `sk.dat`(1920 B)是 RSA-1024 PKCS#1 v1.5
密封的密文,私钥内置于客户端 `DFO.exe`(实现里已内置该私钥)。逐 128 字节块
解密后得到 **52 × 32 字节**页密钥表(本文件 52 页)。
- **新版客户端兼容**:52 页仅是上述 110US 样本,不是解析上限。内置 RSA
无法解封或页头验证失败时,扫描同目录 `DFO.exe` 的 PKCS#8 / PKCS#1 PEM
候选,用 `sk.dat` 逐块解封筛选,再尝试 metadata key 候选。先验证第一页
签名与完整布局方程,通过后才复制并解密整个归档;不依赖固定 VA 地址。
115US 2.38.2.34 的客户端参数、metadata 和密封 `sk.dat` 同样以 `PVFC`
二进制资源内置,该样本无需外部配套文件即可打开。
115US 样本已验证 81 页、路径和分散块读取,以及无修改保存的 SHA256 一致。
此处只验证离线读写,不代表客户端实机接受多余页密钥或修改后的归档。
- 该表还要再整体做一次 **AES-256-CBC(IV=0)** 解包,密钥是 `DFO.exe` 中一段
**64 个十六进制字符**的「metadata key」(本客户端为 `21ad8ff2…4489`,
实现里已内置,必要时会改为扫描可执行文件得到候选)。
只解包前 `len & ~0xFF` 字节,尾部余数不动。
- 分段密钥名改为:**`iNfO`**(头)/ **`Gidx`**(GRPI)/ **`stAs`**/**`stWs`**(名称池)/
**`mAIn`**(body),种子用宽字符公式(与 Protected 变体相同,见 §2 的种子变体),
名称池长度掩码为 `3886938090` / `3101599660`。
- 头部**没有** `0x55` guard。
- **写回已支持**(`SaveTo`):打开时保留解包后的页密钥表(`Archive.pageKeys`),
写出时按页对前 10,240 字节重新做 AES-256-CBC(IV=0) 加密(`writePageGuarded`
流式写,不需要第二份整库内存)。未修改的归档重新写出**逐字节等于原文件**
(已用 543 MB 的 `110US.pvf` 的 MD5 验证)。
- **增删文件也已支持**:HASH 段的种子可从结构反解(§8),所以重建时能重新生成
HASH、重建名称池;Paged110 的池段用**自己的**密钥与长度掩码写回
(`buildNameTable` 过去硬编码 90US 的 `sTrA/sTrW` 与掩码,导致重开时池整个读不到,
已修)。代价是要重新压缩 401 MB 的名称池(实测约 35 秒/次);只改文件内容的编辑
不触发池重建。
- 若 `pageKeys` 不在内存(理论上不会),返回 `ErrPagedReadOnly`;若 HASH 种子
既没给出也解不出来,结构性编辑返回 `ErrPagedStructureLocked`。
### 9.1 字符串表与 `<表号::键名>` 占位符
Paged110 的 `.equ`/`.stk` 不直接存显示文本,`[name]` 等字段是 type 8/10 token,
内容是 `<表号::键名>` 占位符(如 `<31::equip_name_1>`)。解析方式:
- 映射文件按**优先级递减**(靠前者胜):`list/n_string.lst` → `n_string.lst` →
`list/n_string_translate.lst` → `n_string_translate.lst` →
`list/n_string_kor.lst` → `n_string_kor.lst`。
基准表 `list/n_string.lst` 指向 `String/*.uv.str`,存的是客户端自己显示的语言
(110US 里是中文/英文);`*.kor.str` / `*.translate.str` 是语言覆盖层,只映射
3、13 两个表号,仅用于补基准表的空缺。**基准表不能排在后面**:那样会在基准表
有中文时也显示韩文。
`.lst` 是普通 token 流:`整数`(表号) + `字符串`(`.str` 路径) 交替。
- `.str` 是 UTF-16LE 文本,每行 `键>值`,`//` 注释;路径缺失时回退到
`.translate.str` / `.kor.str` / `.uv.str` 变体。
- 查表从基准表往下取**第一个非空**值:基准表用 `键=` 列出所有已知键,其中装备名
有 36.7% 是空值(未翻译),空值不能当命中。`ResolveStringTable` 同时回报来源与
是否回退。
- 解析不到时**原样保留**占位符。解析是只读的:归档里存的就是占位符,
脚本文本不会被改写。
- 装备悬浮预览(`services/equipment_preview.go`)、文件树标签、搜索结果、编辑器标注
四处一致:只由覆盖层答出的名字会追加 `(未翻译)` 标记
(`ScriptMetadata.NameFallback` + `services.markedName`),避免把韩文原文误当成
该客户端的文案。
- 编辑器不下手改文档,而是把译文作为一条 `type = "placeholder"` 的标注标签挂在
占位符后面(tooltip 给出原始占位符与来源 `.str`;来源 <8 MB 时 Cmd/Ctrl+单击
可打开该字符串表)。搜索/树的名称(`ScriptMetadata`)同样取解析后的文本,
因此中文名可搜。
- **就地修改译文**:单击标签 → 弹框 → `SetPlaceholderText`。目标表取「真正答出该键
的那一张」(可能是覆盖层);没有任何表有该键时**追加到基准表**(插在尾部 NUL 填充
之前 —— 读取端在第一个 NUL 处截断,写在填充之后就读不到)。
改动按 **UTF-16LE 字节就地替换**那一行的值,BOM/行尾符/其余字节不变;
解析不了的表拒绝写入。改完立刻刷新编辑器标注、文件树标签与搜索索引里该文件的
名称,并写入版本历史。改动先落在内存 overlay,需保存才写回归档。
- **新建文件的名称**:引用了表里还没有的键时,标签显示红色「未定义」,点它即可创建
条目;工具栏「字符串引用」会插入 `{8=`<表号::键名>`}` 并写入对应表。
注意标记里的 8 是 token 类型,表号在 `<表号::键名>` 中;写完还要把文件注册进
`list/equipment.lst`(或 `list/stackable.lst`)才可被客户端按 ID 找到。
### 9.2 Paged110 的列表布局(`list/*.lst`)
90US 把列表放在被索引目录旁边、条目按列表目录相对存放;110US 把所有列表集中到
`list/` 下、条目改为**归档根相对**:
| | 90US | 110US |
|---|---|---|
| 装备列表 | `equipment/equipment.lst` | `list/equipment.lst` |
| 条目 | `character/a.equ` | `equipment/character/a.equ` |
`Archive.FindList` 两种布局都试(配置 90US 路径也能在新归档上解析),
`listPathCandidates` 对条目先按列表目录解释、再按归档根解释(各自再试 `(r)` 变体)。
`ScriptMetadata` 支持 8/10 token 并对名称做占位符解析,所以搜索索引、文件树标签
用的是显示文本而不是 `<表号::键名>`(只由覆盖层答出的还会加 `(未翻译)`)。
### 9.3 `list/*_indexhash.etc`(列表的伴随索引)
110US 的每个列表还配一个 `<列表名>_indexhash.etc`(90US 没有)。格式与 `.lst` 相同:
`(type 0 整数 id)(type 6 字符串)`,每条 10 字节;第二个 token 的字符串内容是**十进制
uint32**(超过 2^31 所以用字符串存)。实测该值**只与 id 有关**(不同列表里同一 id 的
值相同,即使路径完全不同)。生成函数为两轮 `uint32` 混合:
`x = (x ^ (x >> 16)) * 0x45d9f3b`,最后再执行一次 `x ^ (x >> 16)`。
归档中少量历史/特殊条目可能保留其它值;新增普通列表条目使用该函数。
索引文件是列表的**严格超集**(原厂工具维护列表时会顺带补齐),因此新增 id 会缺条目。
110US 还有一个容易踩的兼容点:第二 token 虽然是 ASCII 十进制文本,仍必须引用
UTF-16 字符串池 `sTrW`(magic offset 为奇数)。写到 UTF-8 池 `sTrA` 后,解析器
仍可能读回正确数字,但客户端读取新增条目时会出错。
工具:`Archive.IndexHashCompanionPath` / `IndexHashPairs` / `IndexHashGaps` /
`IndexHashSiblingPaths` / `IndexHashValue` / `SetIndexHashEntryForID` /
`SetIndexHashEntry`(值写成十进制文本,其余条目字节不变)。
详见 `docs/PVF新格式分析.md` §2.16。
+251
View File
@@ -0,0 +1,251 @@
# MCP 服务设计
状态:首版已实现。工具定义以 services/mcp_tools.go 为准,下文保留设计约定与实现边界。
当前实现:设置页启停及复制连接配置、本机 Streamable HTTP、共享服务实例、异步任务/状态查询、脚本预览应用、归档生命周期、冻结导入及普通/MOD 导出。界面准备屏障覆盖主窗口与脚本窗口,归档写入后同步标签内容和后端基线再释放屏障,避免将已应用变更误判为本地草稿。script_help 的 types 主题嵌入编辑器同源类型声明。连接无需鉴权,本机连接共享任务与计划。已实现标注搜索、详情和实际文件解释,见 [标注文档协议](MCP标注文档协议.md)。
当前限制:任务/请求去重历史仅保存在进程内,最多 256 条,重新启用清空;导入最多 10000 文件、单文件 8 MiB、总计 64 MiB;导出最多 10000 文件、内容 128 MiB。大导入快照保存在受限内存中,超限拒绝;尚未使用磁盘溢出存储。预览有效期 10 分钟,差异与日志有响应大小限制。生命周期和导出发布阶段不支持取消。
## 目标与首版范围
AI 客户端连接已运行的 pvfine,与桌面界面共享当前 PVF,支持打开、关闭、保存、另存为、普通文件/目录导入、普通导出、导出为 MOD,以及搜索、读取和修改归档内脚本。复用现有 `.pvf.js` 执行能力完成复杂查询和批量变更。
首版通过已运行的桌面应用管理归档生命周期及文件导入导出,AI 可直接传入本机归档路径和来源/目标路径,无需弹出文件选择框。用户已确认导入范围为普通文件和目录,不含 MOD 包导入。首版不提供无界面的独立归档进程、任意磁盘读写或系统命令工具。应用归档变更或导入后,可由 AI 调用保存/另存为,也可通过现有界面保存。
这里区分三种数据:归档内脚本、用于操作归档的 `.pvf.js` 程序,以及持久化在用户配置中的文件集。接口及结果必须说明变更的落点。
## 架构与传输
```text
AI MCP 客户端
│ Streamable HTTP /mcp
▼
internal/mcpserver:协议、身份校验、输入输出约束
▼
services:面向 AI 的应用门面、任务与预览管理
├── ArchiveService / EditorService:归档生命周期、搜索和读取
├── ScriptService:编译、运行、预览和应用
├── 导入门面 / ExportService:文件导入、普通导出及 MOD 导出
└── 共享 core:当前归档、版本、Overlay、事件
```
MCP Server 随桌面应用启动和停止,设置中提供启停开关,默认关闭。启用后使用本地 Streamable HTTP 端点,直接注入界面使用的服务实例;尤其必须共享同一个 ScriptService,避免独立实例绕过运行互斥。
使用官方 `github.com/modelcontextprotocol/go-sdk`。实现时选择与项目 Go 版本兼容的稳定版本,依靠 SDK 处理协议版本兼容。MCP 2026-07-28 与旧版本在生命周期和取消语义上存在差异,应用层统一使用任务 ID 和 context,避免自行固化某版协议细节。
固定首选端口可在设置中配置;端口占用时明确报错,界面显示实际端点和连接状态。多个应用实例使用不同端口,并通过 workspace 身份避免操作错归档。仅支持 stdio 的客户端可在后续通过轻量桥接进程连接已有服务,首版不增加桥接进程。
## 首版工具
workspace_info 与首次打开归档允许在空工作区调用。其余归档相关操作携带 `workspaceId`;读取返回 `revision`,执行、应用与生命周期写操作携带预期 revision。分页读取后 revision 变化时要求重读,避免混合不同版本的数据。生命周期另外使用 workspaceEpoch 防止空工作区检查与打开提交之间的竞争。
| 工具 | 输入要点 | 输出与语义 |
| --- | --- | --- |
| `pvf_workspace_info` | 无 | 是否打开、workspaceEpoch、workspaceId、源路径、归档格式、revision、源文件未保存状态、各窗口草稿状态、运行任务、能力版本 |
| `pvf_search` | workspaceId、路径/内容模式、query、scope、cursor、limit | 路径、命中摘要、revision、后续游标;限制单页与总响应大小 |
| `pvf_read_file` | workspaceId、path、行范围、expectedRevision | 脚本文本、总行数、revision、截断状态;路径使用归档内部路径 |
| `pvf_script_help` | topic | 分主题返回 API 类型、说明和示例;不依赖客户端支持 resources |
| `pvf_script_validate` | source | 语法诊断;不会验证业务含义或游戏效果 |
| `pvf_script_run` | workspaceId、expectedRevision、source、label、requestId | 立即返回 taskId;脚本在隔离事务中执行 |
| `pvf_script_status` | taskId、日志游标 | 状态、进度、分页日志、诊断;完成后返回 planId 和变更摘要 |
| `pvf_script_preview` | planId、filter、cursor、limit | 分页差异、changeKey、警告及依赖信息 |
| `pvf_script_apply` | planId、expectedRevision、changeKeys、requestId | 应用明确选中的变更,返回新 revision 和修改统计 |
| `pvf_script_cancel` | taskId | 取消指定任务;不能误取消界面或其他连接发起的任务 |
| `pvf_script_discard` | planId | 释放指定预览及其隔离事务 |
| `pvf_archive_open` | path、expectedWorkspaceEpoch、当前 workspaceId/revision(如有)、dirtyPolicy、requestId | 打开或切换归档;返回 taskId,完成后获取新 workspaceId、源路径、revision 与初始化状态 |
| `pvf_archive_close` | workspaceId、expectedRevision、dirtyPolicy、requestId | 关闭当前归档;返回 taskId,完成后报告空工作区及新 workspaceEpoch |
| `pvf_archive_save` | workspaceId、expectedRevision、requestId | 保存到源路径;返回 taskId,完成后报告 writtenPath、源文件未保存状态及文件标识 |
| `pvf_archive_save_as` | workspaceId、expectedRevision、path、overwrite(默认 false)、目标文件标识(覆盖时)、requestId | 生成副本并保留源路径;返回 taskId,完成后同时报告 writtenPath、sourcePath 与源文件未保存状态 |
| `pvf_task_status` | taskId | 查询生命周期、导入导出任务进度、结果和错误;与脚本任务共用底层任务管理,保留 script_status 的专用视图 |
| `pvf_import_prepare` | workspaceId、expectedRevision、sourcePaths、targetDir、mode、requestId | 冻结本地文件内容并校验映射,返回 taskId;完成后取得 importPlanId 与新增/覆盖摘要 |
| `pvf_import_preview` | importPlanId、cursor、limit | 分页返回源路径、归档目标路径、类型、大小、覆盖状态及差异 |
| `pvf_import_apply` | importPlanId、expectedRevision、selectedKeys、overwriteKeys、requestId | 将明确选择的冻结内容应用到工作区,不保存源 PVF;返回 taskId 和最终 revision |
| `pvf_import_discard` | importPlanId | 释放冻结内容与预览 |
| `pvf_export_formats` | 无 | 格式 ID、读写能力与支持操作,可写 MOD 格式包括 110USextend 和 115CN-mod |
| `pvf_export_prepare` | workspaceId、expectedRevision、source、scopes、mode、format、name、version、modId、includeDependencies、来源附加参数、requestId | 准备普通/MOD 导出快照,115CN-mod 必须提供唯一 modId;返回 taskId,完成后取得 exportPlanId、总数与警告 |
| `pvf_export_preview` | exportPlanId、cursor、limit | 分页输出树、entryKey、依赖提示、跳过项与警告 |
| `pvf_export_execute` | exportPlanId、outputParent、outputName(direct 必填)、selectedEntryKeys、acknowledgedWarningIds、requestId | 发布普通文件目录或 MOD 目录;返回 taskId,完成后返回实际输出路径和统计 |
| `pvf_export_discard` | exportPlanId | 释放导出快照 |
`pvf_script_run` 同时支持只读查询:脚本用 `pvf.log` 输出查询结果,没有变更时不保留无用预览。大型查询优先使用搜索/读取工具,避免把所有结果堆入日志。首版不新增脚本 return/结果集语言语义。
通过 `pvf_script_help` 与可选 MCP resources 暴露相同的分主题文档,例如 `pvfine://script-api/sections`。文档应和 `frontend/src/script-api.d.ts`、脚本能力版本一起维护,并提供一致性检查。归档路径和内容是数据,不作为服务器指令执行。
`.pvf.js` 程序库的列出、读取和保存可后续映射现有 ListScripts/LoadScript/SaveScript;首版通过 source 执行,不默认改写界面中尚未保存的脚本源码。
## 典型调用流程
1. AI 查询工作区;需要时调用 archive_open 打开指定路径,获取 workspaceId 和 revision,搜索候选文件并读取样本。
2. 按需查询脚本 API,生成 source,验证语法。
3. 提交执行任务;服务在接纳任务时记录任务身份和预期 revision。
4. AI 查询进度与运行结果,读取差异及警告。
5. AI 按明确的 changeKeys 应用结果;revision 已变化则拒绝并要求重新运行。
6. UI 刷新受影响文件并显示未保存状态,AI 调用 archive_save/archive_save_as,或用户使用原有保存入口写回 PVF。
7. 结束后可调用 archive_close;有未保存内容时按显式 dirtyPolicy 处理。
导入分支:import_prepare → import_preview → import_apply → 按需保存。导出分支:export_prepare(mode=direct/mod)→ export_preview → export_execute。两种导出都不要求先保存 PVF。
例如“把某目录价格低于 10 的装备提高到 10”,可直接复用脚本工作区的 `pvf.glob → file.parse → section.set → file.write`。工具描述应提示保留数值类型、显式处理重复 section,并检查字符串表与列表依赖。
## 归档生命周期
### 打开与切换
`pvf_archive_open` 接受本机绝对文件路径,不弹对话框,不自动猜测路径。空工作区使用 workspace_info 返回的 expectedWorkspaceEpoch;已有归档时还必须匹配 workspaceId 和 expectedRevision。同一路径重新打开也视为新工作区,不能绕过未保存检查。
打开前取得生命周期操作占用权,在候选归档上完成解析和必要索引准备,成功后才替换当前工作区;失败保留原归档、Overlay 和草稿。切换提交前再次校验工作区身份、revision 和草稿状态,防止解析期间 UI 编辑造成覆盖。后台版本恢复和索引任务按工作区代次过滤,不能将旧归档结果装入新工作区。
成功返回新的 workspaceId、workspaceEpoch、revision,以及 indexStatus/versionStatus。打开完成不等于后台索引和版本恢复完成;未就绪操作返回 NOT_READY,并说明可查询的状态。
### 未保存内容与关闭策略
open/close 的 dirtyPolicy 支持 `reject`(默认)、`save`、`discard`,只处理当前归档的已同步 Overlay:
| 策略 | 当前源文件有未保存变更时的行为 |
| --- | --- |
| reject | 返回 UNSAVED_CHANGES,保留当前工作区 |
| save | 先保存到当前源文件,成功后再切换/关闭;保存失败则保留当前工作区 |
| discard | 明确丢弃当前 Overlay 后切换/关闭;仍需匹配身份和 revision |
这些策略不隐式应用脚本预览,不清除前端未提交草稿。存在前端草稿时统一返回 UI_DRAFT_PENDING,要求在现有编辑器提交或处理草稿后重试。`discard` 不能绕过草稿保护。独立脚本窗口的未保存 `.pvf.js` 源码属于程序草稿,切换/关闭归档应保留,不将其计入归档 Overlay。
运行中的脚本或已有待处理预览分别返回 TASK_BUSY / PLAN_PENDING,先通过既有工具取消、应用或丢弃,再操作归档;不能通过 close/open 隐式终止其他调用方的任务。生命周期操作互斥,同时阻止 UI/MCP 开始新的归档写入或脚本任务。
close 成功释放归档资源、索引与版本会话,并按现有语义清理该源路径的自动备份;桌面应用与 MCP Server 继续运行,允许再次打开。workspaceEpoch 更新,旧工作区句柄失效。同一 requestId 的重试返回原关闭结果;新请求引用旧身份返回 STALE_WORKSPACE。
### 保存
`pvf_archive_save` 保存后端已提交的当前 revision 到 sourcePath。前端仍有文本或 GUI 草稿时返回 UI_DRAFT_PENDING,不返回遗漏草稿的“保存成功”。没有源路径时返回 SAVE_AS_REQUIRED。保存使用现有原子写入、源文件备份、版本记录和索引更新流程。
保存前比较磁盘文件与打开或上次成功保存时的文件标识;不一致返回 SOURCE_CHANGED,禁止覆盖外部修改。文件标识包含必要的文件身份和内容校验,不能只依赖 mtime。文件替换前再次检查;对于不遵守应用锁的外部写入,普通文件系统检查不等于跨进程事务,结果与文档需说明这一边界。
成功返回 writtenPath、sourcePath、savedRevision、当前 revision、源文件未保存状态和更新后的文件标识。必须区分磁盘替换是否完成和后续版本/缓存维护是否完成:若文件已写入但后续维护失败,返回 diskCommitted=true 及告警,不能将其描述为“未保存”并盲目重试。
### 另存为:只生成副本
用户已确认:`pvf_archive_save_as` 将当前内容写入指定目标,sourcePath 保持不变,后续 save 仍写原文件,workspaceId 不因副本生成而变化。
生成副本不代表原文件已保存:保留相对源文件的未保存状态、待同步变更和自动备份,不将当前版本标记为已写回源文件。若当前内容本来就与源文件一致,仍保持无未保存变更。返回 writtenPath、sourcePath、snapshotRevision、sourceDirty,避免客户端将副本路径误认为当前源路径。
当前 SaveAsDialog 会清理 mutations/自动备份并重置前端 dirty 基线,不能原样复用为“生成副本”的完整语义。应提取不弹对话框的 SaveCopy(path, options),隔离序列化过程对工作区的副作用;UI 另存为也复用该门面并保留源文件未保存状态。
目标路径必须是绝对路径,父目录须已存在;目标与源文件等价时返回 SAME_AS_SOURCE,要求调用 save。按规范化路径、符号链接和文件身份识别同一文件,覆盖目标不允许是目录。
默认 overwrite=false,目标已存在返回 TARGET_EXISTS;显式 overwrite=true 时还需提供此前观察到的目标文件标识,并在提交前复核,目标变化返回 TARGET_CHANGED。冲突结果可返回非敏感文件标识供下一次明确覆盖请求使用。不得以“先判断不存在再普通 rename”实现禁止覆盖,须使用平台支持的原子禁止覆盖提交方式。
### 前端同步与任务边界
后端增加统一生命周期门面,UI 打开/关闭/保存/另存为与 MCP 共用互斥和状态检查。原 ArchiveService.Close 会直接丢弃修改,必须由门面保护;不能直接暴露为工具。
前端所有归档编辑视图上报草稿状态与代次;生命周期操作通过带 operationId 的 prepare/ack 检查各窗口并短暂冻结编辑,收到全部有效响应后复核 revision,再进入提交。超时或窗口状态未知返回 UI_NOT_READY,不能按“无草稿”继续。纯前端 dirty 布尔值的延迟上报不足以消除保存竞争。
打开、保存和另存为可能耗时,使用 taskId 与 task_status 返回阶段进度,避免长时间阻塞单次 MCP 调用。首版不开放生命周期任务取消:文件替换开始后不能假定可撤销;退出/停用服务等待不可中断提交完成,任务始终报告最终磁盘结果。
沿用 archive:opened/closed/saved,并增加副本生成事件 archive:copy-saved。事件携带 workspaceId、revision、来源、operationId 和 writtenPath;副本事件不能触发清除原文件 dirty 的逻辑。UI 可在重连时查询后端状态,事件不作为唯一事实来源。
## 导入、导出与 MOD
### 普通文件与目录导入
sourcePaths 使用本机绝对路径,targetDir 使用归档内部路径,空串表示归档根。单文件按文件名映射,目录保留被选目录名称和内部层级,沿用现有 collectImportFiles 行为。例如选择 `/tmp/items/equipment`,导入到根后得到 `equipment/...`。多个来源映射到同一路径时拒绝并列明冲突,不依赖遍历顺序决定覆盖。
mode 支持现有 text 与 raw:text 按现有 UTF-8 校验及归档文本编码规则写入,raw 按原始数据导入;预览报告实际类型。拒绝符号链接来源、特殊文件及非法归档路径;目录遍历设置文件数量、单文件与总字节数上限,沿用项目对 secrets 的禁止读取要求。
prepare 只生成隔离预览,展示新增/覆盖、目标路径、文本差异或二进制大小/摘要。现有 PreviewImport 不保留内容,ImportFiles 会重新读取磁盘;需提取保留冻结字节和来源摘要的导入计划,apply 消费相同快照,不能重新读取变化后的来源。大快照可使用受控临时存储,按 TTL、丢弃、归档切换及应用退出清理;分页替代现有最多 500 条后截断的预览形式。
apply 明确传入 selectedKeys;覆盖已有条目还须在 overwriteKeys 中逐项指定,默认不覆盖。先检查全部目标与依赖,再通过隔离 stage 一次提交所选内容;失败不部分写入归档。提交后的索引或版本维护错误需区分 workspaceCommitted,避免重复导入。成功后更新 revision、树、编辑器与版本状态,源 PVF 保持未保存。
导入不自动删除来源中缺失的归档文件,也不自动注册 `.lst` 或重写字符串表关系;关联修改需显式导入关联文件或使用脚本处理。工具说明需明确文本与 raw 模式的区别。
导入应用与脚本应用共用写入门面、revision 检查和前端草稿保护。已有运行任务或待处理写入计划时拒绝开始新导入;导入计划与脚本计划均属于待处理写入计划,open/close/save/save_as 的 PLAN_PENDING 检查须同时覆盖二者。
### 统一导出与来源
普通导出和导出为 MOD 共享 prepare/preview/execute,通过 mode=direct 或 mod 区分;MOD 格式通过 export_formats 发现。首版来源包含 selection(指定文件/目录,文件集由调用方展开)、memory(未启用版本控制的内存改动)、working(相对 HEAD 的工作区变更)和 commit(指定提交)。来源附加参数包括 commitID;首版不暴露 reverse 或 archive-diff 来源。
direct 沿用现有文本/二进制导出规则,不宣称所有类型都能无损往返;无法表达的类型须报告,不能静默导出空文件。全量导出可使用 selection 的归档根 scope,仍受文件数量与总字节上限约束。现有 archive-diff 来源在 AI 能发现/创建比较结果后再接入,首版不要求提供无法发现的 diffID。
导出读取已提交的后端内容及 Overlay,不应用前端草稿、脚本/导入预览,不创建提交,也不保存 PVF。存在归档文本或 GUI 草稿时返回 UI_DRAFT_PENDING。快照记录 workspaceId、revision、版本会话、HEAD 和来源身份;执行时失效返回 STALE_EXPORT,要求重新准备。
includeDependencies 必须显式声明。预览返回依赖数量、缺失/跳过原因并支持完整分页。selectedEntryKeys 对应预览中的输出文件路径,由服务映射到逻辑内容项,MOD 元数据由服务重建,不能作为普通内容排除。复用现有从冻结快照排除内容的语义,不重新计算或自动补回依赖;最终选择若移除依赖,服务返回依赖警告,执行须显式匹配 acknowledgedWarningIds。
### MOD 格式边界
首版复用 internal/mod 注册的 110USextend Writer,输出为 `<outputParent>/<name>-<version>/`,含 pack.json 和所需的 pvf/、merge/,不是压缩成单个 .mod 文件。
115CN-mod 扩展输出 `<outputParent>/<name>-<version>.zip`,包含根级 mod.json 和 pvf/ 载荷;`export_prepare` 的 `modId` 为稳定唯一 MOD ID,发布同包更新时复用,不能用显示名称替代。仍复用预览、筛选、依赖警告确认和拒绝覆盖的发布流程。MCP 不安装客户端资源,MOD 包导入使用 GUI。
name、version 复用现有校验,版本省略或留空默认 1.0。普通脚本放入 pvf/;`.lst`、`.str` 按格式写入 merge/。工作区/提交来源有准确基线时输出新增或修改的表项;无基线的整表按现有格式规则跳过并返回警告,不回退为整表覆盖。
该格式不支持文件和表项删除,返回 skippedDeletes 与明细;纯删除或筛选后无可应用内容时拒绝输出。direct 的删除项输出修改前内容,这属于文件导出语义,不表示生成删除补丁。不能把导出成功描述为支持全部变更或已验证游戏效果。
跳过、解析失败、依赖不完整项赋予稳定 warningId,execute 需显式回传已查看的 ID;非法路径和无法表达的内容仍硬失败。首版不实现 MOD Reader,也不把 MOD 目录的 pack.json、merge/ 等包装内容当成普通归档资源自动导入。
### 发布、计划与任务
outputParent 使用本机绝对路径。首版 direct 显式指定 outputName,在父目录中新建专属输出目录;MOD 使用 name 作为目录名。同名目录存在一律返回 TARGET_EXISTS,不合并或覆盖。两种模式均先写入同父目录下的临时目录,再原子地以禁止覆盖方式发布,失败只清理本次临时目录。需调整现有 writeDirectDirectory 直接逐文件写入目标的入口,避免半成品和覆盖旧文件。
复用安全路径校验,检查符号链接、大小写及平台文件名冲突。结果返回 outputPath、文件数、依赖数、来源 revision、格式和警告;错误区分是否已经发布。prepare/apply/execute 按 requestId 去重,发布后丢失响应的重试返回同一结果,不能重复生成目录或误报目录已存在。
当前 ExportService 仅保留一份短期预览,新 Prepare 会替换旧预览。首版保持数量限制,但纳入共享占用与来源身份;UI 与 MCP 不得相互替换导出预览,已有计划返回 EXPORT_PLAN_PENDING。导入、导出和脚本计划使用不同 ID 命名空间,共用 task_status、TTL;本机连接共享计划。
导出预览存在期间允许编辑,但编辑使计划过期;open/close 需先显式释放导出计划。执行任务期间阻止切换/关闭及冲突写入,提交/发布阶段不开放取消。UI 显示 AI 来源、进度和最终目录,导入后刷新受影响标签页及目录树。退出/停用时等待不可中断提交,清理其余预览与临时存储。
## 现有实现需要调整的边界
### 单运行与单预览
当前 ScriptService 按实例互斥运行,core 只保存一个 scriptPlan;Run 会丢弃旧预览。首版保持单任务、单预览,新增统一的占用状态,覆盖 UI 和 MCP 两个入口:已有待处理预览时返回 PLAN_PENDING,要求先应用或丢弃,不能静默替换。
预览记录来源与调用方身份。本机 AI 连接共享任务和预览,UI 可以查看、应用或丢弃 AI 预览;若用户在 UI 处理了计划,AI 后续查询得到对应终态。占用检查和任务接纳必须原子执行,不能只在 MCP handler 中检查一次。
### 工作区一致性
继续使用现有 archive 身份和 batchRevision 校验,并增加对外不透明 workspaceId;切换或重新打开归档必须更换身份。传入路径在当前身份下解析,避免长期暴露会因增删文件而改变的 index。
界面和 AI 均可能编辑工作区。执行开始、运行完成和应用提交分别校验身份与 revision,冲突返回 STALE_WORKSPACE 或 STALE_PLAN,不覆盖最新编辑。多文件应用继续通过现有事务和依赖校验完成,不在 MCP 层循环调用 SetText。
### 应用副作用
归档修改应用到 Overlay,沿用版本记录与索引失效逻辑。字符串表关联修改必须一起选中或由服务报告缺失依赖,不能静默部分成功。
现有文件集脚本会在 Apply 时写入用户配置。首版 MCP 明确拒绝包含文件集变更的计划,返回 UNSUPPORTED_CHANGE,避免将其描述为“仅修改未保存的 PVF”。未来开放时应提供独立能力与副作用说明。
### UI 同步
目前运行结果和 planId 主要保存在前端 Pinia 中,仅复用后端日志事件不足以让界面接管 MCP 运行。新增后端任务状态查询,以及 started/completed/preview-created/applied/discarded 状态事件,事件携带 taskId、来源和 workspaceId。
前端打开脚本工作区时先拉取后端状态,事件用于后续增量刷新;AI 生成的源码作为独立任务展示,不能覆盖用户当前未保存的源码。日志按游标和字节数截断,明确返回 hasMore/truncated。
### 超时、重试与释放
沿用现有脚本源码 1 MiB、执行 5 分钟和日志 5000 条限制,再为日志单条大小、总字节、响应大小、预览寿命和任务历史设置上限。Goja 的 context 中断不是内存硬隔离,应限制并行数并验证大分配场景;需要严格内存上限时再考虑执行子进程。
run/apply、open/close/save/save_as,以及导入导出的 prepare/apply/execute 使用调用方范围内的 requestId 去重。同一 ID 和相同参数返回已有结果,相同 ID 配不同参数返回冲突;保存请求摘要及有限期结果,避免网络重试重复应用、保存或发布。去重记录跨工作区切换保留,重试先查去重记录再验证旧工作区身份。应用重启后无法确定的提交结果要求核对磁盘文件,不直接重放。
取消按 MCP 所属 taskId 校验;MCP 请求返回后后台脚本任务仍运行,显式 script_cancel 工具负责停止。MCP open/close 先要求处理已有脚本任务和预览;应用退出和服务停用时取消可取消任务、等待不可中断生命周期提交并释放预览。连接中断不立即丢弃预览,按可配置的有限 TTL 回收。
## 本地访问控制
只绑定 loopback,校验 Host 和 Origin。首版不做鉴权,连接配置只提供服务 URL;本机客户端共享 MCP 任务和计划。关闭服务时停止接受请求。
不把 EditorService 或整个 Wails API 自动注册为 MCP 工具。所有能力使用明确工具列表和参数 schema;返回业务错误码、简短描述和可执行的恢复建议。
## 实施顺序与验收
1. 服务层增加共享任务管理、预览占用和调用方身份,提取归档生命周期门面、SaveCopy、冻结导入计划及无对话框导出接口;保持现有 UI 可用。
2. 加入 MCP Server 和工具,复用现有服务;补充身份、revision、workspaceEpoch、分页、去重与资源释放逻辑。
3. 在现有设置末尾增加 MCP 配置区,并接通 UI 状态查询与事件。
4. 接通各窗口草稿 prepare/ack 与生命周期事件,验证真实 MCP 客户端的发现、打开、搜索、读取、执行、预览、应用、导入、保存/生成副本、普通导出、MOD 导出及关闭全链路。
针对性测试覆盖:UI/MCP 并发互斥、待处理预览保护、归档切换、编辑冲突、调用方隔离、重复请求、取消与超时、过期回收、字符串表依赖、文件集变更拒绝、分页截断、Host/Origin 拒绝。生命周期另覆盖:空工作区打开竞争、打开失败保留原状态、dirtyPolicy 三种分支、各窗口草稿与响应超时、外部源文件修改、目标覆盖竞争、路径别名、保存失败重试、磁盘成功后维护失败、生成副本保留源路径/dirty/自动备份、切换后的请求去重及旧任务结果隔离。运行 `go test ./...` 和前端构建;真实 PVF 回归按项目惯例通过 PVF_TESTFILE 启用。
导入导出另覆盖:来源文件在预览后变化、目录映射冲突、部分选择与覆盖许可、源路径与目标路径混用、快照限额与清理、提交后的维护失败、导出计划互斥和过期、文件筛选后的依赖警告、同名目录竞争、输出失败清理、发布后的重复请求,以及 MOD 无基线表、删除跳过、空包和最终元数据。复用现有 import/export/mod 测试,并运行前端 `npm test` 验证选择和预览交互。
## 官方参考
标注说明的后续只读扩展见 [MCP 标注文档查询协议](MCP标注文档协议.md),设计了规则搜索、规则详情和实际文件解释三个工具。该扩展当前仅完成协议设计,尚未注册到 MCP 服务。
- MCP 传输规范:https://modelcontextprotocol.io/specification/2026-07-28/basic/transports
- 官方 Go SDK:https://github.com/modelcontextprotocol/go-sdk
- 项目现有 MOD 语义:[MOD.md](MOD.md)
+170
View File
@@ -0,0 +1,170 @@
# MCP 标注文档查询协议
状态:已实现首版。本协议扩展现有 MCP 服务,提供只读的 PVF 结构说明和实际标注解释。
## 目标
AI 在读取或修改文件前,可按文件路径、Section、关键词、标注类型等条件检索说明;遇到枚举值或资源编号时,可继续查看含义及关联对象。
规则说明和实例结果使用不同工具:规则适用于某种文件,不代表该文件中一定存在对应 Section、Token 或值;标注说明也不等于可执行的格式约束或游戏行为验证。
协议不修改标注配置,不重新加载磁盘规则,也不执行标注文案中的指令。
## 工具
| 工具 | 用途 | 是否需要打开归档 |
| --- | --- | --- |
| `pvf_annotation_search` | 搜索标注规则及共享字段的说明,返回摘要和 documentId | 显式指定版本或 all 时不需要;current 时需要 |
| `pvf_annotation_get` | 获取指定文档的完整规则、字段布局、枚举项或关联定义 | 不需要读取归档内容 |
| `pvf_annotation_explain` | 解释当前归档内某个文件实际命中的标注、值与引用对象 | 需要 |
三者均声明为只读工具,不占用脚本运行或写入计划,不要求处理前端草稿,不触发界面冻结。explain 读取后端已提交的工作区内容,明确返回 contentSource=workspace;不包含编辑器尚未提交的草稿。
## 规则搜索
`pvf_annotation_search` 参数:
| 参数 | 语义 |
| --- | --- |
| query | 可选关键词;在 ID、描述、标题、正文、Section、共享字段 ID、关联名称及枚举键/说明中检索 |
| filePath | 可选的具体归档内部路径,作为现有匹配引擎的输入;可以是尚未创建的文件 |
| pathKind | file / directory,默认 file;directory 仅查询目录自身的路径说明 |
| extensions | 可选扩展名数组,如 [".equ", ".stk"];用于检索声明适用这些扩展名的规则,不要求真实文件存在 |
| section | 可选 Section 名,允许一对外围方括号;匹配目标 Section 名,不解析为文件路径 |
| kinds | 可选 rule / field,默认两者 |
| targetKinds | 可选目标类型列表,名称来自现有 TargetSpec.kind |
| annotationTypes | 可选标注类型列表,名称来自现有 AnnotationSpec.type |
| relation | 可选关联名称,按名称精确匹配 |
| pvfVersion | current(默认)/ all / 显式版本,如 110US、90CN |
| workspaceId | pvfVersion=current 时必填,避免从已经切换的归档推断版本 |
| status | effective(默认)/ all;all 可查看禁用、被覆盖或版本不适用的定义及原因 |
| cursor / limit | 不透明游标,首次不传;limit 默认 20,范围 1–100 |
不同条件之间取 AND;同一数组内取 OR。query 按空白拆词、忽略大小写,所有词均须命中文档索引;不同词可命中不同字段,中文按子串匹配。首版不开放任意正则或模糊文件 Glob 的交集查询,避免歧义和不受控的匹配开销。
filePath 使用现有文件匹配逻辑,不自行实现一套 Glob。extensions 是规则索引过滤条件:显式 Extensions 对应精确匹配,不受扩展名限制的规则也可成为候选;仅以复杂 Glob 限制文件的规则,无法凭扩展名证明适用时返回 applicability=unknown,而非推断为匹配。提供 filePath 后,以该路径的实际匹配结果为准。
不传 section 时,返回路径、Section 和 Token 等全部类别的候选文档,不能沿用现有 RuleService.Query 在空 section 时仅返回路径规则的行为。不传任何内容过滤条件时允许分页浏览;不会展开全部枚举值或扫描当前归档所有文件。
pvfVersion=current 从指定工作区取得当前 ClientVersion;未打开归档时返回 NO_ARCHIVE,提示显式指定版本或 all。all 表示不按版本排除候选,返回 applicability=unknown,不能把所有定义标为对当前文件生效。未知显式版本返回 INVALID_VERSION,并列出引擎支持的版本。
status=effective 对显式版本/current 仅返回经覆盖、禁用及版本筛选后仍有效的定义;pvfVersion=all 时仅按覆盖和禁用筛选,并保留每条定义的 pvfVersions,不判断版本适用性。
返回结构示意:
```json
{
"schemaVersion": 1,
"rulesRevision": "opaque-revision",
"context": {
"workspaceId": "current-workspace",
"pvfVersion": "110US",
"filePath": "equipment/example.equ"
},
"items": [
{
"documentId": "opaque-document-id",
"kind": "rule",
"ruleId": "实际规则ID",
"title": "实际标注标题",
"summary": "实际规则说明摘要",
"section": "price",
"targetKind": "section",
"annotationType": "实际标注类型",
"pvfVersions": [],
"effective": true,
"applicability": "matched",
"matchReasons": ["filePath", "section", "query"],
"valueCount": 0,
"relation": null
}
],
"nextCursor": null
}
```
示例中的规则 ID、标题和类型均为占位说明,实际值来自当前生效配置。摘要最多 512 个 Unicode 字符,附 summaryTruncated;不在截断处切开字符。查询命中枚举文案时,可返回少量 matchedValues 摘要,但不能默认返回整个 values 映射。
默认顺序为关键词相关度降序,再按 kind、规则 ID、来源层稳定排序。完全命中 ID、Section 或标题时优先于正文子串命中;排序不改变引擎执行规则的先后顺序。
## 文档详情
`pvf_annotation_get` 接受 documentId、rulesRevision,以及可选的 value、valuesCursor、valuesLimit。
documentId 是不透明文档身份,在同一规则快照内定位具体定义及来源层;ruleId 保留现有规则身份。共享字段与普通规则使用不同 kind,不能仅用 ruleId 混合寻址。被覆盖的同 ID 定义也须有不同 documentId。引用新的快照时重新搜索,不能把 documentId 当成永久标识。
返回以下内容:
- 原始说明:ID、描述、标题、正文、版本范围、来源类别、禁用/覆盖状态及原因。
- 原始定义与解析后的有效定义:展开 rule.field 指向的共享字段,保留 fieldId;按照现有引擎的整块继承/覆盖语义解析 Match、Target、Annotation,不能自行做逐字段深合并。
- 文件匹配条件与完整 Target:Section、Index、Range、Offset、RecordTokens、GroupOffset、StandaloneValues、TokensPerLineIndex、ContextIndex、图片相关 Token 索引等;0 值与缺省值不能混淆。
- 枚举说明:valueCount、分页 values;给定 value 时精确查询该键。键按字符串保留,不能把 "001" 和 "1" 合并。未找到时返回 found=false,不猜测最接近值。
- 关联定义:只展开此文档引用的 RelationSpec 及必要的组合关联;返回上下文要求和所需 `.lst` 路径,不枚举整个归档的关联对象。循环引用、缺失定义单独报告。
- 可选 PreviewSpec:共享字段的展示标签、用途与格式,用于辅助理解;不将展示格式当成可写入脚本的值类型。
来源只提供 builtin/user 等可确认的类别和规则标识,不暴露本机配置绝对路径;来源未知时明确标记 unknown。由路径/Section 说明合入的生成规则,只在具备可靠来源元数据时标记 generated。
valuesLimit 默认 50、最多 100;正文及原始定义超过响应上限时返回明确的截断或大小错误,不能把不完整规则表示成完整定义。原始规则 JSON 和关联定义按需读取,搜索摘要不默认携带它们。原始及解析后的定义省略 values 映射,显式返回 definitionsOmitValues=true;完整枚举通过独立 values 分页取得。
## 实际文件解释
`pvf_annotation_explain` 接受 workspaceId、expectedRevision、filePath,以及可选 rulesRevision、section、occurrence、tokenIndex、startLine、endLine、cursor、limit。
filePath 必须对应当前归档实际存在的脚本文件;与 search 的假想路径查询不同。occurrence 从 0 开始,仅在给定 section 时有效;tokenIndex 是该 Section 的直接 Token 索引,从 0 开始,必须同时给定 section 和 occurrence。行号从 1 开始,首尾均包含;行范围按与标注区域相交筛选。
不指定 occurrence 时解释全部同名 Section,并分页返回,不能悄悄只解释第一个。Section/token 条件只筛选此范围内的标注;文件和父目录说明通过独立的 pathContext 返回,避免把文件说明伪装成字段的实际命中。
返回 rulesRevision、workspaceId、revision、textRevision,以及:
- pathContext:文件自身和祖先目录的说明,逐项带 scopePath、规则 ID 与文档身份。
- annotations:实际标题和说明、类型、ruleIds/documentIds、Section 与 occurrence,以及可确定的原始 Token 值。
- 定位:range.start/end 为左闭右开的 0 基 UTF-16 偏移,基于与 read_file 一致的文本并沿用编辑器换行规范化;行号和列号均从 1 开始,列按 UTF-16 码元计数。
- 枚举/引用解析:valueLabel,或 reference 的 relation、id、context、name、归档内部 path、resolutionStatus。不向 AI 提供会因文件增删而失效的 fileIndex 作为操作身份。
- 图片、字符串占位符和自动列表链接等运行时标注:返回结构化含义,标记 origin=runtime;没有配置规则来源时 documentIds 为空,不能虚构规则 ID。
resolutionStatus 使用 resolved / missing / context_required / unsupported。例如上下文关联无法确定职业时返回 context_required,不从其它职业列表中挑一个同编号结果。未命中枚举、缺失引用、解析警告均保留,不能将解释为空视为该内容无意义或合法。
动态分组、分隔符、共享字段和嵌套 Section 的定位复用现有解析与标注引擎。recordIndex 等附加信息只在能够准确追溯时返回,不能根据渲染行号猜测记录位置。文本中的数值范围、格式或业务关系只有在规则明文说明时才作为说明返回,不能生成未定义的强制校验结论。
## 快照与分页一致性
新增独立 rulesRevision,表示实际激活的标注规则快照,包括共享字段、关联定义、覆盖/禁用状态。成功激活规则时更新;磁盘文件被修改但未成功加载时,查询继续使用原生效快照及旧 revision,不读取一个与当前引擎不一致的配置。
搜索游标绑定规则 revision、规范化筛选条件、排序和版本上下文;current 查询还绑定 workspaceId。详情与枚举分页绑定同一快照及 documentId。规则切换返回 STALE_RULES,改变筛选条件继续使用旧游标返回 INVALID_CURSOR。
explain 的游标另外绑定工作区 revision 和 textRevision。textRevision 标识本次解释使用的完整文本,防止渲染重载或换行差异改变定位,而归档内容 revision 尚未变化。归档、内容、规则或文本改变时要求重新解释,不拼接不同快照的结果。
不返回需要遍历全部文件才能计算的统计;totalMatches 对规则搜索可提供,对实例解释不强制。默认实例页长 20,最多 100,单次响应设定字节上限;明确返回 nextCursor、truncated 和 warnings。
这些规则 revision 与现有 MCP 的 workspace revision 分开管理:只改脚本内容不应使静态规则文档失效,只改标注说明也不应伪造归档内容改动。
## AI 使用流程
1. 获取工作区身份,搜索目标路径和 Section 的说明。
2. 阅读少量命中文档;遇到枚举或复杂记录布局时获取详情。
3. 对实际文件调用 explain,确认重复 Section、值含义和关联目标。
4. 结合 read_file 与脚本 API 文档生成修改,继续使用既有预览/应用流程。
例如查装备价格字段:
```json
{
"workspaceId": "从 workspace_info 获取",
"pvfVersion": "current",
"filePath": "equipment/example.equ",
"section": "price",
"limit": 20
}
```
这个参数对象用于 pvf_annotation_search;不需要先将整个标注规则库装入模型上下文。无结果时可去掉 section 或改用 query 搜索,服务不自动放宽条件。
## 落地与验收
服务层增加 AnnotationDocumentation 查询门面,基于激活时保留的不可变规则快照建立轻量索引;不要让 MCP 工具读取任意磁盘 JSON。search/get 不读取 PVF 内容,explain 在 core 锁下取得一致文本与版本,再复用 editorAnnotationsLocked 的匹配/解析流程。
现有 RuleService.Query 可复用规则身份和来源模型,但其路径/Section 过滤面向规则编辑器,不能原样作为全文文档搜索。现有 EditorAnnotation 还需要补充文档身份、路径引用、Section 上下文及明确的坐标单位。
三个工具追加到现有 MCP 工具列表。首版先实现 search/get,再接入 explain;可选资源 URI 或文档订阅以后添加,不能作为客户端使用协议的前提。
针对性测试覆盖:空条件分页、关键词与组合过滤、假想路径/真实文件区分、目录说明、通用与版本专属规则、禁用/覆盖定义、共享字段覆盖、超大枚举、重复 Section、动态记录及分隔符、上下文关联、缺失引用、自动生成标注来源、中文/emoji/CRLF 定位、规则热重载失败和各类过期游标。
+78
View File
@@ -0,0 +1,78 @@
# Mod 格式扩展
## 分层
- `internal/mod` 定义中立包模型、格式注册表和独立的 `Reader` / `Writer` 接口,不依赖 Wails、归档服务或版本库。
- `Package` 保存名称和操作集合。`replace` 携带明确编码的文件内容,`merge-list` / `merge-strings` 携带键值条目,`delete` 表达整文件删除。
- `services.ExportService` 将选中文件、相对 HEAD 的工作区改动或提交前后状态适配为包模型;负责依赖补齐、预检、修订校验和目录发布。
- 前端四类导出入口只指定来源,不包含格式专属的目录或编码规则。
## 添加格式
向注册表注册稳定 ID、显示名称、支持的操作以及读取器或写入器。读取和写入能力根据实际接口计算,不使用空实现冒充支持。
写入器先通过 `Validate` 检查包是否可表达,`Files` 描述实际输出路径及对应的包条目,再通过 `Write` 写入隔离的临时目录。`Files` 中没有 `EntryPath` 的文件为必需的生成元数据,不可移除。写入器不负责目录选择、覆盖确认或修改归档。
导入通过 `Reader.Detect(fs.FS)` 识别格式,`Reader.Read(fs.FS)` 返回同一 `Package`,再由服务层执行导入预览和归档暂存事务。外部文件路径不能直接成为写入路径;导入器需要校验包内路径和操作,并复用现有暂存、索引重建及版本变更记录流程。当前已注册 110USextend 和 115CN-mod,现有“导入”弹窗中的“Mod”选项提供入口。实现 `ArchiveWriter` 的格式直接发布单个 ZIP,其余格式保留目录发布。
## 110USextend
导出结果是父目录下的 `<mod名称>-<版本号>/`,包含 `pack.json` 和实际用到的 `pvf/`、`merge/`。版本号可填写,默认或留空为 `1.0`,不强制 semver;最多 128 字节,不允许控制字符和首尾空格。优先级 `100`、启用,`types` 记录整文件替换项的 PVF 类型。文本使用 UTF-8,无 BOM。
普通脚本和文本整份放入 `pvf/<归档路径>`;`.lst`、`.str` 使用 `merge/<归档路径>`。版本导出只包含新增、修改的表条目(新增表以空表为基线);选中文件、文件集或未启用版本控制的内存改动没有准确基线,跳过显式选择的整表,不全量导出。列表按编号匹配,文字表按第一个 `>` 分割并保留空值及文字中的空格;忽略有缩进的 `//` 注释和空行。
该格式不支持删除,预检会提示跳过的文件或表条目删除。只有删除或没有可应用条目的包不能导出。无法解析的文字表行跳过;无法解析或读取的依赖表、无法准确提取改动的合并表及未能补齐的依赖也跳过,记录文件路径、行号或原因到 `[pvfine:export]` 日志,并在确认弹窗汇总警告。非法目标路径、不能表达的输出内容仍阻止导出,不以改名绕过安全校验。
依赖补齐默认关闭。开启后,从同一来源状态补齐脚本引用的文字表条目、表号映射及配置列表中匹配的登记项;显式内容优先,重复项去重。历史版本使用该提交快照,不读取当前工作区内容。不补齐任意脚本、sprite、sounds、npk 资源。
直接导出保留既有行为:版本新增或修改输出后状态,删除输出前状态。导出不会应用编辑器草稿、创建版本或保存 PVF。
未启用版本控制的“当前内存改动”从归档 overlay 枚举所有现存修改和新增文件;mod 模式跳过无版本基线的 lst/str,直接导出仍保留整文件;已删除文件不在该范围中。
所有确认弹窗列出完整输出树,勾选文件可控制最终输出范围。排除作用于已冻结的预览,不重新计算依赖,也不会自动补回被排除的依赖文件。已有预览刷新或切换模式时保留相同输出路径的排除,新弹窗重置;选空时不可导出。mod 元数据根据最终保留条目重新生成。
未选择合并来源时,只改名称或版本号会复用同一有效内容快照,不重新解码文件、计算差异或扫描依赖。快照过期时间不延长,归档、工作区或版本会话变化后不可复用。日志分别记录锁等待、来源读取、差异提取、依赖补齐和总耗时;普通脚本不读取不必要的修改前内容,无文字表引用时不读取表号映射或文字表。
## 导出时合并已有 MOD
导出为 mod 时,可在依赖开关后选择一个相同格式的已有 MOD。110USextend 选择目录;115CN-mod 可选择目录、ZIP 或 `.dnfmod`。普通文件同路径冲突时使用本次内容;同类型的 lst/str 合并操作按键合并,同键使用本次值,保留已有 MOD 的其余文件及键。选择已有 MOD 后,自动读取包内名称及版本号;纯数字分段版本递增最后一段(如 `1.0 → 1.1`、`1.2.3 → 1.2.4`),保留末段前导零,其他版本保留原值并提示手动修改。名称和版本号仍可编辑,ID 使用本次输入。输出目录名为 `<mod名称>-<版本号>/`,ZIP 为 `<mod名称>-<版本号>.zip`,不覆盖已有 MOD。版本号留空时包名采用格式默认版本;名称与版本号组合必须是合法文件名。
已有内容经过读取、合并和格式校验后进入同一预览树,可以取消勾选。每次准备预览都会重新读取合并来源;执行时使用冻结内容,不再读取源 MOD。切换格式或重新打开弹窗会清除来源;取消文件选择保留原来源。115CN 的全部组件合并为基础内容;互斥组件或组件目标冲突会阻止合并。
当前仅合并 PVF 内容,不复制 NPK/BK2 等客户端资源、包级依赖/冲突和其他包元数据。
## Mod 导入
工具栏“导入”中选择“Mod”和导入格式,再选择单个 mod 目录。格式选项由 `ModImportService.Formats()` 从注册表加载,只列出拥有 `Reader` 的格式;服务按显式传入的格式 ID 调用对应的 `Detect` / `Read`,目录与所选格式不匹配时拒绝预览,不回退到其他读取器。后续格式注册读取器后即可出现在导入选项中。110USextend 目录需包含 `pack.json`。当前支持读取本工具导出的 110USextend 包;`pvf/` 中每个替换文件必须在 `pack.json.types` 中声明类型(1 为脚本、3 为文本),声明的文件必须存在。仅处理 `pvf/` 和 `merge/`,不导入 sprite、sounds、npk 等游戏资源,不支持删除操作。
预览显示所有文件的新增/更新状态、整文件替换或表合并方式;展开表文件显示每个传入键的新增/覆盖状态、新值和原值。整文件替换使用包中声明的类型;`.lst` 按编号合并,`.str` 按第一个 `>` 拆分后按文字键合并,同键覆盖并保留无关条目。文字表保留空值、值中的空格和 `>`,以及当前表中未改动的行和注释。归档中不存在的合并表会新建;已有合并表类型必须匹配。
读取器拒绝非法路径、大小写路径冲突、文件/目录冲突、符号链接、非普通文件、敏感配置路径、无效编码、重复传入合并键及无法解析的合并行。包内文本必须是无 BOM 的 UTF-8;合并行忽略空行和有缩进的 `//` 注释。现有目标表无法准确解析时阻止导入,避免丢失条目。
`ModImportService` 在隔离归档上预检并冻结合并后的文件内容,确认时不重新读取目录。预览有效期为 10 分钟;归档、工作区修订或版本会话发生变化后必须重新预览。取消或重新选择会释放旧预览。确认导入复用文件导入暂存事务、索引更新及版本记录,预检或暂存失败不安装部分文件。启用版本控制时记录一组“导入 Mod”变更,可撤销;不会自动提交版本或保存 PVF。编辑器草稿仍由既有归档变更刷新流程处理。
## 115CN-mod
格式 ID 为 `115CN-mod`。配置使用 UTF-8 `mod.json`,要求 `schema: 1`、`client: "115CN"`、`kind: "resource"`。读取目录、`.zip`、`.dnfmod`,配置可在根目录或唯一的一层包目录内;也兼容旧 ZIP 的 GB18030 中文包目录。RAR、7z、JAR 不接受。不解压到游戏目录,不执行包内程序或脚本。
PVF 操作通过 `component` 关联组件,`source` 相对包根目录,`path` 为小写 PVF 路径:
- `add`:仅新增,目标存在时整批预检失败。
- `replace`:整份替换,目标不存在时新增。
- `merge`:仅支持 `.lst` 按编号合并、`.str` 按文字键合并。同键覆盖,其他条目保留。
- `type`:示例包使用的可选字段,1 为脚本、3 为文本。省略时 `.str` / `.nut` 推断为 3,其他为 1;合并表必须匹配对应类型。
- `patch`:提供的文档片段和示例包未包含标签补丁字段协议,当前明确拒绝,不猜测、忽略或降级成整文件替换。需补充登录器 `ModExamples/README.md` 中的完整协议后接入。
导入预览按 `defaultSelected` 初始化组件,勾选后从冻结的包内容重新预检。`requires` 必须同时选中,`exclusiveGroup` 同组最多选一项;未知组件、循环依赖、所选组件的重复目标及文件/目录冲突拒绝。全不选时禁止执行。切换不延长原预览的 10 分钟期限,源 ZIP 变化不影响已冻结的内容,归档修订变化后必须重新预览。现有 Unicode 文字表的尾部 NUL 终止符原样保留,其他无法解析的行仍阻止合并。
`files` 中的客户端资源仅校验声明的组件、路径、size 与 sha256 字段格式,并在预览警告中报告;不读取或校验资源正文摘要、不写入 PVF、不安装或备份 NPK/BK2。包级 `requires / conflicts` 提示用户在登录器核对,不模拟 MOD 管理器的安装、排序、关闭或恢复状态。资源安装、已有视频恢复和依赖管理仍由登录器负责。
导出复用现有来源、精确表差异和依赖补齐,生成 `<名称>.zip`,根目录为 `mod.json` 和 `pvf/`,单个默认组件 `base`。新增独立 MOD ID 输入框:初次生成唯一 ID,发布同一 MOD 的更新需填回原 ID;ID 只能为 1–64 个小写字母、数字、`-`、`_`。名称/版本/ID 更新复用有效内容快照。筛选文件后同步重建清单,不携带被排除的载荷。ZIP 写完后原子发布,拒绝覆盖已有同名包。MCP `export_prepare` 选择此格式时同样必须提供 `modId`。
ZIP 拒绝路径穿越、大小写路径冲突、符号链接、特殊文件和敏感配置路径;声明文件读取也检查父目录链接。限制 ZIP 最多 20,000 项、单项 64 MiB、总展开大小 512 MiB,清单 4 MiB。目录包同样限制 PVF 单文件和累计读取大小。不会读取未登记的 PVF 载荷。
## 验证
运行 `go test ./...`、前端 `npm test` 和 `npm run build`。真实归档的新增导出验证需要设置 `PVF_TESTFILE`;示例包和游戏目录不作为可写测试目标。
115CN 示例读取回归:设置 `PVF_MOD_TESTFILE` 为 ZIP 路径,运行 `go test ./internal/mod -run '^TestCN115RealMOD$' -v`。真实归档预检另外设置 `PVF_MOD_ARCHIVE` 为测试 PVF,运行 `go test ./services -run '^TestCN115RealMODPreflight$' -v -timeout 15m`。两者均只读源文件,不自动执行或保存 PVF。
+188
View File
@@ -0,0 +1,188 @@
# PVF 变体架构
## 分层
- `internal/pvf/detect.go`:已知标准格式、替代密钥格式、Paged110 解封依次探测,最后尝试未知格式的头部恢复。结果包含逻辑字节、头部、格式规则以及写回所需状态。
- `internal/pvf/archive.go`:公共逻辑容器解析,不负责配套文件发现或分页解封。字符串池和 GRPI 无法解析时返回 `ErrInvalidSection`;HASH 无法恢复时保留读取能力并限制结构修改。
- `internal/pvf/format.go`:格式标识、容器类型、内容规则和写入能力。格式标识用于诊断,功能分支依据内容规则。
- `internal/pvf/save.go`:统一校验、字符串池修复、重建和状态接纳流程;`writeContainer` 仅负责普通写出或分页包装。
- `services/list_registration.go`:依据 `RequiresIndexHash` 决定伴随索引维护及 uint32 ID 约束。界面依据内容能力控制注册和字符串引用入口。
目前标准与替代密钥格式共享内容规则;110US / 115US 使用分页容器,并启用 UTF-16 字符串策略、indexhash 注册和字符串引用入口。100CN 同样使用分页容器与 indexhash、字符串引用,但保留 ASCII / UTF-16 双池。容器与这些内容规则可以独立组合。
`IsPaged110` 和信息快照中的 `paged110` 保留兼容用途。新业务功能不应依据格式名称判断是否可用。
## 写入约束
`WriteCapabilities` 描述归档的基础写入能力。未知 HASH 时,已有内容替换仍可保存,但新增、删除、字符串池扩展或重映射会在保存前拒绝;`CanSave` 不意味着任意待保存修改都被支持。缺少分页写回状态时不可保存。
HASH 是否已知通过独立状态记录,数值为零的有效 seed 不再代表未知。格式规则及分页状态必须随批处理、脚本工作区暂存克隆一起保留。
未修改归档保持原有字节往返约定;启用 UTF-16-only 规则的历史异常归档仍会执行已有字符串池修复,这类修复可能改变输出。保存的结构限制也适用于该修复。
解析错误明确拒绝不可解码的必需段。当前只在打开时验证首个正文分块,其余分块仍按需解压;成功打开不等于完成整个归档的完整性扫描。
## 新变体接入
1. 确定差异属于段参数、物理容器还是内容规则。
2. 只有段参数变化时添加已知探测器及参数,复用公共解析、编辑和重建。
3. 容器变化时扩展解封和 `writeContainer`,保持公共解析接收逻辑字节。
4. 内容行为变化时在 `ContentRules` 中明确表达,并集中配置;不要在服务、编辑器、字符串池中散布格式名称判断。
5. 克隆必须复制新增的可变写回状态;不可把配套文件依赖带入普通内容编辑。
6. 更新生成的前端绑定必须使用 Wails 生成命令。
未知密钥恢复只支持当前共享布局,不能自动推断未知容器结构或客户端内容规则。新变体仍需要样本确认,不能因头部恢复成功就声明完整支持。没有配套文件时,被封装的未知头部目前仍可能返回 `ErrBadSignature`,尚不能可靠区分所有缺失依赖与不支持格式。
## 验证
`format_test.go` 使用合成数据覆盖:
- 标准与替代格式的有/无 guard 探测、未修改字节一致性、增删后的保存重开。
- Paged110 分页包装、解封后的公共解析、暂存编辑、新增及再次保存;使用合成分页状态,不覆盖真实配套文件解封。
- 未知 HASH 的内容保存与结构修改拒绝、已知零值 seed。
- 克隆格式状态、字符串池策略与容器的独立组合。
- 字符串池长度错误的显式拒绝。
常规验证:`go test ./...`、`cd frontend && npm run build`。
真实客户端兼容性仍需使用对应变体样本运行 `PVF_TESTFILE=/path/to/Script.pvf go test ./...`,未匹配内置参数的分页版本还需对应配套文件。测试日志和示例不得包含配套文件内容或密钥;授权内置的客户端解封参数仅存于专用资源。
## 100CN 样本调查
100CN 样本的页首保护边界同样是每 10 MiB 页的前 10,240 字节。配套国服
`DNF.exe` 的 PVF 加载流程明确读取同目录的 `sk.dat`,先解封页表,再解密各页
保护区,最后按宽字符公式处理 `iNfO` 头部。程序内的 `sk.dat` 文件名经过
字符串混淆,不能以普通字符串搜索未找到它作为不需要配套文件的依据。
分页解封现在依次尝试 `DFO.exe` 和 `DNF.exe`;前一个程序不存在或不能解封时
仍会继续尝试后一个。只在配套文件和归档头部完整校验通过后接受结果,不凭
可执行文件名认定客户端版本。100CN 按成功解封候选的公钥身份登记为
`paged100cn`,`ClientVersion()` 返回 `100CN`;未登记身份继续使用通用 `paged`。
合成测试覆盖两种程序名、两种 PEM 编码、错误 `DFO.exe` 与有效 `DNF.exe`
同时存在、无修改字节一致,以及内容编辑和新增文件后的保存重开。
100CN 的 ASCII 池约 145 MB,非 ASCII 文本使用 UTF-16 池。不能对它执行
110US 的 ASCII 池迁移,否则未修改保存也会重建整个归档。装备 indexhash
的 227,634 条原始记录均使用 ASCII 池数字引用,数值符合现有哈希算法;
更新和新增记录沿用双池策略,现有 ASCII 引用不再被误判为需要修复。
真实样本验证通过:2,522,676 个文件、30,402 个分块全部解压和数据范围检查,
未修改输出 SHA-256 与源文件一致,编辑、新增、删除后保存重开,重建 HASH
覆盖当前文件表,未编辑载荷抽样保持一致,并验证新增装备的 LST/indexhash
注册。验证范围为离线读写,未进行游戏客户端实机加载。
真实测试可通过 `PVF_TEST_SIDECAR_DIR` 指定配套目录,避免移动源文件或将
配套文件放进仓库。`PVF_TEST_FULL_SCAN=1` 启用全分块及文件数据范围检查:
```sh
PVF_TESTFILE=/path/to/100CN.pvf \
PVF_TEST_SIDECAR_DIR=/path/to/client \
PVF_TEST_CLIENT_VERSION=100CN PVF_TEST_FULL_SCAN=1 \
go test ./internal/pvf -run '^TestRealPagedClient(Compatibility|EditRoundTrip)$' -count=1
```
110US、100CN 和 115US 的完整解封数据统一放在 `internal/pvf/assets/clients/`,
各用一个 `go:embed` 二进制资源,包含 PKCS#8 客户端参数、metadata 和加密
`sk.dat`。格式为 `PVFC` 签名、uint32 版本号(当前为 1)、uint32 DER 长度、
uint32 密封表长度、DER、32 字节 metadata、密封表;整数均为小端。
110US 原先的源码参数常量、独立 `sk.dat` 和专用加载函数已移除。
`paged_embed.go` 使用共用解码器校验版本、精确长度、公钥身份与密封表,
统一注册三种客户端,并以 `sync.Once` 缓存校验后的客户端和 RSA 解封结果。
身份识别只比较公钥指纹,不再依赖私钥加载。参数不会出现在诊断错误或测试
输出中;每次打开仍需校验归档签名与完整布局,不能仅凭资源身份采用内容规则。
打开时先依次尝试完整的内置 110US、100CN、115US 参数,无需读取同目录文件;
内置参数不匹配才查找归档目录中的 `sk.dat`,并尝试 `DFO.exe` / `DNF.exe`
内的候选。外部 `sk.dat` 仅作为兜底表,不会阻止已匹配的内置客户端打开。
后端也可调用 `OpenWithSidecars` 指定客户端目录。所有候选失败后,错误提示
用户将该 PVF 对应版本的 `sk.dat` 和客户端程序与归档放在同一目录后重试。
真实测试额外覆盖完全没有外部配套文件的打开、从内存 `Parse` 打开、末尾
分块读取,以及错误外部文件不覆盖有效内置参数。合成测试覆盖内置参数不
匹配时的外部客户端兜底和缺少配套文件的中文提示。
115US 样本的内置数据包含 81 页的解封参数;已验证独立打开、抽样读取、
未修改保存的字节一致性,以及编辑、新增、删除和 LST/indexhash 注册后重开。
默认压缩重建字符串池可能让归档超过页表容量,保存时会在超限情况下尝试
更高压缩率的字符串池,并保持字符串偏移与未编辑分块不变。仍然超限时,
返回 `ErrPagedCapacity` 和所需/可用页数,在写出前拒绝保存;`SaveAs` 保留
原目标文件及待保存修改,不扩展客户端配套页表。
## TW 独立目录格式
TW 的格式标识为 `tw`,客户端代号为 `TW`。它使用长度前缀 UUID、版本号、
目录长度/校验值/文件数,以及变长目录记录;目录和每个文件独立进行
CRC 派生的 XOR 与 32 位旋转加解密,没有 S4A21 的 HASH、字符串双池和
压缩正文分组。`tw.go` 将它适配为公共 Archive、字符串池和脚本表示,
服务层可继续使用现有目录树、搜索、编辑、批处理和字符串引用接口。
目录路径沿用参考读取器的 CP949 编码;游戏字符串表、`.str` 文本和 `.nut`
源码按 TW
配置使用 Big5。脚本带 `B0 D0` 前缀,原生 token 编号在读写边界转换;
`9 + 10` 字符串引用对适配为公共 `<index::key>` 引用。打开时校验目录和
非空文件 CRC、字符串索引及脚本尾部对齐,并建立不可变引用映射。
未修改保存逐字节保留原包;修改保存复制未编辑文件的密文及已有路径字节,
保留 UUID、版本和原有文件编号,向 `stringtable.bin` 追加新字符串,已有
字符串编号不变。新增、删除和 `.str` / `.nut` 编辑均写回 TW 格式。字符串表由编辑器
维护,不支持直接替换;Big5 无法表示的新字符会在写出前报错。
`tw_test.go` 覆盖合成归档的繁体文本、引用、二进制文件、增删、克隆、保存
重开和损坏拒绝。真实样本及服务层验证使用独立变量,避免其他格式专用测试
对 TW 产生不适用的假设:
```sh
PVF_TW_TESTFILE="$PWD/testdata/TW.pvf" \
go test ./internal/pvf ./services -run '^TestTW' -count=1
```
真实样本含 951,559 个文件、1,137,629 个字符串;验证范围为离线读写,
未进行游戏客户端实机加载。
### 基础掉率的版本兼容
“基础掉率”工具支持 90US、90CN、TW、110US 和 100CN。五种版本使用
相同的四个掉率文件及 `[basis of rarity dicision]`;TW 每组 6 个值,
90 系列常见每组 7 个值,100CN 每组 8 个值,110US 每组 9 个值。
读写均按各文件的 section 总长度验证分组宽度,只接受完整的 6/7/8/9 值分组。
调整概率只更新前四个累计阈值,保留地狱文件前置值、终止阈值和附加字段。
真实 TW 的四类掉率读取、调整、TW 格式保存重开和附加字段保留已验证。
可使用 `PVF_TW_TESTFILE` 运行 `TestDropRateServiceTWRealRoundTrip`。
110US 样本的翻牌、小怪、精英段长度分别为 9、36、36,地狱段为
1 个前置值加 2 组 9 个值,共 19 个值。100CN 对应长度为 8、32、32、17。
本次仍编辑五档基础概率,后续新增阈值不重新解释;前五档终止阈值不在
既有兼容范围内时继续拒绝,避免把其他档位的份额归入现有五档。
`item drop rarity control`、其他 section 和其他组的内容均原样保留。
真实高版本验证包括四个文件的读取、第一组概率调整、逐 token 确认仅四个
累计阈值变化,以及分页格式保存重开后的完整载荷一致性。仍保留 90US、
90CN 和 TW 的原有支持,未验证的 115US 和通用分页格式暂不开放。
```sh
PVF_TESTFILE="$PWD/testdata/110US.pvf" \
go test ./services -run '^TestDropRateServicePagedRealRoundTrip$' -count=1
PVF_TESTFILE="$PWD/testdata/100CN.pvf" \
go test ./services -run '^TestDropRateServicePagedRealRoundTrip$' -count=1
```
### 掉率字段按版本换行
`config/rendering.json` 中现有的 `section.basis of rarity dicision` 和
`section.basis of rarity dicision.hell` 增加 `format.tokensPerLineByVersion`:
90US/90CN 为 7、TW 为 6、100CN 为 8、110US 为 9。深渊规则继续使用
`offset: 1`,首个数量值独占一行。未配置或未知版本保留 `tokensPerLine: 7`
的回退行为,不推测尚未验证的 115US 分组宽度。
`tokensPerLineByVersion` 是可选的版本→正整数行宽映射;文件级和 section
级规则均可使用。解析时先选择现有优先级规则,再按 `ClientVersion()` 取得
行宽;如另有 `tokensPerLineIndex`,token 中的动态宽度仍优先。旧规则无需
迁移,规则管理界面可通过 JSON 字段调整此映射。
五个版本的真实样本已检查四类掉率的行数和每行宽度;合成 token 测试覆盖
版本映射、未知版本回退、深渊首值单行、普通段分组及原始 token 字节往返。
仓库规则修改后,运行中的应用仍需重新加载渲染规则;生产应用须包含更新后
的内置配置,不会自动读取当前仓库的 JSON。
+752
View File
@@ -0,0 +1,752 @@
# 110US.pvf 新格式分析笔记
本文记录对 `testdata/110US.pvf`(543,423,313 字节)的实测结论,以及从
`testdata/DFO.exe`(212,122,032 字节,Themida 保护)+ `testdata/sk.dat`
(1,920 字节)反推出来的新版本密钥推导。结论分「已确证」与「待解决」两部分。
## 1. 110US.pvf 的实测结构
### 1.1 文件表是明文,且能直接读
24 字节一条,字段与 90US 完全一致:
```
int NameOffset, PathOffset, ChunkIndex, DataOffset, DataSize, DataType;
```
| 项 | 实测值 |
|---|---|
| 表起始(物理) | 0x2808 |
| 表结束 | 0x62AD830(≈98.6 MiB)|
| 记录数 | ≈ 4,310,872 |
| DataType | 1(脚本)/ 3(文本)|
| 同 chunk 内 DataOffset 连续、ChunkIndex 单调递增 | 是 |
注意 `(0x2808 - 0x30) % 24 == 0`,即记录栅格与「标准布局:0x30 起表」一致。
### 1.2 每 10 MiB 有一个 ~10248 字节的加密块
块 0 位于 `0..0x2808`;此后在 `0xA00000`、`0x1400000`、… 每个 10 MiB
边界上都有(整文件约 52 个)。这些块把记录流切开,但长度是 24 的整数倍,
因此记录栅格不变。
实测:块内容高熵、无自相关、非 zlib、用任何已知 LCG 种子在块内任意 48 字节
窗口都解不出 `nkpi`。
### 1.3 表格之后
`0x62AD830` 起为高熵区(全文件扫描无任何明文 zlib 流),一直延续到文件尾
(约 440 MB):即 hash / 名称池 / GRPI / body,处于「压缩 + 加密」状态。
## 2. DFO.exe 中的新密钥推导(已确证)
### 2.1 新种子公式
`DFO.exe` RVA `0x6A997E0`(magicMain)与 `0x6ADDAB0`(magicAlt)是两段
逐段加解密例程,开头即为种子推导(反汇编节选,字面量已核对):
```
41 0f b7 40 02 movzx eax, word ptr [r8+2] ; k1(16 位单元)
44 69 c8 93 03 00 00 imul r9d, eax, 0x393
41 0f b7 40 04 movzx eax, word ptr [r8+4] ; k2
44 03 c8 add r9d, eax
41 0f b7 40 06 movzx eax, word ptr [r8+6] ; k3
45 69 d1 93 03 00 00 imul r10d, r9d, 0x393
44 03 d0 add r10d, eax
41 0f b7 00 movzx eax, word ptr [r8] ; k0
44 69 c0 11 97 9e 33 imul r8d, eax, 0x339E9711
45 69 ca 93 03 00 00 imul r9d, r10d, 0x393
45 03 c8 add r9d, r8d
```
即:
```go
// 密钥为 4 个 16 位单元(宽字符串,即 UTF-16 字符)
seed = 0x339E9711*u0 + 0x393*(u3 + 0x393*(u2 + 0x393*u1))
```
与 90US 的旧公式 `0x76826701*k[0] + 0x1C1*(...)` 结构相同但常量不同,
且键改为 16 位单元(`movzx word`),所以旧实现必然解不开。
### 2.2 密钥名已反解(穷举 4 字符名匹配已知种子)
用上面的公式穷举 4 字符密钥名,恰好命中 `internal/pvf/variant.go` 里
早已内置的「变体固定常量」:
| 段 | 密钥名 | 新公式算出的 seed | 本仓库 variantKeys() |
|---|---|---|---|
| Header | `hEAd` | `0x4A454634` | `0x4A454634` |
| GRPI | `grpi` | `0x1FBB7078` | `0x1FBB7078` |
| Body | `bODy` | `0xDD4FF706` | `0xDD4FF706` |
| sTrA | `StRa` | `0x712A98D4` | `0x712A98D4` |
| sTrW | `StRw` | `0x712AE776` | `0x712AE776` |
结论:所谓「变体」其实**就是新版本(宽字符串密钥)**,此前只能靠反解得到
常量,现在可以直接用公式算;`HASH` 段的名字仍未知(当时未能反解出该段种子),
但现在可以用「名字 → seed → 解 HASH 段并校验」的方式穷举 4 字符名补上。
已验证:`Script.pvf` 与 `Script3.pvf` 的头部都是 `hEAd`(magicMain,
guard=false)解开的——它们属于这一族。
### 2.3 头部读取路径(0x6A99960..0x6A9B230)
该函数:`fopen` → 读入内存 → 构造流对象 → **分页处理** → 拷贝并解密 48 字节
头部 → `cmp dword [buf], 'nkpi'` → 读 `FileCount*24` 的文件表。
分页循环(关键指令):
```
0x6A9A158 lea rbx, [rcx + r15] ; dest = 缓冲区 + 页号*10MiB
0x6A9A16D mov edx, 0x100
0x6A9A179 call 0x5EE4860 ; 用 32 字节键材料设置 AES(256 位)
0x6A9A17E mov r8d, 0x2800 ; 10240 字节
0x6A9A18E call 0x5EE4320 ; 对页首 0x2800 字节做解密
...
0x6A9A268 add r12, 0x20 ; 每页消耗 32 字节键材料
0x6A9A26C add r15, 0xA00000 ; 下一页 = +10 MiB
```
`0x5EE4320` → `0x73720D0` → `0x73A1B00`:16 字节分组、与前一块异或、对每块调用
回调 —— 标准 **CBC** 结构;`0x7370F00` 内是 AES 密钥扩展(S 盒表位于
`0x9C9BB50`/`0x9C9C750`/`0x9C9CB50`/`0x9C9CF50`/`0x9C9D350`,OpenSSL 风格
T 表)。每页密钥长度为 256 位(`mov [rcx+0xF4], 0x100`)。
因此新格式的物理布局为:
```
每个 10 MiB 页:
[0x0000, 0x2800) AES-CBC 加密的「页首块」(10240 字节 = 640×16)
[0x2800, 10 MiB) 明文(文件表 / 各段密文直接落在逻辑流上)
```
页块与逻辑流**重叠**(就地解密),而非插入:头部(LCG 加密的 48 字节)
位于页 0 块内偏移 0,文件表从逻辑 0x30 开始 → 物理 0x30 之后是加密块、
物理 0x2808 之后是明文记录,两者栅格一致。
### 2.4 尚未解决:每页 32 字节键材料的来源
- 客户端在分页循环前先从流里读出「每页 32 字节」的键表(`0x5EE40B0` 包装的
EVP 解密流,块大小 `[obj+0x160]`),循环中 `add r12, 0x20` 取下一页的键。
- `sk.dat` 1,920 字节 = **60 × 32**,很可疑就是这张键表(52 页只需 1664 字节)。
- 但用 `sk.dat` 的所有 32 字节窗口(以及文件头/尾窗口)按 AES-128/256 的
CBC/ECB/CTR/CFB/OFB、IV ∈ {0, key[:16], key[16:32]} 解页 0 块,再按
`hEAd` 等候选 LCG 种子二次解密查 `nkpi`,均未命中。
- 客户端侧的密钥对象表在 `.rdata`(如 `0x9A1CC50`、`0x9A1CBD8`、
`0x9A1CC20`、`0x9A1CC38`、`0x9A27F60`、`0x9A27F78`,访问器
`0x5ED0E10` 校验首 2 字节 `0x2CA1` 后返回 `obj+4`),但**静态文件中
这些字节已被 Themida 加密**(首 u16 读到的是 `0x5400` 而非 `0x2CA1`),
无法直接读出。
## 2.5 客户端目录:`D:\Games\dxf\110US\DFO_2.31.1.117`
- `Script.pvf` 与 `testdata/110US.pvf` **MD5 完全相同**(`754f787f…`),
`sk.dat` 与 `testdata/sk.dat` 完全相同(`e7116fa6…`)。
- 目录里有 `110US补丁说明/`(基础汉化补丁清单):补丁**成套**替换
`Script.pvf`、`sk.dat`、`dstr.dat`、`ChineseLocalization.dll` 四个文件,
并明确提到验证项包含「字符串池前缀、**索引保护**」。
因此 `sk.dat` 是官方客户端包内文件(`localpackage.lst` 里也有),
且与本 PVF 的「索引保护」配对。
- `ChineseLocalization.dll` 只导入 USER32/GDI32/KERNEL32(字体/文本 hook),
不含文件与密码学代码;页块解密在 `DFO.exe` 内。
- 客户端代码路径:`0x6A99960` 打开**两个文件**(第一个读入内存做页解密目标,
第二个整体读入后包装成流,用 `0x5EE40B0` 读出「每页 32 字节」键表),
然后按上面的分页循环逐页 AES-CBC 解密。
- 已尝试但**未命中**的页键来源(都做了「解出的头部必须是合法 PVF 头」的校验):
`sk.dat` / `dstr.dat` / `NGClient64.aes` 的任意 16/24/32 字节窗口,
AES-128/192/256 × CBC(IV=0/键内 IV/密文首块)/ECB/CTR/CFB/OFB,
以及「先用 LCG(名表 1785 万个 4 字符名反查种子)解一层再解 AES」的组合。
对 `sk.dat` 全部窗口做 AES 解密后的熵检测也完全是随机(≈7.98),
说明键表本身很可能还被**另一层固定密钥**加密(该密钥位于 Themida 保护的
`DFO.exe` 数据里)。
## 2.6 顺带确证:`testdata/Script2.pvf` 是另一种官方新格式(godof)
`pvftools.exe`(Go)依赖公开库 `github.com/dof-dev/godof@v1.1.1`,其
`pvf_reader/header.go` 描述的格式与 `testdata/Script2.pvf` **完全吻合**:
```
int32 uuidLen; char uuid[uuidLen];
int32 version; int32 dirTreeLen; uint32 dirTreeCrc32; int32 fileCount;
byte dirTree[dirTreeLen]; // DecryptCrc(dirTree, dirTreeCrc32) 后为条目数组
条目: uint32 fn; uint32 pathLen; char path[pathLen]; uint32 length;
uint32 crc32; uint32 relativeOffset;
后续: 各文件内容(每块用 DecryptCrc(data, crc32) 解密)
```
`DecryptCrc`(godof `utils/binary_helper/encrypt.go`):以
`key = crc32 ^ 0x81A79011` 逐 dword 异或,再做 `(v<<26)|(v>>6)` 位旋转。
实测 `testdata/Script2.pvf`:uuid=`fa08bf71-4395-6a4b-a3e3-2617c9fee116`、
version=66282、dirTreeLen=98,726,484、fileCount=1,075,879,解出的前几条:
```
monster/newmonsters/new/boss/samuel/action/move.act len=92
monster/newmonsters/anton/phase1/strongleg/.../demoniclancer_nattack3.act len=302
equipment/character/common/wrist/brac_2choro994.equ len=342
```
即**这个 614 MB 归档现在已经有完整的解析方案**(与 110US 是两回事:
110US 顶层不是该布局,仍是「90US 骨架 + 页首块保护」)。
## 2.7 服务端:`D:\Games\dxf\110US\DFO110-0.3.6服务端`(本格式的完整实现)
服务端是 .NET 10 程序,其中 `USLocalServer.Pvf.dll`(39,936 B)**就是
110US 保护格式的实现**(`protection.json` 注记为 `Obfuscar 2.2.50` 混淆、
`server: NativeAOT`)。
类/方法(.NET 元数据,名称虽被混淆成 `A/a/B/b`,但语义命名保留):
| 类型 | 关键成员 |
|---|---|
| `Pvf110Container` | `Unprotect`、`UnsealPageKeyTable`、`UnwrapPageKeyTable`、`DecryptPageGuards`、`MetadataKeyCandidates` |
| `PvfCipher` | `ApplyGuard`、`DeriveSeed`、`Transform`、`TransformWithSeed` |
| `PvfLayout` | `KeysFor(scheme)`、静态构造 |
| `PvfArchive` | `Open`、`OpenClient110`、`OpenClient110Bytes`、`DecodeChunk`、`ReadRaw/ReadText` |
| `Pvf110KeyMaterial` | `MetadataKey`、`PageKeys`、`PagesDecrypted`、`PageKeyCount` |
| `PvfArchiveFormat` | 枚举 `Legacy*`、`LegacyNkpi`、`ProtectedNkpi`、**`Paged110`** |
| 常量 | `HeaderSize=48`、`FileItemSize=24`、`GroupItemSize=8`、`TokenSize=5`、`MagicSignature` |
已从 IL 读到的确定逻辑:
1. **头部校验**(`Pvf110Container.A` @0x273C):取文件前 48 字节 →
用 32 字节键做 `ApplyGuard` → `keys = PvfLayout.KeysFor(2)`(即 Paged110)
→ `PvfCipher.Transform(header48, keys.Header, 2531011)` → 要求头 4 字节
等于 `1768975214`(`"nkpi"`)。
注意 **seed 是常量 `2531011 = 0x269EC3`**(就是 magicMain)。
2. **页键表是 RSA 密封的**(`UnsealPageKeyTable`):取 RSA 私钥 →
`keySize = rsa.KeySize/8` → 校验 `sealed.Length % keySize == 0` →
逐块 `rsa.Decrypt(block, RSAEncryptionPadding.Pkcs1)`。
与 `sk.dat` 1920 B 相容的 RSA 尺寸:1024 位(15 块)/2560 位(6 块)/
3072 位(5 块)等。
3. **页键表还要再用 metadata key 解一层**(`UnwrapPageKeyTable`),
而 metadata key 是从头部字节用 `MetadataKeyCandidates` 试出来的多个候选。
4. `Pvf110KeyMaterial` 的 PEM 标记以 UTF-16 常量形式存在于 `#Blob`
(`-----BEGIN PRIVATE KEY-----` / `-----END PRIVATE KEY-----`),
但 **PEM 正文被 Obfuscar 的字符串隐藏加密**,静态未直接取到。
**可用性**:本机已装 .NET 运行时(`dotnet` 存在但无 SDK),`pwsh` 跑在
.NET 8/9 上,无法直接 `LoadFrom` 这个 .NET 10 程序集(
`System.Private.CoreLib, Version=10.0.0.0` 缺失)。服务端自带 .NET 10 运行时,
`GM\DNF110US-GMTool.exe` 是一个 ASP.NET Core 服务(启动后监听
`http://127.0.0.1:18110`),说明**该库确实能打开 110US 的 Script.pvf**。
### 下一阶段的两条路(择一)
- **A. 用现成实现当预言机**:装 .NET SDK,写一个几行的 C# 宿主调用
`PvfArchive.OpenClient110(...)`,导出文件表/路径/内容;
或直接调用 GM 工具的 HTTP API。拿到明文后即可对照实现我们的 Go 版本。
- **B. 继续静态还原**:① 解 Obfuscar 字符串隐藏得到 PEM 私钥;
② dump `PvfLayout.KeysFor`/`.cctor` 拿各 scheme 的分段 seed;
③ dump `PvfCipher.DeriveSeed/Transform/ApplyGuard` 与
`DecryptPageGuards/UnwrapPageKeyTable` 得到完整算法,然后在 `internal/pvf`
里实现 Paged110 变体。
## 2.8 已跑通:110US.pvf 解析成功 + 固定常量已提取
装好 .NET 10 SDK 后,我写了一个宿主程序(`C:\Users\zhyip_xb2zvud\pvfhost`,
通过 `<Reference>` 直接引用服务端那个 DLL),对
`D:\Games\dxf\110US\DFO_2.31.1.117\` 的客户端文件调用:
```csharp
var a = PvfArchive.OpenClient110(archivePath, executablePath /*DFO.exe*/, sealedKeyPath /*sk.dat*/);
```
实测结果(**文件被完整解析**):
```
format = Paged110
fileCount = 4,311,296 chunkCount = 58,372
bodyOffset = 178,654,053
header: bodySize = 364,769,260 groupCount = 58,372
hashSize = 41,190,252 nameSize = 33,525,673
```
`EnumeratePaths()` 返回 4,311,296 条路径,`ReadText/ReadRaw` 能正确反编译,
例如 `appendage/creature/creature.lst`、`aradadventure/equipment/equipment_1.equ`
(`#PVF_File` 文本)都正常。
### 已提取的固定常量(Go 复刻所需)
| 常量 | 值 |
|---|---|
| `Pvf110Container.PageSize` | 10485760 |
| `PageGuardSize` | 10240 |
| `PageKeySize` | 32 |
| `MetadataAlignment` | 256 |
| `PvfCipher.DataConstant` | 2531011 = `0x269EC3` |
| `PvfCipher.StringConstant` | 2531017 = `0x269EC9` |
| `PvfCipher` LCG 乘数 | 214013 = `0x343FD` |
| PEM 标记 | `-----BEGIN PRIVATE KEY-----` / `-----END PRIVATE KEY-----` |
**RSA 私钥**(RSA-1024,位于 `DFO.exe` 内,由 `Pvf110Container` 解析):
```
Modulus = B3120BBA28E9DB7A87BF61EA60FDD281…394F
D = 9ACDEEF570893B04227680DF6E19FFF1…F701
P = EA47490BB1F9402393A621B0C1389EAC…3589
Q = C3AC5F46B88CD24538CABE7EA551DB33…9A17
DP/DQ/InverseQ 亦已导出(见 rsa_pkcs1.der)
```
`sk.dat`(1920 B)经 `rsa.Decrypt(block, Pkcs1)` 逐 128 字节块解封后得到
**1664 B = 52 × 32 的页密钥表**(正好对应 52 个 10 MiB 页);再经
`UnwrapPageKeyTable(table, metadataKey)` 用 metadata key 剥一层。
`MetadataKeyCandidates(DFO.exe)` 返回 3 个 32 字节候选。
### 分段的 LCG 密钥名(关键收获)
`PvfLayout.KeysFor(scheme)` 给出三套 scheme 的字符串密钥:
| scheme | Header | GroupTable | AnsiPool | UnicodePool | BodyChunk |
|---|---|---|---|---|---|
| LegacyNkpi (0) | `HeaD` | `GRPI` | `sTrA` | `sTrW` | `BodY` |
| ProtectedNkpi (1) | `hEAd` | `grpi` | `StRa` | `StRw` | `bODy` |
| **Paged110 (2)** | **`iNfO`** | **`Gidx`** | **`stAs`** | **`stWs`** | **`mAIn`** |
配合从 DFO.exe 反解出的宽字符种子公式
`seed = 0x339E9711*u0 + 0x393*(u3 + 0x393*(u2 + 0x393*u1))`,
即可算出 Paged110 各段 seed。池长度掩码:Legacy/Protected 为
`2859747118 / 2592272439`,Paged110 为 `3886938090 / 3101599660`。
### 尚未还原完整算法、但已可用的部分
`UnwrapPageKeyTable` 的剥壳规则、`DecryptPageGuards` 与
`ApplyGuard`/`Transform` 的细节还没逐条落到 Go;因为宿主已经能直接用,
**读取不受影响**。下一步照 §2.9 在 `internal/pvf` 里实现 Paged110 变体。
## 2.9 已在 Go 中实现(`internal/pvf/paged110.go`)
用宿主程序取测试向量逐条反推后,Paged110 的完整算法已确证并移植到本项目:
| 步骤 | 结论 |
|---|---|
| 页密钥表解封 | `sk.dat` 按 128 字节块做 RSA-1024 PKCS#1 v1.5 解密 → 1664 B(52 页 × 32 B) |
| 二次解包 | `AES-256-CBC(IV=0)`,作用于前 `len & ~0xFF` 字节,尾部 128 B 不动 |
| metadata key | `DFO.exe` 中 64 个十六进制字符的一串(本客户端第 3 段 `21ad8ff2…4489`),已内置 |
| 页首块 | `AES-256-CBC(IV=0)`,密钥 = 表内第 i 个 32 字节 |
| 头部 | 无 `0x55` guard;`seed("iNfO") = 0x1AAEB306`,magic `0x269EC3` → 4 字节 `nkpi` |
| 分段密钥 | `iNfO`/`Gidx`/`stAs`/`stWs`/`mAIn`,宽字符公式;池掩码 `3886938090`/`3101599660` |
实现细节:
- `internal/pvf/paged110.go`:常量、`wideSeed`、`paged110Keys`、内置 RSA 私钥、
解封/解包/页首块解密、metadata key 候选扫描与解锁。
- `internal/pvf/archive.go`:`Open` 把归档所在目录传给 `parse`,标准/变体路径都
失败时尝试 Paged110;`Parse`(纯内存)不尝试。
- `internal/pvf/stringpool.go` + `variant.go`:`keySet` 增加 `maskA/maskW`,
名称池掩码随方案变化。
- `internal/pvf/save.go`:Paged110 **写回已实现** —— 打开时把解包后的页密钥表留在
`Archive.pageKeys`,写出时按页重新加密前 10,240 字节(`writePageGuarded` 流式写);
未修改的归档重新写出逐字节等于原文件。只支持就地修改现有文件内容:增删文件会
重建名称池而 HASH 段密钥未知(只能原样搬运),因此返回
`ErrPaged110StructureLocked`。
- 测试 `internal/pvf/paged110_test.go`:对 `testdata/110US.pvf` 端到端解析
(4,311,296 条、58,372 块、路径可解析、脚本可读)、常量与密钥管线校验、
**未修改保存的 MD5 与原文件一致**、以及「改一条 `.str` 条目 → 保存 → 重开」
的往返测试。
性能:`Open` 只读 `sk.dat`,内置 metadata key 命中时**不会**去读 212 MB 的
`DFO.exe`;全部页首块解密约 53 万字节 AES,可忽略。实测打开 543 MB 归档
约 6–7 秒(与读取文件本身同量级)。
## 2.10 新版本的 token 类型 8 / 10(equ/stk 的 name 字段)
Paged110 归档的脚本 token 流除了 0/2/3/5/6/7,还用了两种字符串池引用 token:
| type | 含义 | 渲染 |
|---|---|---|
| 8 | 字符串池引用 | `{8=\`...\`}`,例如 `[name]` → `{8=\`<31::equip_name_1>\`}` |
| 10 | 字符串池引用(多行命令文本) | `{10=\`...\`}`,例如 `.act` 里的 ` rs(...) ` |
旧实现只处理 5/6/7,因此 8/10 被直接丢弃 —— 表现为 `.equ`/`.stk` 的
`[name]`/`[flavor text]` 渲染成空。已修正的位置:
- `internal/pvf/file.go`:渲染分支并入 `5, 7, 8, 10`(用 `{N=\`...\`}` 通用形式)、
`scriptTokenValue` 把 8/10 当池引用解析。
- `internal/pvf/script.go` / `script_view.go`:`{8=…}` / `{10=…}` 标记可被解析回
type 8/10(原先只认 `{5=`/`{7=`,且硬编码 3 字符前缀)。
- `internal/pvf/batch.go`、`script_document.go`:补 8/10 的名称与
`ScriptTokenBlock8` / `ScriptTokenBlock10` 类型。
顺带修掉一个与格式无关、但会影响编辑保真的旧问题:整数值的**浮点** token
(如 `8000.0f`)过去渲染成 `8000`,再编码就变成整数 token。现在整值浮点写成
`8000.0`(`formatScriptFloat`),token 流可逐字节往返。
> 说明:`<31::equip_name_1>` 这类占位符是客户端运行时按字符串表解析的
> (本归档里对应 `list/n_string.lst`、`n_string_kor.lst` 与 `string/*.str`),
> 参考实现(pvfUtility/服务端库)渲染的也是占位符本身。本项目额外实现了一层
> 只读的占位符解析,见 §2.11。
## 2.11 占位符解析(`<table::key>` → 实际文本)
客户端把物品名放在独立的字符串表里,脚本中只留 `<表号::键名>` 占位符。
解析链路(`internal/pvf/stringtable.go`):
1. `stringTableFiles` 按**优先级递减**列出映射文件:
`list/n_string.lst`(基准)、`n_string.lst`、
`list/n_string_translate.lst`、`n_string_translate.lst`、
`list/n_string_kor.lst`、`n_string_kor.lst`。
**基准表必须排在最前**:`list/n_string.lst`(37 项)指向 `String/*.uv.str`,
里面是客户端自己显示的语言(这份 110US 里是中文/英文:`equipment.uv.str`
62 万条键);`*.kor.str` / `*.translate.str`(只映射 3 和 13 两个表号)是
随包的语言覆盖层。把覆盖层排在前面会**在基准表有中文时也显示韩文**,这正是
早期版本的现象。
2. `.lst` 本身是普通脚本 token 流:`整数` + `字符串` 交替,前者是表号,
后者是该表的 `.str` 路径。
3. `.str` 是 UTF-16LE 文本,每行 `键>值`,`//` 为注释;文件缺失时回退到同名
的 `.translate.str` / `.kor.str` / `.uv.str` 变体。
4. `ResolveStringTable(表号, 键)` 从基准表往下找**第一个非空**值,
同时回报来源路径与是否回退(`Fallback`)。**空值不算命中**:基准表用
`键=` 的写法列出了所有已知键,其中装备名有 36.7%(133,487/363,315)是空值
(中文补丁没翻),若把空值当命中,这些名字会被直接抹掉。
5. `ResolvePlaceholders` 替换文本中出现的所有 `<表号::键名>`;**解析不到时
原样保留**,不丢信息。`ResolvePlaceholdersMarked(text, suffix)` 会给出覆盖层
的取值追加 `suffix`,供界面标注「未翻译」。
暴露的 API:`Archive.ResolveStringTable`(带来源)、`Archive.LookupStringTable`、
`Archive.ResolvePlaceholder(s)` / `ResolvePlaceholdersMarked`、
`Archive.ItemName(i)`(`[name]` 段 + 占位符解析)。
消费方:`services/equipment_preview.go` 的 `ParseEQU` 在构建完 `[name]`/`[name2]`
后调用 `resolvePreviewText`,显示解析后的文本;如果该名只由语言覆盖层答出,
会追加 `(未翻译)` 标记(如 `포니 비즈 뱅글[A타입](未翻译)`)。
解析是**纯展示层**的:归档里存的就是占位符,脚本文本不会被改写。
实测 110US:抽查 40 个含占位符的 `.equ`/`.stk`,仅 1 个未命中
(`aradadventure/equipment/equipment_1.equ` 的 `<31::equip_name_1>`:该表的
`.str` 里确实没有这个键),测试因此按 ≥90% 的比例断言。解析结果示例:
`白色兽语腰带 [A款]`、`海军风情远航长剑`、`The White Beast Fox Ears [Type A]`,
以及基准表优先生效的例子 `equipment/character/archer/avatar/belt/117530002.equ`
→ `稀有克隆装扮腰部`(韩文表里同键是 `레어 허리 클론 아바타`)。
抽查前 2000 个带占位符的 `.equ`:中文 27%、英文 34%、韩文 39%
(韩文全部来自基准表空值后的覆盖层回退,界面上都带 `(未翻译)` 标记)。
> `.str` 解码目前不做 CP437→EUC-KR 修补(`fixKoreanMojibake`);这份归档的
> `.str` 本身就是正常的 UTF-16 韩文/中文,加修补反而有把正常文本改坏的风险。
> 若遇到韩服转制的归档出现 `╟╤` 这类乱码,可在 `readStringTable` 里按行接上该修补。
## 2.12 110US 的列表布局(`list/*.lst`)与索引
110US 不只换了容器和字符串表,**列表文件的布局也变了**:
| | 90US(`Script.pvf`) | 110US(Paged110) |
|---|---|---|
| 装备列表 | `equipment/equipment.lst` | `list/equipment.lst` |
| 列表条目 | 相对列表所在目录(`character/a.equ`) | **相对归档根**(`equipment/character/a.equ`) |
| 其它列表 | `stackable/stackable.lst`、`npc/npc.lst`、… | `list/stackable.lst`、`list/npc.lst`、… 全部集中在 `list/` |
| 技能列表 | `skill/swordmanskill.lst` | 同左(未变) |
归档里共 566 个 `.lst`,其中 37 个在 `list/` 下(`list/n_string.lst` 等字符串表映射
也在这里)。因此**项目里 `config/lists.json` 配的 90US 路径在 110US 上一个都找不到**,
表现就是:搜索索引里没有装备/道具记录、树节点没有名称标签、只能按路径搜。
实现(两条都做,新旧归档通用):
- `Archive.FindList(path)`:先按配置路径找,再退到 `list/<文件名>`,
反向也成立(配了 `list/x.lst` 时会退到 `<x>/x.lst`)。
搜索索引、`FindFileRegistrations`、NPC 名单都改走它。
- `listPathCandidates` 增加「按归档根解释条目」的候选(`(r)` 变体同样两种都试):
先按列表目录解释(90US),再按归档根解释(110US)。
配套的 kernel 修正:`ScriptMetadata`(搜索/树名称的来源)原先只认 token 类型
3/5/6/7,110US 的 `[name]` 是 type 8/10,于是**名称一直是空的**;现在 8/10 与旧类型
同等处理,并且取到的名称会做占位符解析,所以索引里存的是
`白色兽语腰带 [A款]` 这样的显示文本而不是 `<3::name_514530375>`。
实测 110US:`FindList("equipment/equipment.lst")` → `list/equipment.lst`(361,348 条),
`FindList("stackable/stackable.lst")` → `list/stackable.lst`(139,533 条);
索引共 638,542 条记录(跳过 969),构建约 **21 秒**(打开归档约 7 秒另计);
搜索 `白色兽语腰带`、`海军风情远航长剑`、`The White Beast Fox Ears` 均能命中
(中英文都可搜)。
**未翻译标记**:`ScriptMetadata` 除了给出解析后的名称,还回报 `NameFallback`
(该名只由语言覆盖层答出)。`services` 层的 `markedName` 给这种名字追加
`(未翻译)`,因此**文件树标签、搜索结果、装备悬浮预览、编辑器标注**四处一致:
```
[equipment] 포니 비즈 뱅글[A타입](未翻译) | equipment/character/archer/avatar/belt/117530006.equ
[equipment] 白色兽语腰带 [A款] | equipment/character/demoniclancer/avatar/belt/514530375.equ
```
## 2.13 编辑器里显示译文,并就地修改
编辑器**不能**把占位符替换成译文再保存 —— 那会把归档里的数据改掉(占位符本身
才是存储内容)。因此译文以「标注标签」的形式挂在占位符后面,并且可以直接改:
- `services/annotations.go` 的 `appendPlaceholderAnnotationsLocked` 扫描脚本视图里
的 token,凡是 `<表号::键名>` 能解析出来的,就追加一条
`type = "placeholder"` 的 `EditorAnnotation`:`Title` 是译文(覆盖层答出的加
`(未翻译)`),tooltip 里给出原始占位符与来源 `.str`,并带上结构化的
`Placeholder{TableIndex, Key, Fallback}`,供界面直接发起修改。
- 来源 `.str` 若没超过编辑器 8 MB 上限,标签还带 `TargetFileIndex`,
**Cmd/Ctrl+单击可直接打开该字符串表**。
- 文档本身仍是原样的占位符,编辑/保存的字节不受影响;标签位置随编辑自动跟随
(复用 annotation 的 `StateField` 位置映射)。
- 前端样式:`cm-annotation-tag--placeholder`(斜体 + 虚线边框)与
`--editable`(可点、悬停下划线)。
- **改译文**:单击标签 → 弹框(预填当前译文)→ 确定 → 走
`EditorService.SetPlaceholderText(脚本 index, 表号, 键名, 新文本)`
→ `core.setPlaceholderText` → `Archive.SetStringTableEntryAt`。
实现要点:
- 目标表取「当前真正答出这个键的那一张」(可能是韩文/翻译覆盖层,而不是基准表);
没有任何表有这个键时,追加到基准表末尾(用该表主流的行尾符)。
- **按字节就地替换**:在 UTF-16LE 载荷里找 `键>` 所在行,只换值的那段字节,
BOM、行尾符(`\r\n` / `\n`)与其余字节一个不动;解析不了的表直接拒绝写入。
- 改完立刻刷新:编辑器标注(标题变成新译文)、文件树标签、搜索索引里该文件的
名称(`refreshIndexedRecordsLocked`),并记入版本/撤销历史
(`recordVersionMutationLocked("编辑字符串表", …)`)。
- 真实归档测试:`TestSearchIndexRealPaged110` 在 110US 上改
`name_117530002` → `.str` 更新、脚本文本不变、树标签与新名字可搜。
- 注意:改动落在**内存 overlay**,要点「保存」才会写回归档;110US 的保存依赖
§2.9 的页首块回加密(已实现),且只支持就地修改(不能增删文件)。
### 2.13.1 新建文件时怎么写名称
110US 的物品名基本都放在字符串表里(实测抽 368 个 `.equ`/`.stk`:365 个是
`<表号::键名>` 引用、0 个内联字面量),所以**新文件也应该写引用**。完整配方:
1. **建文件**:新建 `xxx.equ` / `xxx.stk`。
2. **写引用**:在 `[name]` 段写 `{8=`<3::你的键名>`}`。
注意花括号里的 **8 是 token 类型**(字符串池引用),**表号在 `<表号::键名>` 里**
(装备=3、道具=13 最常用)。不要写成裸的 `<3::键名>`:那样会被编码成 type 3
标签串,不是名字。
- 手写嫌麻烦:编辑器工具栏「**字符串引用**」按钮会弹框问表号/键名/译文,
确定后自动在光标处插入 `{8=`<表号::键名>`}` 并把条目写进对应字符串表。
- 只写了引用、还没建条目时,标签会显示红色的「未定义」,点它就能直接填译文
(后端把新键追加到该表号的基准表,插在尾部 NUL 填充之前)。
3. **注册进列表**:把 `id 路径` 加进 `list/equipment.lst`(道具是
`list/stackable.lst`)。`.lst` 是普通脚本,可以直接编辑文本,或用脚本工作区的
`pvf.lst("list/equipment.lst").set(id, path)`(361k 条的大表不适合手改)。
4. **保存**:结构性改动会重建名称池与 HASH(110US 约 35 秒),之后重开即可在
树/搜索里看到新名字。
也可以沿用 90US 的写法直接把名字内联(`` `新装备名` ``):解析层同样认(
`ScriptMetadata` 支持 type 5/6/7 与 8/10),但这个客户端的文件 100% 用引用,
建议保持一致。
## 2.14 与 90US 的字符串方案对比
实测对象:`testdata/Script.pvf`(90US,CN 客户端,105 万文件)与
`testdata/110US.pvf`(Paged110,431 万文件)。
| | 90US | 110US |
|---|---|---|
| 映射文件 | 归档根 `n_string.lst`(30 项)+ `n_string_kor.lst` + `n_string_translate.lst` | `list/n_string.lst`(37 项)+ `list/n_string_kor.lst` + `list/n_string_translate.lst`(根目录同名文件也还在) |
| 基准表指向 | `Character/Character.chn.str`、`equipment/equipment.chn.str` … | `String/*.uv.str` |
| `.str` 实际数量 | **20** 个(chn 10 / kor 7 / jpn 3),最大 648 KB | **84** 个(uv 39 / kor 40 / translate 2),`string/equipment.uv.str` **51 MB**、`string/stackable.uv.str` 40 MB |
| 覆盖范围 | 只有 `aicharacter/`、`etc/`、`monster/`、`passiveobject/`;`equipment`、`character`、`dungeon`、`itemshop`、`map` 的 `.str` **在该客户端里根本不存在**(`n_string.lst` 里却列了) | 全部实体:item / character / dungeon / map / monster / npc / passiveobject / … |
| 脚本里的显示文本 | **内联字面量**:`` [name] `羽林将军的黑色头饰` ``(token type 6) | **占位符**:`[name]` → `{8=`<3::name_10018>`}`(token type 8/10) |
| 抽样各 200 个文件的占位符比例 | `.equ` 0、`.stk` 0、`.mob` 0、`.obj` 0 | `.equ` 200、`.stk` 200、`.mob` 198、`.npc` 197、`.obj` 133、`.act` 65 |
| 表号语义 | —(不存在表引用) | `<数字::键名>`,数字是 `n_string.lst` 里的表号 |
| 键名风格 | —(表里也是 `name_<ID>` 这类,但物品表不存在) | `name_<物品ID>`、`basic_explain_<ID>`、`flavor_text_<ID>` … |
| 文件格式 | UTF-16LE、每行 `键>值`、`//` 注释;`.lst` 是 `整数 + 字符串` token 流 | **与 90US 完全相同** |
要点:**格式没变,用法和规模变了**。90US 已经有字符串表机制(`.lst` 映射 + UTF-16
`.str`),但那份客户端只带了 20 个 `.str`(怪物名、被动对象、角色台词、etc 文本),
物品/角色/地下城等名称直接内联在脚本里;110US 把这些名称整体搬进 `.str`,脚本里
只留数字表号占位符,并新增 type 8/10 字符串池引用 token 承载它们。
两个容易混淆的点:
- 90US 的 `<npc::1071>`、`<apc::26514>` **不是字符串占位符**,而是对话文本里的
「说话人/立绘引用」标记,后面还跟着真正的文本(`` `<npc::1071>卢克爷爷是个很伟大的人!` ``)。
实现里 `parsePlaceholder` 只接受数字表号,所以不会误解析它们。
- 90US 那份 CN 客户端的 `.str` 数据质量差:`etc/etc.chn.str` 里非 ASCII 大量变成
`?`,`monster/monster.kor.str` 是「韩文逐字替换成形近汉字」的产物
(如 `agamemnon_01>堡戎狼 酒啊糕稠`)。110US 是正常中文/英文/韩文。
两者的 `.str` 都没有 CP437 伪画(`looksLikeCP437Painting` 为 false),
所以读取路径不需要韩服乱码修补。
容器侧另有一处相关差异:字符串池的 section 名与长度掩码不同 —— 90US 是
`sTrA`/`sTrW`(掩码 2859747118 / 2592272439),110US 是 `stAs`/`stWs`
(掩码 3886938090 / 3101599660,见 §2.9)。
## 2.15 HASH 段密钥:反解与结果
HASH 段布局(`count + count×(nameOff,pathOff) + n + n×偏移表`,见 FORMAT §8)之前
只在标准 90US 有密钥,Protected 变体与 Paged110 都「原样搬运」,导致增删文件时
HASH 会指向失效偏移。这次做完了:
**Protected 变体:密钥名就是 `hash`(全小写)+ `wideSeed` + magic `0x269EC3`。**
做法不是穷举(4 字符名空间太大),而是只试 16 个大小写组合 × 两种公式,
再用结构验证。实测:
| 归档 | 用 `wideSeed("hash")` 解出 | 与本地重建的对比 |
|---|---|---|
| `Script.pvf`(105.7 万文件) | count=1,057,644、n=558,373 | 条目集覆盖我们全部 1,057,625 条,客户端多 19 条(历史残留) |
| `Script3.pvf` | count=1,057,892、n=558,622 | 同上(多 19 条) |
**Paged110:密钥名未知,但种子可由结构反解。** 段首 dword 是条目数(110US 恰好等于
文件数 4,311,296),已知这个明文 dword 就钉住首个 keystream dword 的低 16 位,
每个 (magic, count) 只剩 ≤4 个候选;再用「长度精确满足 `4+count*8+4+n*4` + 抽样
偏移可解析 + 尾部按串递增」筛选。110US 解出 `seed=0x5824C072`、magic `0x269EC3`,
解出来的条目集与尾部偏移集**与我们重建的表 100% 一致**(4,311,296 / 1,674,969 全中)。
90US 变体族因为条目数比文件数多,搜索窗口取文件数 ±64。
**顺带修掉一个真 bug**:`buildNameTable` 过去硬编码 90US 的 `sTrA`/`sTrW` 密钥与
`xorStrA`/`xorStrW` 掩码。90US 两个家族的掩码恰好相同所以没暴露;Paged110 的掩码
不同,写回后段首长度字段解码成垃圾,解析器直接跳过整个池 —— 表现是重开后**所有
路径与名称变空**。现在按归档自身的 `keys.strA/strW` 与 `maskA/maskW` 写回。
**第二个真 bug(新增表项时才会碰到)**:`.str` 载荷末尾有 NUL 填充,而读取端
`decodeUTF16` 在**第一个 NUL 处截断**。原先「追加新键」是直接 append 到文件末尾,
于是新行落在填充之后,谁也看不见(同一个会话里查表就已经是 miss)。现在先把尾部
NUL 填充摘出来,插在填充**之前**,再把填充补回去;顺带支持编辑「没有行尾符的
最后一行」(遇到 NUL 即为行尾)。
验证:
- `TestPaged110StructuralEditRoundTrip`:向零售 110US **新增一个文件** → 保存
(页首块回加密 + HASH 重新生成 + 名称池重建)→ 重开:文件数 +1、新文件可读、
重建的 HASH 索引到新条目、原有物品名仍解析正确。
- `TestVariantHashRegeneratedOnSave`:90US 变体同上,HASH 由「逐字节搬运」改为
「重新生成并覆盖新条目」。
- 代价:结构性编辑要重新压缩名称池(110US 的 UTF-16 池解压后 401 MB,实测约 35 s);
只改文件内容的编辑不触发池重建。
## 2.16 `list/*_indexhash.etc`(新增物品要写的第三个文件)
110US 的 `list/` 下每个列表都配了一个 `<列表名>_indexhash.etc`(13 个,90US 完全没有):
| 列表 | 索引文件 | 列表条目 | 索引条目 |
|---|---|---|---|
| `list/equipment.lst` | `list/equipment_indexhash.etc` | 361,348 | 413,119 |
| `list/stackable.lst` | `list/stackable_indexhash.etc` | 139,533 | 139,754 |
| `list/monster.lst` | `list/monster_indexhash.etc` | 13,731 | 16,751 |
| `list/npc.lst` | `list/npc_indexhash.etc` | 3,361 | 3,977 |
| `list/appendage.lst` | `list/appendage_indexhash.etc`(另有 `_v5.etc`) | 7,843 | 8,345 |
**结构(已确认)**:和 `.lst` 同一套 token 流,`(type 0 整数 id)(type 6 字符串)`,
每条 10 字节。第二个 token 不是 int,而是**字符串**;字符串内容是**十进制数字**
(一个 uint32;因为可能超过 2^31,所以没有用有符号 int token 存)。
**这个数字是什么(已排除的假设)**:
- 不是路径/名字的常见哈希:crc32(IEEE/Castagnoli/Koopman)、fnv1/fnv1a、djb2、sdbm、
java、jenkins、bkdr、LCG 系列(用容器自己的 `0x343FD/0x269EC3` 等)等 ~30 种函数
× 路径/目录/文件名/去扩展名/小写/id 文本/`name_<id>`/UTF-16 字节 ≈ 0 命中(每项 400–2000 样本)。
- 不是路径相关:`stackable` 与 `appendage_v5` 共有 3160 个 id,**路径完全不同但数值完全相同**。
- 不是 id 的线性函数(`A*id+B` 只在前两条上成立);也不是另一条记录的 token/偏移。
- 它只跟 **id** 有关:`stackable` / `monster` / `dnf` / `appendage` / `appendage_v5`
之间共有 163–6094 个 id,数值 100% 相同(`equipment` 的 id 是真实物品 ID,与这些
“序数 id”列表没有可比样本)。`id=0 → 0`。
**覆盖率**:索引文件是列表的**严格超集** —— 五组样本里「在列表里但索引没有」的
条目都是 **0**,而多余条目(历史残留)有几百到 5 万。也就是说:原厂工具每次维护列表时
都会顺带把索引补齐,而**我们自己新增的 id 就会缺这一条** —— 这正是「新增道具失败」
最可疑的地方,但我们无法凭空算出那个数字。
**已实现的工具**(`internal/pvf/indexhash.go`):
- `IndexHashCompanionPath(listPath)`:`list/x.lst` → `list/x_indexhash.etc`。
- `IndexHashPairs(path)`:解析成 `{ID, Value}`。
- `IndexHashGaps(listPath)`:**列出「在列表里、但索引里没有」的 id**(新增物品后
这里就会出现你新加的 id)。
- `SetIndexHashEntry(path, id, value)`:按原文件的写法追加/更新一条
(值写成十进制文本再取字符串池偏移,其余条目逐字节保留)。
- `IndexHashSiblingPaths(listPath)`:附带 `_v5` 变体时一并列出。
- 测试:`TestIndexHashReadWrite`(读写/更新/追加/字节结构)与
`TestIndexHashRealArchive`(零售 110US:两个列表的 gaps 都是 0;注册一个新 id
后 gaps 恰好是那个 id,写入后归零)。
**此前未解决**:那个 uint32 的生成函数。要对上新条目,需要下面任一条件:
1. 在游戏里试验:先只加 `.lst`(现状)→ 看物品是否出现;再把索引条目用
邻近条目的数值补上 → 再看。这能判定客户端是否校验这个值。
2. 找到写这批 `.etc` 的工具(本机 `D:\Games\dxf\tools` 里的工具与 `DFO.exe`、
服务端二进制都没有 "indexhash" 字样,客户端也不含 `n_string`/`equipment.lst`
这类字面量,所以文件名是运行时拼的,无法用字符串搜索定位)。
3. 另一份同版本客户端的同名文件:若同一个 **新 id** 在别处已有条目,就能直接读出
正确值甚至拟合函数。
**已解决(2026-09-19)**:用 `testdata/110US.pvf` 的主列表在用条目以及
其它普通索引条目拟合并逐项校验后,新增 ID 的生成函数确认为无初始常量的
32 位可逆混合(两轮乘法):
```text
x = ((x ^ (x >> 16)) * 0x45d9f3b) // uint32 溢出保留低 32 位
x = ((x ^ (x >> 16)) * 0x45d9f3b)
value = x ^ (x >> 16)
```
因此 `id=0` 的值为 0;例如 `id=1` → `824515495`,`id=1000` →
`4040455147`。实现为 `internal/pvf.IndexHashValue`,新增条目可通过
`Archive.SetIndexHashEntryForID` 写入。
另一个写入约束也已由实际闪退归档确认:110US 中这些十进制 hash 文本即使只含
ASCII,也必须放在 `sTrW` UTF-16 池(magic offset 为奇数)。放入 `sTrA` 后,
工具自身仍能解析出相同数字,但客户端在取新增物品时会失败;修复逻辑会强制使用
UTF-16 池,并可把已有的偶偏移条目重新写正。
归档中仍有少量历史/特殊行不遵循这条规则:`equipment_indexhash.etc` 有 6 条
不在当前 `equipment.lst` 的残留,`itemshop_indexhash.etc` 有一条显式为 0,
`passiveobject_indexhash.etc` 有一批特殊对象记录。它们不是新增普通条目的生成依据;
回归测试覆盖 `equipment/stackable/monster/npc/appendage` 的在用条目。
## 2.17 中文写进脚本后游戏内乱码的原因(字符串池选错)
现象:在 90CN 归档里给物品写中文名(或改任何非 ASCII 文本),保存后进游戏显示乱码。
原因:**客户端只从 sTrW(UTF-16 池)读非 ASCII 文本**,而旧实现把一切新字符串都
append 到 sTrA(UTF-8 池)并用偶偏移引用。我们自己读得回来(两边都认),客户端却按
自己的约定取不到这段文本。
实测两代归档的既有文本,规则 100% 一致:
| 归档 | ASCII → sTrA(偶偏移) | 非 ASCII → sTrW(奇偏移) | 反例 |
|---|---|---|---|
| `D:\Games\dxf\90CN\Script.pvf`(Protected 变体,105 万文件) | 9,904 条 | 1,141 条 | 0 |
| `testdata/110US.pvf`(Paged110) | 0(它的 sTrA 为空) | 24,821 条(ASCII 也在 sTrW) | 0 |
例:`equipment/character/swordman/avatar/cap/chn_equ_special_avatar_swordman_cap_101550507.equ`
的 `羽林将军的黑色头饰` 存在 `off=871953`(奇 → sTrW)。
修复(`internal/pvf/stringpool.go` + `script_document.go`):
1. 先按原样查两个池的索引,命中就沿用原偏移(不动既有文本);
2. 未命中:非 ASCII → sTrW;ASCII → 归档 sTrA 非空则进 sTrA,否则进 sTrW
(110US 的 sTrA 为空,该客户端把所有字符串都放 sTrW)。
顺带修掉 TypeUnicode 的另一半问题:韩服转制的 `.str` 载荷是「EUC-KR 字节按 CP437
字体逐字映射」的伪画形式,编辑器读的时候会还原成可读韩文,但保存时旧代码写回普通
UTF-16LE → 客户端读到的就不是它要的字节。现在 `SetText` 检测原载荷是否伪画,是则用
`EncodeKoreanMojibake` 伪画回写,读写一致。
> 注意:90CN 的 sTrA 里本来就有 13.7 万个非 ASCII 字节(历史遗留),所以回归测试
> 校验的是「新增文本没有让 sTrA 的非 ASCII 字节变多」,而不是「sTrA 纯 ASCII」。
测试:`TestStringOffsetPoolConvention`、`TestSetTextKeepsPaintedKoreanPayloads`、
`TestVariantChineseNameEncoding`(真实 90CN:把某物品名改成 `测试中文名称` → 保存 →
重开:名字正确、落在 sTrW、sTrA 非 ASCII 未增加)。
## 3. 复现用脚本(当时临时创建,已删除)
分析时用过:
- 用 `capstone` 反汇编 PE 指定 RVA(按 `.pdata` 取函数边界);
- 用穷举 4 字符密钥名 + `newKeySeed` 匹配已知种子;
- 用 `pycryptodome` 尝试 AES 键/模式组合。
如需继续,建议保留其中「PE 反汇编 + 新公式穷举」两步。
## 4. 建议的后续步骤
1. ~~补 HASH 段的密钥名~~ **已完成**,见 §2.15。
2. **`Script2.pvf`(godof 布局)**:按 §2.6 实现独立解析路径,
项目里的 614 MB 归档即可打开(可用 `dof-dev/godof` 作为对照实现)。
3. **字符串的编辑体验**(承接 §2.14):**已完成** —— 编辑器里点占位符标签弹框改译文,
后端按 UTF-16LE 字节就地替换对应 `.str` 条目(详见 §2.13),保存后生效。
大表(`String/equipment.uv.str` 48.9 MB)不需要整文件打开也能改单条。
4. **界面消费占位符解析**:装备预览、编辑器标注、搜索结果与文件树标签都已接入;
其余面板(批次处理、版本对比里的名称)如需显示译文再复用
`ResolvePlaceholders` / `markedName` 即可。
### 历史备注(已完成)
- 110US 的页键:由 `sk.dat` → RSA-1024 PKCS#1 v1.5 → AES-256-CBC(IV=0) 解出,
见 §2.8;不需要借助第三方工具或 dump 进程内存。
- 110US 的写回:页首块在保存时重新加密,见 §2.9;未修改归档逐字节还原。
- HASH 段密钥:Protected 变体是 `wideSeed("hash")`,Paged110 由结构反解,
见 §2.15;增删文件因此可用。
+184
View File
@@ -0,0 +1,184 @@
# SQLite 索引增量优化方案
状态:文件投影、语义搜索和高级字符串引用的增量路径已实施。下述设计保留原始目标,实际采用兼容现有 fileIndex API 的身份映射方案。更新于 2026-10-13。
## 完成情况
- 文件投影:新建、删除、导入、脚本结构变更和版本物化复用活动数据库;删除在一个事务中调整全部关联表编号。普通内容变更在服务返回前同步文件大小、类型和变更状态,失败向调用者返回错误并禁用不一致投影。
- 语义搜索:通过稳定条目身份和路径变更队列定位影响范围,读取列表登记后只解析受影响文件的元数据;在 WAL 事务中替换其记录、标签、图标和依赖。保留未改动行和数据库索引,不复制完整候选库。
- 依赖:记录字符串表及缺失的本地化候选路径;字符串映射改变后刷新引用文件;NPC 改动刷新商城关系;关系配置变更按新旧登记差异更新。首次建库和用户强制重建仍走全量路径。
- 后台发布:校验归档对象、generation、batchRevision 和变更队列 epoch。过期结果重新排队,失败保留待处理范围;不会用旧结果清除新修改。
- 查询:语义未完成时普通搜索回退到文件路径,精确登记查询报告索引更新中,树中隐藏脏语义的标签与图标。分页绑定索引 generation 和查询数据源;高级搜索分页也绑定语义生成及完成状态。
- 高级字符串搜索:保留不可变引用分片,私有元数据库维护文件编号映射、修改覆盖引用和新增池字符串。只解析新增/修改文件,删除无需重写引用分片。旧池前缀指纹不兼容时,自动回退到完整构建。
- 缓存:SQLite 文件索引校验加载内容摘要;摘要按不可变打包基线缓存,保存后失效。高级搜索的保存后覆盖结果可发布成 v4 缓存,元数据库使用 backup 快照,引用分片使用不可变硬链接;恢复后再次编辑不会修改缓存。
- 全量重建优化:候选库只复制 `meta/files/dirs`,创建空语义表,避免复制后清空旧语义数据。普通未登记文件直接查询 `files`,语义表不再保存重复文件记录;按语义流、文件流分别分页,保留多列表登记。依赖路径按表编号缓存、按文件去重,128 条一批使用预编译 SQL,全量完成后建立依赖索引。文件索引 schema 升级为 5,旧缓存不直接复用;高级字符串缓存仍为 v4。
- 文件身份在解析、新增时分配,克隆、压缩删除及 TW 保存时保留,不写入 PVF。SQL 仍用现有 file_index,结构事务统一重映射关联表;这是对原“持久化 entry_id”设计的兼容替代,不改变绑定和文件格式。
仍有线性成本:条目身份扫描、暂存克隆、登记列表读取、删除编号重映射,以及首次从干净缓存分离工作区时的数据库复制。首次建库、schema 升级、损坏修复、强制重建和不兼容池代际允许全量构建。新增 NPK 增量扫描不在本方案范围。
## 第一批实现范围
- 为归档条目增加进程内稳定身份,解析、新增时分配,克隆与删除压缩时保留;变更日志同时记录身份。身份不写入 PVF,不改变文件格式。
- SQLite 文件投影保留现有 schema:使用稳定身份计算新旧编号映射,事务内同步调整 files、records、tags、visuals 的编号。持久化 `entry_id` 及语义表关联迁移留待下一阶段,当前采用兼容现有查询的过渡实现。
- 已接入新建、删除、导入,以及复用结构重建入口的脚本、撤销、恢复;历史结构切换可复用活动文件投影。打开归档时的版本工作区恢复仍使用原来的准备流程。
- 新建、删除、导入使用暂存归档,SQLite 事务失败时不发布暂存状态;对已经修改 live 归档的入口,失败后禁用旧投影并报告错误,用户可通过重建入口修复。
- 按受影响目录更新直接子项计数,修正原全量构建根目录把自身计入子项的问题。
- 首次编辑干净缓存前复制到独立会话库,后续复用会话库。保存发布缓存使用一致的 SQLite backup 快照,活动连接仍指向会话文件。
- 第一批交付时语义索引和高级字符串索引仍沿用原来的全量路径;现已由上方“完成情况”中的增量实现替代。
- 当前仍扫描条目身份数组,并在新建/删除暂存时克隆归档;后续性能优化可减少这些内存操作。此批主要消除同步重建文件数据库,不承诺整个操作为 O(变更数)。
## 目标与范围
将正常编辑、新建、删除、导入和版本恢复从“重建数据库”改为“更新受影响的行”。优先优化大归档的文件、目录及语义索引;高级字符串反向索引后续接入相同变更协议。NPK 图片索引独立处理,不纳入本次实施。
不承诺所有操作为 O(变更文件数):当前 PVF 删除会压缩整个文件表,必须处理编号变化;消除全量解析和数据库重建,不等于消除所有全量遍历。
## 当前限制
- `CreateFile`、`deleteFiles`、脚本结构提交、结构性版本恢复调用 `rebuildArchiveIndexesLocked`。
- 导入无论新增还是覆盖,都调用 `prepareArchiveDerivedIndexes`。
- `openSQLiteArchiveIndexContext` 在归档已修改时跳过缓存,重新生成 files、dirs 和数据库索引。
- SQLite 语义刷新调用 `prepareSemanticCandidate` 复制整个数据库,再清空 records、tags、visuals 并重新生成;没有使用内存索引已有的增量路径。
- `RemoveFiles` 压缩编号,当前多张表直接引用 `file_index`。仅删除目标行会导致存量记录指向错误文件。
- 高级字符串搜索在变更后整库失效,下次查询按需重新构建。
- 当前完整性标志不能表达文件投影版本、语义完成版本和待更新范围,需要扩展。
## 一致性要求
1. 操作成功返回时,归档和文件投影属于同一修订:路径、编号、类型、大小、变更状态、目录及直接子项计数正确。
2. 已删除文件不能继续出现在树、搜索或引用详情中;所有返回的文件编号必须来自当前映射,不能复用旧快照编号。
3. 同一路径允许存在多个归档条目。路径用于查找和语义关系解析,不能作为文件身份的唯一键。
4. 语义数据可以后台更新,但未完成范围必须显式标记。默认查询对脏范围使用路径回退,不展示过期名称、标签或图标为最新结果;精确语义查询报告“索引更新中”,不得把缺失结果宣称为完整结果。
5. SQL 失败不能出现“归档已成功修改、数据库仍停留在旧版本”而服务继续正常返回。必须回滚归档变更,或禁用不匹配投影并进入可见的恢复状态。
6. 后台结果只能发布到其对应的文件内容、关系配置和归档会话;旧任务不能清除新变更留下的脏标记。
7. 未保存工作区数据库不能冒充原始 PVF 的干净缓存。缓存不匹配或损坏时必须拒绝复用。
## 数据模型
原设计建议升级 schema,增加 SQLite 内部稳定身份,不修改 PVF 文件格式和 Wails 的 `fileIndex` API。最终实现保留现有 SQL 编号关联,使用进程内稳定身份生成事务映射;下表为原始目标模型,并非当前数据库完整定义。
| 数据 | 建议字段与职责 |
| --- | --- |
| files | `entry_id` 为稳定主键;`file_index` 为当前归档编号,唯一;保留路径、类型、大小等现有字段 |
| records | 使用 `entry_id` 关联文件;记录所属 `relation_key`,允许同一文件存在多个列表登记 |
| tags / visuals | 使用 `entry_id`;标签保留来源关系,避免删除一条关系时误删其他来源 |
| dirs | 保持路径主键,记录直接文件和直接子目录数量 |
| dirty_entries | `entry_id`、目标内容修订、影响掩码 |
| dirty_relations | 关系标识、目标关系修订 |
| meta | schema、会话身份、源内容身份、文件投影修订、语义完成修订、配置指纹、完成状态 |
查询通过 files 连接得到当前 `file_index`;不向前端暴露 `entry_id`。删除文件时事务内显式删除关联数据,或使用经过验证的外键级联。
`entry_id` 由索引会话分配,删除后不复用。克隆归档、导入和版本物化必须携带条目存续映射;不能仅凭规范化路径推断身份。重新打开归档可从有效缓存恢复身份,重新全量构建时允许重新分配身份。
## 统一变更协议
现有 `MutationSummary` 保留作为兼容入口,但结构操作应生成一次提交级别的 IndexDelta:
- 基础与目标归档修订、会话身份。
- 新增、删除、修改条目及前后元数据。
- 存续条目的旧编号到新编号映射,或由核心操作提供的等价压缩映射。
- 受影响目录、列表关系、字符串表及 NPC 依赖。
- 内容类型变化、路径变化和关系配置变化。
不能直接使用日志中的 `Index` 查询当前文件:它可能在多次删除之后已经过期。批量操作、脚本和恢复应保留提交前身份,按最终状态归并增删改;新增后又删除可抵消,删除后重建同路径不能误当成存续条目。
### 发布步骤
1. 捕获当前会话、修订与条目身份,准备归档暂存变更及 IndexDelta。优先复用现有隔离暂存机制,避免每次复制完整归档。
2. 在暂存归档或受保护的读取阶段计算必要元数据。耗时解析与磁盘 IO 不放在长期持有的核心写锁内。
3. 在 SQLite 写事务中校验基础修订,更新文件、目录、身份映射,清除删除条目和失效语义,写入脏范围与目标修订。
4. 最终发布阶段统一使用锁顺序 `core.mu → index.dbMu`,再校验会话与基础修订。提交 SQL 后安装对应归档状态、更新内存修订,并在释放锁后发送变更事件。
5. 读取必须遵守同一发布屏障;SQL 提交到内存切换之间不允许服务请求读取新数据库配旧归档。数据库提交后内存安装应设计为不会失败;无法做到时必须有撤销状态或明确的失效恢复分支。
6. 启动后台语义刷新。操作返回不等待语义解析,但必须已经完成文件结构一致性提交。
实现时校核 SQLite 读事务的启动时间和归档快照修订,不能只在查询末尾检查 generation。持有旧 SQL 快照的请求不能取得新的归档编号映射。
## 文件和目录的增量更新
### 新建、覆盖、导入
- 新建:插入 files;沿祖先链补齐缺失目录;只更新直接受影响目录的计数。
- 覆盖:更新对应条目的类型、大小、变更状态,标记必要语义依赖;无须生成完整目录投影。
- 导入:全部变更在一次文件投影事务中提交;新增和覆盖混合处理,不因导入入口而重建数据库。
- 创建新文件时,仅在相关列表已经登记该路径时安排关系修复;同时检查此前未解析的登记,避免新增目标后登记仍永久缺失。
### 删除与编号压缩
- 使用删除前身份删除条目及其关联记录。
- 根据核心返回的压缩映射,仅更新存续条目的 `file_index`;records、tags、visuals 无须逐表重新编号。
- 映射更新使用事务内临时映射及两阶段唯一值转换,避免新旧编号交叉导致唯一约束冲突。内部临时编号不能对读者可见。
- 删除空目录自底向上进行;每移除一个空子目录,只调整其父目录的直接子项计数;根目录保留。
- 重复路径删除后可能改变 `.lst` 目标的首选条目,需重算引用该路径的关系,不能只级联删除旧目标。
- 删除靠前条目仍可能需要 O(N) 映射更新,这是保留当前 PVF 紧凑编号的成本,但不再重新解析元数据、复制数据库或重建所有数据库索引。
不建议首轮改 PVF 核心为墓碑或永久稀疏编号:这会扩展到解析、保存、编辑器和绑定契约,超出当前优化范围。
## 语义索引的增量更新
后台只解析受影响的数据,在内存暂存小范围结果,随后用短事务替换受影响行。长期保留数据库二级索引,不再每次 DROP/CREATE,不复制完整候选数据库。
| 变更 | 刷新范围 |
| --- | --- |
| 已登记文件名称、稀有度、图标变化 | 对应条目的元数据及其全部登记记录 |
| 可识别 `.lst` 修改 | 该列表的新旧登记差异;第一版可整体替换单列表关系 |
| 新增、删除 `.lst` | 对应关系;影响不可判定时扩展到相关关系组 |
| `.str` 修改 | 引用受影响表或键的条目;第一版允许按整张字符串表扩展 |
| NPC 名称或登记修改 | 依赖对应 NPC 的商城记录;第一版允许刷新整个商城分类 |
| 搜索关系配置变更 | 新增、移除或改变的关系定义;显示注释、渲染规则单独变化不触发语义重建 |
| 普通内容变更且搜索字段未变化 | 只更新文件元数据,无须语义刷新 |
需保存列表目标依赖及字符串表、NPC 依赖。依赖包含未解析目标;依赖尚未完整时回退到单关系、单分类刷新,不能把“未找到依赖”当作“没有影响”。同一文件在多列表登记时保留所有关系来源。
每个结果携带条目内容修订、关系修订和配置指纹。发布前逐项校验;发生并发修改的部分重新排队,其余结果可提交。仅删除与本次目标修订匹配的 dirty 行,连续编辑合并为最新任务。
列表任务按关系串行或用关系修订校验;文件任务解析和发布时必须关联稳定身份。若字符串解析器不能提供隔离快照,解析阶段使用受控读锁,禁止后台直接读取正在修改的归档。
## 事务、缓存与恢复
- 活动会话数据库使用 WAL 和 `synchronous=NORMAL`,写事务有可靠回滚;禁止沿用全量临时构建使用的 `journal_mode=OFF`。索引属于可重建派生数据,NORMAL 不保证突然断电后保留最新提交,但重启必须验证缓存身份并拒绝不完整缓存。
- 首次全量构建仍可使用独立候选库和快速参数;未完成候选不能作为活动投影发布。
- 干净缓存与可变会话库分离,禁止直接修改不可变缓存或其硬链接。首次需要写入时允许一次性复制或使用文件系统支持的复制优化;后续局部刷新禁止反复复制整库。
- 保存或另存为后,按已保存内容身份与索引修订发布新的干净缓存快照。若语义尚未追平,可只发布明确有效的文件投影,重新打开后继续构建语义。
- 后台失败保留 dirty 范围,标记错误并允许重试;不得提前设置 ready。文件投影可继续使用,语义结果按前述回退规则读取。
- 查询分页游标绑定会话、结构修订及相关查询修订。结构或排序依据变化后拒绝旧游标,不能返回混合版本分页。
## 高级字符串反向索引:第二阶段
复用稳定条目身份、内容修订与 dirty 队列。普通文件编辑只替换该文件的字符串引用,新建插入、删除移除;存量引用通过映射取得当前编号。
字符串池偏移必须携带池身份或代际。确认池为追加写且旧偏移有效时才增量追加;池整体重排、重建或归档替换后偏移不兼容,需重建受影响池及其引用,必要时全量回退。占位符解析依赖变化单独更新 resolved 值。
引用底库更新与查询结果缓存失效分离:一次编辑可以废弃查询结果和游标,但不应销毁整个引用底库。现有持久缓存/硬链接必须保持不可变,写入前建立独立会话库。
## 保留全量重建的边界
- 首次建库、旧 schema 无法安全迁移、缓存损坏或源身份不匹配。
- 文件身份映射缺失、结构差异不可判定、池偏移代际不兼容。
- 关系解析语义整体变化,且无法确定受影响范围。
- 用户明确要求强制重建或修复。
大批量变更优先执行集合更新和按分类替换;是否改用全量候选由实测成本决定,不设置未经验证的固定百分比阈值。全量回退记录原因,并且不能在正常新建或单文件编辑中静默触发。
## 实施顺序
1. 引入修订、稳定身份、IndexDelta 与事务发布协议;迁移查询到稳定身份关联;完成失败注入测试。
2. 接入新建、删除和导入,替换同步文件投影重建;保留内存索引现有分支。活动 SQLite 会话即使删除后低于 10 万文件,也继续沿用,避免跨阈值立即迁移。
3. 接入脚本、撤销、单文件恢复、历史切换及备份恢复,统一结构变更映射。
4. 实现已登记文件与列表的语义增量,再补齐字符串表、NPC 和规则依赖;删除常规更新中的完整候选库复制。
5. 优化高级字符串反向索引,保留不可兼容池的重建边界。
每一步均保留带原因的全量修复入口,但新旧路径必须共享一致性检查。
## 验证与验收
- 以同一最终归档全量构建的索引为参照,对比目录、文件、语义记录、标签、图标和搜索结果;比较逻辑内容,忽略内部 ID 和行顺序差异。
- 覆盖首部/中部/尾部删除、多次压缩、重复路径、多列表登记、缺失登记目标后新增、同路径删除后重建、空目录、批量导入与类型变化。
- 覆盖撤销/历史切换、连续编辑、语义发布前再修改、构建中关闭或重新打开归档、旧分页游标、阈值穿越。
- 注入 SQL 执行、提交、后台解析、缓存发布失败,验证操作不会报告虚假成功,脏标记不丢失且恢复后与全量参照一致。
- 崩溃后重启拒绝身份不匹配或未完成缓存;独立验证活动会话不会污染干净缓存。
- 在同一真实归档和相同 Go 编译参数下测量冷/热状态至少三次,记录操作返回、语义追平、锁等待、SQL 写入行数、磁盘写入量和峰值内存;沿用《索引性能验证》的环境约束。
- 单文件新增/覆盖和局部语义编辑不得调用全量建库、整库复制、全表清空或 DROP/CREATE INDEX。删除允许映射扫描,但不得重新解析全部文件。
- 文件投影提交后立即可读且一致;语义未完成状态可辨识,完成后结果与全量参照一致。
性能目标在基线测试后确定,本方案不将未测量的毫秒级耗时作为承诺。实现阶段运行 `go test ./...`、前端构建;真实归档回归使用 `PVF_TESTFILE`,不提交归档和个人路径。
+1
View File
@@ -0,0 +1 @@
- [x] NPK并发扫描
+61
View File
@@ -0,0 +1,61 @@
# 独立掉落编辑器
入口:工具 → 独立掉落;也可打开 `etc/independent_drop.etc` 切换 GUI。
外部共享列表位于 `etc/independentdrop/`,同样支持 GUI。
支持按来源、物品名称或 ID 搜索,按掉落模式过滤;编辑、复制、删除、新增记录,
转换直接物品/内嵌列表/外部列表三种模式;对筛选结果批量设置概率、数量和等级。
内嵌列表支持物品选择、增删、ID 与权重编辑;保留重复项、零权重及顺序。
外部列表通过 `etc/independentdrop.lst` 解析,可更换引用或打开共享文件编辑。
副本平衡参数按原字段顺序编辑。未知物品不阻止加载。
修改先进入当前文件草稿,保存独立掉落后进入工作区,再通过保存 PVF 写入归档。
文件 GUI 与文本编辑器共用草稿、撤销及保存流程,归档切换或文本改变后旧表单自动失效。
共享列表修改影响所有引用它的记录。
## 格式与变体研究
参考上游 [pvfUtility 独立掉落模型](https://github.com/Lancarus/pvfUtility/tree/master/PvfCode/ViewModels/independent_drop),
依据实际数据结构重新实现 Go 投影与 Vue GUI,不引入 WPF、商业组件或上游二进制。
17 项主体依次为:来源类型、来源 ID、物品 ID、五档概率、五档数量、最低等级、
最高等级、职业、掉落模式。常规模式的概率分母为 1000000,每档独立计算。
界面保留原始难度顺序,不把不同客户端的第五档强行解释为同一难度名称。
`0` 为直接物品,`1` 为内嵌 `[list]` ID/权重对,`2` 为 `[list]` 中的外部列表 ID。
上游采用三种模式;模式 2 由合成样例验证,当前真实样例没有使用该模式。
本地 `testdata` 验证结果:
| 变体 | 主体记录数 | 模式 | 差异 |
| --- | ---: | --- | --- |
| TW | 3898 | 0、1 | 132 个外部列表;6 个副本平衡参数 |
| 90CN(两份样例) | 2173 | 0、1 | 外部列表索引为空 |
| 90US | 2178 | 0、1 | 外部列表索引为空 |
| 100CN | 2000 | 0、1、5 | 模式 5 与 replace list 条件段 |
| 110US | 1834 | 0、1、5 | 模式 5 与 replace list 条件段 |
| 115US | 1806 | 0、1、5 | 模式 5 与 replace list 条件段 |
| 60ACT(A1/A4/A5/A12 样例) | — | — | 未包含该文件,入口明确提示不支持 |
模式 5 的概率含义尚未确认:展示原始值,禁止 GUI 修改概率、列表、模式及结构性增删,
允许已确认的来源、数量、等级等主体字段编辑。附属条件及其他未知 section 原样保留。
批量修改概率若包含扩展模式会明确拒绝,应先过滤为常规模式。
不对其他变体套用 TW 的整个文件序列化;字段编辑仅替换精确 UTF-16 源码范围。
已观察到的 `[replace list]`/`[dungeon condition]` 不会在保存时丢失。
## 验证
```sh
go test ./...
cd frontend && npm test && npm run build
```
真实样例回归(源文件只读;保存至测试临时目录,测试结束自动清理):
```sh
PVF_TESTFILE=/path/to/Script.pvf PVF_DROP_SAVE_ROUNDTRIP=1 \
go test ./services -run '^TestIndependentDropReal$' -v
```
该测试读取 GUI 文档、验证所有注册的外部列表,修改单个概率字段后比较整个脚本
二进制 payload,要求除该整数之外所有 token 逐字节不变;可选保存重开再次比对。
60ACT 缺少配置时跳过,不能把跳过视为已支持独立掉落。
+36
View File
@@ -0,0 +1,36 @@
# 就地编辑标注与渲染规则
右键脚本中的 Section 标签,或资源管理器中的单个文件、目录,选择菜单末尾的“标注规则…”或“渲染规则…”。多选不提供规则编辑;开始、结束标签定位到同名 Section,字符串和注释中的标签不会触发菜单。
规则列表显示匹配范围、来源、用户覆盖和屏蔽状态。渲染规则还会标明当前文件实际选中的规则;标注的“匹配生效”表示规则进入当前文件、Section 的标注范围,具体 token 是否存在仍由脚本内容决定。高级 JSON 只编辑当前规则,与表单共用 Go 校验。
## 默认范围
- Section:当前文件后缀下的同名 Section;没有后缀则使用精确路径。
- 文件标注:该文件的路径说明;文件渲染:该文件的排版。
- 目录标注:仅目录自身;目录渲染:该目录下的后代文件。
可以切换到当前文件、同后缀、目录、全局或自定义 glob。选择更大的范围会影响其他匹配文件;不同 ID 的规则继续按现有标注叠加、渲染具体度与顺序规则处理。
## 开发与生产存储
开发环境由 Go 的 `!production` 构建标签决定。新增手写标注写入 `config/annotations.json`,新增渲染写入 `config/rendering.json`。编辑、删除已有项写回其实际来源,包括 `paths.json`、`sections.json`。开发环境找不到源目录时拒绝保存,不改写用户配置。
生产环境在 `os.UserConfigDir()/pvfine/` 下维护:
- `annotations.user.json`:`version: 1`、`rules`、可选的 `fields`、`disabledIds`、`disabledFieldIds` 与 `fieldSuppressions`。
- `rendering.user.json`:`version: 1`、`rules` 与可选的 `disabledIds`。
内置项先组装,再应用屏蔽和用户覆盖。同 ID 整条替换并保留原位置,新增用户项追加末尾。编辑内置项创建用户覆盖;删除覆盖恢复内置项;屏蔽内置项使其停止参与匹配;删除用户新增项直接移除。
共享字段修改会影响关联预览和显式引用规则,操作前显示提示。生产环境屏蔽字段会同步停用依赖规则,恢复字段只撤销该字段导致的屏蔽,保留独立规则屏蔽。删除用户新增字段或开发环境字段会同时删除同来源的引用规则;开发环境若存在其他来源的引用,需要先解除引用,以免产生无效配置。关联列表定义不提供编辑入口。
首次生产加载时,如果用户增量文件不存在,会将旧运行时镜像中新增或不同于当前内置的规则、字段迁移为用户项;缺失项不视为删除。旧文件保留,旧关联列表的差异作为只读兼容数据保存。增量文件建立后,旧镜像不会再次自动导入。独立标注编辑器继续编辑原有源配置格式。
## 保存与错误处理
保存前检查配置修订号、校验并编译合并结果,再原子替换文件并激活新引擎。配置被外部修改时拒绝保存;重新加载会读取最新规则,需要确认放弃尚未保存的规则输入。
保存后更新资源树、脚本标注和相关预览。渲染刷新保留未保存的脚本文本,并根据当前草稿重新计算标注位置,不把草稿写入归档。
损坏的用户配置会在启动时显示错误并回退到内置规则,原文件保留;手动重载失败保持当前引擎。损坏配置修复之前禁止通过编辑器覆盖保存。真实 PVF 回归通过 `PVF_TESTFILE` 启用。
+57
View File
@@ -0,0 +1,57 @@
# 技能树工具
入口:打开 PVF 后,在“工具”菜单末尾选择“技能树”。选择职业、转职及 SP / TP;顶部显示该职业的通用技能,点击技能可查看其 `[explain]` 描述(解析字符串引用并保留换行),或打开技能文件。同一坐标的技能合为一个图标,角标标出数量,默认显示文件中靠后的技能,点击后通过右侧列表切换。每个位置的选择在当前归档会话中独立保留,切换技能树或关闭弹窗后仍有效,保留原位置与文件顺序。支持画布缩放,图标复用现有 NPK 图片索引。未配置客户端资源时显示占位图标。
## 数据来源
- `clientonly/skillshoptreespindex.co`、`clientonly/skillshoptreetpindex.co` 的 `[skill tree]` 按职业 token / 相对路径读取,路径相对于 `clientonly/`。
- 树文件中的每个 `[character job]` 独立保留,第二个值 `none` 表示未转职;不将未转职树合并进已转职树。
- 技能定义从 `skill/skilllist.lst` 注册的职业列表查找。ID 属于职业上下文,通用技能也必须通过当前职业的列表解析,不能共用全局 ID 索引。
- `clientonly/commonskilllist.co` 的 `[common skill]` 保留顺序;合并所有匹配职业的 `[except skill info]/[info]` 排除项。
- `[next skill]` 允许重复出现,每个值依源顺序保留。连线只连接同一分支存在的目标;缺失目标显示数据提示,不推断服务器学习条件。
## 坐标
使用 `testdata/110US.pvf` 检查了全部 17 个职业的 34 个 SP / TP 注册及所有转职分支。样例中的 `[cell pos]` 是列、行,换算为 `x = 列 × 47`、`y = 行 × 67`;`[icon pos]` 是直接像素坐标。两种写法可在同一分支混用,缔造者还使用 23、117 等半格横向偏移,不能先取整成网格坐标。
这些间距来自真实数据的规律,尚未通过客户端代码验证;未来遇到不同版本的布局参数时应独立扩展换算策略。SP 画布左侧显示该行最左侧技能的 `[required level]`,同位置按文件顺序取第一个;缺失等级或空行不显示数值。TP 不显示等级标注。技能卡片下方显示技能名,不模拟玩家学习等级。
技能卡片在 47×67 网格内居中显示,连线及拖拽预览跟随显示偏移,不修改文件中的坐标;原有半格偏移仍保留。
关闭弹窗后,在本次应用会话中保留职业、转职及 SP / TP 选择;重开时重新读取所选技能树,若归档不再包含该选项则回退到首个可用选项。Cmd / Ctrl + 单击左下角路径可在编辑器打开技能树源文件。
浏览优先使用当前编辑器草稿;尚无草稿时读取归档覆盖层。拖拽只修改草稿,关闭弹窗仍保留。点击“保存到工作区”将当前技能树草稿应用到覆盖层,主工具栏的保存/另存为才写入 PVF 文件。未知转职 token 原样显示。拖拽保留原始坐标种类、技能顺序和后继关系,只替换坐标数字。
## 点击空白格添加技能
点击空白网格,在选择框中按技能名或 ID 搜索当前职业的已注册技能,确认后添加到当前转职分支。候选项排除该分支已有的技能 ID;不会创建新的技能定义文件。空分支也可直接点击网格添加。
新增记录使用 `[cell pos]`,插在当前 `[character job]` 的结束标签前,不改变其他分支及已有记录。修改进入同一份编辑器草稿,沿用“保存到工作区”和 PVF 保存流程。选择期间切换归档、分支或修改文本会取消该次操作。
## 拖拽编辑
- 同位置技能整组移动,包含当前隐藏的技能记录。
- 落点吸附到 47×67 网格,必须位于当前画布内,且不能与其他技能卡片交叠;半格位置的卡片也参与碰撞校验。画布末尾始终保留一行空白落点,拖入后自动补出下一行,可继续向下扩展。
- 绿色预览表示允许放置,红色表示无效;无效放置、取消拖拽或归档/文本发生变化时,不应用该次移动。
- 保留 `cell pos` / `icon pos` 各自的写法,移动一组混合坐标时分别换算。通用技能栏不参与拖拽。
- 草稿与文本编辑器共用。打开源文件可以继续编辑;文本更新后重新解析,避免用旧坐标覆盖新改动。
## 删除技能
右键技能选择“从技能树删除当前技能”,或在右侧信息面板点击同名按钮,可移除当前分支中选中的一条技能记录;同位置其他技能保留。删除同时移除当前分支指向它的全部 `[next skill]` 数值,其他分支及技能定义文件不变。通用技能栏不提供删除。修改进入草稿,沿用现有保存流程。当前树内的拖拽、添加、删除和保存均保留画布,避免重新解析时滚动条跳回顶部。
## 画布关联编辑
- 选中技能,拖动卡片右侧的绿色圆点到目标技能,新增一条有方向的 `[next skill]` 关联。
- 点击已有连线,拖动箭头端的绿色圆点到其他技能,可替换该条关联的目标,原有顺序保持不变。
- 右键连线可“修改关联目标”或“删除这条关联”;右键技能可“添加后继关联”,随后点击目标技能。Esc 或点击空白处取消连线。
- 目标来自当前分支,不能指向自身或新增重复目标;原文件已有的重复关联保留,按条目分别编辑。新增关联追加在该技能记录末尾,修改和删除只操作对应的数值,保留其他字段、注释及换行。重叠技能之间的关联以绕过卡片的折线展示。
- 修改先进入共享草稿,保存方式与技能移动相同,画布滚动位置保持不变。
## 验证
```sh
go test ./...
PVF_TESTFILE="$PWD/testdata/110US.pvf" go test ./services -run 'TestSkillTree|TestCommonSkillsJobExclusions' -v
cd frontend && npm test && npm run build
```
+37
View File
@@ -0,0 +1,37 @@
# 通用二进制 ANI 解码
实现位于 `internal/ani/binary.go`,独立于 PVF 容器与客户端版本。编辑器通过公共文件读取流程展示二进制 `.ani` 的只读源码,并接入现有 ANI 动画预览。普通文本 ANI 和令牌脚本 ANI 沿用原有流程。
当前支持解码,不包含文本到二进制的重编译。查看与预览不会改变文件类型、原始载荷或归档的修改状态,导出及归档保存继续使用原始二进制。无法解码的文件显示错误;不能识别的属性不会被跳过。
## 布局
所有数值按小端读取。
1. `uint16` 帧数、`uint16` 图片路径数。
2. 每条图片路径:`int32` 字节长度,随后为 ASCII 字节。
3. `uint16` 全局属性数量,随后为属性编号与对应数据。
4. 每一帧:碰撞盒数量及盒数据、`int16` 图片路径索引、可选的 `uint16` 图片编号、两个 `int32` 图片坐标、帧属性数量及属性数据。
负图片路径索引表示空图片,不占图片编号字段。碰撞盒含六个 `int32`。解码器检查长度、索引、属性编号和尾部数据,最多接受三个零对齐字节;限制单个资源字符串和解码输出大小,防止异常计数或路径复用导致过大内存占用。
支持的全局属性为 LOOP、SHADOW、COORD、OPERATION 和 SPECTRUM(间隔、寿命、颜色、效果)。帧属性包括 LOOP、SHADOW、COORD、IMAGE RATE、IMAGE ROTATE、RGBA、INTERPOLATION、GRAPHIC EFFECT、DELAY、DAMAGE TYPE、PLAY SOUND、PRELOAD、SET FLAG、FLIP TYPE、LOOP START、LOOP END、CLIP,以及 DAMAGE BOX / ATTACK BOX。MONOCHROME 和 SPACEDISTORT 的附加参数也会读取。
源码使用 `[FRAME000]`、`[IMAGE]`、`[IMAGE POS]` 等标准 ANI Section。帧内属性缩进,避免帧级 LOOP / SHADOW 被预览解析器误认为全局设置。现有预览显示图片、位置和延时等已支持信息;完整属性保留在解码文本中,并不表示预览器已模拟所有特效。
## 参考与验证
格式依据 `pvfUtility` 提交 `53dc8e48bba0f7871ca96bcf10bfe6d2028d0582` 的读取逻辑及本地样本验证,Go 实现独立编写,未复制其恢复源码。上游 README 明确说明没有统一的顶层许可证。
- [BinaryAniCompiler.cs](https://github.com/Lancarus/pvfUtility/blob/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/SourceLibraries/PvfCode.Services/PvfCode/Services/BinaryAniCompiler.cs#L166)
- [格式枚举](https://github.com/Lancarus/pvfUtility/tree/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/SourceLibraries/PvfCode.NPK.Utils/PvfCode/NPK/Utils/AniModel/Enums)
- [上游来源与许可说明](https://github.com/Lancarus/pvfUtility/blob/53dc8e48bba0f7871ca96bcf10bfe6d2028d0582/README.md#来源与许可说明)
回归覆盖所有已知属性、负坐标、浮点数、空图片、字符串转义、未知枚举值、每个截断位置、异常计数/索引/属性、对齐和异常尾部、标准容器中的读取及保存不变、编辑器只读状态与动画预览。
```sh
go test ./internal/ani ./internal/preview ./internal/pvf ./services
PVF_TESTFILE=/path/to/Script.pvf go test ./internal/pvf -run '^TestRealBinaryANIDecode$' -v -count=1
```
本地样本验证:A5 全部 74,688 个、A12 全部 84,804 个原生二进制 ANI 解码成功。A4 样本不含原生二进制 ANI。
+53
View File
@@ -0,0 +1,53 @@
# 商城 GUI
工具栏“工具 → 商城编辑器”优先打开当前归档的 `etc/cerashop.etc`,仅当该文件不存在时回退到 `etc/(r)cerashop.etc`,并切换 GUI 模式;两者都不存在时提示错误。也可手动打开商城文件后切换 GUI 模式。
## 浏览与编辑
- 一级页签遵循 `[shop button list and sequence]` 的顺序,“退出”作为关闭按钮;遗漏的非空商品类型补在后面,保证商品可以访问。
- 二级分类对应商品区段。卡片展示物品图标、名称、商品编号、物品编号、点券/金币价格、购买数量和非空日期参数。
- 工具栏“商品名/物品名”切换作用于所有商城页签,并在本次应用会话中保持。未配置商品名的时装优先回退到物品名,找不到名称时显示编号。
- 按住 Cmd/Ctrl 点击卡片上的物品编号,可在当前窗格打开对应的物品文件。
- 搜索匹配商品名、物品名和两种编号。物品名称分批加载;图标只查询当前页,卡片每页 60 件。
- 热卖与新品按 `[hotnew sell ipg]` 聚合;积分页按 `[specific product mileage]` 聚合,并非新增一种积分购买规则。商品售价和原有积分参数分开处理。
- 新增、编辑、删除在弹窗中进行。新增默认取现有商品最大编号加一,跳过已知历史引用占用的编号;可手动指定唯一的正整数编号。
- 普通道具提供数量、名称、金币、点券和日期参数;时装和礼包分别使用独立布局。其他参数保持原值,可展开编辑。
- 修改先进入编辑器草稿。“保存商城”写入工作区;保存 PVF 才会写入归档文件。
## 格式与保护
当前 110US 样本中普通道具、不可交易时装均为 14 字段,礼包为 13 字段。另通过同归档 `(f)cerashop.etc` 核对了可交易时装的 13 字段布局;不将其当作普通道具的数量和名称布局。
90CN 普通版 `etc/cerashop.etc` 按内容识别为旧版布局:时装 6 字段、道具 9 字段、礼包 11 字段、名称装饰 8 字段,角色服务礼包等采用独立字段映射。复活币、材料、恢复道具、魔王之契约、社区等分类一并展示,新增/编辑使用当前文件实际布局;旧版限购和按钮控制中的商品引用按原位置同步。
周边商品是外部链接记录,按只读卡片展示原始名称和价格参数,不套用点券单位。6 字段时装表尚未确认价格列,显示“未配置价格”,其类型及期限参数保留在其他参数中。`-1` 占位编号不作为重复商品编号,也不会在删除占位记录时移除其他引用。
TW 商城按内容识别为独立布局:道具 10 字段、礼包 10 字段,可交易/不可交易时装分别为 7/8 字段。保留配置中的独立“宠物”页签。`etc/cerashop.etc` 的真实样本有 133 件商品,`etc/cerashop_first.etc` 有 54,476 条记录;两个文件都可通过文件 GUI 打开,工具栏仍按普通版优先的既定规则选择文件。尚未确认的时装价格参数不作为售价展示。
首次打开优先选择配置顺序中有商品的页签,避免 TW 默认进入没有推荐配置的空“热卖与新品”页;手动选择的页签仍保留。
90CN 样本中商城导航的结束标签实际写作 `[/shop button and sequence]`,解析时将其匹配到 `[shop button list and sequence]`;编辑不会重写这一历史拼写。其他不匹配的标签仍会报错。
商品改号/删除同步以下已知商品编号引用:限购、购物车限制、赠送限制、仅赠送、不可叠加购买、立即生效商品、热卖、新品、默认推广页、指定商品积分、已购限购隐藏列表,以及 `storage`/`visible storage` 的仓库映射。不会改写物品编号或任意未知区段。
仅替换变化的 token,保留未知参数、未改动的区段、字符串类型标记及原始换行。重复编号、残缺记录、重复商品区段或类型不匹配均提示错误。无法识别的分类锁定编辑;存在未识别商品时,禁止新增、改号及删除,防止遗漏编号冲突或引用。
日期参数尚未确认代表过期时间,因此界面不将它标为“过期”或自动下架。价格列按此次样本分析映射,未确认的其他字段不赋予业务含义。
## 验证
```sh
cd frontend
npm test -- --run tests/cera-shop.test.ts tests/gui-launcher.test.ts
npm run build
```
真实样本回归通过 `PVF_CERA_TEXT` 指向从归档只读提取的目标脚本文本,避免将个人数据放进测试夹具:
```sh
PVF_CERA_TEXT=/path/to/cerashop.txt npm test -- --run tests/cera-shop.test.ts
```
普通版真实样本回归使用 `PVF_CERA90_LEGACY_TEXT`;90CN `(r)` 版使用 `PVF_CERA90_TEXT`,均指向只读导出的脚本文本。
TW 普通版和 first 版真实样本回归分别使用 `PVF_CERA_TW_TEXT`、`PVF_CERA_TW_FIRST_TEXT`,指向从归档只读导出的脚本文本。
+58
View File
@@ -0,0 +1,58 @@
# ⚡️ pvfine 官方网站设计方案与技术规范 (v2.0 Cyber-Precision Edition)
本文档阐述 `pvfine` 官方宣传主页(`website/`)的视觉设计规范、交互式技术演示架构、全世代功能矩阵以及 GitHub Releases 自动解析机制。
---
## 🎯 1. 产品定位与核心视觉升级
`pvfine` 是一款面向某横版动作游戏 `Script.pvf` 归档文件的**现代化、高性能跨平台桌面编辑器与逆向解析套件**,基于 **Go 1.26 + Wails v3 + Vue 3 + CodeMirror 6** 构建。
v2.0 官网摒弃了传统静态模板与简易 Emoji 堆砌,采用 **Abyssal Cyber Dark(深渊赛博暗黑)与 Studio Light(高精密工程浅色)双主题架构**,融合多项现代原生 Web 技术:
- **交互式二进制星云背景(`#cyber-bg-canvas`)**:HTML5 Canvas 实时渲染 5 字节 PVF Token 流(`#PVF_File`、`sTrW::UTF-16LE`、`115CN-mod`、`MCP :19245`)与鼠标引力节点网络,后台标签页自动休眠零耗电。
- **CSS `@property` 旋转激光流光边框与聚光灯卡片(Spotlight Cards)**:基于鼠标坐标实时追踪的径向辉光(`--mouse-x` / `--mouse-y`)与可开关的 3D 视差悬浮桌面视窗。
- **View Transitions API & 原生 `<dialog>` 命令面板(`⌘K` / `Ctrl+K`)**:丝滑切换深浅主题与工作区标签页,支持全键盘快捷导航。
---
## 🖥️ 2. 六大在线交互式桌面工作台(Hero IDE Playground)
Hero 区域内嵌高保真拟真桌面客户端视窗,提供 **6 个可真实交互的在线子工作台**:
1. **`100300001.equ`(实时双向联动编辑器 + 粒子光环)**:
- 点击工具栏即可动态切换装备品级(史诗 / 神话 / 太初)、强化三攻与全属强、就地改写 110US/115CN 译文。
- 底部实时重算 **5 字节 Little-Endian 二进制 Token 流**(`02 7C 01 00 00`),右侧同步渲染装备属性面板与 Canvas 粒子环绕光环。
2. **`skill_slash.ani`(原生 Canvas 2D 帧动画与判定框引擎)**:
- 60FPS 程序化渲染剑士拔刀斩关键帧序列、DXT5 Linear-Dodge 剑气弧光、暴击伤害跳字以及 `[hit box]` / `[damage box]` 实时碰撞判定框。
- 支持播放/暂停、逐帧步进(`◀` / `▶`)、12 帧时间轴拖拽、倍速切换(`0.5x` / `1.0x` / `2.0x`)与图层开关。
3. **`swordman_tree.skl`(12+ 可视化 GUI 套件:技能树 + 商城/娃娃机)**:
- 交互式切换技能节点、拖拽等级滑块(`Lv.1` ~ `Lv.40`)与全等级倍率叠加,实时重算 `[level info]` 静态/等级参数矩阵与模板占位符语义描述。
4. **`CompareWorkbench`(双栏归档对比与实时合并工作台)**:
- 演示 `Script_115CN_Base.pvf` 与 `SeasonMod.pvf` 双栏差异比对,支持单段点击 `« 合入段落` 或 `⚡ 一键合入全部差异 (F7)` 并实时刷新脏块计数。
5. **`MCP AI Server (:19245)`(Streamable HTTP AI 终端)**:
- 交互式演示 `pvf_annotation_explain`、内置掉率 Skill 预览(`pvf_script_preview`)与 `115CN-mod` 自动合并递增版本号导出(`pvf_mod_export`)。
6. **`batch_boost.pvf.js`(Goja 进程内 JS 沙箱)**:
- 在线运行模拟 Goja 批处理脚本,毫秒级输出 Staging 事务隔离 Diff。
---
## 🔬 3. 在线 5 字节二进制 Token 反编译实验室(`#pipeline`)
页面内置实时 5 字节二进制 Token 编译器,支持点击预设或自由输入任意 PVF 语句(如 `[grade] 5`、`[physical attack] 2450`、`` `无影剑-艾雷诺` ``、`{8=<31::sword_epic>}`):
- 实时将语句拆解为 `FF B0` 头部与 `0x05 Section`、`0x02 Int32`、`0x04 Float32 (IEEE-754)`、`0x07 String`、`0x08 StrRef` 5 字节小端序数据包。
- 实时计算 DJB2 `indexhash` 并展示 `sTrW` (UTF-16LE) 智能防乱码路由结果。
---
## 🔗 4. GitHub Release 自动解析与直链分发机制
1. **全平台智能识别**:通过 `navigator.userAgent` 与 `navigator.userAgentData` 自动识别 `macOS`、`Windows`、`Linux`,高亮推荐下载卡片并适配快捷键提示(`⌘K` / `Ctrl+K`)。
2. **GitHub API 异步拉取与缓存**:
- 请求 `https://api.github.com/repos/dof-dev/pvfine/releases/latest`,配合 `sessionStorage` 10 分钟缓存避免触发 GitHub API 速率限制。
- 自动提取最新 `tag_name`、发布日期、`body`(更新日志弹窗)与 `assets` 列表。
- 自动匹配 `.github/workflows/release.yml` 发布的全部真实构建产物:
- macOS:`pvfine-*-macos-universal.dmg` 与 `pvfine-*-darwin-universal-arm64-amd64.zip`
- Windows:`pvfine-*-windows-amd64-installer.exe` 与 `pvfine-*-windows-amd64.exe`
- Linux:`pvfine-*-linux-amd64.AppImage`
- 校验码:`SHA256SUMS`
- 所有下载按钮初始 `href` 均指向 `https://github.com/dof-dev/pvfine/releases/latest`,在离线或 API 受限时无损回退。
+37
View File
@@ -0,0 +1,37 @@
# 强化与增幅批量编辑
在文件 GUI 或工具菜单打开强化/增幅概率,点击“批量设置”。用双端滑块或起止等级输入框选择范围,勾选要更改的项目:成功率、失败操作及降级等级、消耗道具、消耗数量。可同时修改多项,点击“应用到 N 个等级”统一更新草稿,再保存到工作区。
成功率和失败处理仅作用于当前选中的武器或防具;材料 ID 和消耗数量由两类共用。选择“不处理”或“损坏”时,当前装备类型的降级参数自动清零。
## 按等级计算
在 JavaScript 脚本工作区使用 `PVFFile.editUpgrade(options)`。强化文件自动选择 `normal` 表,增幅文件自动选择 `amplify` 表。`equipment` 为 `weapon`(默认)或 `armor`;旧版 17 项表不提供独立防具数据。
```js
const file = pvf.find("etc/upgrade.etc");
if (!file) throw new Error("未找到强化文件");
const count = file.editUpgrade({
equipment: "weapon",
fromLevel: 1,
toLevel: 20,
// level 为目标强化等级;current 为当前成功率(0~100)。
successRate: (level, current) => Math.max(10, 100 - level * 4),
// current 为当前消耗数量,计算结果必须是非负 32 位整数。
costCount: (level, current) => level * 10
});
pvf.log("已计算", count, "个等级");
```
`successRate` 和 `costCount` 也可以直接传数值;省略其中一项时保持该项不变。`fromLevel` 默认 1,`toLevel` 默认表内最后一个可编辑等级。新格式支持全部等级,旧版保留 +1~+31 的编辑范围。
成功率保留到 0.001%,按 `失败值 = round((100 - 成功率) × 1000)` 存储。所有回调完成且返回值校验通过后才写入一次事务;无效结果会拒绝该次编辑。回调应只做计算,勿在回调中修改文件。脚本运行后通过现有预览/应用流程写入工作区,再保存 PVF。
## 安全强化 / 安全增幅编辑
含 `[safe upgrade]` 的强化文件会增加“安全强化”页签,增幅文件则显示“安全增幅”。当前按每条 8 个整数解析,可逐条或按等级范围批量编辑,保留原始值与位置。该 section 的格式异常单独显示,不阻断普通强化编辑。
样例含 12 条记录,首列原始值为 0~11,界面加 1 显示为等级 +1~+12;编辑时保留原始等级值。字段 2 为失败率,分母为 100000,界面按 `失败率 / 1000` 显示百分比,例如 94000 显示为 94%(基础成功率为 6%)。字段 3 为失败补正,暂保留原始值;字段 5/6/7 分别为材料 ID、数量与金币消耗。字段 1、4 的含义及补正单位仍待确认:这两列不显示且保持原值,失败补正按原始整数编辑。
安全强化的批量设置聚合失败率、失败补正、材料 ID、消耗数量和金币消耗,使用双端滑块或起止输入框选择显示等级范围,支持从 +1 开始。只更新勾选的字段和范围内的记录。失败率输入 0~100%,保存时乘以 1000 并取整。应用后更新草稿,点击“保存安全强化”或“保存安全增幅”写入工作区,再保存 PVF。
+77
View File
@@ -0,0 +1,77 @@
批处理是针对文件集的,也支持资源管理器中多选右键。
UI 使用弹窗去做。
### 批量查找/替换
- 全匹配查找替换
- 正则匹配替换
替换前预览 diff
比如:
查找:
[cool time]
10000
替换:
[cool time]
5000
最好结果页做成:
328 个文件命中 / 1267 处
☑ skill/a.stk
- [cool time] 10000
+ [cool time] 5000
☑ skill/b.stk
- [cool time] 10000
+ [cool time] 5000
### 结构化批处理
这块最好在后端做,操作二进制更精确
- 批量设置 section
类似:
批量操作:
设置字段,需要选择是否具有结束标签
section: [price]
新值: 1000
- 批量数值运算
[physical attack]
× 1.10
[cool time]
× 0.8
[price]
+ 500
- 批量删除 section,需要同时删除值,以及处理有成对的 section
- 批量插入 section
在 [name] 后插入或在文件尾部追加:
[custom flag]
1
运算符可以支持:
```ini
=
+
-
×
÷
百分比 +%
百分比 -%
min
max
clamp
round
```
+119
View File
@@ -0,0 +1,119 @@
# 文件 GUI 模式
GUI 模式与侧边文件预览独立。文件默认显示文本,匹配 GUI provider 后才显示模式切换。当前支持 `.shp` 商店、全局掉率与 `.skl` 技能参数。
## 扩展入口
- `frontend/src/gui/registry.ts` 注册 provider:唯一 `id`、显示名称、`readOnly` 能力、文件匹配条件及异步 Vue 组件。
- provider 组件接收 `file`(文件索引、路径、当前草稿文本、可编辑标记)和 `active`。通过 `FileGUIHost` 装载;隐藏时保留组件状态,关闭文件时销毁。
- 显示模式归属窗格,每个窗格独立保存文件模式。文本编辑器使用隐藏而非卸载保留光标、滚动和撤销历史。
- `useFileGUIStore` 提供归档 epoch 与内容 revision。异步读取必须检查请求代次,防止关闭、切换归档或重复请求的旧结果覆盖新内容。
## 商店数据
`FileGUIService.ReadShop(fileIndex, text)` 只读取数据:商店结构来自调用者传入的当前草稿;商品、材料和职业引用来自当前归档 overlay。展示使用 overlay;编辑表单通过 `ReadItem(id, drafts)` 优先读取关联商品的未保存草稿。
`ShopDocument` 保留 Tab、分类区块及商品出现顺序,不合并重复商品。区块和商品记录包含 UTF-16 源位置,用于精确修改或删除某一次商品出现。
商品成本来自商品文件:`[price]` 为金币,`[need material]` 每两个值组成道具 ID 和数量。两类成本共存;缺失成本与显式零金币不同。未知引用和异常结构返回数据提示,缺图使用占位。
商店同时支持旧版 `[item list]` 整数列表及 115CN 的
`[sell item list]/[item]/[index]` 记录列表,兼容 `sell info/tab`、
根级 `tab` 及没有显式分页的 `sell info`;分类列表可位于显式或隐式分页内。
新记录的直接整数是记录键,`index` 是商品 ID;`index = -1` 的空位不展示且保留。
替换仅改商品 ID,删除整条记录,限购、购买数量等其他字段原样保留。
新增记录不复制其他商品的局部配置,记录键避开整份商店已有记录键。
115CN 商品记录内的 `price`、`need material` 分别优先于共享金币/材料成本。
局部成本只读,替换商品时保留;GUI 拒绝其单条成本修改及包含它们的批量成本修改。
此类商品仍可替换 ID 或删除。没有显式分页的文件可编辑商品,但分页管理需文本模式。
缺少分类 ID 的商品显示在“未分类”入口,并保留结构诊断;闭合异常区块不允许 GUI 编辑。
`[use category]` 的 `expert job` 与 `expert job non filter` 使用相同的副职业枚举:0 炼金术师、1 附魔师、2 控偶师、3 分解师。展示保留原始分类类型。
职业关系与搜索索引均使用角色文件 `[growtype name]` 的第一个值,缺失时回退 `[name]`。此规则位于共享元数据提取及关联名称解析中;语义缓存版本变化会使已有内存持久化缓存和 SQLite 缓存失效。
## 编辑与检索
- 商品 hover 或键盘聚焦后显示编辑、删除按钮。更换商品 ID 会先读取新商品的成本,再允许修改;删除只移除当前出现,不删除物品文件。
- 底部入口为添加商品、分页管理、批量设置。分页管理支持新增、重命名及删除,删除分页会同时移除其中全部分类的引用。
- 批量设置覆盖当前分页的全部大分类,按关联物品文件去重。金币和兑换材料可分别选择保持或替换;空金币会移除 `[price]`,空材料列表会移除 `[need material]`。
- 价格是物品文件的共享属性,修改会影响所有引用它的商店;界面明确提示此行为。
- `ApplyShopEdit` 校验归档 revision、文件路径与定位,再在独立 staging 中构造所有受影响文件;全部校验完成后一次性提交 overlay,并记录一次版本撤销操作。原 PVF 不会自动写盘。
- 前端携带未保存草稿,后端只使用实际受影响的文件;前端提交期间锁定文本输入,成功后同步相关标签。过期表单会被拒绝,不覆盖更新后的内容。
- `ItemPicker.vue` 是通用装备/道具选择器,支持图标、ID/名称/路径检索、延迟搜索、分页及过期请求隔离。`ArchiveService.SearchItems` 在内存和 SQLite 索引中都先限定物品范围再分页。
## 技能参数
- 工具菜单的「技能参数」仅扫描当前归档 `skill/` 目录及子目录中的 `.skl` 文件,支持按职业、名称、路径筛选。选择技能只加载独立草稿,不创建编辑器标签;点击「在文件 GUI 中打开」才将同一草稿转为标签。两个入口使用相同的 `SkillViewer.vue`,已有标签时共享内容。
- 通用 GUI 切换技能或关闭窗口会保留草稿,草稿参与单技能保存、主工具栏保存、自动保存和归档关闭保护,不占用编辑器标签上限。归档关闭时清空;归档重载、批处理和文件索引变化时按现有文件刷新规则同步。
- `FileGUIService.ReadSkill` 读取当前草稿,分别提取 `[dungeon]` 与 `[pvp]` 的静态数据、等级数据和描述绑定。等级表首个数值为列数,各模式按实际数据行数提供等级滑块。
- `[level property]` 前两个值忽略,模板先解析字符串引用,再统一处理字面换行及转义的 `\n`、`\r\n`、`\r`。按模板内容识别 `<int>` / `<quorum>` 整型与 `<floatN>` / `<decimalN>` 小数占位符,在后端标准化为同一预览格式,不依赖 PVF 变体编号;支持同一模板混用、多行、多占位符及 `%%` 转义。非负数据类型引用静态数据,负数据类型引用当前等级的动态数据,预览乘以绑定倍率。模式内部的描述优先于顶层共享描述。
- 描述数值、静态数据和表格单元格均可点击打开弹窗。动态参数默认修改全部等级,也可选择仅当前等级;支持直接赋值、加固定值、乘固定值。编辑原始数值,倍率仅用于预览;整数运算结果四舍五入。
- 编辑仅替换目标 token 的 UTF-16 源位置,保留其他字段、注释和换行;所有数值及位置校验通过后一次性更新草稿。沿用编辑器保存、脏状态和关闭保护,原 PVF 不会自动写盘。
- 两个技能 GUI 入口共用「保存技能」按钮,仅保存当前技能草稿到工作区;写入 PVF 使用主工具栏保存。没有修改或正在保存时禁用按钮,未应用的参数弹窗会阻止保存。
- 异常列数、重复数据区块和复杂表达式绑定显示提示;无法确定位置的数据禁止编辑,缺失绑定的占位符显示「无对应数据」。
- 真实样例为 `skill/atgunner/c4.skl`;使用 `PVF_TESTFILE=/path/to/90CN.pvf go test ./services -run '^TestSkill' -count=1` 验证技能扫描与参数修改编解码往返。
- 110US 使用 `skill/atgunner/c4.skl` 与 `skill/gunner/c4.skl` 验证引用模板、六项参数绑定、转义换行和参数编辑往返:`PVF_TESTFILE=/path/to/110US.pvf go test ./services -run '^TestSkillReal110US$' -count=1`。
## 加载状态与验证
首次加载使用两列骨架屏,刷新保留上次内容并暂时禁用分类和 Tab;失败显示重试入口,明确标记旧数据。图标独立加载和淡入,遵守系统减少动态效果设置。
验证命令:
```sh
go test ./...
PVF_TESTFILE=/path/to/90CN.pvf go test ./services -run 'TestShopReal90CN|TestShopEditReal90CNRoundTrip' -count=1
cd frontend
npm test
npm run build
```
真实样例为 `itemshop/101_joann_box.shp` 和 `itemshop/equipmentshop1.shp`。图标需要在应用中配置可用的 NPK 资源目录。
## 装备与道具基础编辑
- `.equ` 与 `.stk` 文件支持「物品」GUI,入口追加在现有文件 GUI provider 后。使用当前标签草稿,可编辑基础信息、职业限制、装备数值、多行说明,以及图标和地面图像引用;装备页复用游戏风格预览。
- 点击字段旁的「编辑」或「添加」打开表单。「应用修改」更新当前草稿,「保存物品」保存到工作区;写入 PVF 继续使用主工具栏保存。表单应用前校验归档 epoch、文件路径和草稿基线,拒绝覆盖更新后的内容。
- `frontend/src/gui/item.ts` 按原始 token 位置修改,保留未知段落、嵌套触发器、注释、换行和字符串类型标记。缺失字段追加到末尾;已有多值字段保留参数数量与顺序,不自动排序或归一化全文。
- 重复字段、复杂嵌套、特殊数值类型与字符串表引用显示只读提示,仍可通过 DSL 编辑。引用文本优先显示已有标注中的译文,不把译文替换成脚本字面字符串。数值使用脚本原始单位,基础表单不施加特定 90 版本的等级上限或属性换算。
- 本期未引入原编辑器的 Python/.NET 桥接、静态技能/特效数据库;礼包与新建登记见下一节,触发器与特效见后文。
- 针对性验证:`cd frontend && npm test -- tests/item-gui.test.ts tests/file-gui.test.ts`。
## 礼包配置与物品创建
- 普通礼包(`[booster]`)在基础字段后展示奖励列表,奖励同时展示关联物品名称与 ID,可增删奖励、通过通用物品选择器查找物品,以及修改数量和原始权重。名称按奖励 ID 批量查询并去重,复用归档的装备/道具关联及译文解析;修改奖励 ID、归档切换或内容刷新后重新读取,旧请求不会覆盖新结果,未知或未命名物品保留 ID。已有 `etc` / `stackable` 奖励段保留类型与定位,支持段首随机获得项数后连续排列多组「物品 ID、权重、数量」;GUI 按奖励池显示并可修改随机获得项数;新增候选物品追加到指定池,不创建额外抽取池。编辑和删除按组定位,保留剩余奖励的原文,删除整个段内奖励时移除该段;修改不会重建整个文件。
- 礼包内容支持直接行内编辑,保留原「编辑礼包」按钮。分类下拉框旁提供新增、重命名、删除操作,支持单层分类和二维父/子分类;删除分类同步调整名称、坐标及矩形占位,保留其余奖励块原文。无分类礼包添加分类会把已有物品保留在默认分类;删除最后分类后变为无分类空列表。每行提供编辑、删除按钮,可在当前列表中选择物品、修改数量、随机权重及 avatar 附加参数;随机奖励池可直接修改抽取项数和添加候选。删除前在内容区确认;修改写入当前文件草稿,支持撤销,保存 PVF 后落盘。尚未应用的行内表单参与草稿检查,外部草稿变化后不允许覆盖旧版本。
- 礼包内容以图标、物品名、数量的滚动列表展示,物品名及强化前缀复用商店 GUI 的稀有度颜色(含神话、太初渐变),缺少稀有度时使用相同的默认青色;自选礼包有分类时使用下拉框切换,二维分类显示两个联动下拉框,无分类时隐藏下拉框。自选奖励右侧提供预览复选框,最多选择配置的可选数量,切换分类清空选择;预览不会修改文件。普通礼包不显示复选框,保留权重显示。图标仅批量读取当前分类的奖励元数据,缺少资源时使用占位图。
- 自选礼包(`[booster selection]`)支持奖励增删、可选数量、提示与分类文本编辑,以及新增单层分类。`[booster category num] 0` 按无分类候选列表处理:一个 `0 0` 内容段、一条选择提示,奖励容器兼容 `etc`、`stackable`、`equipment` 与 `avatar`;`avatar` 按 ID、数量、两个附加参数四项一组解析,附加参数可编辑且保留原值,新增奖励沿用已有参数,编辑时保留原标签及零维度,不自动创建分类。无分类列表兼容完全省略 `[booster category name]`,编辑奖励或可选数量时保留缺省,不自动补写该段。`1 0` 同样按无分类列表显示,兼容单条空字符串提示及两条旧式名称;编辑时保留原维度和空提示,不自动补写名称。分类内支持 `[booster equipment upgrade]` 强化等级,物品名称显示为「+10 物品名」,仅作用于对应分类,编辑奖励时保留该字段。已有二维分类按坐标显示并可修改奖励,保留层级和坐标。兼容只含坐标的空分类及空奖励容器,允许末尾空分类省略名称;未修改时保留原文,编辑其他奖励不会补写空位。已有名称的空分类和新增分类可添加奖励并沿用原礼包的奖励容器类型;没有名称的空位需先通过 DSL 补充名称。分类文本为字符串表引用时不覆盖引用;包含特殊奖励结构、重复或不匹配坐标时显示只读提示。
- 物品 GUI 顶部「新建物品」支持复制当前草稿、选择归档中的参考物品,以及使用材料、普通礼包、自选礼包、武器、防具基础模板。参考物品使用当前未保存草稿;新名称写入复制文件,不修改原名称引用的字符串表。模板中的默认奖励 ID 仅为占位,创建后应配置实际奖励。
- 「检查 ID 与路径」同时查询装备和道具的 LST,包含未保存的列表草稿,区分重复 ID、已有文件与悬空路径登记。新 ID 使用 1–2147483647 的整数;路径限定为 `equipment/` 下的 `.equ` 或 `stackable/` 下的 `.stk`。
- `FileGUIService.CreateItem` 在独立暂存归档中完成新脚本、目标 LST 和需要的 indexhash 更新。90 系列表使用目录相对路径,集中 `list/` 布局使用根相对路径。提交前校验 revision,完成目录/SQLite 投影后一次发布,记录一次「新建并登记物品」版本操作。失败不会留下半个文件或单独登记。
- 未保存的其他列表草稿参与查重,但只消费实际修改的目标 LST/indexhash 草稿;源物品及其他草稿保持未保存状态。前端在提交期间锁定编辑,并仅推进被消费文件的基线。创建完成自动打开新文件的物品 GUI;此操作写入工作区,主工具栏保存才会写入 PVF。
- 验证:`go test ./services -run '^TestItemCreation' -count=1`;`cd frontend && npm test -- tests/item-booster.test.ts tests/item-creation-store.test.ts tests/item-gui.test.ts`。覆盖复制引用、两种列表布局、草稿查重、失败原子性、SQLite 投影与版本撤销;真实客户端写盘与游戏内行为仍需使用对应 PVF 做集成验证。
## 装备技能增强
- 装备 GUI 在已有字段后追加「技能增强」区,支持 `[skill levelup]`、`[skill data up]` 和 `[all skill item container]` 的行内新增、编辑、删除;保存沿用当前文件草稿与工作区流程。
- 技能等级按「职业、技能 ID、等级加成」三项分组,技能数据按「职业、技能 ID、作用范围、数据类型、数据序号、操作符、数值」七项分组。同类新增记录优先合并已有有效段落,支持重复段落;删除与修改按 token 定位,保留注释、CRLF、字符串类型标记及未知段落。触发器内部的同名段落不作为顶层加成修改。
- 等级区间加成支持职业、成长类型、技能组、等级上下限、加成等级、技能阶段和额外条件。已有区间记录逐字段修改,保留未修改的条件;包含特殊结构的记录保留原文并提示使用 DSL,其他有效增强仍可编辑。
- 职业选项复用当前归档注册的技能树,技能名称与 ID 从该职业的技能 LST 读取。技能 ID 按职业隔离;缓存随归档或引用刷新失效,延迟响应不会写回旧归档。列表不可用时仍可手动填写原始职业标签及技能 ID。
- 等级加成使用整数,数据修改支持整数和小数;数据序号的含义由目标技能自身的数据表决定。未应用表单参与 GUI 草稿检查;保存、切换显示模式、外部草稿变化均保留现有一致性保护。
- 验证:`cd frontend && npm test -- tests/item-skill-effects.test.ts tests/item-skill-catalog.test.ts && npm run build`。本期未迁入原编辑器的静态技能特化库与自动生成描述;触发器和 obj/ptl 引用编辑与预览见后文。
- 技能数据修改表单提供「选择技能数据」:复用技能参数 GUI,按当前归档职业登记列表选择技能,点击属性描述数值或静态/等级数据表数值后,回填职业、技能 ID、`[static]` / `[level]` 和原始数据索引。选择模式不修改或保存技能;显示倍率与预览等级不参与回填,装备作用范围、操作符和数值保持原值。
## 装备触发器、被动对象与粒子
- 装备 GUI 在技能增强之后追加「触发器与特效」。按 `[if]` 与相邻 `[then]` / `[multiple then]` 分组,支持新增、删除触发器,逐项增删改条件和动作,以及随机分支管理。同一分支可以配置多个动作;目标、触发概率、分支概率、持续时间均作为独立设置,保持原文中的先后次序。
- 条件覆盖命中、施放技能、被击、周期、冷却、动作状态、附加物、目标类型、HP/MP 阈值、人数、连击、装备品级等;动作覆盖属性、增伤、异常状态、附加物、消耗、恢复、召唤、台词和被动对象。操作键条件只复用文件中已有的 `[command]` 数据,不生成跨版本二进制常量。未知动作及无法确认参数形状的结构显示「DSL 编辑」,不会自动归一化或删除。
- 普通触发器添加分支时转换为 `[multiple then]`,保留原动作;原分支未声明分支概率时补 `100.0`,新增分支概率为 `0.0`。不自动平均或覆盖已有概率,需按实际需求编辑「分支概率」。最后一个分支不能单独删除,可删除整个触发器。
- `[passive object]` 按七列记录编辑:对象 ID、X/Y/Z、等级、跟随参数、粒子路径。支持同段追加多个对象、单独删除记录和预览。资源选择器从当前归档查询对象、粒子、附加物、怪物及 APC,支持分页、手填 ID/路径、异步过期隔离;未引入原工具的静态 90 版本数据库。
- `ArchiveService.SearchEffectResources` 在分页前限定资源范围。对象、附加物及怪物使用现有语义索引;粒子按全归档 `.ptl` 路径检索,APC 直接读取当前版本的 `aicharacter.lst`,适配普通列表及 `list/` 集中列表布局。
- `FileGUIService.ReadItemEffectPreview` 使用当前归档 overlay 及匹配路径的未保存标签草稿,解析对象的 `[basic motion]`、`[basic action]` → `[BASE ANI]`、`[string data]` 和 `[add particles]` 引用。装备第七参数支持归档路径、相对装备路径及 `equipment/character/particle/` 中的裸文件名;粒子内部引用相对粒子文件解析。
- 预览复用 ANI 解析器与 NPK 图片缓存,按帧播放基础动画,并按粒子的寿命、频率、实例上限、位置、速度、加速度、重力和透明度近似叠加;支持暂停、重播、倍速及逐层开关。最多 16 个动画层、每层 64 个粒子和 1200 个唯一贴图帧。无 `[object]` 的粒子和缺失资源明确提示。预览不执行 ACT 战斗/碰撞脚本,不模拟旋转和随机速度;IMG 直接引用仅显示首帧。
- 所有写操作只更新当前草稿,按 token 位置保留注释、未知段落、类型标记、重复记录及换行。触发器实际发生变更时,仅调整当前组语法行首的缩进:条件/动作相对容器增加一级,多分支中的 then 再增加一级;不修改多行字符串内容,也不额外插入动作前的空行。无变化应用保持原文。编辑弹窗参与保存与离开保护;归档、文件或草稿变化后拒绝使用旧定位。原 PVF 仍通过主工具栏保存。
- 验证:`go test ./...`;`cd frontend && npm test && npm run build`。新增 `item-triggers.test.ts`、`effect-playback.test.ts` 与 `services/item_effects_test.go`,覆盖精确编辑、多分支、七列对象、资源分页、两种列表布局、草稿与缺失资源。本地浏览器使用合成数据验证表单应用、随机分支转换和预览弹窗;未进行真实客户端内效果验证。
+288
View File
@@ -0,0 +1,288 @@
设计一个类似标注的功能,核心目的是解决“哪个配置配什么东西”的问题:
- 标注走规则配置,而非针对具体内容的单独标注
- 标注内容包括 title/content
- 标注在编辑器内的显示:
- 在被标注内容旁边直接加上一个 tag 显示标题,hover tag 显示 tooltip
- 复制内容的时候不要复制标注
- 标注规则:
- 可指定生效的文件后缀
- 如:.equ 文件下的 [rarity],标注为“装备品级”
- 如果不指定后缀,则全局生效
- 可对 section 本身进行标注
- 可对 section 内的数据进行标注
- 指定 section 内第几个 token(或者范围)
- 标注类型
- 枚举,如稀有度的 3-神器、4-史诗
- 关联 id,可指定关联的类型,标注显示其 id 对应的 name,且可通过 cmd/ctrl + 单击跳转到对应文件
- 可对路径,包括目录和文件进行标注,此标注显示在现有文件树的 name tag 位(一般不会对有 name 的做标注,但设计上可以共存)
- 标注规则第一版用 json 文件定义,设计好 json 结构。
## 已确认的产品约束
- 第一版规则为系统内置规则,不在应用内提供用户编辑入口。
- 规则通过单独的 HTML 可视化编辑器维护,由 Go 本地服务提供页面、校验、预览和实时写盘能力。
- 配置统一放在根目录 `config/`:`annotations.json` 保存手写规则,`paths.json` 保存批量目录/文件树注释,`sections.json` 保存批量 section 注释,`lists.json` 保存固定的 lst/关联索引。HTML 编辑器只写入 `config/annotations.json`,写入前将旧内容保存为 `annotations.json.bak`。
- 规则和列表配置通过 `go:embed` 提供启动默认值;应用工具栏提供手动重载按钮,可在运行中校验并切换到磁盘上的最新配置。
- section 名称重复时,规则匹配全部同名 section,不配置 occurrence。
- Tag 的显示位置通过设置面板切换,设置面板在标注功能之前完成。
- 多条规则命中同一位置时,Tag 只显示规则列表中的第一条,Tooltip 显示全部命中规则。
- 引用 ID 的 Cmd/Ctrl + 单击只负责打开目标文件,不要求定位到目标 section 或 token。
## 可行性结论
整体可行性较高,但这是一个跨越 PVF 解析、规则匹配、编辑器渲染和文件跳转的功能,不是简单的文本后缀处理。
当前 PVF 脚本由固定 5 字节 token 流组成,section 和 token 类型已经明确,适合实现结构化匹配。现有反编译器只返回纯文本,没有保留 token 对应的文本范围;CodeMirror 目前也只接收纯文本文档。因此最关键的工作是建立“语义 token 到编辑器文本范围”的映射。
文件树已经有 Tag 展示能力,现有搜索索引也具备 ID、名称和路径关联的基础。路径标注可以较低成本复用这部分能力;section/token 标注和引用跳转需要新增通用的标注引擎。
## 推荐总体方案
### 1. 内置规则
建议使用以下结构:
```text
config/annotations.json
config/paths.json
config/sections.json
config/lists.json
internal/annotations/loader.go
tools/annotation-editor.html
```
Go 通过 `go:embed` 加载这些配置文件,应用启动时完成规则和列表配置解析、合并与校验。配置文件不写入 PVF。
`config/paths.json` 存放批量生成的 `target.kind: "path"` 规则(目录树与文件树注释),glob 都是精确路径:目录注释只描述该目录本身,不向子孙继承,因此没有自己规则的路径不显示标注。加载器把它追加在 `annotations.json` 的规则之后,因此手写规则仍决定同一路径的首个标题;两个文件出现相同 `id` 时保留 `annotations.json` 中的那条并跳过重复项。它由外部数据整理生成,不由 HTML 编辑器维护。手写规则仍可使用 `/**` 递归 glob,批量 path 规则在编译期按字面 glob 和 `<dir>/**` 前缀建索引,避免每个树节点都全量扫描规则。
`config/sections.json` 存放批量生成的 `target.kind: "section"` 规则,来自 pvfUtility `PvfComments`(按扩展名限定,title 为中文注释名,content 保留原始说明与官方示例;只保留能在实际 PVF 数据中确认存在的段)。合并与去重规则同上,手写 section 规则仍决定同名段的首个标题。
HTML 编辑器负责规则列表管理、规则排序、表单编辑、JSON 导入导出、错误展示和示例预览。它不直接依赖浏览器文件系统权限,而是由 `cmd/annotation-editor` 启动仅监听 loopback 的 Go HTTP 服务,并自动打开系统浏览器。
启动命令为:
```text
task annotation:editor
```
服务监听 `127.0.0.1:0`,使用系统分配的随机端口,并为 API 请求附加当前会话令牌。提供以下接口:
- `GET /api/rules`:读取并校验当前规则(只含 `annotations.json`,不含批量 `paths.json` / `sections.json`),同时提供只读的列表关联供规则选择。
- `PUT /api/rules`:只校验并写入 `annotations.json`;通过后创建 `.bak`,再使用临时文件和 rename 原子替换源文件,`lists.json`、`paths.json` 与 `sections.json` 不由规则编辑器修改。
- `POST /api/preview`:使用提交的当前规则、示例路径和反编译文本调用 Go 规则引擎,返回编辑器标注和路径标注。
表单的有效修改会在短暂防抖后实时保存。校验失败时展示具体错误,且不能覆盖现有 JSON。导入和导出继续保留,便于批量维护和迁移规则。
每次有效保存还会同步运行时规则和列表配置到 `os.UserConfigDir()/pvfine/annotations.json`、`lists.json`。应用内“重载标注规则”按钮在开发环境优先读取仓库源文件,发布环境读取运行时副本;`paths.json`、`sections.json` 取同一目录下的同名文件,不存在时回退到内置数据。重载会先完整校验这些文件;失败时保留当前规则引擎,成功后重建路径标注、清空 relation/编辑器标注缓存、搜索索引,并刷新已打开文件和当前文件树。
规则文件中应保留 `version` 和可选的 `description` 字段,便于后续升级格式和说明规则用途。
### 2. JSON 结构
建议第一版只使用扩展名匹配、路径 glob 匹配和精确 section 名称匹配,不引入正则表达式。
```json
{
"version": 1,
"rules": [
{
"id": "equ.rarity",
"match": {
"extensions": [".equ"]
},
"target": {
"kind": "token",
"section": "rarity",
"index": 0
},
"annotation": {
"title": "装备品级",
"type": "enum",
"values": {
"3": "神器",
"4": "史诗"
}
}
},
{
"id": "equ.related-equipment",
"match": {
"extensions": [".equ"]
},
"target": {
"kind": "token",
"section": "related",
"index": 0
},
"annotation": {
"title": "关联装备",
"type": "reference",
"relation": "equipment"
}
},
{
"id": "path.equipment",
"match": {
"glob": "equipment/**/*.equ"
},
"target": {
"kind": "path"
},
"annotation": {
"title": "装备配置",
"type": "text",
"content": "装备相关配置文件"
}
}
]
}
```
固定列表和关联索引单独放在 `config/lists.json`:
```json
{
"version": 1,
"relations": {
"装备": {
"listPath": "equipment/equipment.lst",
"idToken": 0,
"pathToken": 1,
"recordTokens": 2,
"nameSection": "name"
}
}
}
```
结构约定如下:
- `target.kind` 支持 `path`、`section`、`token`。
- `section` 目标匹配所有同名 section。
- token 下标使用 0-based;范围使用 `start` 和 `endExclusive`。
- token 目标可以配置 `recordTokens`,按固定数量的 token 重复匹配每条记录;`offset` 会跳过 section 开头的若干 token,再开始重复匹配;`tokensPerLineIndex` 指定 section 内直接 token 的 0-based 下标,其正整数值会覆盖 `recordTokens`,无效或缺失时回退到 `recordTokens`;`contextIndex` 可覆盖关联类型指定的上下文 token 下标。
- 默认只计算 section 的直接 token,不递归计算嵌套 section。
- `nameSection`(relation 取名)、itemshop 的 `[NPC]` 取值,以及 `[name]` 名字索引只认一级 section;嵌套 section 里的同名 `[name]` 属于子记录,不会被当作文件自身的名字。
- `annotation.type` 支持 `text`、`image`、`enum`、`reference`、`path`。
- `path` 用单个 token 的路径值查找归档内文件;`annotation.pathRoot` 可填写归档内相对目录作为前缀,留空时从归档根目录查找。路径中的反斜杠会按正斜杠处理;找到目标后可 Cmd/Ctrl+单击跳转。
- `annotation.title` 可以留空;留空时不显示标注 Tag,已有的正文标注交互(例如路径跳转)仍然可用。
- enum 使用 token 的规范化值作为 key。
- reference 通过 `config/lists.json` 中的 relation 类型解析目标文件、目标 ID 和 name。
- `kind: "union"` 的 relation 可以组合多个固定列表;默认提供 `物品`,按 `装备`、`道具` 的顺序查找,适合 ID 可混用的字段。
- 普通 relation 使用固定 `listPath`;`kind: "contextual"` 的 relation 使用 `contextToken` 指定的同一条记录上下文值,从 `contextPaths` 选择对应的 lst,再用目标 ID 查找技能文件。例如 `[skill levelup]` 每条记录有 3 个 token,目标为 `index: 1`、`recordTokens: 3`,关联类型可把 `[fighter]` 映射到 `skill/fighterskill.lst`。
- 规则数组顺序就是优先级。
### 3. 编辑器标注模型
后端解析当前文件时,同时返回编辑器文本和标注范围:
```text
text
annotations: [
{
start,
end,
title,
content,
type,
targetFileIndex
}
]
```
`start/end` 使用 JavaScript UTF-16 code unit 偏移,避免中文和代理对字符导致 CodeMirror 定位错误;CRLF 和 CR 按 CodeMirror 的规则各占一个换行单元。
标注不能写入编辑器文档。前端使用 CodeMirror Decoration/Widget 在 token 或 section 旁边渲染 Tag,Tooltip 使用聚合后的全部命中规则。由于 Widget 不属于 `state.doc`,复制内容时不会复制标注;复制行为仍需要通过前端测试验证。
同一文件、同一 section/token 范围的多条命中应聚合成一个 anchor:
- 可见 Tag 使用第一条规则的 title。
- Tooltip 按规则顺序显示全部 title/content。
- 引用 Tag 携带 `targetFileIndex`,Cmd/Ctrl + 单击调用现有文件打开流程。
编辑文本后只重新计算当前文件的标注,不能通过整体替换 CodeMirror 文档刷新,否则可能导致光标和选择范围跳动。
### 4. 文件树标注
路径标注需要同时支持目录和文件。当前文件树标签主要按文件索引保存,应扩展为按归档路径保存,例如:
```go
map[string][]AnnotationTag
```
`ListChildren` 返回目录或文件节点时,根据完整路径附加标注。文件原有的 name、ID 关联标签与路径标注可以同时存在。
### 5. 显示设置
Tag 的显示位置属于前端设置,不放入规则 JSON。设置面板完成后建议预留以下设置:
```text
annotation.tagPlacement:
after-target
line-end
hidden
```
显示设置只影响 Tag 的渲染位置和可见性,不影响 Tooltip 中的完整标注内容。
设置通过 Wails `SettingsService` 读写,不由前端直接操作文件。设置文件位于:
```text
os.UserConfigDir()/pvfine/settings.json
```
默认值为 `after-target`,保存时同样使用临时文件和 rename 原子替换。第一版设置面板只提供标注显示位置,保留后续扩展通用设置的结构。
## 性能策略
- 路径标注在建立目录索引时计算。
- 搜索索引从 `relations` 中读取固定 `listPath` 和 contextual `contextPaths`,装备、道具、技能等列表不再在索引器内重复配置。
- section/token 标注只在打开文件或编辑当前文件时计算,不在打开归档时解析所有 `.equ` 文件。
- reference 索引按 relation 类型懒加载并缓存。
- 当前文件标注按文件索引和精确编辑器文本缓存;切换归档或修改文本时失效。
- 对不支持的 `TypeUnicode` 文件,第一版只提供路径标注;section/token 标注先限定为 `TypeScript`。
## 实施阶段
1. 增加内置 JSON 加载、版本校验和规则错误提示。
2. 实现独立 HTML 规则编辑器和 JSON 导入导出。
3. 重构 PVF 反编译流程,同时输出 token/section 位置。
4. 实现路径标注和文件树 Tag。
5. 实现编辑器 Tag、Tooltip、复制排除和 Tag 显示设置。
6. 实现 enum 标注、规则冲突聚合和编辑后刷新。
7. 实现 reference relation、name 解析和 Cmd/Ctrl 点击打开文件。
## 主要测试
- JSON 版本、字段和规则错误校验。
- 扩展名、路径 glob、section 和 token 范围匹配。
- 重复 section 全部命中。
- 中文、反引号、嵌套 section 和不同 token 类型的范围偏移。
- 多条规则命中时只显示第一条 Tag、Tooltip 显示全部内容。
- 编辑文本后标注刷新且光标不跳动。
- 复制文本不包含 Tag。
- reference ID 不存在、重复和跨类型时的降级行为。
- 目录和文件路径标注同时存在时的文件树显示。
## 适用 PVF 版本
规则 `rules[]` 和共享字段 `fields[]` 均支持可选的 `pvfVersions` 数组:
```json
{
"id": "equ.rarity",
"pvfVersions": ["90US", "110US"],
"match": {"extensions": [".equ"]},
"target": {"kind": "token", "section": "rarity", "index": 0},
"annotation": {"title": "装备品级", "type": "enum", "values": {"4": "史诗"}}
}
```
- 只接受 `90US`、`90CN`、`110US`、`115US`;省略属性或空数组表示全版本可用,旧配置无需迁移,顶层 `version` 仍为 `1`。`110US` 和 `115US` 独立匹配,共用规则须显式列出两个版本或不限定版本。
- 当前已知格式映射为 `standard → 90US`、`alternate → 90CN`、`paged110 → 110US`、`paged115 → 115US`。`paged`(未登记客户端的分页容器)、`recovered` 及其他未知格式只启用未限定版本的配置。映射用于配置适用性,不决定解析或写入能力。
- 规则引用共享字段时,两处版本限制同时生效。交集为空时规则不生效;字段自身若适用,仍可生成隐式标注。先筛选,再判断显式规则是否覆盖字段。
- 共享字段的版本限制同时影响标注和结构化预览;关联列表配置不受影响。
- 后端保留完整规则文档,按当前归档建立运行视图,统一作用于编辑器、目录树和字段预览。切换归档、恢复状态及热重载时更新运行视图与相关缓存。
- 配置编辑器表单末尾提供版本复选框;试运行区域提供四个版本及“未知版本”单选,默认 `90US`。`POST /api/preview` 可传 `pvfVersion`,省略或空字符串表示未知版本,非法值返回 HTTP 400。
+48
View File
@@ -0,0 +1,48 @@
# 脚本渲染规则
脚本渲染规则只影响 TypeScript 文件在编辑器和导出中的展示格式,不改变
`SetText` 的编码语义和 PVF 内的 token 数据。
## 配置位置
开发环境使用仓库根目录的 `config/rendering.json`。应用启动时使用内置的
默认配置;设置中的“渲染规则热重载”会重新读取开发环境配置,发布环境则
读取用户配置目录下的 `pvfine/rendering.json`。
## JSON 结构
```json
{
"version": 1,
"description": "script rendering rules",
"rules": [
{
"id": "skl.custom-section",
"match": {
"extensions": [".skl"],
"glob": "skills/**/*.skl"
},
"target": {
"kind": "section",
"section": "custom section"
},
"format": {
"offset": 2,
"tokensPerLineIndex": 0,
"tokensPerLine": 4
}
}
]
}
```
`target.kind` 为 `file` 时规则作用于整个文件;为 `section` 时按 section
名称匹配,同名 section 全部应用规则。后缀、路径和 section 名称均不区分
大小写。`tokensPerLine` 必须为正数。`offset` 表示前多少个 token 各自
单独占一行;`tokensPerLineIndex` 为 section 内从 0 开始的 token 下标,
对应 token 的正整数值会覆盖 `tokensPerLine`,通常与 `offset` 配合使用。
动态值缺失或无效时回退到配置中的 `tokensPerLine`。
同一作用域下,后缀或 glob 约束更多的规则优先;约束相同时使用 JSON 中
靠后的规则。section 规则始终覆盖 file 规则,file 规则作为没有专用
section 规则时的回退。
+41
View File
@@ -0,0 +1,41 @@
# 版本控制
pvfine 的版本控制面向 PVF 内部的逻辑文件,不把完整 `.pvf` 文件作为版本对象。
## 存储
打开并初始化版本控制后,会在源文件旁创建:
```text
Script.pvf.pvfine/
├── repo.db
├── base.pvf
└── objects/
```
`base.pvf` 是唯一的不可变基线;`objects` 使用 SHA-256 内容寻址。脚本和 Unicode 文件保存逻辑文本对象,因为脚本 token 依赖 PVF 字符串池;其他类型保存原始 payload。
初始化需要扫描整个 PVF,可能持续数分钟,并会额外复制一份基线文件和建立对象索引,因此会占用较多磁盘空间。面板中的“取消版本控制”只删除对应的 `.pvfine` 侧车目录,不删除源 PVF 或当前内存编辑。
重新打开已启用版本控制的 PVF 时,普通归档会先完成打开;版本库发现、产物校验、快照恢复和基线加载在后台执行。恢复期间可以浏览和读取文件,版本操作及会改变归档的编辑/保存操作会暂时不可用。若最近的提交尚未保存到 PVF,后台恢复完成后会再发起一次内存归档重载。
当前版本库使用 SQLite 保存提交、父提交、变更路径和对象引用。V1 只提供 `main` 分支,不提供合并和远程同步。
## 操作语义
```text
编辑/批处理 → Working Tree
Ctrl+Enter → 提交版本,不写 PVF
Ctrl+S → 保存 PVF 产物,不创建版本
版本面板 → 查看变更、历史、撤销或加载历史版本
```
工作区和提交中的可用文件路径可以直接打开当前归档中的对应文件。历史版本支持导出该版本涉及的文件;新增/修改文件导出提交后的内容,删除文件导出删除前的内容,并保留归档内的相对目录结构。
批处理、创建文件、删除文件和一次编辑操作都会进入工作区变更历史。撤销按一次变更集执行。
Checkout 通过“基线 + 提交变更”重建内存中的归档,不会立即覆盖磁盘上的 PVF。保存后才会生成新的 PVF 产物。
## 兼容限制
V1 使用规范化路径作为文件身份,因此含有重复规范化路径的归档不会启用版本控制。源 PVF 被外部修改后,版本库会拒绝继续关联,避免覆盖未知内容。
+255
View File
@@ -0,0 +1,255 @@
# 索引性能验证
开发版和基准必须使用相同的 Go 编译参数。开发构建使用
`-gcflags="pvfine/...=-l"`,仅关闭项目代码的内联;SQLite 和标准库保持优化。
原来的 `-gcflags=all=-l` 会同时关闭纯 Go SQLite 及其依赖的内联,不能与
默认 `go test` 的耗时直接比较。
## 真实归档强制重建
在仓库根目录运行,`PVF_TESTFILE` 可替换为待测文件:
```sh
PVF_MEMORY_TEST=1 PVF_REBUILD_TEST=1 PVF_TESTFILE="$PWD/testdata/90CN.pvf" \
go test -gcflags='pvfine/...=-l' ./services \
-run '^TestLargeArchiveMemory$' -count=1 -v
```
若要复现旧开发版的性能问题,仅将编译参数替换为 `-gcflags=all=-l`。
两种测试应顺序运行,避免相互竞争 CPU 和磁盘。测试末尾的 GC 用于单独测量
存活堆;`forced_rebuild` 不包含这次 GC。
2026-09-21 在同机、同一份 1,057,873 文件归档和同一份索引实现上的各一次结果:
| 指标 | 原开发参数 `all=-l` | 修正参数 `pvfine/...=-l` |
| --- | ---: | ---: |
| 强制重建总耗时 | 54.34 s | 10.41 s |
| 语义加载 | 11.525 s | 3.790 s |
| 文件补齐 | 19.424 s | 2.499 s |
| 创建搜索索引 | 18.907 s | 2.211 s |
| GC 后存活堆 | 437.5 MiB | 437.5 MiB |
| 测试进程累计堆分配 | 32,906.6 MiB | 4,521.7 MiB |
这组结果用于定位编译参数造成的性能差异,不代表三次冷/热缓存验收、
桌面 WebView 物理内存测量或所有平台的性能保证。累计分配不是峰值或物理内存。
修改 Taskfile 后需重新构建并重启开发应用;仅刷新前端不会替换 Go 进程。
可用 `go version -m bin/pvfine` 检查二进制中的 `-gcflags`。
强制重建仍重新解析语义并构建候选数据库,不通过缓存命中跳过重建。
## 110US 全量重建优化(2026-10-13)
归档包含 4,311,296 个文件。用户提供的开发应用日志中,强制重建耗时
202.29 秒。优化后使用同一份本地归档,按开发配置关闭项目代码内联,
顺序运行三次;每次创建新的 Core、打开已有完整文件索引缓存,再执行强制
重建。重建仍读取脚本元数据,不通过语义缓存跳过重建;操作系统缓存未清空。
```sh
PVF_MEMORY_TEST=1 PVF_REBUILD_TEST=1 PVF_TESTFILE="$PWD/testdata/110US.pvf" \
go test -gcflags='pvfine/...=-l' ./services \
-run '^TestLargeArchiveMemory$' -count=3 -timeout=15m -v
```
三次强制重建:**72.09 / 72.11 / 72.47 秒**,中位数 **72.11 秒**。
| 阶段 | 用户原日志 | 优化后三次中位数 |
| --- | ---: | ---: |
| 候选库准备(复制文件投影) | 10.05 s | 16.36 s |
| 清理旧语义数据 | 28.50 s | 0.00 s |
| 语义加载及关联写入 | 115.99 s | 31.51 s |
| 建立文件关联索引 | 2.10 s | 1.24 s |
| 普通文件补齐 | 21.85 s | 0.00 s |
| 建立搜索及依赖索引 | 23.49 s | 22.36 s |
旧路径先复制整库,再删除旧语义及普通文件重复记录;新候选库只复制
`meta/files/dirs` 并创建空语义表。未登记文件直接从 `files` 查询,以两个有序
数据流分页,省去 3,674,085 条重复记录。语义依赖按表编号缓存路径、按文件
去重,每 128 条使用预编译 SQL 批量写入;全量加载后统一建立依赖索引。
增量刷新复用相同依赖写入器,但保留已有索引。
三次均得到 638,532 条登记记录、637,211 条 visuals、979 个跳过条目,
与用户日志的对应计数一致,元数据解析错误均为零。新增回归覆盖虚拟普通
文件搜索、跨数据流分页、多列表登记、取消/恢复登记、WAL 中已提交文件投影、
批量尾部依赖,以及关闭期间后台重试;全量 Go 测试和相关竞态测试通过。
发布仍校验归档代际与修订,完整候选事务提交后才替换活动索引。
相较用户日志,中位耗时约减少 64%。原数据为应用日志,新数据为服务层测试,
没有对原二进制做相同缓存条件下的三次重跑,因此这是日志对照,不是严格
控制变量的提速倍数。重建计时不含归档打开、最后的 GC 和 WebView。
新 `create-indexes` 阶段也包含依赖索引建立,阶段内部工作量与旧路径不同。
剩余主要成本是文件投影复制、字符串依赖写入和索引排序。
文件索引 schema 升级为 5,并使用新的缓存身份;旧缓存需要首次建库,之后
可复用。重新构建并重启 Go 应用进程才能启用实现;仅刷新前端不会生效。
## SQLite 语义增量刷新
2026-10-13,新增 `BenchmarkSQLiteSemanticRefresh`,在合成归档中创建
100,000 个装备脚本及一个登记列表。初次建库不计时;每次改变一个脚本名称,
对比完整语义构建与增量刷新。两种模式使用同一个测试可执行文件和默认 Go
编译参数,各运行三次,每次测量一次刷新。
```sh
go test ./services -run '^$' -bench '^BenchmarkSQLiteSemanticRefresh$' \
-benchtime=1x -count=3
```
| 模式 | 三次耗时 | 中位数 |
| --- | --- | --- |
| 完整语义构建 | 1003.4 / 984.4 / 982.6 ms | 984.4 ms |
| 增量刷新 | 184.0 / 179.7 / 179.1 ms | 179.7 ms |
这组数据约为 5.5 倍差异。测量包含增量暂存克隆、登记读取和 SQL 发布,
不包含初次建库、实际编辑动作、前端桥接和文件投影更新。合成脚本的未修改
内容为空,不能视为真实百万文件归档或完整用户操作的耗时保证。
普通修改保留未受影响的语义行和数据库索引;首次建库、强制重建和修复仍
允许全量构建。删除仍需同步文件编号映射,并不是常数时间操作。
## 高级字符串搜索(SQLite)
高级字符串搜索统一使用磁盘反向索引,小归档也不再构建服务层的全量
`StringPoolIndex`。字符串池值只存一份,引用表保存真实池偏移、文件编号、
出现次数、token 类型和文件表字段。普通子串仍使用 Go 的 Unicode 小写规则,
正则仍使用 Go regexp。查询只扫描池记录,不重复解压归档;按路径、文件编号
排序的结果文件编号另存会话库,详情按当前页关联读取。
- 首次使用才建库。构建分批提交,解码时只保留当前块和当前文件的引用。
- 干净归档以加载内容的 SHA-256 和索引版本作为缓存键,缓存命中执行完整性检查。
索引放在用户缓存目录 `pvfine/advanced-search`,历史缓存预算 2 GiB。
- v4 缓存使用不可变文件及会话硬链接,历史缓存淘汰不会删除活动会话使用的索引。
不支持硬链接的文件系统仍可使用会话索引,但不能跨打开复用。
- 未保存归档只使用会话库,不发布干净缓存。编辑、结构变化和版本切换使查询
结果及游标失效,已完成的引用底库保留。下一次搜索仅解析新增/修改文件,
通过私有覆盖表和编号映射屏蔽旧引用;引用分片不重写。池字符串只追加新增
部分,已有占位符重新解析。池前缀改变时才回退到完整构建。
- 首次更新只复制元数据库,不复制引用分片;保存后追平的覆盖结果可发布
新缓存。可变元数据库使用 backup 快照,引用分片继续使用不可变硬链接。
- 查询会话最多 8 个,结果库合计预算 256 MiB,淘汰会删除实际文件;单次查询
超过可用预算会明确报错。游标包含查询身份,旧游标不能用于新查询或新归档。
- 取消按钮、归档关闭和编辑会取消旧任务并异步释放连接及会话目录;干净缓存保留。
- 这次迁移针对字符串模式。二进制搜索沿用现有实现。
复现:
```sh
PVF_MEMORY_TEST=1 PVF_ADVANCED_TEST=1 PVF_TESTFILE="$PWD/testdata/90CN.pvf" \
go test -gcflags='pvfine/...=-l' ./services \
-run '^TestLargeArchiveMemory$' -count=1 -v
```
2026-09-21,同机顺序运行、每场景一次,查询 `npc`,每页 200 个文件:
| 指标 | 测量值 |
| --- | ---: |
| 池字符串数 | 1,643,770 |
| 文件与池偏移引用数 | 26,804,667 |
| 首次建库 | 76.7 s |
| 反向索引文件 | 1,162,321,920 字节(约 1.08 GiB) |
| 已有缓存的完整性检查及打开 | 6.9 s |
| 已有缓存的首次查询(包含检查) | 7.6 s |
| 首次建库之后,普通查询生成结果 | 约 0.8 s |
| Go 正则查询生成结果 | 约 1.1 s |
| 首次建库后连续 10 页,端到端耗时范围 | 8–71 ms |
| 高级搜索后 GC 存活堆 | 约 364 MiB |
这些是服务层真实归档测量,未包含 WebView 物理内存,不是跨机器性能承诺。
终端日志使用 `[pvfine:advanced]`,仅记录条数、容量和耗时,不记录池内容或查询文本。
## 元数据提取与按需图标合批(2026-10-14)
`ScriptMetadata` 保留配对/非配对 section、嵌套层级和文档顺序语义,
只收集名称、图标、场景图、稀有度和角色名称所需的值。名称只保存首个有效
字符串,图标只保存首个有效相邻路径/帧编号对,其余目标最多保存首个 token;
无关 section 的值不解码。每次提取按偏移复用标签解析结果,不建立跨编辑的
标签缓存,也不修改字符串池。
Apple M4 Max、默认 Go 编译参数下,同一合成脚本分别在修改前后运行三次:
```sh
go test ./internal/pvf -run '^$' -bench '^BenchmarkScriptMetadataLargeScript$' \
-benchmem -count=3
```
| 指标 | 修改前 | 修改后 |
| --- | ---: | ---: |
| 单次耗时(三次中位数) | 220,775 ns | 42,124 ns |
| 单次分配字节(三次中位数) | 769,279 B | 28,208 B |
| 单次分配次数 | 814 | 23 |
样本含 100 个无关 section,每个含 40 个字符串及 40 个整数,并带名称、
图标、稀有度字段。建样本不计时,测量为重复读取内存脚本的热路径,不包含
真实归档打开、分块解压、本地化首次加载、SQLite 写入或 WebView。因此约
5.2 倍的提取差异不能解释为真实归档整体重建的提速倍数。
图标仍按实际请求加载。前端等待 8 ms 合并请求,每批最多 128 帧、最多
两个桥接批次同时执行。后端按 NPK 分组、按 IMG 排序,同批每个 NPK 只打开
一次,每个 IMG 只解析一次帧表;整个服务最多同时处理四个 NPK 解码任务。
每个任务只保留当前 IMG 帧表。重复路径别名按索引中的实际成员路径去重,
错误按帧返回,资源代际或快照变化后丢弃旧结果。旧 `GetImage` 入口复用相同
并发限制和缓存,绑定由 Wails 重新生成。
合成回归覆盖两个 NPK、三个 IMG、多帧、重复别名及无效引用的混合请求,
验证首次请求恰好打开两个 NPK、解析三个帧表,已缓存帧的重复请求不执行 IO。
另覆盖损坏成员隔离、过期代际、读取中切换代际、跨请求并发上限,以及前端
合批、排队、缓存、重试和迟到响应失效。这些是行为与读取次数验证,尚未测量
真实 NPK 目录下的滚动延迟或物理内存变化。
## 高级字符串搜索:worker 解析工作区复用(2026-10-14)
引用建库的每个 worker 独占一个 `StringReferenceWorkspace`,复用偏移去重
map、引用数组及 token 类型、文件字段小切片。返回结果为借用切片,对应
SQLite 分片 writer 必须同步消费;写入完成后 channel 只传文件信息和引用
数量,避免下一文件覆盖尚未读取的结果。原有 `StringReferenceInput.References`
继续返回独立结果,扫描器及增量调用的结果生命周期不变。
工作区超过 32,768 条引用容量时,在消费后立即释放;这个限制只控制后续保留
的临时容量,不截断大文件引用。正常工作区清理逻辑状态后复用,错误、取消
及 worker 退出仍遵循原来的任务取消协议,不复用可能被其他输入持有的解压块。
在 Apple M4 Max、36 GiB 内存、macOS 26.6、Go 1.25.8 darwin/arm64 上,
使用 4,311,296 文件的 110US 归档及开发编译参数顺序测试。实施前与复用原型
各运行三轮;正式实现再复测一轮,均绕过索引缓存,未清空操作系统缓存。
| 指标 | 实施前(三轮中位数) | 正式实现(一次复测) |
| --- | ---: | ---: |
| 完整高级字符串索引建库 | 103.68 s | 94.14 s |
| 累计分配 | 78.75 GiB | 57.55 GiB |
| 分配次数 | 1,025,812,242 | 756,516,298 |
| 文件与字符串偏移引用数 | 122,196,685 | 122,196,685 |
累计分配减少约 21.2 GiB(26.9%),分配次数减少约 26.3%。实施前耗时为
110.61 / 103.68 / 96.30 秒,复用原型为 95.26 / 96.79 / 98.32 秒;原型测试
之后补跑实施前代码得到 95.45 秒。因此耗时差异仍可能来自缓存及环境波动,
本次优化不承诺稳定的完整建库提速。累计分配不是峰值、存活堆或 RSS,不能
据此声称应用物理内存下降 26.9%。
此前独立解压及引用扫描探针在同一真实归档上交替运行现有/原型各三轮:
中位耗时 16.07 / 14.18 秒,累计分配 40.97 / 19.77 GiB,GC 次数 24 / 11。
该探针包含解压、解析、排序及计数,不含 SQLite、归档打开或文件路径投影,
不能把扫描提速直接视为完整建库提速。
真实归档逐文件校验全部引用的偏移、出现次数、token 类型顺序和字段标记,
与独立结果解析一致。新增回归覆盖同偏移多字段、类型顺序、尾部不完整 token、
跨文件复用、大容量回收、解析中取消、同步 writer 消费及阻塞 writer 取消。
全量 Go 测试、相关竞态检查及前端构建通过。
真实归档一致性验证(未设置 `PVF_REFERENCE_TEST=1` 时跳过):
```sh
PVF_REFERENCE_TEST=1 PVF_TESTFILE="$PWD/testdata/110US.pvf" \
go test -gcflags='pvfine/...=-l' ./internal/pvf \
-run '^TestStringReferenceWorkspaceRealArchive$' -count=1 -v -timeout=5m
```
完整建库复测:
```sh
PVF_TESTFILE="$PWD/testdata/110US.pvf" \
go test -gcflags='pvfine/...=-l' ./services -run '^$' \
-bench '^BenchmarkAdvancedIndexRealArchive$' -benchtime=1x -count=1 \
-benchmem -timeout=15m
```
该基准只在内存中追加一个未引用的池字符串来阻止缓存复用,不保存归档。
建库计时不含归档打开、缓存恢复校验和关闭清理;缓存校验时间单独报告。
+146
View File
@@ -0,0 +1,146 @@
# JavaScript 脚本工作区
脚本工作区使用 Goja 在进程内运行 `.pvf.js`。脚本只能操作当前打开归档的虚拟文件,运行阶段写入隔离事务;运行成功后生成预览,只有点击“应用选中”才会写入当前工作区 Overlay。应用后仍需通过归档编辑器的保存操作写回 PVF 文件。
## 最小示例
```js
for (const file of pvf.glob("equipment/**/*.equ")) {
const document = file.parse();
const price = document.section("price");
if (price && price.get() < 10) {
price.set(10);
file.write(document);
}
}
```
`sections(path)` 返回精确路径的全部 section;`section(path)` 和 `get/set/delete` 默认只访问第一个匹配项。重复 section 必须显式遍历 `sections()` 或传入 `occurrence`。`set` 接收字符串时,110US 自动注册为 type 8 字符串表引用,其它版本写入旧式 type 6 文本;数字替换保留已有类型,新增数字按整数/浮点范围推断。精确值对象始终按声明类型写入。`append` / `setValues` 的类型推断规则不变。
需要精确控制 token 类型时传入值对象:
```js
document.set("price", { type: "float", value: 3.5 });
section.set({ type: "quoted", value: "展示名", pool: "utf16" });
```
## 字符串引用
110US 的 type 8/10 与旧式字符串类型均可读写。`get` / `getValue` 保留引用本身;
`getOriStrValue` 将 `<表号::键名>` 解析为字符串表中的文本,也支持文本中嵌入的多个引用。
旧式文本和无法解析的引用原样返回;document/section 的值不存在时返回 `undefined`,
值不是字符串时报错。解析使用当前脚本暂存状态,包含同次运行对 `.str` 和映射列表的修改。
```js
const file = pvf.find("equipment/item.equ");
const document = file.parse();
const name = document.section("name");
const text = name.getOriStrValue(); // 等同于 document.getOriStrValue("name")
const original = pvf.getOriStrValue("<31::equip_name_1>");
name.set("新的展示名"); // 110US 自动注册 .str 条目,写入 type 8
document.set("description", "新的描述", { create: true });
name.setStrValue("旧式内联文本"); // 所有版本固定写入 type 6
file.write(document);
```
`section.setStrValue(text, index?)` 和 `document.setStrValue(path, text, options?)`
始终写入旧式 type 6,后者的选项和返回值与 `set` 相同,不注册或修改 `.str` 表。
110US 的 `set` 优先选用当前值引用的可写字符串表,否则使用基础映射中第一个可写表。
自动生成的内容键可复用相同文本,不覆盖原有共享引用。没有可写表、文本包含换行或 NUL 时报错,
可改用 `setStrValue`。自动注册产生的 `.str` 变更也会进入预览,应用引用文件时必须一并选择依赖表。
精确控制已有引用时可传入 `{ type: "block8", value: "<31::key>" }`,或使用 `"block10"`。
## 移除子 Section
```js
const count = document.section("parent").removeChildren("child");
file.write(document);
```
`removeChildren(children_name)` 删除全部同名直接子 section 及其子树,返回删除数量;
无匹配返回 `0`。名称支持 `"child"` 或 `"[child]"`,只处理直接子节点,不影响其它
section 和父节点的直接值。被删除子树的旧句柄不可再使用。
## 新建、复制与删除文件
```js
const created = pvf.createFile("equipment/new.equ", pvf.types.script, "[price]\n100");
created.setText("[price]\n200"); // 之后可继续编辑
pvf.copyFile("equipment/a.equ", "equipment/b.equ"); // 目标已存在时报错
pvf.copyFile("equipment/a.equ", "equipment/b.equ", true); // 显式覆盖
pvf.deleteFile("equipment/old.equ"); // 返回是否删除了条目
```
- `createFile(path, dataType?, text?)`:`dataType` 省略时默认为 `pvf.types.script`,`text` 是脚本可读的初始内容。路径必须非空且不含 `.`、`..` 或空段;路径中不存在的目录会随文件一起创建;路径已存在时报错,不会静默覆盖。
- `copyFile(from, to, overwrite?)`:复制当前暂存状态的最终内容与数据类型,因此同一脚本中先编辑再复制会带上新内容。目标已存在时必须显式传 `overwrite`。
- `deleteFile(path)`:只删除文件本体,不修改 `.lst` 等注册表文件。路径不存在时返回 `false`。需要同步维护列表时用下面的 `pvf.lst`。
### 结构变更与句柄失效
`createFile` / `copyFile` / `deleteFile` 会改变归档的文件表,删除之后的条目索引会被重新编号。因此:
- 删除发生后再使用之前通过 `pvf.find` / `pvf.glob` / `pvf.files` 取得的旧句柄会报错,需要重新查询。
- 预览面板按路径(`changeKey`)而不是文件索引标识变更,行状态区分 `changed` / `added` / `deleted`。
- 应用后需要重新加载归档视图,不能按旧索引原地刷新。
## .lst 列表操作
`.lst` 文件按固定两 token 记录(id、路径)存放资源注册表。`pvf.lst(path)` 返回一个列表句柄:
```js
const lst = pvf.lst("equipment/equipment.lst");
const entries = lst.get(); // => { "1008": "character/a.equ", ... }
lst.forEach((id, path) => { /* 按文件顺序遍历 */ });
lst.set("1009", "character/b.equ"); // id 已存在则改,不存在则追加
lst.mset({ "1010": "character/c.equ" }); // 批量写入
lst.unset("1008"); // => true / false
lst.getId("character/b.equ"); // => "1009",未注册时返回 null
```
- `get()`:返回普通对象,键是 id、值是路径;同一 id 有多条记录时只保留第一条。
- `forEach(callback)`:按 `.lst` 文件顺序调用 `callback(id, path)`。因为 JS 对象会把整数形式的键按数值重排,需要按文件顺序处理时应当用 `forEach`,而不是遍历 `get()` 的结果。回调里可以修改列表,遍历基于进入时的快照。
- `set(id, path)` / `mset(entries)`:id 已存在则原地改写它的路径(保留记录位置与 id 的 token 编码),不存在则追加到末尾。id 可以传字符串或整数(如 `set(1010, ...)`),内部统一按字符串处理。写入某个 id 时,该 id 的重复记录会被合并为一条,因此一个 id 始终只对应一个条目。
- `unset(id)`:删除该 id 的**全部**记录,返回是否真的删除了条目。
- `getId(path)`:反向查询路径注册的 id,未注册时返回 `null`。它只做路径换算,**不要求目标文件存在**。
- 旧布局按 `.lst` 所在目录存储相对路径,例如 `equipment/equipment.lst` 里的条目写作 `character/a.equ`;110US 和 `list/` 集中布局按归档根存储完整路径,例如 `list/equipment.lst` 里的条目写作 `equipment/character/a.equ`。这里的完整路径是 PVF 内部路径,不是操作系统路径。`set` / `mset` 同时接受列表目录相对路径和完整归档路径,按布局自动换算;有歧义时优先采用列表原生存储形式。目标文件必须存在,否则报错且不写入,避免登记出悬空条目。`getId` 使用相同的路径换算,但不要求目标存在;`get` / `forEach` 返回实际存储路径。
- `pvf.lst(path)` 兼容两种列表位置:旧路径 `equipment/equipment.lst` 可打开集中布局的 `list/equipment.lst`,反向也支持;如果指定路径确实存在,优先使用它。
列表编辑属于普通内容修改,会以 `changed` 行进入预览,不需要重新加载归档视图。
## 文件集操作
文件集是用户配置目录下 `file-sets.json` 里的路径集合,与当前归档无关。`pvf.fileset(name)` 按名称取得句柄,不存在时返回 `null`;`pvf.createFileset(name, paths?)` 新建一个集合并返回句柄。
内置的「默认文件集」与侧边栏一致,始终存在(即使从未保存过),所以可以直接读改它,不需要先创建:
```js
const set = pvf.fileset("默认文件集"); // 一定存在,初值为空
if (set) {
const paths = set.getAll(); // => ["equipment/a.equ", ...]
set.setAll(paths.filter((path) => path.endsWith(".equ")));
}
pvf.createFileset("武器", ["equipment/character/sword.equ"]);
pvf.createFileset("空集合"); // paths 省略即空集
pvf.fileset("不存在"); // => null
```
- `getAll()`:返回集合内的路径列表,顺序与存储一致。同一脚本内先 `createFileset` 再 `fileset` 也能查到。
- `setAll(paths)`:用给定列表整体替换集合内容,返回替换后的条目数。路径会规范化(反斜杠转正斜杠、去首尾斜杠)并去重,空路径被忽略;**不校验路径是否存在于当前归档**,所以可以预先录入尚未创建的文件,未命中的条目在侧边栏显示为失效。已存在路径会保留其名称与 id 等元数据。
- `createFileset(name, paths?)`:名称首尾空白会被忽略,与已有集合重名时报错(包括内置的「默认文件集」),不会静默覆盖。`paths` 规则与 `setAll` 相同。
- 文件集是用户配置,不是归档内容,因此它的变更**不改动 PVF**:预览面板里以「文件集 · <名称>」单列一组(整组共用一个勾选框,显示 `+`/`-` 的路径增减),点“应用选中”才写入 `file-sets.json`。只改文件集的脚本没有归档变更,`modifiedFiles` 为 0,但依然可以应用;反之只勾选归档行时文件集不会被写入。
- 应用后前端广播 `fileset:changed`,侧边栏重新读取磁盘内容。若侧边栏此刻有未保存的改动,不会自动刷新而是提示冲突,避免脚本写入被下一次保存覆盖。
## 运行边界
- `file.parse()` 只接受 TypeScript 文件;`text()` / `setText()` 也支持 Unicode 文本文件。
- 结构化文档保留 token 顺序、类型和字符串池编码,不保留空白、注释或原始排版。
- 新建、复制、删除、`.lst` 列表编辑、文件集修改与内容修改一样进入隔离事务,只有点击“应用选中”才会写入当前工作区 Overlay(文件集例外,它写入 `file-sets.json`);可用 `pvf.modifiedCount` 查看包括结构变更在内的待应用数量。
- 每次运行最多 5 分钟,同时只能有一个活动运行实例;停止、超时、归档切换和运行异常都会丢弃事务。
- Goja 沙箱不注册 `require`、`process`、`fetch`、Node.js 模块、本地文件、网络或 shell 能力。
- 脚本目录固定为 `os.UserConfigDir()/pvfine/scripts/`,只接受目录内 basename 格式的 `.pvf.js` 文件。
脚本服务事件:`script:state`、`script:log`、`script:progress`。
+15
View File
@@ -0,0 +1,15 @@
# 锻造配置 GUI
支持文件 `etc/upgrade_separate.etc`。通过通用工具菜单“强化/增幅/锻造 → 锻造配置”,或该文件的 GUI 模式进入;两个入口共用文件草稿。
- 锻造等级:每级读取 `[table]` 的 4 项,顺序为属性系数 A、属性系数 B、成功率、材料倍率。成功率分母为 10000,GUI 按百分比编辑,精度 0.01%。前两个系数只读。
- 最大等级:编辑 `[separate upgrade max]`,允许范围为 1 到已有锻造表的行数。不会自动增加锻造表,也不会改写 `[level]` 或特效等配套数据。
- 材料消耗:`[item weights by grade]` 与 `[item weights by grade 100lv]` 分页显示,每条为装备等级、材料 ID、数量基数。道具名称从归档物品索引解析,未找到时显示 ID。
- 批量设置:锻造页聚合成功率和材料倍率;材料页聚合材料 ID 和数量基数。使用双端滑块或数字框选择范围,仅更新勾选项。
- 其他参数:折叠区只读展示 `[level]`、稀有度权重、武器权重、特效与其他数值参数,暂不推定未知字段的含义。
材料数量展示的是基数,不是游戏内最终消耗。`100lv` 表的选择条件、稀有度位置映射与取整规则尚未确认,GUI 不计算最终消耗。
数值编辑保留源文件整数/浮点类型;源字段是整数时拒绝写入小数材料倍率。成功率按整数万分比保存。编辑保留未修改字段、注释和换行格式;文件或归档发生变化后需重新打开表单。
点击“应用到草稿”后,再点击“保存锻造”写入工作区,最后保存 PVF 写入归档。