Files
dcalc/backend/core/open/skillTree.md
T

9.6 KiB
Raw Blame History

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 是当前解析实现补入的对齐字节,不是解压后原始数据的一部分。该行为为兼容现有返回格式而保留。

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

对应技能名称:

  1. 禁魂斩
  2. 滑斩 → 暗影斩 → 旋涡斩 → 邪影·波动剑 → 冥炎破空斩
  3. 跃斩 → 黑暗十字刃 → 跃地斩 → 挑击 → 邪影升龙斩
  4. 瞬影斩 → 冥光斩 → 暗影鞭 → 暗劲爆发 → 魔影剑舞
  5. 突刺 → 暗月斩 → 暗影·波动剑 → 暗魂之手 → 暗炎冥魂波
  6. 暗影升龙击 → 暗影三击剑 → 冥魂之陨 → 破魂斩 → 斩龙破

7. decode_skill_hash() 返回结构

普通职业:

{
    'header': dict,
    'skills': list,
    'evolutions': list,
    'enhancements': list,
    'keybinds': list,
    'section2_remaining': bytes,
    'raw_size': int,
}

黑暗武士在上述结构基础上增加:

{
    'arrangements': list,
}

普通职业不会增加空的 arrangements 字段,以保持原有返回结构不变。

8. 技能索引到名称的解析

decode_skill_hash() 只负责二进制结构和技能索引,不直接写入名称。

get_skill_tree_info() 根据职业加载:

backend/openapi/static/data/cn/{job_name}/{job_grow_name}/skill_tree.json

并通过 load_skill_tree_name_map() 建立:

OpenAPI 技能索引 -> 技能名称

随后再按技能名称连接到 character.skills 中的计算对象。

需要区分:

  • 加点代码中的技能索引:二进制原始索引;
  • 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. 普通职业的返回结构应保持兼容;职业专属字段只在对应职业出现。