服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
44 KiB
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.dat1,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.pvfMD5 完全相同(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 读到的确定逻辑:
- 头部校验(
Pvf110Container.A@0x273C):取文件前 48 字节 → 用 32 字节键做ApplyGuard→keys = PvfLayout.KeysFor(2)(即 Paged110) →PvfCipher.Transform(header48, keys.Header, 2531011)→ 要求头 4 字节 等于1768975214("nkpi")。 注意 seed 是常量2531011 = 0x269EC3(就是 magicMain)。 - 页键表是 RSA 密封的(
UnsealPageKeyTable):取 RSA 私钥 →keySize = rsa.KeySize/8→ 校验sealed.Length % keySize == 0→ 逐块rsa.Decrypt(block, RSAEncryptionPadding.Pkcs1)。 与sk.dat1920 B 相容的 RSA 尺寸:1024 位(15 块)/2560 位(6 块)/ 3072 位(5 块)等。 - 页键表还要再用 metadata key 解一层(
UnwrapPageKeyTable), 而 metadata key 是从头部字节用MetadataKeyCandidates试出来的多个候选。 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; ③ dumpPvfCipher.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):
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.str62 万条键);*.kor.str/*.translate.str(只映射 3 和 13 两个表号)是 随包的语言覆盖层。把覆盖层排在前面会在基准表有中文时也显示韩文,这正是 早期版本的现象。.lst本身是普通脚本 token 流:整数+字符串交替,前者是表号, 后者是该表的.str路径。.str是 UTF-16LE 文本,每行键>值,//为注释;文件缺失时回退到同名 的.translate.str/.kor.str/.uv.str变体。ResolveStringTable(表号, 键)从基准表往下找第一个非空值, 同时回报来源路径与是否回退(Fallback)。空值不算命中:基准表用键=的写法列出了所有已知键,其中装备名有 36.7%(133,487/363,315)是空值 (中文补丁没翻),若把空值当命中,这些名字会被直接抹掉。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 个内联字面量),所以新文件也应该写引用。完整配方:
- 建文件:新建
xxx.equ/xxx.stk。 - 写引用:在
[name]段写{8=<3::你的键名>}。 注意花括号里的 8 是 token 类型(字符串池引用),表号在<表号::键名>里 (装备=3、道具=13 最常用)。不要写成裸的<3::键名>:那样会被编码成 type 3 标签串,不是名字。- 手写嫌麻烦:编辑器工具栏「字符串引用」按钮会弹框问表号/键名/译文,
确定后自动在光标处插入
{8=<表号::键名>}并把条目写进对应字符串表。 - 只写了引用、还没建条目时,标签会显示红色的「未定义」,点它就能直接填译文 (后端把新键追加到该表号的基准表,插在尾部 NUL 填充之前)。
- 手写嫌麻烦:编辑器工具栏「字符串引用」按钮会弹框问表号/键名/译文,
确定后自动在光标处插入
- 注册进列表:把
id 路径加进list/equipment.lst(道具是list/stackable.lst)。.lst是普通脚本,可以直接编辑文本,或用脚本工作区的pvf.lst("list/equipment.lst").set(id, path)(361k 条的大表不适合手改)。 - 保存:结构性改动会重建名称池与 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 的生成函数。要对上新条目,需要下面任一条件:
- 在游戏里试验:先只加
.lst(现状)→ 看物品是否出现;再把索引条目用 邻近条目的数值补上 → 再看。这能判定客户端是否校验这个值。 - 找到写这批
.etc的工具(本机D:\Games\dxf\tools里的工具与DFO.exe、 服务端二进制都没有 "indexhash" 字样,客户端也不含n_string/equipment.lst这类字面量,所以文件名是运行时拼的,无法用字符串搜索定位)。 - 另一份同版本客户端的同名文件:若同一个 新 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):
- 先按原样查两个池的索引,命中就沿用原偏移(不动既有文本);
- 未命中:非 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. 建议的后续步骤
补 HASH 段的密钥名已完成,见 §2.15。Script2.pvf(godof 布局):按 §2.6 实现独立解析路径, 项目里的 614 MB 归档即可打开(可用dof-dev/godof作为对照实现)。- 字符串的编辑体验(承接 §2.14):已完成 —— 编辑器里点占位符标签弹框改译文,
后端按 UTF-16LE 字节就地替换对应
.str条目(详见 §2.13),保存后生效。 大表(String/equipment.uv.str48.9 MB)不需要整文件打开也能改单条。 - 界面消费占位符解析:装备预览、编辑器标注、搜索结果与文件树标签都已接入;
其余面板(批次处理、版本对比里的名称)如需显示译文再复用
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;增删文件因此可用。