# -*- 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 = "" 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 = "") -> 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(可用 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER," "或直接写名字)" % 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