DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -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;增删文件因此可用。
|
||||
Reference in new issue
Block a user