472 lines
20 KiB
Python
472 lines
20 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""舞萌DX 谱面达成率 / DX 分计算器。
|
||
|
||
正向:给 谱面 + 各判定数量 -> 算达成率与 DX 分
|
||
反向:给 谱面 + 目标达成率 -> 倒算所有可行的判定组合与 DX 分
|
||
|
||
只依赖 Python 标准库。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import sys
|
||
from fractions import Fraction
|
||
from typing import Dict, List, Optional
|
||
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
|
||
from ma2 import (DIFFICULTY_NAMES, GROUP_LABEL, GROUP_ORDER, Chart,
|
||
chart_path, find_music_dir, level_display, parse_difficulty,
|
||
parse_ma2, parse_music_xml)
|
||
from score import (DX_PER, JUDGE_LABEL, JUDGMENTS, Theory, dx_score,
|
||
reference_achievement, solve_forward, solve_reverse,
|
||
theory_from_chart)
|
||
|
||
try: # 中文输出到管道时避免 UnicodeEncodeError
|
||
sys.stdout.reconfigure(errors="replace")
|
||
sys.stderr.reconfigure(errors="replace")
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
LINE = "-" * 60
|
||
|
||
|
||
def pct(value) -> str:
|
||
return "%.4f%%" % float(value)
|
||
|
||
|
||
def die(msg: str, code: int = 2) -> "None":
|
||
print("[错误] " + msg, file=sys.stderr)
|
||
raise SystemExit(code)
|
||
|
||
|
||
# --------------------------------------------------------------- 载入谱面
|
||
def _enrich(chart: Chart, path: str) -> Chart:
|
||
"""从同目录的 Music.xml 补全曲名 / 难度 / 等级(拿不到就跳过)。"""
|
||
base = os.path.basename(path)
|
||
stem = os.path.splitext(base)[0]
|
||
if "_" in stem:
|
||
left, right = stem.split("_", 1)
|
||
try:
|
||
chart.music_id = int(left)
|
||
chart.difficulty_index = int(right)
|
||
chart.difficulty_name = DIFFICULTY_NAMES[chart.difficulty_index]
|
||
except (ValueError, IndexError):
|
||
pass
|
||
xml_path = os.path.join(os.path.dirname(path), "Music.xml")
|
||
if os.path.isfile(xml_path):
|
||
info = parse_music_xml(xml_path)
|
||
chart.song_name = info.get("name") or chart.song_name
|
||
notes = info.get("notes") or []
|
||
idx = chart.difficulty_index
|
||
if idx is not None and 0 <= idx < len(notes):
|
||
entry = notes[idx]
|
||
chart.level_text = level_display(entry.get("level"), entry.get("levelDecimal"))
|
||
try:
|
||
chart.max_notes_xml = int(entry.get("maxNotes") or 0)
|
||
except (TypeError, ValueError):
|
||
chart.max_notes_xml = None
|
||
return chart
|
||
|
||
|
||
def load_from_file(path: str) -> Chart:
|
||
path = os.path.abspath(os.path.expanduser(path))
|
||
if not os.path.isfile(path):
|
||
die("谱面文件不存在:%s" % path)
|
||
return _enrich(parse_ma2(path), path)
|
||
|
||
|
||
def load_from_game(game_path: str, music_id: int, difficulty: int) -> Chart:
|
||
music_dir = find_music_dir(game_path)
|
||
path = chart_path(music_dir, music_id, difficulty)
|
||
if not os.path.isfile(path):
|
||
alt = os.path.join(music_dir, "music%06d" % music_id)
|
||
hint = ""
|
||
if os.path.isdir(alt):
|
||
have = sorted(f for f in os.listdir(alt) if f.lower().endswith(".ma2"))
|
||
hint = ";该曲目现有谱面:" + (", ".join(have) if have else "无")
|
||
die("找不到谱面:%s%s" % (path, hint))
|
||
chart = parse_ma2(path)
|
||
chart.music_id = music_id
|
||
chart.difficulty_index = difficulty
|
||
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
|
||
chart = _enrich(chart, path)
|
||
chart.difficulty_index = difficulty
|
||
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
|
||
return chart
|
||
|
||
|
||
# ------------------------------------------------------------------ 展示
|
||
def print_chart(chart: Chart, th: Theory) -> None:
|
||
print(LINE)
|
||
print("谱面")
|
||
print(" 文件 %s" % chart.path)
|
||
if chart.song_name:
|
||
print(" 曲目 %s" % chart.song_name)
|
||
if chart.music_id is not None:
|
||
print(" 编号 %d" % chart.music_id)
|
||
if chart.difficulty_name:
|
||
print(" 难度 %s (%d)" % (chart.difficulty_name.upper(),
|
||
chart.difficulty_index if chart.difficulty_index is not None else 0))
|
||
if chart.level_text:
|
||
print(" 等级 %s" % chart.level_text)
|
||
parts = []
|
||
for g in GROUP_ORDER:
|
||
n = th.counts.get(g, 0)
|
||
if n:
|
||
parts.append("%s %d" % (GROUP_LABEL[g], n))
|
||
print(" 音符 %s" % " · ".join(parts))
|
||
print(" 合计 %d 个计分音符" % th.total_notes)
|
||
if chart.connect_count:
|
||
print(" (另有 %d 个接续滑条节点,不计分)" % chart.connect_count)
|
||
bonus = " + 绝赞奖励 %d" % th.break_bonus_score if th.break_bonus_score else ""
|
||
print(" 理论分 %d%s" % (th.all_perfect_score, bonus))
|
||
print(" 理论DX %d" % th.max_dx)
|
||
print(" 满分 %s" % pct(th.max_achievement))
|
||
for w in chart.warnings:
|
||
print(" [警告] %s" % w)
|
||
declared = chart.totals.get("TTM_SCR_SS")
|
||
if declared is not None and declared != th.all_perfect_score:
|
||
print(" [警告] 谱面自带 TTM_SCR_SS=%d 与计算出的理论分 %d 不一致"
|
||
% (declared, th.all_perfect_score))
|
||
|
||
|
||
def print_forward(chart: Chart, result) -> None:
|
||
th = result.theory
|
||
print(LINE)
|
||
print("判定输入")
|
||
for j in JUDGMENTS:
|
||
print(" %-18s %d" % (JUDGE_LABEL[j], result.judges.get(j, 0)))
|
||
print(" %-18s %d / %d" % ("合计", sum(result.judges.values()), th.total_notes))
|
||
print(LINE)
|
||
print("结果")
|
||
print(" 达成率 %s ~ %s" % (pct(result.achievement_min), pct(result.achievement_max)))
|
||
print(" 按比例参考 %s" % pct(result.achievement_ref))
|
||
print(" DX 分 %d / %d (%.2f%%)"
|
||
% (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0))
|
||
if result.achievement_min != result.achievement_max:
|
||
print()
|
||
print(" 说明:只给聚合判定数时,达成率取决于「哪些音符」被判成 Great/Good/Miss——")
|
||
print(" 区间下界 = 扣分都落在权重最高的音符上,上界 = 都落在权重最低的音符上。")
|
||
print(LINE)
|
||
|
||
|
||
def print_reverse(chart: Chart, outcome) -> None:
|
||
th = outcome["theory"]
|
||
rows = outcome["rows"]
|
||
mode = outcome.get("mode", "exact")
|
||
print(LINE)
|
||
print("目标达成率 %s [%s]" % (pct(outcome["target"]),
|
||
"精确模式" if mode == "exact" else "区间模式"))
|
||
note = outcome.get("note")
|
||
if note:
|
||
print(" [说明] %s" % note)
|
||
if not rows:
|
||
if mode == "exact":
|
||
cap = outcome.get("exact_cap") or 0
|
||
print(" (在严格验证范围内没有找到解:坏音符 ≤ %d 的组合里没有能精确打出该值的)" % cap)
|
||
if outcome.get("more_unverified"):
|
||
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
|
||
else:
|
||
print(" (无解:该达成率在这个谱面上打不出来)")
|
||
print(LINE)
|
||
return
|
||
if mode == "exact":
|
||
print(" Miss Good Great CP+P CP 范围 DX 分范围")
|
||
else:
|
||
print(" Miss Good Great CP+P CP 范围 达成率范围 DX 分范围")
|
||
for r in rows:
|
||
cp_range = "%d~%d" % (r.cp_min, r.cp_max) if r.cp_min != r.cp_max else str(r.cp_min)
|
||
dx_range = "%d~%d" % (r.dx_min, r.dx_max) if r.dx_min != r.dx_max else str(r.dx_min)
|
||
if mode == "exact":
|
||
print(" %4d %4d %5d %4d %-11s %s"
|
||
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, dx_range))
|
||
else:
|
||
ach = "%s ~ %s" % (pct(r.achievement_min), pct(r.achievement_max))
|
||
print(" %4d %4d %5d %4d %-11s %-24s %s"
|
||
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, ach, dx_range))
|
||
print()
|
||
if mode == "exact":
|
||
print(" 共 %d 组能精确打出 %s 的判定组合(CP+P = Critical Perfect 与 Perfect 之和)"
|
||
% (len(rows), pct(outcome["target"])))
|
||
cap = outcome.get("exact_cap") or 0
|
||
print(" 已逐个严格验证 Miss+Good+Great ≤ %d 的组合;每个组合都真的能打出该达成率" % cap)
|
||
if outcome.get("unverified"):
|
||
print(" [注意] 另有 %d 组候选(坏音符超过 %d 个)未做精确验证,未列入结果"
|
||
% (outcome["unverified"], cap))
|
||
if outcome.get("more_unverified"):
|
||
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
|
||
else:
|
||
print(" 共 %d 组可行组合(CP+P = Critical Perfect 与 Perfect 之和)" % len(rows))
|
||
print(" 区间模式:只保证达成率落在这个范围内,不保证能恰好打出目标值")
|
||
if outcome["truncated"]:
|
||
print(" [注意] 结果被 --limit / 扫描上限截断,可能还有更多组合")
|
||
print(" DX 分 = 3*CP + 2*Perfect + 1*Great;CP 越靠范围上端,DX 分越高")
|
||
print(LINE)
|
||
|
||
|
||
# ------------------------------------------------------------------ JSON
|
||
def to_json(chart: Chart, th: Theory, payload: Dict) -> str:
|
||
data = {
|
||
"chart": {
|
||
"path": chart.path,
|
||
"music_id": chart.music_id,
|
||
"song_name": chart.song_name,
|
||
"difficulty": chart.difficulty_name,
|
||
"difficulty_index": chart.difficulty_index,
|
||
"level_text": chart.level_text,
|
||
"notes": th.counts,
|
||
"total_notes": th.total_notes,
|
||
"connect_slide": chart.connect_count,
|
||
"all_perfect_score": th.all_perfect_score,
|
||
"break_bonus_score": th.break_bonus_score,
|
||
"max_dx": th.max_dx,
|
||
"max_achievement": float(th.max_achievement),
|
||
"warnings": chart.warnings,
|
||
},
|
||
}
|
||
data.update(payload)
|
||
return json.dumps(data, ensure_ascii=False, indent=2)
|
||
|
||
|
||
# ------------------------------------------------------------------ 交互
|
||
def ask(prompt: str, default: str = "") -> str:
|
||
try:
|
||
text = input(prompt).strip()
|
||
except EOFError:
|
||
text = ""
|
||
return text or default
|
||
|
||
|
||
def ask_int(prompt: str, default: Optional[int] = None) -> Optional[int]:
|
||
while True:
|
||
raw = ask(prompt, "" if default is None else str(default))
|
||
if raw == "" and default is None:
|
||
return None
|
||
try:
|
||
return int(raw)
|
||
except ValueError:
|
||
print(" 请输入整数")
|
||
|
||
|
||
def interactive(args) -> None:
|
||
print("=" * 60)
|
||
print("舞萌DX 谱面达成率 / DX 分 计算器")
|
||
print("=" * 60)
|
||
chart = None
|
||
while chart is None:
|
||
mode = ask("谱面来源:1) .ma2 文件路径 2) 游戏路径 + musicid [1]: ", "1")
|
||
if mode.startswith("2"):
|
||
game = ask("游戏路径(Sinmai_Data 目录): ")
|
||
if not game:
|
||
continue
|
||
mid = ask_int("musicid: ")
|
||
if mid is None:
|
||
continue
|
||
diff = ask("难度 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER [3]: ", "3")
|
||
try:
|
||
chart = load_from_game(game, mid, parse_difficulty(diff))
|
||
except SystemExit:
|
||
chart = None
|
||
else:
|
||
path = ask("谱面文件路径: ")
|
||
if not path:
|
||
continue
|
||
try:
|
||
chart = load_from_file(path)
|
||
except SystemExit:
|
||
chart = None
|
||
th = theory_from_chart(chart)
|
||
print_chart(chart, th)
|
||
|
||
mode = ask("计算模式:1) 已知判定数算达成率 2) 已知达成率倒算判定数 [1]: ", "1")
|
||
if mode.startswith("2"):
|
||
while True:
|
||
raw = ask("目标达成率(如 99.5 或 100.5): ")
|
||
if not raw:
|
||
return
|
||
try:
|
||
target = Fraction(raw.rstrip("%"))
|
||
break
|
||
except (ValueError, ZeroDivisionError):
|
||
print(" 请输入数字")
|
||
mode = ask("反算方式:1) 精确(只列真能打出该达成率的组合) 2) 区间(判定数只是估算时用) [1]: ", "1")
|
||
args.mode = "range" if mode.startswith("2") else "exact"
|
||
run_reverse(chart, th, target, args)
|
||
else:
|
||
values = {}
|
||
for j in JUDGMENTS:
|
||
if j == "miss":
|
||
continue
|
||
values[j] = ask_int(" %s 数 [0]: " % JUDGE_LABEL[j], 0) or 0
|
||
values["miss"] = ask_int(" Miss 数(留空 = 总数减去以上判定): ")
|
||
run_forward(chart, th, values, args)
|
||
|
||
|
||
# ------------------------------------------------------------------ 执行
|
||
def normalize_judges(th: Theory, raw: Dict[str, Optional[int]]) -> Dict[str, int]:
|
||
given = {j: int(raw.get(j) or 0) for j in JUDGMENTS}
|
||
if raw.get("miss") is None:
|
||
given["miss"] = th.total_notes - sum(given[j] for j in JUDGMENTS if j != "miss")
|
||
if given["miss"] < 0:
|
||
die("CP+Perfect+Great+Good 合计 %d 超过了音符总数 %d"
|
||
% (sum(given[j] for j in JUDGMENTS if j != "miss"), th.total_notes))
|
||
total = sum(given.values())
|
||
if total != th.total_notes:
|
||
die("判定数之和 %d 与音符总数 %d 不一致(Miss 请留空自动补足)" % (total, th.total_notes))
|
||
return given
|
||
|
||
|
||
def run_forward(chart: Chart, th: Theory,
|
||
raw: Dict[str, Optional[int]], args) -> None:
|
||
judges = normalize_judges(th, raw)
|
||
result = solve_forward(th, judges)
|
||
if args.json:
|
||
print(to_json(chart, th, {
|
||
"mode": "forward",
|
||
"judges": judges,
|
||
"achievement_min": float(result.achievement_min),
|
||
"achievement_max": float(result.achievement_max),
|
||
"achievement_reference": float(result.achievement_ref),
|
||
"dx_score": result.dx,
|
||
}))
|
||
return
|
||
print_chart(chart, th)
|
||
print_forward(chart, result)
|
||
|
||
|
||
def run_reverse(chart: Chart, th: Theory,
|
||
target: Fraction, args) -> None:
|
||
outcome = solve_reverse(th, target, limit=args.limit, mode=args.mode)
|
||
if args.json:
|
||
print(to_json(chart, th, {
|
||
"mode": "reverse",
|
||
"search_mode": outcome["mode"],
|
||
"target": float(target),
|
||
"truncated": outcome["truncated"],
|
||
"exact_cap": outcome.get("exact_cap"),
|
||
"unverified": outcome.get("unverified", 0),
|
||
"more_unverified": outcome.get("more_unverified", False),
|
||
"note": outcome.get("note"),
|
||
"solutions": [{
|
||
"miss": r.miss, "good": r.good, "great": r.great,
|
||
"critical_perfect_plus_perfect": r.cp_plus_perfect,
|
||
"critical_perfect_min": r.cp_min, "critical_perfect_max": r.cp_max,
|
||
"achievement_min": float(r.achievement_min),
|
||
"achievement_max": float(r.achievement_max),
|
||
"dx_min": r.dx_min, "dx_max": r.dx_max,
|
||
} for r in outcome["rows"]],
|
||
}))
|
||
return
|
||
print_chart(chart, th)
|
||
print_reverse(chart, outcome)
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
p = argparse.ArgumentParser(
|
||
prog="chart_achievement.py",
|
||
description="舞萌DX 谱面达成率 / DX 分计算器(正向计算 + 由达成率倒算判定数)",
|
||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||
epilog="""示例:
|
||
# 直接给谱面文件,正向计算
|
||
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --cp 290 --perfect 10 --great 2
|
||
|
||
# 给游戏路径 + musicid + 难度(难度用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)
|
||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --cp 300 --perfect 2
|
||
|
||
# 由达成率倒算判定组合(精确模式:只列真能打出该值的组合)
|
||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5
|
||
|
||
# 判定数只是估算时,用区间模式
|
||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5 --mode range
|
||
""")
|
||
src = p.add_argument_group("谱面来源(二选一)")
|
||
src.add_argument("--chart", metavar="PATH", help=".ma2 谱面文件路径")
|
||
src.add_argument("--game", metavar="PATH", help="游戏目录(Sinmai_Data / StreamingAssets / A000 / music 均可)")
|
||
src.add_argument("--music-id", type=int, metavar="ID", help="曲目编号(配合 --game 使用)")
|
||
src.add_argument("--level", "--difficulty", dest="level", default=None, metavar="D",
|
||
help="谱面难度,用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT "
|
||
"3=MASTER 4=RE:MASTER(也可直接写名字;默认 3=MASTER)")
|
||
|
||
fw = p.add_argument_group("正向计算:已知判定数")
|
||
fw.add_argument("--cp", type=int, metavar="N", help="Critical Perfect 个数")
|
||
fw.add_argument("--perfect", type=int, metavar="N", help="Perfect 个数")
|
||
fw.add_argument("--great", type=int, metavar="N", help="Great 个数")
|
||
fw.add_argument("--good", type=int, metavar="N", help="Good 个数")
|
||
fw.add_argument("--miss", type=int, metavar="N", help="Miss 个数(留空 = 总数减去其它判定)")
|
||
|
||
rv = p.add_argument_group("反向计算:已知达成率")
|
||
rv.add_argument("--achievement", metavar="A", help="目标达成率(如 99.5 / 100.5)")
|
||
rv.add_argument("--mode", choices=("exact", "range"), default="exact",
|
||
help="exact(默认):只列真的能打出该达成率的组合;"
|
||
"range:列出达成率区间覆盖该值的组合(更快,输入不精确时用)")
|
||
rv.add_argument("--limit", type=int, default=200, metavar="N",
|
||
help="反向计算最多输出多少组解(默认 200)")
|
||
|
||
p.add_argument("--json", action="store_true", help="以 JSON 输出结果")
|
||
return p
|
||
|
||
|
||
def main(argv: Optional[List[str]] = None) -> int:
|
||
args = build_parser().parse_args(argv)
|
||
|
||
if args.chart and args.game:
|
||
die("--chart 与 --game 只能给一个")
|
||
if not args.chart and not args.game:
|
||
interactive(args)
|
||
return 0
|
||
if args.chart:
|
||
chart = load_from_file(args.chart)
|
||
if args.level is not None:
|
||
try:
|
||
want = parse_difficulty(args.level)
|
||
except ValueError as exc:
|
||
die(str(exc))
|
||
if chart.difficulty_index is not None and want != chart.difficulty_index:
|
||
print("[注意] --level 给的是 %s,但谱面文件名里是 %s,按文件名算"
|
||
% (DIFFICULTY_NAMES[want], DIFFICULTY_NAMES[chart.difficulty_index]),
|
||
file=sys.stderr)
|
||
else:
|
||
if args.music_id is None:
|
||
die("使用 --game 时必须同时给 --music-id")
|
||
if args.level is None:
|
||
print("[提示] 没有给 --level,按默认难度 3(MASTER)计算"
|
||
"(0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)", file=sys.stderr)
|
||
try:
|
||
difficulty = parse_difficulty(args.level if args.level is not None else "master")
|
||
except ValueError as exc:
|
||
die(str(exc))
|
||
chart = load_from_game(args.game, args.music_id, difficulty)
|
||
|
||
th = theory_from_chart(chart)
|
||
if th.total_notes == 0:
|
||
die("谱面里没有解析到任何计分音符:%s" % chart.path)
|
||
|
||
if args.achievement:
|
||
try:
|
||
target = Fraction(str(args.achievement).rstrip("%"))
|
||
except (ValueError, ZeroDivisionError):
|
||
die("--achievement 需要是数字,如 99.5")
|
||
if target > th.max_achievement:
|
||
die("目标达成率 %s 超过该谱面上限 %s" % (pct(target), pct(th.max_achievement)))
|
||
if target < 0:
|
||
die("目标达成率不能为负")
|
||
run_reverse(chart, th, target, args)
|
||
return 0
|
||
|
||
raw = {"cp": args.cp, "perfect": args.perfect, "great": args.great,
|
||
"good": args.good, "miss": args.miss}
|
||
if all(v is None for v in raw.values()):
|
||
die("请给判定数(--cp/--perfect/--great/--good),或用 --achievement 做反向计算;"
|
||
"两个都不给则进入交互模式(需去掉其它参数)")
|
||
run_forward(chart, th, raw, args)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|