From 0f648a94e8e3243c7f85ddd771fa8c1f26ad9f8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A5=BF=E7=93=9C=C2=B0?= Date: Mon, 10 Aug 2026 14:47:46 +0800 Subject: [PATCH] =?UTF-8?q?new:=E8=BF=9B=E4=B8=80=E6=AD=A5=E5=AE=8C?= =?UTF-8?q?=E5=96=84=E5=8A=A0=E7=82=B9=E4=BB=A3=E7=A0=81=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/core/open/skillTree.md | 409 +++++++++++++++++++++++++++++++++ backend/core/open/skillTree.py | 124 ++++++++-- 2 files changed, 512 insertions(+), 21 deletions(-) create mode 100644 backend/core/open/skillTree.md diff --git a/backend/core/open/skillTree.md b/backend/core/open/skillTree.md new file mode 100644 index 00000000..d1113a5f --- /dev/null +++ b/backend/core/open/skillTree.md @@ -0,0 +1,409 @@ +# 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` 是当前解析实现补入的对齐字节,不是解压后原始数据的一部分。该行为为兼容现有返回格式而保留。 + +## 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, + 'evolutions': list, + 'enhancements': list, + 'keybinds': list, + 'section2_remaining': bytes, + 'raw_size': int, +} +``` + +黑暗武士在上述结构基础上增加: + +```python +{ + 'arrangements': list, +} +``` + +普通职业不会增加空的 `arrangements` 字段,以保持原有返回结构不变。 + +## 8. 技能索引到名称的解析 + +`decode_skill_hash()` 只负责二进制结构和技能索引,不直接写入名称。 + +`get_skill_tree_info()` 根据职业加载: + +```text +backend/openapi/static/data/cn/{job_name}/{job_grow_name}/skill_tree.json +``` + +并通过 `load_skill_tree_name_map()` 建立: + +```text +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. 普通职业的返回结构应保持兼容;职业专属字段只在对应职业出现。 + diff --git a/backend/core/open/skillTree.py b/backend/core/open/skillTree.py index 1c68d33b..2d9a9f6b 100644 --- a/backend/core/open/skillTree.py +++ b/backend/core/open/skillTree.py @@ -81,7 +81,7 @@ def decode_skill_hash(encoded: str) -> dict: break skills.append( { - 'skill_index': high[i], + 'skill_index': high[i] + 0x100 if low[i + 1] else high[i], 'level': high[i + 1], 'skill_flag': low[i], 'level_flag': low[i + 1], @@ -91,7 +91,7 @@ def decode_skill_hash(encoded: str) -> dict: sec2_bytes = data[(1 + num_pairs * 2) * 2 :] evolutions, enhancements, keybinds, remaining = _parse_section2(sec2_bytes) - return { + result = { 'header': header, 'skills': skills, 'evolutions': evolutions, @@ -100,6 +100,10 @@ def decode_skill_hash(encoded: str) -> dict: 'section2_remaining': remaining, 'raw_size': len(raw), } + # 黑暗武士才有技能排列;其他职业保持原有返回结构不变。 + if header['job_bytes'][0] == 9: + result['arrangements'] = _parse_dark_knight_arrangements(remaining) + return result def _parse_header(raw: bytes) -> dict: @@ -141,7 +145,7 @@ def _parse_section2(sec2: bytes): break evolutions.append( { - 'skill_index': sec2[off], + 'skill_index': sec2[off] + 0x100 if sec2[off + 1] else sec2[off], 'flag': sec2[off + 1], 'type': sec2[off + 2] + 1, } @@ -159,7 +163,7 @@ def _parse_section2(sec2: bytes): break enhancements.append( { - 'skill_index': sec2[off], + 'skill_index': sec2[off] + 0x100 if sec2[off + 1] else sec2[off], 'flag': sec2[off + 1], 'type': sec2[off + 2] + 1, } @@ -175,7 +179,7 @@ def _parse_section2(sec2: bytes): for slot in range(key_count): if off + 2 > len(sec2): break - idx = sec2[off] + idx = sec2[off] + 0x100 if sec2[off + 1] else sec2[off] flag = sec2[off + 1] keybinds.append( { @@ -190,23 +194,61 @@ def _parse_section2(sec2: bytes): return evolutions, enhancements, keybinds, remaining -def _resolve_openapi_skill_id(skill_index: int, level_flag: int, skill_name_map: dict[str, str]) -> int | None: - if level_flag: - high_skill_id = skill_index + 0x100 - if str(high_skill_id) in skill_name_map: - return high_skill_id +def _parse_dark_knight_arrangements(remaining: bytes) -> list[dict]: + """从原始 remaining 前缀中读取黑暗武士技能排列。 + + 黑暗武士的排列格式为:排列组数(1B),随后每组依次存储 + 排列槽索引(2B)、组内技能数(1B)和对应数量的技能索引(2B)。 + + 本函数只读取已确认的排列前缀,不解析或截断排列后的未知数据; + decode_skill_hash 仍按原格式返回完整的 section2_remaining。 + """ + if not remaining: + return [] + + arrangement_count = remaining[0] + off = 1 + arrangements = [] + for _ in range(arrangement_count): + if off + 3 > len(remaining): + break + + slot_index = remaining[off] + 0x100 if remaining[off + 1] else remaining[off] + off += 2 + skill_count = remaining[off] + off += 1 + if off + skill_count * 2 > len(remaining): + break + + skills = [] + for _ in range(skill_count): + skill_index = remaining[off] + 0x100 if remaining[off + 1] else remaining[off] + skills.append(skill_index) + off += 2 + + arrangements.append( + { + 'slot_index': slot_index, + 'skills': skills, + } + ) + + return arrangements + + +def _resolve_openapi_skill_id(skill_index: int, skill_name_map: dict[str, str]) -> int | None: if str(skill_index) in skill_name_map: return skill_index return None -def _resolve_section_skill_id(skill_index: int, skill_name_map: dict[str, str]) -> int | None: - high_skill_id = skill_index + 0x100 - if str(high_skill_id) in skill_name_map: - return high_skill_id - if str(skill_index) in skill_name_map: - return skill_index - return None +# def _resolve_section_skill_id(skill_index: int, skill_name_map: dict[str, str]) -> int | None: +# high_skill_id = skill_index + 0x100 +# if str(high_skill_id) in skill_name_map: +# return high_skill_id +# if str(skill_index) in skill_name_map: +# return skill_index +# return None def get_skill_tree_info(encoded: str) -> dict: @@ -241,7 +283,7 @@ def get_skill_tree_info(encoded: str) -> dict: skillMap = {} for index, i in enumerate(r['skills']): skillIndex = i['skill_index'] - actualSkillId = _resolve_openapi_skill_id(skillIndex, i.get('level_flag'), skill_name_map) + actualSkillId = _resolve_openapi_skill_id(skillIndex, skill_name_map) skillName = skill_name_map.get(str(actualSkillId)) if actualSkillId is not None else None skills = [skill for skill in character.skills if skill.name == skillName] if skillName else [] i['actual_skill_id'] = actualSkillId @@ -273,14 +315,14 @@ def get_skill_tree_info(encoded: str) -> dict: } # 处理进化 for evo in r['evolutions']: - actualSkillId = _resolve_section_skill_id(evo['skill_index'], skill_name_map) + actualSkillId = _resolve_openapi_skill_id(evo['skill_index'], skill_name_map) for s in skillMap.get(actualSkillId, []): if getattr(s, 'hasVP', False) and str(s.id) in skillLearn: skillLearn[str(s.id)]['vp'] = evo['type'] # 处理强化 for enh in r['enhancements']: - actualSkillId = _resolve_section_skill_id(enh['skill_index'], skill_name_map) + actualSkillId = _resolve_openapi_skill_id(enh['skill_index'], skill_name_map) for s in skillMap.get(actualSkillId, []): if getattr(s, 'hasUP', False) and str(s.id) in skillLearn: skillLearn[str(s.id)]['up'] = enh['type'] @@ -292,11 +334,51 @@ def get_skill_tree_info(encoded: str) -> dict: 'vp': 0, 'name': i.name, } + shortcuts = [] + for k in r['keybinds']: + actualSkillId = _resolve_openapi_skill_id(k['skill_index'], skill_name_map) + if actualSkillId is None: + shortcuts.append({ + 'slot': k['slot'], + 'skill_id': None, + 'skill_name': None, + }) + continue + for s in skillMap.get(actualSkillId, []): + if str(s.id) in skillLearn: + shortcuts.append({ + 'slot': k['slot'], + 'skill_id': s.id, + 'skill_name': s.name, + }) + if s is None: + shortcuts.append({ + 'slot': k['slot'], + 'skill_id': None, + 'skill_name': None, + }) + combos = [] + for a in r.get('arrangements', []): + combo = { + 'slot_index': a['slot_index'], + 'skills': [], + } + for skillIndex in a['skills']: + actualSkillId = _resolve_openapi_skill_id(skillIndex, skill_name_map) + for s in skillMap.get(actualSkillId, []): + if str(s.id) in skillLearn: + combo['skills'].append({ + 'skill_id': s.id, + 'skill_name': s.name, + }) + combos.append(combo) return { "name": header['name'], "level": header['level'], "job":advInfo, - "skills": skillLearn + "skills": skillLearn, + "shortcuts":shortcuts, + "combos":combos, }