v1进行时:新增谱面达成率小工具;extra.json 不再入库
This commit is contained in:
@@ -0,0 +1,366 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user