# DNF 加点代码解析说明 本文档说明 [`skillTree.py`](./skillTree.py) 当前实现的加点代码解析规则。 规则来源包括普通职业和黑暗武士的实际加点代码、游戏内技能栏截图,以及项目本地 OpenAPI 技能树数据。文档只把已经能够稳定复现的结构列为已解析字段;`remaining` 中尚未确认的内容继续保留为原始字节。 ## 1. 解码流程 加点代码依次经过: ```text Base64 字符串 -> base64.b64decode -> zlib.decompress -> DNF 加点二进制 ``` 顶层二进制结构: ```text Header(固定 14 字节) 角色名(UTF-16LE,变长) Section1(技能等级,变长) Section2(进化、强化、快捷栏,变长) remaining(黑武排列前缀及其他未解析数据) 尾部数据 ``` 入口函数: ```python 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: ```python 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` 读取: ```python uint16s = struct.unpack('> 8 low = value & 0xFF ``` 每个 `uint16` 的高字节为主要数值,低字节为标志位。 ### 3.1 技能数量 第一个 `uint16` 的高字节表示技能记录数量: ```python num_pairs = high[0] ``` ### 3.2 技能记录 每条技能记录占两个 `uint16`,即 4 字节: ```text 技能索引 uint16 技能等级 uint16 ``` 当前输出字段: ```python { 'skill_index': int, 'level': int, 'skill_flag': int, 'level_flag': int, } ``` 完整技能索引按当前样本规则计算: ```python skill_index = skill_index_high + (0x100 if level_flag else 0) ``` 例如: ```text 高字节 0x98,level_flag = 1 => skill_index = 0x98 + 0x100 = 0x198 = 408 ``` 技能记录区总长度: ```python (1 + num_pairs * 2) * 2 ``` 其中第一个 `1` 是技能数量所在的 `uint16`。 ## 4. Section2:进化、强化和快捷栏 Section2 从 Section1 结束位置开始,按字节解析。 ```text 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 第一个字节当前只跳过,尚未赋予业务含义: ```python off += 1 ``` ### 4.2 进化选择 每条进化记录占 3 字节: ```text 技能索引低字节 技能索引标志 进化类型原始值 ``` 输出: ```python { 'skill_index': index_byte + (0x100 if flag else 0), 'flag': flag, 'type': raw_type + 1, } ``` ### 4.3 强化选择 强化记录与进化记录格式相同,每条占 3 字节: ```python { 'skill_index': index_byte + (0x100 if flag else 0), 'flag': flag, 'type': raw_type + 1, } ``` ### 4.4 普通技能栏 `key_count` 表示后续快捷栏记录数量。当前普通职业和黑暗武士样本均为 14。 每个快捷栏槽位占 2 字节: ```text 技能索引低字节 技能索引标志 ``` 索引计算: ```python skill_index = index_byte + (0x100 if flag else 0) ``` 当计算后的索引为 `0` 时,输出 `None`,表示空槽。 输出: ```python { 'slot': 0, # 从 0 开始 'skill_index': 57, # 空槽为 None 'flag': 0, } ``` 黑暗武士的快捷栏前六项通常不是普通技能,而是连续技排列槽索引 `118` 到 `123`。需要结合 `arrangements` 继续展开。 ## 5. remaining `_parse_section2()` 完成快捷栏解析后,将剩余字节原样返回: ```python remaining = sec2[off:] ``` 公开字段保持为: ```python 'section2_remaining': remaining ``` 除黑暗武士排列前缀外,后续内容尚未确认,当前不会输出 `unknown`、`trailer`、偏移或诊断等推测字段。 ### 当前实现的补零行为 `decode_skill_hash()` 为了按 `uint16` 读取数据,会在角色名之后的数据总长度为奇数时补一个 `0x00`: ```python if len(data) % 2: data += b'\x00' ``` 因此部分样本返回的 `section2_remaining` 末尾为: ```text ... F3 00 ``` 其中最后的 `00` 是当前解析实现补入的对齐字节,不是解压后原始数据的一部分。该行为为兼容现有返回格式而保留。 ### 5.1 普通职业技能连招 版本 5 的非黑暗武士编码新增 `skill_combo`,由 `_parse_skill_combo()` 读取: ```python '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`: ```python header['job_bytes'][0] == 9 ``` 解析函数: ```python _parse_dark_knight_arrangements(remaining: bytes) -> list[dict] ``` ### 6.1 排列区结构 排列区位于 `remaining` 开头: ```text arrangement_count 1 字节 重复 arrangement_count 次: slot_index 2 字节 skill_count 1 字节 skills skill_count * 2 字节 后续数据 不解析,仍保留在 section2_remaining 中 ``` 排列槽索引和技能索引均使用与快捷栏一致的两字节规则: ```python index = index_byte + (0x100 if flag else 0) ``` 排列函数只读取已确认的前缀,不修改或截断原始 `section2_remaining`。 ### 6.2 返回格式 排列结果只包含实际解析出的字段: ```python [ { '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` | 对应技能名称: 1. 禁魂斩 2. 滑斩 → 暗影斩 → 旋涡斩 → 邪影·波动剑 → 冥炎破空斩 3. 跃斩 → 黑暗十字刃 → 跃地斩 → 挑击 → 邪影升龙斩 4. 瞬影斩 → 冥光斩 → 暗影鞭 → 暗劲爆发 → 魔影剑舞 5. 突刺 → 暗月斩 → 暗影·波动剑 → 暗魂之手 → 暗炎冥魂波 6. 暗影升龙击 → 暗影三击剑 → 冥魂之陨 → 破魂斩 → 斩龙破 ## 7. `decode_skill_hash()` 返回结构 普通职业: ```python { 'header': dict, 'skills': list, 'auto_skills': list, 'evolutions': list, 'enhancements': list, 'keybinds': list, 'section2_remaining': bytes, 'raw_size': int, } ``` 黑暗武士在上述结构基础上增加: ```python { 'arrangements': list, } ``` 普通职业不会增加空的 `arrangements` 字段,以保持原有返回结构不变。 ## 8. 技能索引到名称的解析 `decode_skill_hash()` 保留代码中的原始 `skills`,并根据 Header 职业读取本地 `backend/openapi/static/data/cn/{job_name}/{job_grow_name}/skill_tree.json`,新增: ```python '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 缓存并重启进程(或升级缓存版本)。 解析和计算器适配分为三步: 1. `decode_skill_hash(encoded)`:二进制解析及 `auto_skills` 补充。 2. `resolve_skill_tree(decoded, skill_tree)`:合并两类技能,映射名称、进化、强化、快捷栏和排列。 `get_openapi_skill_tree_info(encoded)` 封装前两步,以 OpenAPI ID 返回结果,不调用 `createCharacter`。 3. `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. 维护要求 后续扩展解析时应遵守: 1. 保留完整 `section2_remaining`,不要因为新增解析而改变原返回数据。 2. 只解析能够通过多个样本或游戏界面对照确认的字段。 3. 新职业专属结构应抽取为独立函数,并按职业头条件调用。 4. 不向公开结果加入 `raw_hex`、猜测偏移、临时诊断或未确认的 trailer 字段。 5. 新增高位索引样本时,同时验证 Section1、进化、强化、快捷栏和职业专属结构。 6. 普通职业的返回结构应保持兼容;职业专属字段只在对应职业出现。