v1进行时:新增谱面达成率小工具;extra.json 不再入库

This commit is contained in:
2026-10-07 23:27:30 +08:00
parent 938341828c
commit 3a0f3435f7
6 changed files with 1833 additions and 1 deletions
@@ -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())