Files
90CNServer/pvf/docs/PVF新格式分析.md
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

44 KiB
Raw Permalink Blame History

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

即:

// 密钥为 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\ 的客户端文件调用:

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 位可逆混合(两轮乘法):

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;增删文件因此可用。