v1进行时:新增谱面达成率小工具;extra.json 不再入库
This commit is contained in:
@@ -0,0 +1,456 @@
|
||||
#!/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, level: Optional[str]) -> 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 (%02d)" % (chart.difficulty_name, chart.difficulty_index or 0))
|
||||
shown_level = level or chart.level_text
|
||||
if shown_level:
|
||||
print(" 等级 %s" % shown_level)
|
||||
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, level: Optional[str], 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": level or 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("难度 basic/advanced/expert/master/remaster [master]: ", "master")
|
||||
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)
|
||||
level = ask("等级(仅用于显示,可留空): ") or None
|
||||
print_chart(chart, th, level)
|
||||
|
||||
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, level, 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, level, 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, level: Optional[str],
|
||||
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, level, {
|
||||
"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, level)
|
||||
print_forward(chart, result)
|
||||
|
||||
|
||||
def run_reverse(chart: Chart, th: Theory, level: Optional[str],
|
||||
target: Fraction, args) -> None:
|
||||
outcome = solve_reverse(th, target, limit=args.limit, mode=args.mode)
|
||||
if args.json:
|
||||
print(to_json(chart, th, level, {
|
||||
"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, level)
|
||||
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" --level 12+ --cp 290 --perfect 10 --great 2
|
||||
|
||||
# 给游戏路径 + musicid(默认 Master 难度)
|
||||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --difficulty master --cp 300 --perfect 2
|
||||
|
||||
# 由达成率倒算判定组合(精确模式:只列真能打出该值的组合)
|
||||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --achievement 99.5
|
||||
|
||||
# 判定数只是估算时,用区间模式
|
||||
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --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("--difficulty", default="master", metavar="D",
|
||||
help="难度 basic/advanced/expert/master/remaster 或 0~4(默认 master)")
|
||||
src.add_argument("--level", metavar="TEXT", help="等级,仅用于显示(如 12+ / 13.4)")
|
||||
|
||||
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)
|
||||
else:
|
||||
if args.music_id is None:
|
||||
die("使用 --game 时必须同时给 --music-id")
|
||||
chart = load_from_game(args.game, args.music_id, parse_difficulty(args.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, args.level, 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, args.level, raw, args)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user