12 KiB
DNF 加点代码解析说明
本文档说明 skillTree.py 当前实现的加点代码解析规则。
规则来源包括普通职业和黑暗武士的实际加点代码、游戏内技能栏截图,以及项目本地 OpenAPI 技能树数据。文档只把已经能够稳定复现的结构列为已解析字段;remaining 中尚未确认的内容继续保留为原始字节。
1. 解码流程
加点代码依次经过:
Base64 字符串
-> base64.b64decode
-> zlib.decompress
-> DNF 加点二进制
顶层二进制结构:
Header(固定 14 字节)
角色名(UTF-16LE,变长)
Section1(技能等级,变长)
Section2(进化、强化、快捷栏,变长)
remaining(黑武排列前缀及其他未解析数据)
尾部数据
入口函数:
decode_skill_hash(encoded: str) -> dict
2. Header
Header 固定为 14 字节。
| 偏移 | 长度 | 当前字段 | 解析方式 |
|---|---|---|---|
0 |
1 | magic |
原始字节,已观察到 0xF3 |
1 |
1 | version |
编码格式版本 |
2 |
1 | job_bytes[0] |
职业大类字节 |
3 |
1 | job_bytes[1] |
职业分支字节 |
4 |
1 | 未解析 | 保留在原始数据中 |
5 |
1 | level |
角色等级 |
6..7 |
2 | 未解析 | 保留在原始数据中 |
8 |
1 | job_marker |
当前按单字符读取,样本中为 K |
9..12 |
4 | 未解析 | 保留在原始数据中 |
13 |
1 | name_byte_len |
角色名的字节长度 |
角色名紧跟 Header:
name = raw[14 : 14 + name_byte_len].decode('utf-16-le')
当前已验证的职业头示例:
job_bytes |
职业 |
|---|---|
(5, 4) |
女弹药 |
(9, 0) |
黑暗武士 |
完整职业大类转换使用 role_map;职业分支在 get_skill_tree_info() 中结合 get_adv_info() 解析。
3. Section1:技能等级
角色名结束后的数据先按小端序 uint16 读取:
uint16s = struct.unpack('<H', ...)
high = value >> 8
low = value & 0xFF
每个 uint16 的高字节为主要数值,低字节为标志位。
3.1 技能数量
第一个 uint16 的高字节表示技能记录数量:
num_pairs = high[0]
3.2 技能记录
每条技能记录占两个 uint16,即 4 字节:
技能索引 uint16
技能等级 uint16
当前输出字段:
{
'skill_index': int,
'level': int,
'skill_flag': int,
'level_flag': int,
}
完整技能索引按当前样本规则计算:
skill_index = skill_index_high + (0x100 if level_flag else 0)
例如:
高字节 0x98,level_flag = 1
=> skill_index = 0x98 + 0x100 = 0x198 = 408
技能记录区总长度:
(1 + num_pairs * 2) * 2
其中第一个 1 是技能数量所在的 uint16。
4. Section2:进化、强化和快捷栏
Section2 从 Section1 结束位置开始,按字节解析。
marker 1 字节
evolution_count 1 字节
evolutions evolution_count * 3 字节
enhancement_count 1 字节
enhancements enhancement_count * 3 字节
key_count 1 字节
keybinds key_count * 2 字节
remaining 剩余全部字节
4.1 Section2 marker
Section2 第一个字节当前只跳过,尚未赋予业务含义:
off += 1
4.2 进化选择
每条进化记录占 3 字节:
技能索引低字节
技能索引标志
进化类型原始值
输出:
{
'skill_index': index_byte + (0x100 if flag else 0),
'flag': flag,
'type': raw_type + 1,
}
4.3 强化选择
强化记录与进化记录格式相同,每条占 3 字节:
{
'skill_index': index_byte + (0x100 if flag else 0),
'flag': flag,
'type': raw_type + 1,
}
4.4 普通技能栏
key_count 表示后续快捷栏记录数量。当前普通职业和黑暗武士样本均为 14。
每个快捷栏槽位占 2 字节:
技能索引低字节
技能索引标志
索引计算:
skill_index = index_byte + (0x100 if flag else 0)
当计算后的索引为 0 时,输出 None,表示空槽。
输出:
{
'slot': 0, # 从 0 开始
'skill_index': 57, # 空槽为 None
'flag': 0,
}
黑暗武士的快捷栏前六项通常不是普通技能,而是连续技排列槽索引 118 到 123。需要结合 arrangements 继续展开。
5. remaining
_parse_section2() 完成快捷栏解析后,将剩余字节原样返回:
remaining = sec2[off:]
公开字段保持为:
'section2_remaining': remaining
除黑暗武士排列前缀外,后续内容尚未确认,当前不会输出 unknown、trailer、偏移或诊断等推测字段。
当前实现的补零行为
decode_skill_hash() 为了按 uint16 读取数据,会在角色名之后的数据总长度为奇数时补一个 0x00:
if len(data) % 2:
data += b'\x00'
因此部分样本返回的 section2_remaining 末尾为:
... F3 00
其中最后的 00 是当前解析实现补入的对齐字节,不是解压后原始数据的一部分。该行为为兼容现有返回格式而保留。
5.1 普通职业技能连招
版本 5 的非黑暗武士编码新增 skill_combo,由 _parse_skill_combo() 读取:
'skill_combo': {
'skills': [309, 305, 312, 311],
'interval_ms': 5000,
}
remaining[92:100]:固定四个技能索引,每项两字节;沿用快捷栏的高位标志规则。- 技能为 OpenAPI 数字 ID,空槽返回
None,保留四个槽位及顺序。 remaining[100:102]:小端 uint16 初始化间隔,单位毫秒。remaining[102]:尾部F3。长度不足或标记不匹配时skill_combo返回None。remaining[91]尚未确认,不解释为开关或数量。
女蓝拳样本的四个技能为拳震八方、巨力炮拳、致命冲拳、怒气贯地击,
顺序已由用户确认。仅把间隔从 0 秒改为 5 秒后,唯一变化是
remaining[100:102] 从 00 00 变为 88 13(小端整数 5000)。
剑豪样本也能对应三个技能和一个空槽。
该字段当前仅在 decode_skill_hash() 输出;计算器接口及黑暗武士的
arrangements / combos 不变。黑暗武士和其他编码版本不套用此偏移。
原始 section2_remaining 始终完整保留。
6. 黑暗武士连续技排列
只有满足以下条件时,顶层结果才增加 arrangements:
header['job_bytes'][0] == 9
解析函数:
_parse_dark_knight_arrangements(remaining: bytes) -> list[dict]
6.1 排列区结构
排列区位于 remaining 开头:
arrangement_count 1 字节
重复 arrangement_count 次:
slot_index 2 字节
skill_count 1 字节
skills skill_count * 2 字节
后续数据 不解析,仍保留在 section2_remaining 中
排列槽索引和技能索引均使用与快捷栏一致的两字节规则:
index = index_byte + (0x100 if flag else 0)
排列函数只读取已确认的前缀,不修改或截断原始 section2_remaining。
6.2 返回格式
排列结果只包含实际解析出的字段:
[
{
'slot_index': 118,
'skills': [60],
},
{
'slot_index': 119,
'skills': [8, 5, 58, 117, 82],
},
]
列表顺序就是游戏内排列列顺序,skills 顺序就是该列从第一阶段到最后阶段的顺序。
6.3 已验证样本
黑暗武士“霧失樓臺”样本解析为 6 列:
| 列 | slot_index |
技能索引 |
|---|---|---|
| 1 | 118 | 60 |
| 2 | 119 | 8, 5, 58, 117, 82 |
| 3 | 120 | 108, 64, 65, 46, 253 |
| 4 | 121 | 72, 50, 111, 24, 73 |
| 5 | 122 | 107, 77, 20, 31, 252 |
| 6 | 123 | 68, 112, 44, 9, 251 |
对应技能名称:
- 禁魂斩
- 滑斩 → 暗影斩 → 旋涡斩 → 邪影·波动剑 → 冥炎破空斩
- 跃斩 → 黑暗十字刃 → 跃地斩 → 挑击 → 邪影升龙斩
- 瞬影斩 → 冥光斩 → 暗影鞭 → 暗劲爆发 → 魔影剑舞
- 突刺 → 暗月斩 → 暗影·波动剑 → 暗魂之手 → 暗炎冥魂波
- 暗影升龙击 → 暗影三击剑 → 冥魂之陨 → 破魂斩 → 斩龙破
7. decode_skill_hash() 返回结构
普通职业:
{
'header': dict,
'skills': list,
'auto_skills': list,
'evolutions': list,
'enhancements': list,
'keybinds': list,
'section2_remaining': bytes,
'raw_size': int,
}
黑暗武士在上述结构基础上增加:
{
'arrangements': list,
}
普通职业不会增加空的 arrangements 字段,以保持原有返回结构不变。
8. 技能索引到名称的解析
decode_skill_hash() 保留代码中的原始 skills,并根据 Header 职业读取本地
backend/openapi/static/data/cn/{job_name}/{job_grow_name}/skill_tree.json,新增:
'auto_skills': [
{'skill_index': 174, 'level': 115},
]
仅补充具有 autoLevel 且未出现在原始 skills 中的技能,等级直接使用
autoLevel,不覆盖代码中的显式等级。没有补充项时返回空列表。
这些条目来自技能树,没有二进制 skill_flag / level_flag。
解码不创建角色;通过 _get_skill_tree_context() 优先读取 Redis,缓存键为
openapi:skillTreeMap:v1:{job_name}:{job_grow_name}:map,与旧名称映射隔离。
未命中或 Redis 读写失败时,按现有 get_redis_info() 规则回退本地加载;
DEBUG 模式跳过 Redis。文件加载另有进程内缓存,更新数据需清理对应 Redis
缓存并重启进程(或升级缓存版本)。
解析和计算器适配分为三步:
decode_skill_hash(encoded):二进制解析及auto_skills补充。resolve_skill_tree(decoded, skill_tree):合并两类技能,映射名称、进化、强化、快捷栏和排列。get_openapi_skill_tree_info(encoded)封装前两步,以 OpenAPI ID 返回结果,不调用createCharacter。get_skill_tree_info(encoded):调用上述入口,再按名称匹配计算器技能,保持现有接口字段和计算器 ID。
尚未新增 HTTP 路由;后续对外接口可直接调用 get_openapi_skill_tree_info()。
需要区分:
- 加点代码中的技能索引:二进制原始索引;
- OpenAPI 技能名称:本地
skill_tree.json解析结果; character.skills中的技能 ID:项目计算对象 ID。
三者不能在没有映射证据时直接视为同一个字段。
9. 已知边界
当前已经验证:
- Base64 和 zlib 解码链路;
- 14 字节 Header 及 UTF-16LE 角色名;
- Section1 技能数量、索引、等级和标志位;
- Section2 进化、强化和 14 槽快捷栏;
- 高位技能索引的
+0x100规则; - 黑暗武士连续技排列的组数、槽索引、组内技能数和技能顺序;
- 黑暗武士快捷栏
118..123与六组排列的连接关系。
当前尚未解析:
- Header 中未命名的常量或状态字节;
- Section2 首字节 marker 的业务含义;
- 黑暗武士排列前缀之后的 remaining 数据;
- 普通职业 remaining 中除上述技能连招以外的字段含义;
- 尾部
0xF3之前其他状态值的业务含义。
在获得“只修改一个游戏设置后重新导出的对照代码”之前,不应为这些字节添加业务字段。
10. 维护要求
后续扩展解析时应遵守:
- 保留完整
section2_remaining,不要因为新增解析而改变原返回数据。 - 只解析能够通过多个样本或游戏界面对照确认的字段。
- 新职业专属结构应抽取为独立函数,并按职业头条件调用。
- 不向公开结果加入
raw_hex、猜测偏移、临时诊断或未确认的 trailer 字段。 - 新增高位索引样本时,同时验证 Section1、进化、强化、快捷栏和职业专属结构。
- 普通职业的返回结构应保持兼容;职业专属字段只在对应职业出现。