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

497 lines
18 KiB
Python
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.
import base64
import zlib
import struct
from api.core.Redis import get_redis_info
from api.utils.skill_tree import SKILL_TREE_MAP_VERSION, load_skill_tree_map
from core.character.adventure import get_adv_info
from database.connect import get_redis_client
role_map = {
# swordman_male
'0': 0,
# fighter_female
'1': 3,
# gunner_male
'2': 4,
# mage_female
'3': 7,
# priest_male
'4': 8,
# gunner_female
'5': 5,
# thief
'6': 10,
# fighter_male
'7': 2,
# mage_male
'8': 6,
# 黑暗武士
'9': 15,
# 缔造者
'10': 15,
# swordman_female
'11': 1,
# knight
'12': 11,
# demonic_lancer
'13': 12,
# priest_female
'14': 9,
# gunblader
'15': 13,
# archer
'16': 14,
# imperialknight
'17': 16,
}
def decode_skill_hash(encoded: str) -> dict:
"""
解码 DNF 技能加点编码,并根据职业技能树补充自动学习技能。
输入 encoded 为游戏导出的 Base64 加点字符串。
解码流程: base64 -> zlib decompress -> 二进制
二进制结构:
[Header 14B,首字节 0xF3] [UTF-16LE 角色名]
[Section1: N个(技能索引,等级)对]
[Section2: 进化 | 强化 | 键位 | 职业排列及其他尾部数据]
Section1 中每个 uint16 的高字节=数据值,低字节=标志位。
技能索引在 level_flag 非零时加 0x100。
Section2 按字节读取,各子结构由数量字段和对应数量的定长条目组成;
其中技能索引为低字节加 0x100(第二字节标志非零时)。
所有职业均返回:
header: 角色名、等级、职业字节、编码版本等头信息。
skills: 原始加点列表,每项包含 skill_index、level、skill_flag、
level_flag;不将自动学习技能混入此列表。
auto_skills: 技能树中具有 autoLevel 且未出现在 skills 中的技能,
每项为 {skill_index, level},等级直接取 autoLevel。
不覆盖显式加点,无补充项时为 []。
evolutions: 进化列表,每项为 {skill_index, flag, type},type 从 1 开始。
enhancements: 强化列表,字段与 evolutions 相同。
keybinds: 快捷栏列表,每项为 {slot, skill_index, flag};
slot 从 0 开始,空槽的 skill_index 为 None。
section2_remaining: 快捷栏之后的完整原始字节,不因扩展解析而截断。
若角色名之后的数据长度为奇数,末尾含对齐补入的 0x00。
raw_size: zlib 解压后的原始字节数,不包含解析器补入的对齐字节。
按职业和版本额外返回:
arrangements: 仅黑暗武士返回,格式为 [{slot_index, skills}],
skills 保留该排列内技能索引的顺序。
skill_combo: 仅版本 5 的非黑暗武士返回,格式为
{skills: [309, 305, 312, 311], interval_ms: 5000}。
skills 为四个有序槽位,空槽为 None;interval_ms 为
初始化间隔(毫秒)。读取 remaining[92:100] 的技能索引
和 remaining[100:102] 的小端 uint16 间隔;长度不足或
remaining[102] 不是 0xF3 时返回 None。
remaining[91] 含义未确认,不解释为数量或开关。
未确认的尾部字段仅保留原始字节;黑暗武士不套用普通职业连招偏移。
"""
raw = zlib.decompress(base64.b64decode(encoded))
header = _parse_header(raw)
data = raw[14 + header['name_byte_len'] :]
if len(data) % 2:
data += b'\x00'
uint16s = [struct.unpack_from('<H', data, i)[0] for i in range(0, len(data), 2)]
high = [v >> 8 for v in uint16s]
low = [v & 0xFF for v in uint16s]
num_pairs = high[0]
skills = []
for p in range(num_pairs):
i = 1 + p * 2
if i + 1 >= len(high):
break
skills.append(
{
'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],
}
)
sec2_bytes = data[(1 + num_pairs * 2) * 2 :]
evolutions, enhancements, keybinds, remaining = _parse_section2(sec2_bytes)
result = {
'header': header,
'skills': skills,
'evolutions': evolutions,
'enhancements': enhancements,
'keybinds': keybinds,
'section2_remaining': remaining,
'raw_size': len(raw),
}
# 黑暗武士才有技能排列;其他职业保持原有返回结构不变。
if header['job_bytes'][0] == 9:
result['arrangements'] = _parse_dark_knight_arrangements(remaining)
elif header['version'] == 5:
# 普通职业的技能连招与黑暗武士排列是两种结构,不能共用偏移。
result['skill_combo'] = _parse_skill_combo(remaining)
_, skill_tree = _get_skill_tree_context(header)
encoded_ids = {str(skill['skill_index']) for skill in skills}
result['auto_skills'] = [
{'skill_index': int(skill_id), 'level': metadata['autoLevel']}
for skill_id, metadata in skill_tree.items()
if 'autoLevel' in metadata and skill_id not in encoded_ids
]
return result
def _parse_header(raw: bytes) -> dict:
nlen = raw[13]
return {
'magic': raw[0],
'version': raw[1],
'job_bytes': (raw[2], raw[3]),
'level': raw[5],
'job_marker': chr(raw[8]),
'name': raw[14 : 14 + nlen].decode('utf-16-le'),
'name_byte_len': nlen,
}
def _parse_section2(sec2: bytes):
"""
Section2 子结构:
byte[0]: 标记位
byte[1]: 进化数量 E
byte[2..2+3E-1]: E 个进化条目,每条 3 字节 (技能索引, 标志, 类型-1)
byte[2+3E]: 强化数量 H
byte[2+3E+1..2+3E+3H]: H 个强化条目,每条 3 字节
byte[2+3E+3H+1]: 键位数量 K (固定 0x0E=14)
byte[..+2K]: K 个键位条目,每条 2 字节 (技能索引, 标志)
剩余: remaining = sec2[off:],包含职业排列、技能连招及未解析数据。
技能连招位于快捷栏之后的 remaining 中,不属于上面的 K 个键位条目。
版本 5 普通职业(不含黑暗武士)的已确认布局如下,偏移从 remaining
开头计算,不是从 sec2 或整个解压数据开头计算:
remaining[91]: 含义未确认,不解释为数量或开关。
remaining[92:100]: 四个有序连招技能,每项 2 字节;索引为低字节
加 0x100(第二字节非零时),0 表示空槽。
remaining[100:102]: 初始化间隔,小端 uint16,单位毫秒。
remaining[102]: 尾部标记 0xF3,其后可能有解析器补入的对齐 0x00。
本函数仅返回完整 remaining,不直接解析连招;decode_skill_hash()
根据职业和版本调用 _parse_skill_combo(),输出 skill_combo。
黑暗武士的 remaining 前缀由 _parse_dark_knight_arrangements() 解析,
不套用上述普通职业偏移。任何扩展解析均不截断原始 remaining。
"""
if len(sec2) < 2:
return [], [], [], sec2
off = 0
off += 1 # byte[0]: marker
evo_count = sec2[off]
off += 1
evolutions = []
for _ in range(evo_count):
if off + 3 > len(sec2):
break
evolutions.append(
{
'skill_index': sec2[off] + 0x100 if sec2[off + 1] else sec2[off],
'flag': sec2[off + 1],
'type': sec2[off + 2] + 1,
}
)
off += 3
if off >= len(sec2):
return evolutions, [], [], b''
enh_count = sec2[off]
off += 1
enhancements = []
for _ in range(enh_count):
if off + 3 > len(sec2):
break
enhancements.append(
{
'skill_index': sec2[off] + 0x100 if sec2[off + 1] else sec2[off],
'flag': sec2[off + 1],
'type': sec2[off + 2] + 1,
}
)
off += 3
if off >= len(sec2):
return evolutions, enhancements, [], b''
key_count = sec2[off]
off += 1
keybinds = []
for slot in range(key_count):
if off + 2 > len(sec2):
break
idx = sec2[off] + 0x100 if sec2[off + 1] else sec2[off]
flag = sec2[off + 1]
keybinds.append(
{
'slot': slot,
'skill_index': idx if idx != 0 else None,
'flag': flag,
}
)
off += 2
remaining = sec2[off:]
return evolutions, enhancements, keybinds, remaining
def _parse_skill_combo(remaining: bytes) -> dict | None:
"""读取版本 5 普通职业尾部的技能连招,不修改 remaining。
已通过女蓝拳顺序确认及仅将间隔从 0 秒改为 5 秒的代码验证:
remaining[92:100]:四个技能索引,每个 2 字节,0 表示空槽;
remaining[100:102]:小端 uint16 初始化间隔,单位毫秒;
remaining[102]:尾部 0xF3,解析器可能在它后面补齐一个 0x00。
remaining[91] 的含义尚未确认,不将其解释为数量或开关。
数据不足或尾部标记不匹配时返回 None,避免把未知布局当成空连招。
"""
if len(remaining) < 103 or remaining[102] != 0xF3:
return None
skills = []
for offset in range(92, 100, 2):
# 与快捷栏一致:低字节为索引,第二字节非零表示加 0x100。
index = remaining[offset] + (0x100 if remaining[offset + 1] else 0)
skills.append(index if index else None)
return {
'skills': skills,
'interval_ms': struct.unpack_from('<H', remaining, 100)[0],
}
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_skill_tree(decoded: dict, skill_tree: dict[str, dict]) -> dict:
"""将二进制结果映射到 OpenAPI 技能 ID;不依赖角色计算对象。
autoLevel 仅补充代码中缺失的技能,不覆盖显式等级。
"""
skills = {}
for item in decoded['skills'] + decoded['auto_skills']:
skill_id = str(item['skill_index'])
metadata = skill_tree.get(skill_id)
if metadata is None:
continue
if skill_id not in skills or item['level'] > skills[skill_id]['lv']:
skills[skill_id] = {
'lv': item['level'], 'up': 0, 'vp': 0, 'name': metadata['name'],
}
for section, field in [('evolutions', 'vp'), ('enhancements', 'up')]:
for item in decoded[section]:
skill = skills.get(str(item['skill_index']))
if skill is not None:
skill[field] = item['type']
def reference(skill_index):
metadata = skill_tree.get(str(skill_index))
return {
'skill_id': skill_index if metadata is not None else None,
'skill_name': metadata['name'] if metadata is not None else None,
}
return {
'skills': skills,
'shortcuts': [
{'slot': item['slot'], **reference(item['skill_index'])}
for item in decoded['keybinds']
],
'combos': [
{
'slot_index': item['slot_index'],
'skills': [reference(index) for index in item['skills']],
}
for item in decoded.get('arrangements', [])
],
}
def _get_skill_tree_context(header: dict) -> tuple[dict, dict[str, dict]]:
"""根据编码职业读取 Redis 技能树缓存,未命中时回退本地文件。"""
role = role_map.get(str(header['job_bytes'][0]), None)
job_id = int(header['job_bytes'][1]) - 1
# 黑暗武士
if header['job_bytes'][0] == 9:
job_id = 1
# 缔造者
if header['job_bytes'][0] == 10:
job_id = 0
id = base64.b64encode(f'{role:02}{job_id:02}00'.encode()).decode()
advInfo = get_adv_info(id)
className = advInfo['class']
class_parts = className.split('.')
job_name = class_parts[1]
job_grow_name = class_parts[2].replace('_carry', '')
with get_redis_client() as redis:
skill_tree = get_redis_info(
redis,
f'openapi:{SKILL_TREE_MAP_VERSION}:{job_name}:{job_grow_name}:map',
lambda: load_skill_tree_map(job_name, job_grow_name),
)
return advInfo, skill_tree
def get_openapi_skill_tree_info(encoded: str) -> dict:
"""解析并补全技能树,返回 OpenAPI 技能 ID,不创建计算角色。"""
r = decode_skill_hash(encoded)
header = r['header']
advInfo, skill_tree = _get_skill_tree_context(header)
return {
'name': header['name'],
'level': header['level'],
'job': advInfo,
**resolve_skill_tree(r, skill_tree),
}
def get_skill_tree_info(encoded: str) -> dict:
"""将完整 OpenAPI 加点映射为计算器技能 ID,保持现有接口格式。"""
from core.basic.character import createCharacter
info = get_openapi_skill_tree_info(encoded)
character = createCharacter(info['job']['class'])
skills_by_name = {}
for skill in character.skills:
skills_by_name.setdefault(skill.name, []).append(skill)
skill_learn = {}
for item in info['skills'].values():
candidates = skills_by_name.get(item['name'], [])
if not candidates:
continue
# 保持原有同名技能等级选首项、进化/强化排除觉醒分身的规则。
chosen = candidates[0]
chosen_id = str(chosen.id)
if chosen_id not in skill_learn or skill_learn[chosen_id]['lv'] < item['lv']:
skill_learn[chosen_id] = {**item, 'up': 0, 'vp': 0}
targets = [s for s in candidates if s.learnLv not in [50, 85]] if len(candidates) > 1 else candidates
for skill in targets:
learned = skill_learn.get(str(skill.id))
if learned is not None:
if getattr(skill, 'hasVP', False):
learned['vp'] = item['vp']
if getattr(skill, 'hasUP', False):
learned['up'] = item['up']
for skill in character.skills:
skill_learn.setdefault(str(skill.id), {
'lv': skill.calculate_lv() if skill.name == '基础精通' else 0,
'up': 0, 'vp': 0, 'name': skill.name,
})
def match_reference(item):
candidates = skills_by_name.get(item['skill_name'], [])
if len(candidates) > 1:
candidates = [s for s in candidates if s.learnLv not in [50, 85]]
return [{'skill_id': s.id, 'skill_name': s.name} for s in candidates]
shortcuts = []
for item in info['shortcuts']:
matches = match_reference(item) or [{'skill_id': None, 'skill_name': None}]
shortcuts.extend({'slot': item['slot'], **match} for match in matches)
combos = [
{
'slot_index': combo['slot_index'],
'skills': [match for item in combo['skills'] for match in match_reference(item)],
}
for combo in info['combos']
]
return {**info, 'skills': skill_learn, 'shortcuts': shortcuts, 'combos': combos}
# def print_decoded(encoded: str):
# """解码并打印技能编码的全部信息。"""
# r = decode_skill_hash(encoded)
# h = r['header']
# print('=' * 70)
# print('DNF 技能加点编码解析')
# print('=' * 70)
# print(f'\n【角色信息】')
# print(f' 角色名: {h["name"]}')
# print(f' 等级: {h["level"]}')
# print(f' 职业字节: ({int(h["job_bytes"][0])}, {int(h["job_bytes"][1])})')
# print(f' 编码版本: {h["version"]}')
# print(f' 数据大小: {r["raw_size"]} bytes')
# print(f'\n 主动投入技能 (等级 > 1):')
# for s in reversed(r['skills']):
# f = ''
# if s['skill_flag']:
# f += f' sf={s["skill_flag"]}'
# if s['level_flag']:
# f += f' lf={s["level_flag"]}'
# print(f' 技能 {int(s["skill_index"]):<5d} Lv.{s["level"]:<3d}{f}')
# print(f'\n【进化选择】({len(r["evolutions"])} 个)')
# for e in r['evolutions']:
# t = '选项一' if e['type'] == 1 else '选项二'
# print(f' 技能 {int(e["skill_index"]):<5d} {t}')
# print(f'\n【强化选择】({len(r["enhancements"])} 个)')
# for e in r['enhancements']:
# t = f'类型{e["type"]}'
# print(f' 技能 {int(e["skill_index"]):<5d} {t}')
# active = [k for k in r['keybinds'] if k['skill_index'] is not None]
# print(f'\n【快捷键】({len(active)}/{len(r["keybinds"])} 槽位已使用)')
# for k in r['keybinds']:
# if k['skill_index'] is not None:
# f = f' (flag={k["flag"]})' if k['flag'] else ''
# print(f' 槽位 {k["slot"]:2d}: 技能 {int(k["skill_index"]):<5d}{f}')
# else:
# print(f' 槽位 {k["slot"]:2d}: (空)')