497 lines
18 KiB
Python
497 lines
18 KiB
Python
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}: (空)')
|