feat:完善加点代码导入

This commit is contained in:
2026-09-14 14:53:05 +08:00
parent e65dfef083
commit 9453106dc1
5 changed files with 394 additions and 141 deletions
+198 -129
View File
@@ -3,8 +3,7 @@ import zlib
import struct
from api.core.Redis import get_redis_info
from api.utils.skill_tree import SKILL_TREE_NAME_MAP_VERSION, load_skill_tree_name_map
from core.basic.character import createCharacter
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
@@ -50,17 +49,47 @@ role_map = {
def decode_skill_hash(encoded: str) -> dict:
"""
解码 DNF 技能加点编码。
解码 DNF 技能加点编码,并根据职业技能树补充自动学习技能。
编码流程: base64 -> zlib decompress -> 二进制
输入 encoded 为游戏导出的 Base64 加点字符串。
解码流程: base64 -> zlib decompress -> 二进制
二进制结构:
[0xF3] [Header 14B] [UTF-16LE 角色名]
[Header 14B,首字节 0xF3] [UTF-16LE 角色名]
[Section1: N个(技能索引,等级)对]
[Section2: 进化 | 强化 | 键位 | 其他]
[零填充] [0xF3]
[Section2: 进化 | 强化 | 键位 | 职业排列及其他尾部数据]
Section1 中每个 uint16 的高字节=数据值,低字节=标志位。
Section2 按字节读取,包含三部分定长子结构。
技能索引在 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)
@@ -103,6 +132,16 @@ def decode_skill_hash(encoded: str) -> dict:
# 黑暗武士才有技能排列;其他职业保持原有返回结构不变。
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
@@ -129,7 +168,21 @@ def _parse_section2(sec2: bytes):
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
@@ -194,6 +247,30 @@ def _parse_section2(sec2: bytes):
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 前缀中读取黑暗武士技能排列。
@@ -236,24 +313,52 @@ def _parse_dark_knight_arrangements(remaining: bytes) -> list[dict]:
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_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 _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:
r = decode_skill_hash(encoded)
header = r['header']
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
# 黑暗武士
@@ -269,119 +374,83 @@ def get_skill_tree_info(encoded: str) -> dict:
job_name = class_parts[1]
job_grow_name = class_parts[2].replace('_carry', '')
def get_skill_name_map():
return load_skill_tree_name_map(job_name, job_grow_name)
with get_redis_client() as redis:
skill_name_map = get_redis_info(
skill_tree = get_redis_info(
redis,
f'openapi:{SKILL_TREE_NAME_MAP_VERSION}:{job_name}:{job_grow_name}:nameMap',
get_skill_name_map,
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
character = createCharacter(className)
skillMap = {}
for index, i in enumerate(r['skills']):
skillIndex = i['skill_index']
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
i['skill_name'] = skillName
i['candidates'] = skills[0] if skills else None
if actualSkillId is not None:
skillMap[actualSkillId] = skills
if len(skills) == 0:
r['skills'][index] = None
for k, v in skillMap.items():
if len(v) > 1:
skillMap[k] = [s for s in v if s.learnLv not in [50, 85]]
skillLearn = {}
# 处理技能等级
for skill in r['skills']:
if skill is None:
continue
candidates = skill.get('candidates', [])
skillName = skill.get('skill_name')
chosen = None
if candidates:
chosen = candidates
if chosen and skillLearn.get(str(chosen.id), {'lv': 0})['lv'] < skill['level']:
skillLearn[str(chosen.id)] = {
'lv': skill['level'],
'up': 0,
'vp': 0,
'name': skillName or chosen.name,
}
# 处理进化
for evo in r['evolutions']:
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_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']
for i in character.skills:
if str(i.id) not in skillLearn:
skillLearn[str(i.id)] = {
'lv': i.calculate_lv() if (i.learnLv in [50,85] or i.name == '基础精通') and str(i.id) else 0,
'up': 0,
'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)
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,
"skills": skillLearn,
"shortcuts":shortcuts,
"combos":combos,
'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)