Files
2026-09-14 14:53:05 +08:00

448 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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('<H', ...)
high = value >> 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. 普通职业的返回结构应保持兼容;职业专属字段只在对应职业出现。