From 3a0f3435f78050a9cae27af956fa1e8e97b21c75 Mon Sep 17 00:00:00 2001 From: Txiaochen Date: Wed, 7 Oct 2026 23:27:30 +0800 Subject: [PATCH] =?UTF-8?q?v1=E8=BF=9B=E8=A1=8C=E6=97=B6=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E8=B0=B1=E9=9D=A2=E8=BE=BE=E6=88=90=E7=8E=87=E5=B0=8F?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=EF=BC=9Bextra.json=20=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E5=85=A5=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- extra.json | 1 - tools/README.md | 30 + tools/chart_achievement/README.md | 135 +++ tools/chart_achievement/chart_achievement.py | 456 ++++++++++ tools/chart_achievement/ma2.py | 366 ++++++++ tools/chart_achievement/score.py | 846 +++++++++++++++++++ 6 files changed, 1833 insertions(+), 1 deletion(-) delete mode 100644 extra.json create mode 100644 tools/README.md create mode 100644 tools/chart_achievement/README.md create mode 100644 tools/chart_achievement/chart_achievement.py create mode 100644 tools/chart_achievement/ma2.py create mode 100644 tools/chart_achievement/score.py diff --git a/extra.json b/extra.json deleted file mode 100644 index ad76d67..0000000 --- a/extra.json +++ /dev/null @@ -1 +0,0 @@ -{"userKaleidxScopeList": [{"gateId": 1, "isGateFound": true, "isKeyFound": true, "isClear": false,"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,"bestAchievement": 0, "bestDeluxscore": 0,"bestAchievementDate": "", "bestDeluxscoreDate": "","playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false},{"gateId": 3, "isGateFound": true, "isKeyFound": true, "isClear": false,"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,"bestAchievement": 0, "bestDeluxscore": 0,"bestAchievementDate": "", "bestDeluxscoreDate": "","playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false},{"gateId": 4, "isGateFound": true, "isKeyFound": true, "isClear": false,"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,"bestAchievement": 0, "bestDeluxscore": 0,"bestAchievementDate": "", "bestDeluxscoreDate": "","playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false},{"gateId": 5, "isGateFound": true, "isKeyFound": true, "isClear": false,"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,"bestAchievement": 0, "bestDeluxscore": 0,"bestAchievementDate": "", "bestDeluxscoreDate": "","playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false},{"gateId": 6, "isGateFound": true, "isKeyFound": true, "isClear": false,"totalRestLife": 0, "totalAchievement": 0, "totalDeluxscore": 0,"bestAchievement": 0, "bestDeluxscore": 0,"bestAchievementDate": "", "bestDeluxscoreDate": "","playCount": 0, "clearDate": "", "lastPlayDate": "", "isInfoWatched": false}],"isNewKaleidxScopeList": "11111"} \ No newline at end of file diff --git a/tools/README.md b/tools/README.md new file mode 100644 index 0000000..b36146c --- /dev/null +++ b/tools/README.md @@ -0,0 +1,30 @@ +# tools/ —— 独立小工具 + +这个目录放**与游戏协议无关的独立小工具**:纯计算、纯文件处理。 +它们**不联网**、**不碰游戏服务器**、也**不依赖 `api/` 与 `config.py`**。 + +## 约定 + +| 约定 | 说明 | +|---|---| +| 一个工具一个子目录 | `tools/<工具名>/` | +| 自包含 | 工具之间不互相依赖,单独拷走也能跑 | +| 纯本地 | 不联网、不写游戏服务器;最多读本机文件 | +| 无第三方依赖 | 只用 Python 标准库(除非工具自己的 README 里写明) | +| 登记 | 新增工具时,在下面的「已有工具」表里加一行 | + +## 已有工具 + +| 工具 | 目录 | 干什么 | +|---|---|---| +| 谱面达成率计算器 | [`chart_achievement/`](chart_achievement/) | 按判定数算谱面达成率与 DX 分;也可由达成率倒算判定组合 | + +## 怎么跑 + +```bash +# 进工具目录,直接 python 跑(各工具的参数见它自己的说明) +cd tools/chart_achievement +python chart_achievement.py --help +``` + +--- diff --git a/tools/chart_achievement/README.md b/tools/chart_achievement/README.md new file mode 100644 index 0000000..3bfb1b8 --- /dev/null +++ b/tools/chart_achievement/README.md @@ -0,0 +1,135 @@ +# chart_achievement —— 舞萌DX 谱面达成率 / DX 分计算器 + +给定谱面(`.ma2`)和判定数,算出**达成率**与 **DX 分**;反过来,给定目标达成率, +倒算出所有能打出该达成率的判定组合。 + +纯本地、纯标准库(不需要联网、不写任何游戏文件)。 + +## 怎么跑 + +```powershell +cd tools/chart_achievement +python chart_achievement.py --help +python chart_achievement.py # 不给参数 = 交互向导 +``` + +## 谱面从哪来(二选一) + +| 参数 | 说明 | +| --- | --- | +| `--chart <路径>` | 直接给 `.ma2` 文件,如 `.../music/music000008/000008_03.ma2` | +| `--game <目录> --music-id ` | 给游戏目录(`Sinmai_Data`、`StreamingAssets`、`A000`、`music` 都能认),按 `musicid` 自动找谱面 | + +`--difficulty basic|advanced|expert|master|remaster`(也接受 0~4),默认 **master**。 +`--level` 只用于显示(如 `12+`),不参与计算。 + +## 正向:已知判定数 → 达成率 + DX 分 + +```powershell +python chart_achievement.py --chart "<...>\000008_03.ma2" --level 12+ --cp 290 --perfect 8 --great 3 --good 1 +``` + +- `--cp` / `--perfect` / `--great` / `--good` 都给个数,`--miss` 留空时自动补足 + (`miss = 总音符数 - CP - Perfect - Great - Good`)。 +- 输出达成率的**区间**(下界 ~ 上界)和「按比例分摊」的参考值: + 只给聚合判定数时,达成率取决于这些 Great/Good/Miss **落在哪些音符上** + (落在绝赞上扣得多、落在 Tap 上扣得少),所以是一个区间而不是一个数。 + 下界 = 扣分尽量落在高权重音符上,上界 = 尽量落在低权重音符上。 +- DX 分是确定的:`3×CP + 2×Perfect + 1×Great`。 + +## 反向:已知达成率 → 判定组合 + DX 分 + +```powershell +python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --achievement 100.75 +python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --achievement 99 --mode range +``` + +两种模式(`--mode`): + +| 模式 | 含义 | 什么时候用 | +| --- | --- | --- | +| `exact`(默认) | 只列**真的能打出**该达成率的 (Miss, Good, Great) 组合,每组都做过严格验证 | 数据可信、想知道精确答案 | +| `range` | 列达成率**区间覆盖**该值的组合(更快、更宽松) | 判定数只是估算 / 想快速看个大概 | + +`--limit N` 限制最多输出多少组(默认 200)。`--json` 输出 JSON。 + +输出里的 `CP+P` 是「Critical Perfect + Perfect 的总数」;`CP 范围` 是其中 +CP 的物理可能范围(Perfect 只能落在绝赞音符上,所以 CP 不能少于 +`CP+P - 绝赞音符数`)。CP 越多 DX 分越高,所以 DX 分也是这个范围。 + +### 精确模式的限制 + +精确验证用一个「坏音符背包 DP」,代价随坏音符个数增长,所以: + +- 只严格验证 `Miss+Good+Great ≤ 12` 的组合; +- 坏音符更多的组合不验证(输出里会提示「坏音符超过 12 个的候选组合没有验证」); +- 若候选组合全都超过 12 个坏音符(例如 1000+ 绝赞的异常谱面),会自动退回区间模式并说明原因; +- DP 有 8 秒时间预算,超时也退回区间模式。 + +## 计分模型(与游戏一致) + +达成率按**音符权重**加权,不是按个数平均: + +``` +达成率 = 100 × Σ(单音符得分) / 理论分W + Σ(绝赞奖励分) / 绝赞理论奖励分B +``` + +- 理论分 W = Σ 每个计分音符的权重(Tap/Star/Touch 500、Hold 系 1000、Slide 系 1500、Break 系 2500) +- 绝赞理论奖励分 B = 100 × 绝赞音符个数(Break / Break(长按判定) / BreakSlide / EXBreak) +- 满分 = **101.0000%**(谱面有绝赞)/ **100.0000%**(无绝赞) +- 接续滑条节点(`CNSI_` 等 ConnectSlide)**不计分、不计入总数** +- DX 分 = `3×CP + 2×Perfect + 1×Great`(与音符类型无关) + +### 各类音符的失分 / 奖励分 + +| 音符组(权重) | CP | Perfect | Great | Good | Miss | +| --- | --- | --- | --- | --- | --- | +| Tap / Star / Touch(500) | 0 | 0 | 100 | 250 | 500 | +| Hold 系(1000) | 0 | 0 | 200 | 500 | 1000 | +| Slide 系(1500) | 0 | 0 | 300 | 750 | 1500 | +| Break(2500,分档) | 0 | 0 | 500 / 1000 / 1250 | 1500 | 2500 | +| Break 长按判定(2500) | 0 | 0 | 500 | 1500 | 2500 | +| BreakSlide(2500,分档) | 0 | 0 | 500 / 1000 / 1250 | 1500 | 2500 | +| EX(500) | 0 | — | — | — | 500 | +| EXBreak(2500) | 0 | — | — | — | 2500 | + +(表里是「失分」,即 `权重 - 得分`;CP/Perfect 的失分都是 0,所以 +**除绝赞外,Critical Perfect 与 Perfect 加的达成率完全一样**。) + +绝赞音符额外有奖励分: + +| 判定 | 奖励分 | +| --- | --- | +| Critical Perfect | 100 | +| Perfect | 75(FastPerfect/LatePerfect)/ 50(Perfect2nd) | +| Great | 40 | +| Good | 30 | +| Miss | 0 | + +这就是绝赞必须区分 CP / Perfect 的原因:普通分一样,但奖励分不同 +(CP 100、Perfect 75 或 50)。 + +### 音符组的划分依据(反编译源码) + +- 走长按结算的 Break(`BRHLD`)判定档位更少(Great 只有一档、Perfect 只有 75 档),所以单独成组。 +- 滑条系(`BRSI_`/`BRSCL`…)走 SlideOut 判定表,只有 Great 三档、没有 Perfect 档。 +- EX 音符(`XTP`/`XST`/`BXTAP`/`BXSTR`,即 ExTap/ExStar/ExBreakTap/ExBreakStar) + 走 ExTap 判定表,非 Critical 的窗口宽度为 0,所以**只能判 Critical 或 Miss**。 +- `XHO`/`BXHLD`(ExHold/ExBreakHold)头部用 ExTap 表但结果还要过长按结算, + 等价于普通 Hold / Break(长按判定)。 + +## 文件 + +| 文件 | 内容 | +| --- | --- | +| `ma2.py` | `.ma2` 解析、指令→音符组映射、`Music.xml` 读取、路径拼接 | +| `score.py` | 计分模型、正向求解、反向求解(区间 / 精确) | +| `chart_achievement.py` | 命令行界面 / 交互向导 | + +## 已知局限 + +- 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布(例如绝赞 Perfect 是 75 还是 50 档, + 正向计算里只能给区间)。 +- 精确反向只验证坏音符数 ≤ 12 的组合(见上文)。 +- 若 `.ma2` 里的 `T_NUM_ALL` 与解析出的计分音符数不一致,会在输出里给警告 + (全部 5419 个游戏谱面实测一致)。 diff --git a/tools/chart_achievement/chart_achievement.py b/tools/chart_achievement/chart_achievement.py new file mode 100644 index 0000000..1fb4119 --- /dev/null +++ b/tools/chart_achievement/chart_achievement.py @@ -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()) diff --git a/tools/chart_achievement/ma2.py b/tools/chart_achievement/ma2.py new file mode 100644 index 0000000..16b0639 --- /dev/null +++ b/tools/chart_achievement/ma2.py @@ -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 = "" + 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(可用 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 diff --git a/tools/chart_achievement/score.py b/tools/chart_achievement/score.py new file mode 100644 index 0000000..4375380 --- /dev/null +++ b/tools/chart_achievement/score.py @@ -0,0 +1,846 @@ +# -*- coding: utf-8 -*- +"""舞萌DX 达成率 / DX 分计算模型(按音符组精确建模)。 + +达成率(source/Assembly-CSharp/Manager/GameScoreList.cs:1238-1310 CalcUpdate()):: + + Achivement = num / _allPerfectScore * 100 + num2 / _breakBonusScore + +其中 num = Σ 普通得分 NoteScore.GetJudgeScore(timing, 计分类型) + num2 = Σ 绝赞奖励分(只有绝赞音符有)NoteScore.GetJudgeScore(timing, BreakBonus) + _allPerfectScore = Σ 每个音符的满权重(NotesReader.calcTotal()) + _breakBonusScore = 100 × 绝赞音符数 +满分 = 101.0000%(有绝赞)/ 100.0000%(无绝赞)。 + +DX 分(JudgeResultSt.UpdateScore + NoteJudge.ConvertJudge):: + + DX = 3×CriticalPerfect + 2×Perfect + 1×Great(Good/Miss 不加分) + +与音符类型无关,理论 DX = 3 × 计分音符数。 + +模型要点 +-------- +* 音符按「组」划分,同一组内计分行为完全一致(权重、可选判定、失分档位、绝赞奖励档位)。 +* 绝赞(Break 类)的 Great 有 3 档失分、Perfect 奖励有 2 档(75 / 50),滑条判定的绝赞 + 没有 Perfect 档,长按判定的绝赞不分档;EX 判定(ExTap 表)只能 Critical 或 Miss。 +* 因此「给定各判定的总个数」并不能唯一确定达成率:达成率依赖这些判定落在哪些音符上。 + 本模块用最小费用流求达成率的精确上下界,并可严格判定「能否恰好打出某个达成率」。 +""" + +from __future__ import annotations + +import math +import time +from collections import deque +from dataclasses import dataclass, field +from fractions import Fraction +from typing import Dict, List, Optional, Sequence, Tuple + +from ma2 import (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX, + GROUP_EX_BREAK, GROUP_HOLD, GROUP_LABEL, GROUP_ORDER, + GROUP_SLIDE, GROUP_TAP, GROUP_WEIGHT, Chart) + +# ------------------------------------------------------------------ 常量 +JUDGMENTS = ("cp", "perfect", "great", "good", "miss") +JUDGE_LABEL = { + "cp": "Critical Perfect", + "perfect": "Perfect", + "great": "Great", + "good": "Good", + "miss": "Miss", +} +DX_PER = {"cp": 3, "perfect": 2, "great": 1, "good": 0, "miss": 0} + +#: 每个音符组里,某判定实际可能出现的 (失分, 绝赞奖励分) 组合。 +#: 失分 = 该音符满权重 - 实际得分;奖励分只对绝赞(计分类型 Break)音符存在。 +GROUP_OPTIONS: Dict[str, Dict[str, Tuple[Tuple[int, int], ...]]] = { + GROUP_TAP: { + "cp": ((0, 0),), + "perfect": ((0, 0),), + "great": ((100, 0),), # 400 + "good": ((250, 0),), # 250 + "miss": ((500, 0),), # 0 + }, + GROUP_HOLD: { + "cp": ((0, 0),), + "perfect": ((0, 0),), + "great": ((200, 0),), # 800 + "good": ((500, 0),), # 500 + "miss": ((1000, 0),), # 0 + }, + GROUP_SLIDE: { + "cp": ((0, 0),), + "perfect": ((0, 0),), + "great": ((300, 0),), # 1200 + "good": ((750, 0),), # 750 + "miss": ((1500, 0),), # 0 + }, + GROUP_BREAK: { + "cp": ((0, 100),), + "perfect": ((0, 75), (0, 50)), # FastPerfect/LatePerfect 与 2nd 档 + "great": ((500, 40), (1000, 40), (1250, 40)), # 2000 / 1500 / 1250 + "good": ((1500, 30),), # 1000 + "miss": ((2500, 0),), # 0 + }, + GROUP_BREAK_FLAT: { + "cp": ((0, 100),), + "perfect": ((0, 75),), # 长按判定没有 2nd 档 + "great": ((500, 40),), # 只有 2000 一档 + "good": ((1500, 30),), + "miss": ((2500, 0),), + }, + GROUP_BREAK_SLIDE: { + "cp": ((0, 100),), + # 滑条判定没有非 Critical 的 Perfect + "great": ((500, 40), (1000, 40), (1250, 40)), + "good": ((1500, 30),), + "miss": ((2500, 0),), + }, + GROUP_EX: { + "cp": ((0, 0),), + "miss": ((500, 0),), # ExTap 判定表只有 Critical / TooFast / TooLate + }, + GROUP_EX_BREAK: { + "cp": ((0, 100),), + "miss": ((2500, 0),), + }, +} + +_BAD_JUDGMENTS = ("great", "good", "miss") + +#: 坏音符落在该组时,对 (可出现的绝赞音符数 bad_b, 加权和 slo) 的贡献。 +#: slo = 2×bad_break + 3×bad_break_flat + 4×bad_break_slide + 4×bad_ex_break, +#: 用于还原「绝赞音符里 cp/perfect 的奖励分取值范围」。 +_GROUP_BAD_STEP = { + GROUP_BREAK: (1, 2), + GROUP_BREAK_FLAT: (1, 3), + GROUP_BREAK_SLIDE: (1, 4), + GROUP_EX_BREAK: (1, 4), +} + +#: 精确模式严格验证的最大坏音符数(Miss+Good+Great 总和)。超过则只做区间判定。 +EXACT_MAX_BAD = 12 # 精确验证时最多考虑多少个坏音符(Miss+Good+Great) +EXACT_TIME_BUDGET = 8.0 # 精确验证 DP 的时间预算(秒),超时退回区间模式 + + +# ------------------------------------------------------------------ 理论值 +@dataclass +class Theory: + """一个谱面的计分理论值。""" + + counts: Dict[str, int] # 音符组 -> 音符数 + weights: Dict[str, int] # 音符组 -> 单音符满权重 + all_perfect_score: int # _allPerfectScore + break_bonus_score: int # _breakBonusScore = 100 × 绝赞音符数 + total_notes: int # 计分音符总数 N(不含接续滑条) + max_dx: int # 理论 DX = 3N + lattice: int # 所有单音符失分-奖励组合的最大公约数 + + @property + def has_break(self) -> bool: + return self.break_bonus_score > 0 + + @property + def break_notes(self) -> int: + """绝赞音符总数(计分类型为 Break 的音符)。""" + return sum(self.counts.get(g, 0) for g in _GROUP_BAD_STEP) + + @property + def denominator(self) -> int: + """达成率公式的分母:100 - Σc/D 中的 D。""" + if self.break_bonus_score: + return self.all_perfect_score * self.break_bonus_score + return self.all_perfect_score + + @property + def max_achievement(self) -> Fraction: + return Fraction(101 if self.break_bonus_score else 100) + + @property + def perfect_capacity(self) -> int: + """允许出现 Perfect 的绝赞音符数(Break 与 Break(长按判定))。""" + return (self.counts.get(GROUP_BREAK, 0) + + self.counts.get(GROUP_BREAK_FLAT, 0)) + + +def _lattice_of(th: "Theory") -> int: + g = 0 + for group in GROUP_ORDER: + for opts in GROUP_OPTIONS[group].values(): + for loss, bonus in opts: + g = math.gcd(g, _cost(th, loss, bonus)) + return g + + +def theory_from_counts(counts: Dict[str, int]) -> Theory: + """由「音符组 -> 音符数」构造理论值。""" + clean: Dict[str, int] = {} + for key, value in (counts or {}).items(): + if key not in GROUP_OPTIONS: + raise ValueError("未知的音符组:%r" % (key,)) + value = int(value or 0) + if value < 0: + raise ValueError("音符数不能为负:%s=%d" % (key, value)) + if value: + clean[key] = value + all_perfect = 0 + break_notes = 0 + total = 0 + for group in GROUP_ORDER: + n = clean.get(group, 0) + if not n: + continue + weight = GROUP_WEIGHT[group] + all_perfect += weight * n + total += n + if group in _GROUP_BAD_STEP: + break_notes += n + th = Theory( + counts={g: clean.get(g, 0) for g in GROUP_ORDER}, + weights={g: GROUP_WEIGHT[g] for g in GROUP_ORDER}, + all_perfect_score=all_perfect, + break_bonus_score=100 * break_notes, + total_notes=total, + max_dx=3 * total, + lattice=0, + ) + th.lattice = _lattice_of(th) + return th + + +def theory_from_chart(chart: Chart) -> Theory: + return theory_from_counts(chart.counts()) + + +# ------------------------------------------------------------------ 单音符代价 +def _cost(th: Theory, loss: int, bonus: int) -> int: + """把 (失分, 奖励分) 折算成一个整数代价 c,使达成率 = 100 - Σc / D。""" + if th.break_bonus_score: + return 100 * th.break_bonus_score * loss - th.all_perfect_score * bonus + return 100 * loss + + +def _edge_cost(th: Theory, group: str, judge: str, want_max: bool) -> Optional[int]: + opts = GROUP_OPTIONS.get(group, {}).get(judge) + if not opts: + return None + values = [_cost(th, loss, bonus) for loss, bonus in opts] + # want_max 表示「追求最大达成率」,而达成率 = 100 - Σc/D,所以要取最小代价。 + return min(values) if want_max else max(values) + + +def _flex_edge_cost(th: Theory, group: str, judge: str, want_max: bool) -> Optional[int]: + """与 _edge_cost 相同,但「好音符」(CP/Perfect)可以在 CP 与 Perfect 之间自由选择。 + + 只在反向求解的上下界里用:用户没给 CP/Perfect 拆分时,这个选择会影响达成率 + (绝赞的 CP 奖励 100、Perfect 只有 75/50)。 + """ + if judge == "cp": + # 「其余音符」这一档:既可以是 Critical Perfect,也可以是 Perfect + # (Perfect 只能落在绝赞音符上,交给流自己挑) + opts = (tuple(GROUP_OPTIONS.get(group, {}).get("cp", ())) + + tuple(GROUP_OPTIONS.get(group, {}).get("perfect", ()))) + else: + opts = GROUP_OPTIONS.get(group, {}).get(judge) + if not opts: + return None + values = [_cost(th, loss, bonus) for loss, bonus in opts] + return min(values) if want_max else max(values) + + +# ------------------------------------------------------------------ 最小费用流 +class _MinCostFlow: + """连续最短路 + 整条增广(边数很少,够快)。""" + + def __init__(self, n: int) -> None: + self.n = n + self.graph: List[List[List[int]]] = [[] for _ in range(n)] + + def add_edge(self, u: int, v: int, cap: int, cost: int) -> None: + if cap <= 0: + return + self.graph[u].append([v, cap, cost, len(self.graph[v])]) + self.graph[v].append([u, 0, -cost, len(self.graph[u]) - 1]) + + def min_cost(self, s: int, t: int, need: int) -> Optional[int]: + if need <= 0: + return 0 + total = 0 + flow = 0 + INF = float("inf") + while flow < need: + dist = [INF] * self.n + prev_v = [-1] * self.n + prev_e = [-1] * self.n + in_queue = [False] * self.n + dist[s] = 0 + queue = deque([s]) + in_queue[s] = True + while queue: + u = queue.popleft() + in_queue[u] = False + du = dist[u] + for i, edge in enumerate(self.graph[u]): + v, cap, cost, _rev = edge + if cap > 0 and du + cost < dist[v]: + dist[v] = du + cost + prev_v[v] = u + prev_e[v] = i + if not in_queue[v]: + in_queue[v] = True + queue.append(v) + if dist[t] == INF: + return None + add = need - flow + v = t + while v != s: + add = min(add, self.graph[prev_v[v]][prev_e[v]][1]) + v = prev_v[v] + v = t + while v != s: + edge = self.graph[prev_v[v]][prev_e[v]] + edge[1] -= add + self.graph[v][edge[3]][1] += add + v = prev_v[v] + flow += add + total += add * dist[t] + return total + + +def _extreme(th: Theory, judges: Dict[str, int], want_max: bool, + flex_good: bool = False) -> Optional[Fraction]: + """在给定各判定总数的前提下,求达成率的极大值 / 极小值。 + + flex_good=True 时,「good」这一类里的 CP/Perfect 拆分由流自己挑 + (反向求解用:只给 Miss/Good/Great 时,CP 与 Perfect 的拆分是自由的)。 + """ + total = sum(int(judges.get(j, 0)) for j in JUDGMENTS) + if total != th.total_notes: + raise ValueError("判定总数 %d 与谱面音符数 %d 不一致" % (total, th.total_notes)) + if total == 0: + return Fraction(0) + groups = [g for g in GROUP_ORDER if th.counts.get(g, 0)] + src = 0 + gbase = 1 + jbase = gbase + len(groups) + sink = jbase + len(JUDGMENTS) + mcf = _MinCostFlow(sink + 1) + for i, group in enumerate(groups): + mcf.add_edge(src, gbase + i, th.counts[group], 0) + for j, judge in enumerate(JUDGMENTS): + count = int(judges.get(judge, 0)) + if count: + mcf.add_edge(jbase + j, sink, count, 0) + for i, group in enumerate(groups): + for j, judge in enumerate(JUDGMENTS): + count = int(judges.get(judge, 0)) + if not count: + continue + cost = (_flex_edge_cost(th, group, judge, want_max) if flex_good + else _edge_cost(th, group, judge, want_max)) + if cost is None: + continue # 该判定不可能落在这类音符上:不连边,交给流去判定可行性 + # 最小费用流只会最小化总代价:求最小达成率时把代价取负,最后再翻回来。 + mcf.add_edge(gbase + i, jbase + j, min(th.counts[group], count), + cost if want_max else -cost) + total_cost = mcf.min_cost(src, sink, total) + if total_cost is None: + return None + if not want_max: + total_cost = -total_cost + return Fraction(100) - Fraction(total_cost, th.denominator) + + +def achievement_bounds(th: Theory, judges: Dict[str, int]) -> Tuple[Fraction, Fraction]: + """返回 (达成率下界, 达成率上界)。""" + lo = _extreme(th, judges, want_max=False) + hi = _extreme(th, judges, want_max=True) + if lo is None or hi is None: + raise ValueError("这些判定数无法落在该谱面上(例如把 Perfect 给了 EX 音符)") + return lo, hi + + +def reference_achievement(th: Theory, judges: Dict[str, int]) -> Fraction: + """按比例分摊的参考达成率:各判定按总数比例摊到每个音符组,档位取中值。""" + n = th.total_notes + if n == 0: + return Fraction(0) + loss = Fraction(0) + bonus = Fraction(0) + for group in GROUP_ORDER: + count = th.counts.get(group, 0) + if not count: + continue + for judge in JUDGMENTS: + given = int(judges.get(judge, 0)) + if not given: + continue + opts = GROUP_OPTIONS[group].get(judge) + if not opts: + continue + losses = sorted({o[0] for o in opts}) + bonuses = sorted({o[1] for o in opts}) + share = Fraction(given * count, n) + loss += share * Fraction(losses[0] + losses[-1], 2) + bonus += share * Fraction(bonuses[0] + bonuses[-1], 2) + result = Fraction(100) - 100 * loss / th.all_perfect_score + if th.break_bonus_score: + result += bonus / th.break_bonus_score + return result + + +def dx_score(judges: Dict[str, int]) -> int: + return sum(DX_PER[j] * int(judges.get(j, 0)) for j in JUDGMENTS) + + +# ------------------------------------------------------------------ 正向 +@dataclass +class ForwardResult: + theory: Theory + judges: Dict[str, int] + achievement_min: Fraction + achievement_max: Fraction + achievement_ref: Fraction + dx: int + + @property + def exact(self) -> bool: + return self.achievement_min == self.achievement_max + + @property + def achievement(self) -> Fraction: + return self.achievement_min + + +def solve_forward(th: Theory, judges: Dict[str, int]) -> ForwardResult: + clean = {j: int(judges.get(j, 0) or 0) for j in JUDGMENTS} + total = sum(clean.values()) + if total != th.total_notes: + raise ValueError("判定总数 %d 与谱面音符数 %d 不一致" % (total, th.total_notes)) + lo, hi = achievement_bounds(th, clean) + return ForwardResult( + theory=th, + judges=clean, + achievement_min=lo, + achievement_max=hi, + achievement_ref=reference_achievement(th, clean), + dx=dx_score(clean), + ) + + +# ------------------------------------------------------------------ 反向:工具函数 +def _judges_for(th: Theory, miss: int, good: int, great: int, + cp: int, perfect: int) -> Dict[str, int]: + return {"cp": cp, "perfect": perfect, "great": great, "good": good, "miss": miss} + + +def _best(th: Theory, miss: int, good: int, great: int) -> Optional[Fraction]: + """给定 Miss/Good/Great 数时,达成率能达到的最大值(CP/Perfect 拆分自由)。""" + s = th.total_notes - miss - good - great + if s < 0: + return None + return _extreme(th, _judges_for(th, miss, good, great, s, 0), want_max=True, + flex_good=True) + + +def _worst(th: Theory, miss: int, good: int, great: int) -> Optional[Fraction]: + """给定 Miss/Good/Great 数时,达成率能达到的最小值(CP/Perfect 拆分自由)。""" + s = th.total_notes - miss - good - great + if s < 0: + return None + return _extreme(th, _judges_for(th, miss, good, great, s, 0), want_max=False, + flex_good=True) + + +def _max_true(lo: int, hi: int, pred) -> int: + """pred 在 [lo, hi] 上单调不增时,求满足 pred 的最大值(无则返回 lo-1)。""" + best = lo - 1 + while lo <= hi: + mid = (lo + hi) // 2 + if pred(mid): + best = mid + lo = mid + 1 + else: + hi = mid - 1 + return best + + +def _min_true(lo: int, hi: int, pred) -> int: + """pred 在 [lo, hi] 上单调不增时,求满足 pred 的最小值(无则返回 hi+1)。""" + best = hi + 1 + while lo <= hi: + mid = (lo + hi) // 2 + if pred(mid): + best = mid + hi = mid - 1 + else: + lo = mid + 1 + return best + + +# ------------------------------------------------------------------ 反向:候选行 +def _candidate_rows(th: Theory, target: Fraction, max_scan: int, + bad_limit: Optional[int] = None, stats: Optional[Dict] = None): + """生成「可能覆盖 target」的 (miss, good, great) 组合,顺序由少到多。 + + bad_limit 非空时只产出 Miss+Good+Great <= bad_limit 的组合, + 并在 stats["clipped"] 里记下「还有更多(坏音符更多的)组合没产出」。 + """ + n = th.total_notes + scanned = 0 + + def best_m(m): + v = _best(th, m, 0, 0) + return v is not None and v >= target + + miss_max = _max_true(0, n, best_m) + if miss_max < 0: + return + if bad_limit is not None and miss_max > bad_limit and stats is not None: + stats["clipped"] = True + miss_hi = miss_max if bad_limit is None else min(miss_max, bad_limit) + for miss in range(miss_hi + 1): + def best_g(g): + v = _best(th, miss, g, 0) + return v is not None and v >= target + + good_max = _max_true(0, n - miss, best_g) + if good_max < 0: + continue + if bad_limit is not None and good_max > bad_limit - miss: + if stats is not None: + stats["clipped"] = True + good_max = bad_limit - miss + for good in range(good_max + 1): + room = n - miss - good + + def best_gr(gr): + v = _best(th, miss, good, gr) + return v is not None and v >= target + + def worst_gr(gr): + v = _worst(th, miss, good, gr) + return v is not None and v <= target + + # 先求「Great 放得下」的最大个数:超过它整行不可行(例如 EX 音符不能判 Great), + # 否则不可行的高端会让 worst_gr 不再单调,二分就会找错。 + feasible_hi = _max_true(0, room, lambda gr: _worst(th, miss, good, gr) is not None) + if feasible_hi < 0: + continue + g_high = _max_true(0, feasible_hi, best_gr) + g_low = _min_true(0, feasible_hi, worst_gr) + if bad_limit is not None and g_high > bad_limit - miss - good: + if stats is not None: + stats["clipped"] = True + g_high = bad_limit - miss - good + if g_low > g_high: + continue + for great in range(g_low, g_high + 1): + scanned += 1 + if scanned > max_scan: + if stats is not None: + stats["clipped"] = True + return + yield miss, good, great + + +@dataclass +class ReverseRow: + miss: int + good: int + great: int + cp_plus_perfect: int + cp_min: int + cp_max: int + achievement_min: Fraction + achievement_max: Fraction + dx_min: int = 0 + dx_max: int = 0 + verified: bool = True # 精确模式下是否已严格验证 + exact: bool = False # 是否恰好等于目标达成率 + + def summary(self) -> str: + return ("miss %d · good %d · great %d · cp+perfect %d" + % (self.miss, self.good, self.great, self.cp_plus_perfect)) + + +def _row_dx_bounds(th: Theory, row: ReverseRow) -> None: + base = 2 * row.cp_plus_perfect + row.great + row.dx_min = base + row.cp_min + row.dx_max = base + row.cp_max + + +def _cp_bounds_simple(th: Theory, s: int) -> Tuple[int, int]: + """cp 的物理取值范围:Perfect 只能出现在 Break / Break(长按判定) 上。""" + cp_min = max(0, s - th.perfect_capacity) + return cp_min, s + + +# ------------------------------------------------------------------ 反向:区间模式 +def solve_reverse_range(th: Theory, target: Fraction, limit: int = 300, + max_scan: int = 200000) -> Dict[str, object]: + rows: List[ReverseRow] = [] + scanned = 0 + truncated = False + for miss, good, great in _candidate_rows(th, target, max_scan): + scanned += 1 + lo = _worst(th, miss, good, great) + hi = _best(th, miss, good, great) + if lo is None or hi is None: + continue + s = th.total_notes - miss - good - great + cp_min, cp_max = _cp_bounds_simple(th, s) + row = ReverseRow( + miss=miss, good=good, great=great, cp_plus_perfect=s, + cp_min=cp_min, cp_max=cp_max, + achievement_min=lo, achievement_max=hi, + ) + _row_dx_bounds(th, row) + rows.append(row) + if len(rows) >= limit: + truncated = True + break + return { + "target": target, + "mode": "range", + "theory": th, + "rows": rows, + "truncated": truncated, + "scanned": scanned, + "exact_cap": EXACT_MAX_BAD, + "unverified": 0, + } + + +# ------------------------------------------------------------------ 反向:精确模式 +def _bad_options(group: str) -> Dict[str, Tuple[Tuple[int, int], ...]]: + return {j: GROUP_OPTIONS[group][j] for j in _BAD_JUDGMENTS + if j in GROUP_OPTIONS[group]} + + +def _dx_delta(th: Theory, loss: int, bonus: int) -> int: + """一个坏音符对「目标量 X」的贡献;X = rhs 等价于达成率等于目标。""" + if th.break_bonus_score: + return 100 * th.break_bonus_score * loss - th.all_perfect_score * bonus + return 100 * loss + + +def _exact_suffix(th: Theory, groups: List[str]) -> List[Tuple[int, int, int, int]]: + """每个后缀(含自身)的 (dX 负向下界, dX 上界, slo 上界, bad 上界),用于剪枝。 + + 注意 dX 下界必须按「最多只有这些音符是坏音符、也可以是 0 个」来算, + 所以只累计负的贡献(min(0, ...)·个数),否则会错误剪掉合法解。 + """ + suf: List[Tuple[int, int, int, int]] = [(0, 0, 0, 0)] * (len(groups) + 1) + for i in range(len(groups) - 1, -1, -1): + group = groups[i] + n = th.counts.get(group, 0) + opts = _bad_options(group) + dxs = [_dx_delta(th, dl, db) + for choices in opts.values() for dl, db in choices] + d_bad, d_slo = _GROUP_BAD_STEP.get(group, (0, 0)) + pl, ph, ps, pn = suf[i + 1] + suf[i] = (pl + n * min(0, min(dxs)), ph + n * max(dxs), ps + n * d_slo, + pn + n * (1 if d_bad else 0)) + return suf + + +def _exact_table(th: Theory, max_bad: int, rhs: Fraction, + set_cap: int = 20000, total_cap: int = 1500000, + deadline: Optional[float] = None): + """DP:状态 (miss, good, great) -> 可达的 (X, bad_b, slo) 集合。 + + X = 100*B*失分和 - W*坏音符奖励分(无绝赞时 X = 100*失分和),与达成率一一对应; + 只统计「坏音符」(Great/Good/Miss);绝赞音符里 cp/perfect 的奖励分用 slo 还原成区间。 + 每个候选元组都用「剩余音符最大/最小贡献」做目标剪枝(只对当前 rhs 有效)。 + 超出预算时返回 None(调用方退回区间判定)。 + """ + groups = [g for g in GROUP_ORDER if th.counts.get(g, 0) and _bad_options(g)] + suf = _exact_suffix(th, groups) + w = th.all_perfect_score + b = th.break_bonus_score + klo_const = (2 * th.counts.get(GROUP_BREAK, 0) + + 3 * th.counts.get(GROUP_BREAK_FLAT, 0) + + 4 * th.counts.get(GROUP_BREAK_SLIDE, 0) + + 4 * th.counts.get(GROUP_EX_BREAK, 0)) + break_notes = sum(th.counts.get(g, 0) for g in _GROUP_BAD_STEP) + + table: Dict[Tuple[int, int, int], set] = {(0, 0, 0): {(0, 0, 0)}} + for gi, group in enumerate(groups): + n = th.counts.get(group, 0) + opts = _bad_options(group) + d_bad, d_slo = _GROUP_BAD_STEP.get(group, (0, 0)) + deltas = {j: [_dx_delta(th, dl, db) for dl, db in choices] + for j, choices in opts.items()} + dxmin_g = min(v for vs in deltas.values() for v in vs) + dxmax_g = max(v for vs in deltas.values() for v in vs) + rest_after = suf[gi + 1] + slots = min(n, max_bad) + for k in range(slots): + if deadline is not None and time.monotonic() > deadline: + return None + new: Dict[Tuple[int, int, int], set] = {} + for state, tset in table.items(): + carried = new.setdefault(state, set()) + carried.update(tset) + if len(carried) > set_cap: + return None + if state[0] + state[1] + state[2] >= max_bad: + continue + for judge, vs in deltas.items(): + nxt = (state[0] + (1 if judge == "miss" else 0), + state[1] + (1 if judge == "good" else 0), + state[2] + (1 if judge == "great" else 0)) + bucket = new.setdefault(nxt, set()) + rem = min(slots - k - 1, max_bad - sum(nxt)) + dmin = rem * min(0, dxmin_g) + rest_after[0] + dmax = rem * dxmax_g + rest_after[1] + slomax = rem * d_slo + rest_after[2] + if b: + need_lo = rhs + 25 * w * (klo_const - slomax) + need_hi = rhs + 25 * w * 4 * break_notes + for x, bad_b, slo in tset: + cbb = bad_b + d_bad + cs = slo + d_slo + for dx in vs: + cx = x + dx + if b: + if cx + dmax < need_lo - 25 * w * cs: + continue + if cx + dmin > need_hi - 25 * w * 4 * cbb: + continue + else: + if cx + dmax < rhs or cx + dmin > rhs: + continue + bucket.add((cx, cbb, cs)) + if len(bucket) > set_cap: + return None + table = new + if sum(len(s) for s in table.values()) > total_cap: + return None + return table + + +def _exact_row_ok(th: Theory, table, miss: int, good: int, great: int, + rhs: Fraction) -> bool: + """严格判定:能否把 (miss, good, great) 判定恰好打出达成率 target。""" + tset = table.get((miss, good, great)) + if not tset: + return False + w = th.all_perfect_score + b = th.break_bonus_score + klo_const = (2 * th.counts.get(GROUP_BREAK, 0) + + 3 * th.counts.get(GROUP_BREAK_FLAT, 0) + + 4 * th.counts.get(GROUP_BREAK_SLIDE, 0) + + 4 * th.counts.get(GROUP_EX_BREAK, 0)) + break_notes = sum(th.counts.get(g, 0) for g in _GROUP_BAD_STEP) + if not b: + # 没有绝赞:达成率只由失分决定,100 * 失分和 == 分母 * (100 - 目标) + for x, _bad_b, _slo in tset: + if x == rhs: + return True + return False + for x, bad_b, slo in tset: + num = x - rhs + if num % (25 * w): + continue + k = num // (25 * w) + if klo_const - slo <= k <= 4 * (break_notes - bad_b): + return True + return False + + +def solve_reverse_exact(th: Theory, target: Fraction, limit: int = 300, + max_scan: int = 200000) -> Dict[str, object]: + d = th.denominator + rhs = Fraction(d) * (100 - target) + note = None + table = None + max_bad_used = 0 + pending: List[Tuple[int, int, int]] = [] + scanned = 0 + + if rhs.denominator != 1: + note = ("该达成率不是本谱面能精确打出的值(达成率只能取 100 - 整数/分母 的形式)," + "下面给出区间判定结果。") + elif th.lattice and rhs.numerator % th.lattice: + note = ("该达成率不满足谱面的失分格点(失分只能是 %d 的倍数)," + "下面给出区间判定结果。" % th.lattice) + stats: Dict[str, bool] = {} + if note is None: + # 只枚举「坏音符不超过 EXACT_MAX_BAD」的组合:超出的组合没法精确验证, + # 全枚举它们会白花很多时间(大谱面可能有几十万组)。 + for miss, good, great in _candidate_rows(th, target, max_scan, + EXACT_MAX_BAD, stats): + scanned += 1 + pending.append((miss, good, great)) + if pending: + max_bad_used = min(max(sum(p) for p in pending), EXACT_MAX_BAD) + table = _exact_table(th, max_bad_used, rhs, + deadline=time.monotonic() + EXACT_TIME_BUDGET) + if table is None: + note = ("判定数较大,超出精确验证的预算(最多 %d 个 Miss+Good+Great)," + "下面给出区间判定结果。" % EXACT_MAX_BAD) + if note is not None: + fallback = solve_reverse_range(th, target, limit=limit, max_scan=max_scan) + fallback["note"] = note + fallback["exact_cap"] = max_bad_used or EXACT_MAX_BAD + fallback["more_unverified"] = bool(stats.get("clipped")) + return fallback + + if not pending and stats.get("clipped"): + # 一组候选都没法精确验证(坏音符都超过上限),退回区间模式并说明原因 + fallback = solve_reverse_range(th, target, limit=limit, max_scan=max_scan) + fallback["note"] = ("这个谱面/达成率下,坏音符(Miss+Good+Great)超过 %d 个的组合太多," + "精确验证代价过高,下面给出区间模式结果。" % EXACT_MAX_BAD) + fallback["exact_cap"] = EXACT_MAX_BAD + fallback["more_unverified"] = True + return fallback + + rows: List[ReverseRow] = [] + truncated = False + unverified = 0 + for miss, good, great in pending: + bad = miss + good + great + if bad > max_bad_used: + unverified += 1 + continue + if not _exact_row_ok(th, table, miss, good, great, rhs): + continue + s = th.total_notes - bad + cp_min, cp_max = _cp_bounds_simple(th, s) + row = ReverseRow( + miss=miss, good=good, great=great, cp_plus_perfect=s, + cp_min=cp_min, cp_max=cp_max, + achievement_min=target, achievement_max=target, + verified=True, exact=True, + ) + _row_dx_bounds(th, row) + rows.append(row) + if len(rows) >= limit: + truncated = True + break + return { + "target": target, + "mode": "exact", + "theory": th, + "rows": rows, + "truncated": truncated, + "scanned": scanned, + "exact_cap": max_bad_used, + "unverified": unverified, + "more_unverified": bool(stats.get("clipped")), + "note": None, + } + + +def solve_reverse(th: Theory, target: Fraction, limit: int = 300, + max_scan: int = 200000, mode: str = "exact") -> Dict[str, object]: + target = Fraction(target) + if target > th.max_achievement: + raise ValueError("目标达成率 %s 超过该谱面上限 %s" + % (target, th.max_achievement)) + if mode == "range": + return solve_reverse_range(th, target, limit=limit, max_scan=max_scan) + if mode != "exact": + raise ValueError("mode 只能是 exact 或 range") + return solve_reverse_exact(th, target, limit=limit, max_scan=max_scan)