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('> 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(' 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}: (空)')