Files
Astral/tools/chart_achievement/ma2.py
T

367 lines
16 KiB
Python
Raw 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.
# -*- coding: utf-8 -*-
"""舞萌DX (maimai DX) .ma2 谱面解析。
指令 -> 音符类型 的对应表来自反编译源码
source/Assembly-CSharp/Manager/Ma2fileRecordID.cs
(s_Ma2fileRecord_Data 里 Ma2Category.MA2_Note 的 102 条记录)
计分类别的划分复刻
source/Assembly-CSharp/Manager/GamePlayManager.cs NoteType2ScoreType()
理论分权重来自
source/Assembly-CSharp/Manager/NotesReader.cs calcTotal()
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Dict, List, Optional
# ---------------------------------------------------------------- 计分权重
WEIGHT_TAP = 500 # TAP / XTP / STR / XST / TTP(Touch 与 Tap 同为 500)
WEIGHT_HOLD = 1000 # HLD / XHO / THO
WEIGHT_SLIDE = 1500 # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_
WEIGHT_BREAK = 2500 # BRK / BST / BRTAP / BRHLD / BRSTR / BRSI_ ... 以及 ExBreak 系
BREAK_BONUS_THEORY = 100 # 每个绝赞(Break)的理论奖励分
CATEGORY_TAP = "Tap"
CATEGORY_HOLD = "Hold"
CATEGORY_SLIDE = "Slide"
CATEGORY_BREAK = "Break"
CATEGORY_CONNECT = "ConnectSlide"
CATEGORY_ORDER = (CATEGORY_TAP, CATEGORY_HOLD, CATEGORY_SLIDE, CATEGORY_BREAK)
CATEGORY_WEIGHT = {
CATEGORY_TAP: WEIGHT_TAP,
CATEGORY_HOLD: WEIGHT_HOLD,
CATEGORY_SLIDE: WEIGHT_SLIDE,
CATEGORY_BREAK: WEIGHT_BREAK,
}
CATEGORY_LABEL = {
CATEGORY_TAP: "Tap/Touch",
CATEGORY_HOLD: "Hold",
CATEGORY_SLIDE: "Slide",
CATEGORY_BREAK: "Break",
}
# ------------------------------------------------- ma2 指令 -> NotesTypeID.Def
NOTE_COMMAND_TYPES: Dict[str, str] = {
# --- 现行指令 ---
"TAP": "Begin", "BRK": "Break", "XTP": "ExTap", "HLD": "Hold", "XHO": "ExHold",
"STR": "Star", "BST": "BreakStar", "XST": "ExStar",
"TTP": "TouchTap", "THO": "TouchHold",
"SI_": "Slide", "SCL": "Slide", "SCR": "Slide", "SUL": "Slide", "SUR": "Slide",
"SSL": "Slide", "SSR": "Slide", "SV_": "Slide", "SXL": "Slide", "SXR": "Slide",
"SLL": "Slide", "SLR": "Slide", "SF_": "Slide",
# --- 旧式别名(游戏数据里真实存在)---
"NMTAP": "Begin", "BRTAP": "Break", "EXTAP": "ExTap", "BXTAP": "ExBreakTap",
"NMHLD": "Hold", "BRHLD": "BreakHold", "EXHLD": "ExHold", "BXHLD": "ExBreakHold",
"NMSTR": "Star", "BRSTR": "BreakStar", "EXSTR": "ExStar", "BXSTR": "ExBreakStar",
"NMTTP": "TouchTap", "NMTHO": "TouchHold",
"NMSI_": "Slide", "BRSI_": "BreakSlide", "EXSI_": "ExSlide", "BXSI_": "ExBreakSlide", "CNSI_": "ConnectSlide",
"NMSCL": "Slide", "BRSCL": "BreakSlide", "EXSCL": "ExSlide", "BXSCL": "ExBreakSlide", "CNSCL": "ConnectSlide",
"NMSCR": "Slide", "BRSCR": "BreakSlide", "EXSCR": "ExSlide", "BXSCR": "ExBreakSlide", "CNSCR": "ConnectSlide",
"NMSUL": "Slide", "BRSUL": "BreakSlide", "EXSUL": "ExSlide", "BXSUL": "ExBreakSlide", "CNSUL": "ConnectSlide",
"NMSUR": "Slide", "BRSUR": "BreakSlide", "EXSUR": "ExSlide", "BXSUR": "ExBreakSlide", "CNSUR": "ConnectSlide",
"NMSSL": "Slide", "BRSSL": "BreakSlide", "EXSSL": "ExSlide", "BXSSL": "ExBreakSlide", "CNSSL": "ConnectSlide",
"NMSSR": "Slide", "BRSSR": "BreakSlide", "EXSSR": "ExSlide", "BXSSR": "ExBreakSlide", "CNSSR": "ConnectSlide",
"NMSV_": "Slide", "BRSV_": "BreakSlide", "EXSV_": "ExSlide", "BXSV_": "ExBreakSlide", "CNSV_": "ConnectSlide",
"NMSXL": "Slide", "BRSXL": "BreakSlide", "EXSXL": "ExSlide", "BXSXL": "ExBreakSlide", "CNSXL": "ConnectSlide",
"NMSXR": "Slide", "BRSXR": "BreakSlide", "EXSXR": "ExSlide", "BXSXR": "ExBreakSlide", "CNSXR": "ConnectSlide",
"NMSLL": "Slide", "BRSLL": "BreakSlide", "EXSLL": "ExSlide", "BXSLL": "ExBreakSlide", "CNSLL": "ConnectSlide",
"NMSLR": "Slide", "BRSLR": "BreakSlide", "EXSLR": "ExSlide", "BXSLR": "ExBreakSlide", "CNSLR": "ConnectSlide",
"NMSF_": "Slide", "BRSF_": "BreakSlide", "EXSF_": "ExSlide", "BXSF_": "ExBreakSlide", "CNSF_": "ConnectSlide",
}
_BREAK_DEFS = frozenset((
"Break", "BreakHold", "BreakStar", "BreakSlide",
"ExBreakTap", "ExBreakHold", "ExBreakStar", "ExBreakSlide",
))
_SLIDE_DEFS = frozenset(("Slide", "BreakSlide", "ExSlide", "ExBreakSlide"))
_HOLD_DEFS = frozenset(("Hold", "BreakHold", "ExHold", "ExBreakHold", "TouchHold"))
_TOUCH_DEFS = frozenset(("TouchTap", "TouchHold"))
_CONNECT_DEFS = frozenset(("ConnectSlide",))
# 判定时机只有 Critical / TooLate 的 EX 音符(ExTap/ExStar/ExBreakTap/ExBreakStar)
EX_DEFS = frozenset(("ExTap", "ExStar", "ExBreakTap", "ExBreakStar"))
# ---------------------------------------------------------------- 音符组
# 「音符组」= 计分行为完全相同的一类音符:权重、可选判定、失分档位、绝赞奖励档位都一样。
# 划分依据(反编译源码):
# * 计分类型 NoteScore.EScoreType 由 GamePlayManager.NoteType2ScoreType() 决定
# (Break 系 -> Break,滑条 -> Slide,长按 -> Hold,其余 -> Tap/Touch)
# * 判定时机表 NoteJudge.EJudgeType 由各音符类决定:
# TapNote/StarNote -> Tap(或 ExNote 时 ExTap)
# HoldNote/BreakHoldNote/TouchHoldC -> 长按结算 JudgeHoldTotal(不分 2nd/3rd 档)
# SlideRoot/SlideFan -> SlideOut(不分 2nd/3rd 档,也没有非 Critical 的 Perfect)
# BreakNote/BreakStarNote -> Break 表(Great 有 1250/1500/2000 三档)
# * ExTap 判定表 [-9,-9,-9,-9,-9,-9,-9,0,9,9,9,9,9,9,9] 使非 Critical 的档位全部不可达
# -> ExTap/ExStar(含 ExBreak 版)只能 Critical 或 Miss
# * ExHold/ExBreakHold 的头部用 ExTap 表,但结果还要过 JudgeHoldTotal 重映射,
# 最终仍可得到 Critical/Perfect/Great/Good/Miss -> 与普通 Hold / BreakHold 相同
# * ExSlide / ExBreakSlide 的 JudgeType 恒为 SlideOut,且 isBreak() 不含 ExBreakSlide
# -> 计分与普通 Slide 完全一致(游戏数据里这两种指令出现次数为 0)
GROUP_TAP = "tap" # TAP NMTAP STR NMSTR TTP NMTTP 权重 500
GROUP_HOLD = "hold" # HLD NMHLD XHO EXHLD THO NMTHO 权重 1000
GROUP_SLIDE = "slide" # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_ + NM/EX/CN 变体 权重 1500
GROUP_BREAK = "break" # BRK BRTAP BST BRSTR 权重 2500,Great 三档、Perfect 奖励两档
GROUP_BREAK_FLAT = "break_flat" # BRHLD BXHLD 权重 2500,长按判定,不分档
GROUP_BREAK_SLIDE = "break_slide" # BRSI_ BRSCL BRSCR BRSUL BRSUR BRSSL BRSSR BRSV_ BRSXL BRSXR BRSLL BRSLR BRSF_
GROUP_EX = "ex" # XTP EXTAP XST EXSTR 权重 500,只能 Critical / Miss
GROUP_EX_BREAK = "ex_break" # BXTAP BXSTR 权重 2500,只能 Critical / Miss
GROUP_ORDER = (GROUP_TAP, GROUP_HOLD, GROUP_SLIDE, GROUP_BREAK,
GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX, GROUP_EX_BREAK)
GROUP_LABEL = {
GROUP_TAP: "Tap/Star/Touch",
GROUP_HOLD: "Hold",
GROUP_SLIDE: "Slide",
GROUP_BREAK: "Break",
GROUP_BREAK_FLAT: "Break(长按判定)",
GROUP_BREAK_SLIDE: "Break(滑条判定)",
GROUP_EX: "EX Tap/Star",
GROUP_EX_BREAK: "EX Break",
}
GROUP_WEIGHT = {
GROUP_TAP: WEIGHT_TAP,
GROUP_HOLD: WEIGHT_HOLD,
GROUP_SLIDE: WEIGHT_SLIDE,
GROUP_BREAK: WEIGHT_BREAK,
GROUP_BREAK_FLAT: WEIGHT_BREAK,
GROUP_BREAK_SLIDE: WEIGHT_BREAK,
GROUP_EX: WEIGHT_TAP,
GROUP_EX_BREAK: WEIGHT_BREAK,
}
GROUP_IS_BREAK = frozenset((GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK))
_GROUP_BY_DEF = {
"Begin": GROUP_TAP, "Star": GROUP_TAP, "TouchTap": GROUP_TAP,
"Hold": GROUP_HOLD, "ExHold": GROUP_HOLD, "TouchHold": GROUP_HOLD,
"Slide": GROUP_SLIDE, "ExSlide": GROUP_SLIDE, "ExBreakSlide": GROUP_SLIDE,
"Break": GROUP_BREAK, "BreakStar": GROUP_BREAK,
"BreakHold": GROUP_BREAK_FLAT, "ExBreakHold": GROUP_BREAK_FLAT,
"BreakSlide": GROUP_BREAK_SLIDE,
"ExTap": GROUP_EX, "ExStar": GROUP_EX,
"ExBreakTap": GROUP_EX_BREAK, "ExBreakStar": GROUP_EX_BREAK,
}
_CATEGORY_BY_GROUP = {
GROUP_TAP: CATEGORY_TAP, GROUP_HOLD: CATEGORY_HOLD, GROUP_SLIDE: CATEGORY_SLIDE,
GROUP_BREAK: CATEGORY_BREAK, GROUP_BREAK_FLAT: CATEGORY_BREAK,
GROUP_BREAK_SLIDE: CATEGORY_BREAK, GROUP_EX: CATEGORY_TAP, GROUP_EX_BREAK: CATEGORY_BREAK,
}
def note_group(def_name: str) -> Optional[str]:
"""NotesTypeID.Def 名 -> 音符组;接续滑条等不计分音符返回 None。"""
return _GROUP_BY_DEF.get(def_name)
def score_category(def_name: str) -> Optional[str]:
"""复刻 GamePlayManager.NoteType2ScoreType() 的粗分类(Tap/Hold/Slide/Break)。"""
grp = note_group(def_name)
if grp is None:
return None
return _CATEGORY_BY_GROUP[grp]
# ------------------------------------------------------------------ 谱面对象
@dataclass
class Chart:
path: str = "<memory>"
command_counts: Dict[str, int] = field(default_factory=dict)
category_counts: Dict[str, int] = field(default_factory=dict)
group_counts: Dict[str, int] = field(default_factory=dict)
connect_count: int = 0
header: Dict[str, str] = field(default_factory=dict)
totals: Dict[str, int] = field(default_factory=dict)
warnings: List[str] = field(default_factory=list)
# 外部补充的信息(游戏路径模式)
music_id: Optional[int] = None
difficulty_index: Optional[int] = None
difficulty_name: Optional[str] = None
song_name: Optional[str] = None
level_text: Optional[str] = None
max_notes_xml: Optional[int] = None
@property
def total_notes(self) -> int:
"""计分音符总数 N(不含接续滑条)。"""
return sum(self.group_counts.get(g, 0) for g in GROUP_ORDER)
@property
def declared_total(self) -> Optional[int]:
return self.totals.get("T_NUM_ALL")
def counts(self) -> Dict[str, int]:
"""按音符组统计(供计分模型使用)。"""
return {g: self.group_counts.get(g, 0) for g in GROUP_ORDER}
def category_summary(self) -> Dict[str, int]:
return {c: self.category_counts.get(c, 0) for c in CATEGORY_ORDER}
def parse_ma2(path: str) -> Chart:
with open(path, "rb") as fp:
raw = fp.read()
chart = parse_ma2_bytes(raw, path)
return chart
def parse_ma2_bytes(raw: bytes, path: str = "<memory>") -> Chart:
chart = Chart(path=path)
text = raw.decode("latin1")
unknown = set()
for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) == 1:
parts = line.split()
if not parts:
continue
cmd = parts[0]
args = parts[1:]
def_name = NOTE_COMMAND_TYPES.get(cmd)
if def_name is not None:
chart.command_counts[cmd] = chart.command_counts.get(cmd, 0) + 1
grp = note_group(def_name)
if grp is None:
chart.connect_count += 1
else:
chart.group_counts[grp] = chart.group_counts.get(grp, 0) + 1
cat = score_category(def_name)
chart.category_counts[cat] = chart.category_counts.get(cat, 0) + 1
continue
if cmd.startswith("T_") or cmd.startswith("TTM_"):
if args:
try:
chart.totals[cmd] = int(args[0])
except ValueError:
pass
continue
if cmd not in chart.header and args:
chart.header[cmd] = "\t".join(args)
elif not args and cmd.isupper() and len(cmd) > 2:
unknown.add(cmd)
declared = chart.declared_total
if declared is not None and declared != chart.total_notes:
chart.warnings.append(
"T_NUM_ALL=%d 与解析出的计分音符数 %d 不一致" % (declared, chart.total_notes)
)
return chart
# ------------------------------------------------------------ 游戏路径解析
DIFFICULTY_NAMES = ("Basic", "Advanced", "Expert", "Master", "Re:Master")
_DIFFICULTY_ALIASES = {
"basic": 0, "bas": 0, "bsc": 0, "0": 0, "00": 0,
"advanced": 1, "adv": 1, "1": 1, "01": 1,
"expert": 2, "exp": 2, "2": 2, "02": 2,
"master": 3, "mas": 3, "mst": 3, "3": 3, "03": 3,
"remaster": 4, "re:master": 4, "remas": 4, "re": 4, "4": 4, "04": 4,
}
def parse_difficulty(text) -> int:
if isinstance(text, int):
if 0 <= text <= 4:
return text
raise ValueError("难度序号必须是 0~4")
key = str(text).strip().lower()
if key in _DIFFICULTY_ALIASES:
return _DIFFICULTY_ALIASES[key]
raise ValueError("无法识别的难度:%s(可用 basic/advanced/expert/master/remaster 或 0~4)" % text)
def find_music_dir(game_path: str) -> str:
"""把「游戏根目录 / Sinmai_Data / StreamingAssets / A000」都归一化到 A000/music。"""
p = os.path.abspath(os.path.expanduser(game_path))
if not os.path.isdir(p):
raise FileNotFoundError("路径不存在:%s" % p)
if os.path.basename(p).lower() == "music" and os.path.basename(os.path.dirname(p)).upper() == "A000":
return p
candidates = [
p,
os.path.join(p, "music"),
os.path.join(p, "A000", "music"),
os.path.join(p, "StreamingAssets", "A000", "music"),
os.path.join(p, "Sinmai_Data", "StreamingAssets", "A000", "music"),
os.path.join(p, "Package", "Sinmai_Data", "StreamingAssets", "A000", "music"),
]
for c in candidates:
if os.path.isdir(c) and os.path.basename(c).lower() == "music":
return c
raise FileNotFoundError("在 %s 下找不到 A000/music 目录" % p)
def music_dir_path(music_dir: str, music_id: int) -> str:
return os.path.join(music_dir, "music%06d" % music_id)
def chart_path(music_dir: str, music_id: int, difficulty: int) -> str:
return os.path.join(music_dir_path(music_dir, music_id), "%06d_%02d.ma2" % (music_id, difficulty))
def parse_music_xml(path: str) -> Dict[str, object]:
"""读取 Music.xml(只用标准库),取出曲名与各难度信息。"""
import xml.etree.ElementTree as ET
info: Dict[str, object] = {"notes": []}
try:
root = ET.parse(path).getroot()
except Exception as exc: # noqa: BLE001
return {"error": str(exc), "notes": []}
def text_of(node, tag):
child = node.find(tag)
if child is None:
return None
if child.text and child.text.strip():
return child.text.strip()
sub = child.find("str")
if sub is not None and sub.text and sub.text.strip():
return sub.text.strip()
return None
info["name"] = text_of(root, "name")
info["artist"] = text_of(root, "artistName")
info["genre"] = text_of(root, "genreName")
info["bpm"] = text_of(root, "bpm")
notes_data = root.find("notesData")
notes = []
if notes_data is not None:
for node in notes_data.findall("Notes"):
f = node.find("file/path")
notes.append({
"file": f.text.strip() if f is not None and f.text else None,
"level": text_of(node, "level"),
"levelDecimal": text_of(node, "levelDecimal"),
"musicLevelID": text_of(node, "musicLevelID"),
"maxNotes": text_of(node, "maxNotes"),
"designer": text_of(node, "notesDesigner"),
"isEnable": text_of(node, "isEnable"),
})
info["notes"] = notes
return info
def level_display(level, level_decimal) -> str:
"""12 + levelDecimal 4 -> '12+';10 + 2 -> '10+';7 + 0 -> '7'。"""
try:
lv = int(level)
except (TypeError, ValueError):
return ""
try:
dec = int(level_decimal or 0)
except (TypeError, ValueError):
dec = 0
if dec >= 3:
return "%d+" % lv
if dec > 0:
return "%d.%d" % (lv, dec)
return "%d" % lv