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

+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。