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

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

313 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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。