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
-1
View File
@@ -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"}
+30
View File
@@ -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
```
---
+135
View File
@@ -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 <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 个游戏谱面实测一致)。
@@ -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())
+366
View File
@@ -0,0 +1,366 @@
# -*- coding: utf-8 -*-
"""舞萌DX (maimai DX) .ma2 谱面解析。
指令 -> 音符类型 的对应表来自反编译源码
source/Assembly-CSharp/Manager/Ma2fileRecordID.cs
(s_Ma2fileRecord_Data 里 Ma2Category.MA2_Note 的 102 条记录)
计分类别的划分复刻
source/Assembly-CSharp/Manager/GamePlayManager.cs NoteType2ScoreType()
理论分权重来自
source/Assembly-CSharp/Manager/NotesReader.cs calcTotal()
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Dict, List, Optional
# ---------------------------------------------------------------- 计分权重
WEIGHT_TAP = 500 # TAP / XTP / STR / XST / TTP(Touch 与 Tap 同为 500)
WEIGHT_HOLD = 1000 # HLD / XHO / THO
WEIGHT_SLIDE = 1500 # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_
WEIGHT_BREAK = 2500 # BRK / BST / BRTAP / BRHLD / BRSTR / BRSI_ ... 以及 ExBreak 系
BREAK_BONUS_THEORY = 100 # 每个绝赞(Break)的理论奖励分
CATEGORY_TAP = "Tap"
CATEGORY_HOLD = "Hold"
CATEGORY_SLIDE = "Slide"
CATEGORY_BREAK = "Break"
CATEGORY_CONNECT = "ConnectSlide"
CATEGORY_ORDER = (CATEGORY_TAP, CATEGORY_HOLD, CATEGORY_SLIDE, CATEGORY_BREAK)
CATEGORY_WEIGHT = {
CATEGORY_TAP: WEIGHT_TAP,
CATEGORY_HOLD: WEIGHT_HOLD,
CATEGORY_SLIDE: WEIGHT_SLIDE,
CATEGORY_BREAK: WEIGHT_BREAK,
}
CATEGORY_LABEL = {
CATEGORY_TAP: "Tap/Touch",
CATEGORY_HOLD: "Hold",
CATEGORY_SLIDE: "Slide",
CATEGORY_BREAK: "Break",
}
# ------------------------------------------------- ma2 指令 -> NotesTypeID.Def
NOTE_COMMAND_TYPES: Dict[str, str] = {
# --- 现行指令 ---
"TAP": "Begin", "BRK": "Break", "XTP": "ExTap", "HLD": "Hold", "XHO": "ExHold",
"STR": "Star", "BST": "BreakStar", "XST": "ExStar",
"TTP": "TouchTap", "THO": "TouchHold",
"SI_": "Slide", "SCL": "Slide", "SCR": "Slide", "SUL": "Slide", "SUR": "Slide",
"SSL": "Slide", "SSR": "Slide", "SV_": "Slide", "SXL": "Slide", "SXR": "Slide",
"SLL": "Slide", "SLR": "Slide", "SF_": "Slide",
# --- 旧式别名(游戏数据里真实存在)---
"NMTAP": "Begin", "BRTAP": "Break", "EXTAP": "ExTap", "BXTAP": "ExBreakTap",
"NMHLD": "Hold", "BRHLD": "BreakHold", "EXHLD": "ExHold", "BXHLD": "ExBreakHold",
"NMSTR": "Star", "BRSTR": "BreakStar", "EXSTR": "ExStar", "BXSTR": "ExBreakStar",
"NMTTP": "TouchTap", "NMTHO": "TouchHold",
"NMSI_": "Slide", "BRSI_": "BreakSlide", "EXSI_": "ExSlide", "BXSI_": "ExBreakSlide", "CNSI_": "ConnectSlide",
"NMSCL": "Slide", "BRSCL": "BreakSlide", "EXSCL": "ExSlide", "BXSCL": "ExBreakSlide", "CNSCL": "ConnectSlide",
"NMSCR": "Slide", "BRSCR": "BreakSlide", "EXSCR": "ExSlide", "BXSCR": "ExBreakSlide", "CNSCR": "ConnectSlide",
"NMSUL": "Slide", "BRSUL": "BreakSlide", "EXSUL": "ExSlide", "BXSUL": "ExBreakSlide", "CNSUL": "ConnectSlide",
"NMSUR": "Slide", "BRSUR": "BreakSlide", "EXSUR": "ExSlide", "BXSUR": "ExBreakSlide", "CNSUR": "ConnectSlide",
"NMSSL": "Slide", "BRSSL": "BreakSlide", "EXSSL": "ExSlide", "BXSSL": "ExBreakSlide", "CNSSL": "ConnectSlide",
"NMSSR": "Slide", "BRSSR": "BreakSlide", "EXSSR": "ExSlide", "BXSSR": "ExBreakSlide", "CNSSR": "ConnectSlide",
"NMSV_": "Slide", "BRSV_": "BreakSlide", "EXSV_": "ExSlide", "BXSV_": "ExBreakSlide", "CNSV_": "ConnectSlide",
"NMSXL": "Slide", "BRSXL": "BreakSlide", "EXSXL": "ExSlide", "BXSXL": "ExBreakSlide", "CNSXL": "ConnectSlide",
"NMSXR": "Slide", "BRSXR": "BreakSlide", "EXSXR": "ExSlide", "BXSXR": "ExBreakSlide", "CNSXR": "ConnectSlide",
"NMSLL": "Slide", "BRSLL": "BreakSlide", "EXSLL": "ExSlide", "BXSLL": "ExBreakSlide", "CNSLL": "ConnectSlide",
"NMSLR": "Slide", "BRSLR": "BreakSlide", "EXSLR": "ExSlide", "BXSLR": "ExBreakSlide", "CNSLR": "ConnectSlide",
"NMSF_": "Slide", "BRSF_": "BreakSlide", "EXSF_": "ExSlide", "BXSF_": "ExBreakSlide", "CNSF_": "ConnectSlide",
}
_BREAK_DEFS = frozenset((
"Break", "BreakHold", "BreakStar", "BreakSlide",
"ExBreakTap", "ExBreakHold", "ExBreakStar", "ExBreakSlide",
))
_SLIDE_DEFS = frozenset(("Slide", "BreakSlide", "ExSlide", "ExBreakSlide"))
_HOLD_DEFS = frozenset(("Hold", "BreakHold", "ExHold", "ExBreakHold", "TouchHold"))
_TOUCH_DEFS = frozenset(("TouchTap", "TouchHold"))
_CONNECT_DEFS = frozenset(("ConnectSlide",))
# 判定时机只有 Critical / TooLate 的 EX 音符(ExTap/ExStar/ExBreakTap/ExBreakStar)
EX_DEFS = frozenset(("ExTap", "ExStar", "ExBreakTap", "ExBreakStar"))
# ---------------------------------------------------------------- 音符组
# 「音符组」= 计分行为完全相同的一类音符:权重、可选判定、失分档位、绝赞奖励档位都一样。
# 划分依据(反编译源码):
# * 计分类型 NoteScore.EScoreType 由 GamePlayManager.NoteType2ScoreType() 决定
# (Break 系 -> Break,滑条 -> Slide,长按 -> Hold,其余 -> Tap/Touch)
# * 判定时机表 NoteJudge.EJudgeType 由各音符类决定:
# TapNote/StarNote -> Tap(或 ExNote 时 ExTap)
# HoldNote/BreakHoldNote/TouchHoldC -> 长按结算 JudgeHoldTotal(不分 2nd/3rd 档)
# SlideRoot/SlideFan -> SlideOut(不分 2nd/3rd 档,也没有非 Critical 的 Perfect)
# BreakNote/BreakStarNote -> Break 表(Great 有 1250/1500/2000 三档)
# * ExTap 判定表 [-9,-9,-9,-9,-9,-9,-9,0,9,9,9,9,9,9,9] 使非 Critical 的档位全部不可达
# -> ExTap/ExStar(含 ExBreak 版)只能 Critical 或 Miss
# * ExHold/ExBreakHold 的头部用 ExTap 表,但结果还要过 JudgeHoldTotal 重映射,
# 最终仍可得到 Critical/Perfect/Great/Good/Miss -> 与普通 Hold / BreakHold 相同
# * ExSlide / ExBreakSlide 的 JudgeType 恒为 SlideOut,且 isBreak() 不含 ExBreakSlide
# -> 计分与普通 Slide 完全一致(游戏数据里这两种指令出现次数为 0)
GROUP_TAP = "tap" # TAP NMTAP STR NMSTR TTP NMTTP 权重 500
GROUP_HOLD = "hold" # HLD NMHLD XHO EXHLD THO NMTHO 权重 1000
GROUP_SLIDE = "slide" # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_ + NM/EX/CN 变体 权重 1500
GROUP_BREAK = "break" # BRK BRTAP BST BRSTR 权重 2500,Great 三档、Perfect 奖励两档
GROUP_BREAK_FLAT = "break_flat" # BRHLD BXHLD 权重 2500,长按判定,不分档
GROUP_BREAK_SLIDE = "break_slide" # BRSI_ BRSCL BRSCR BRSUL BRSUR BRSSL BRSSR BRSV_ BRSXL BRSXR BRSLL BRSLR BRSF_
GROUP_EX = "ex" # XTP EXTAP XST EXSTR 权重 500,只能 Critical / Miss
GROUP_EX_BREAK = "ex_break" # BXTAP BXSTR 权重 2500,只能 Critical / Miss
GROUP_ORDER = (GROUP_TAP, GROUP_HOLD, GROUP_SLIDE, GROUP_BREAK,
GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX, GROUP_EX_BREAK)
GROUP_LABEL = {
GROUP_TAP: "Tap/Star/Touch",
GROUP_HOLD: "Hold",
GROUP_SLIDE: "Slide",
GROUP_BREAK: "Break",
GROUP_BREAK_FLAT: "Break(长按判定)",
GROUP_BREAK_SLIDE: "Break(滑条判定)",
GROUP_EX: "EX Tap/Star",
GROUP_EX_BREAK: "EX Break",
}
GROUP_WEIGHT = {
GROUP_TAP: WEIGHT_TAP,
GROUP_HOLD: WEIGHT_HOLD,
GROUP_SLIDE: WEIGHT_SLIDE,
GROUP_BREAK: WEIGHT_BREAK,
GROUP_BREAK_FLAT: WEIGHT_BREAK,
GROUP_BREAK_SLIDE: WEIGHT_BREAK,
GROUP_EX: WEIGHT_TAP,
GROUP_EX_BREAK: WEIGHT_BREAK,
}
GROUP_IS_BREAK = frozenset((GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK))
_GROUP_BY_DEF = {
"Begin": GROUP_TAP, "Star": GROUP_TAP, "TouchTap": GROUP_TAP,
"Hold": GROUP_HOLD, "ExHold": GROUP_HOLD, "TouchHold": GROUP_HOLD,
"Slide": GROUP_SLIDE, "ExSlide": GROUP_SLIDE, "ExBreakSlide": GROUP_SLIDE,
"Break": GROUP_BREAK, "BreakStar": GROUP_BREAK,
"BreakHold": GROUP_BREAK_FLAT, "ExBreakHold": GROUP_BREAK_FLAT,
"BreakSlide": GROUP_BREAK_SLIDE,
"ExTap": GROUP_EX, "ExStar": GROUP_EX,
"ExBreakTap": GROUP_EX_BREAK, "ExBreakStar": GROUP_EX_BREAK,
}
_CATEGORY_BY_GROUP = {
GROUP_TAP: CATEGORY_TAP, GROUP_HOLD: CATEGORY_HOLD, GROUP_SLIDE: CATEGORY_SLIDE,
GROUP_BREAK: CATEGORY_BREAK, GROUP_BREAK_FLAT: CATEGORY_BREAK,
GROUP_BREAK_SLIDE: CATEGORY_BREAK, GROUP_EX: CATEGORY_TAP, GROUP_EX_BREAK: CATEGORY_BREAK,
}
def note_group(def_name: str) -> Optional[str]:
"""NotesTypeID.Def 名 -> 音符组;接续滑条等不计分音符返回 None。"""
return _GROUP_BY_DEF.get(def_name)
def score_category(def_name: str) -> Optional[str]:
"""复刻 GamePlayManager.NoteType2ScoreType() 的粗分类(Tap/Hold/Slide/Break)。"""
grp = note_group(def_name)
if grp is None:
return None
return _CATEGORY_BY_GROUP[grp]
# ------------------------------------------------------------------ 谱面对象
@dataclass
class Chart:
path: str = "<memory>"
command_counts: Dict[str, int] = field(default_factory=dict)
category_counts: Dict[str, int] = field(default_factory=dict)
group_counts: Dict[str, int] = field(default_factory=dict)
connect_count: int = 0
header: Dict[str, str] = field(default_factory=dict)
totals: Dict[str, int] = field(default_factory=dict)
warnings: List[str] = field(default_factory=list)
# 外部补充的信息(游戏路径模式)
music_id: Optional[int] = None
difficulty_index: Optional[int] = None
difficulty_name: Optional[str] = None
song_name: Optional[str] = None
level_text: Optional[str] = None
max_notes_xml: Optional[int] = None
@property
def total_notes(self) -> int:
"""计分音符总数 N(不含接续滑条)。"""
return sum(self.group_counts.get(g, 0) for g in GROUP_ORDER)
@property
def declared_total(self) -> Optional[int]:
return self.totals.get("T_NUM_ALL")
def counts(self) -> Dict[str, int]:
"""按音符组统计(供计分模型使用)。"""
return {g: self.group_counts.get(g, 0) for g in GROUP_ORDER}
def category_summary(self) -> Dict[str, int]:
return {c: self.category_counts.get(c, 0) for c in CATEGORY_ORDER}
def parse_ma2(path: str) -> Chart:
with open(path, "rb") as fp:
raw = fp.read()
chart = parse_ma2_bytes(raw, path)
return chart
def parse_ma2_bytes(raw: bytes, path: str = "<memory>") -> Chart:
chart = Chart(path=path)
text = raw.decode("latin1")
unknown = set()
for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
line = line.strip()
if not line:
continue
parts = line.split("\t")
if len(parts) == 1:
parts = line.split()
if not parts:
continue
cmd = parts[0]
args = parts[1:]
def_name = NOTE_COMMAND_TYPES.get(cmd)
if def_name is not None:
chart.command_counts[cmd] = chart.command_counts.get(cmd, 0) + 1
grp = note_group(def_name)
if grp is None:
chart.connect_count += 1
else:
chart.group_counts[grp] = chart.group_counts.get(grp, 0) + 1
cat = score_category(def_name)
chart.category_counts[cat] = chart.category_counts.get(cat, 0) + 1
continue
if cmd.startswith("T_") or cmd.startswith("TTM_"):
if args:
try:
chart.totals[cmd] = int(args[0])
except ValueError:
pass
continue
if cmd not in chart.header and args:
chart.header[cmd] = "\t".join(args)
elif not args and cmd.isupper() and len(cmd) > 2:
unknown.add(cmd)
declared = chart.declared_total
if declared is not None and declared != chart.total_notes:
chart.warnings.append(
"T_NUM_ALL=%d 与解析出的计分音符数 %d 不一致" % (declared, chart.total_notes)
)
return chart
# ------------------------------------------------------------ 游戏路径解析
DIFFICULTY_NAMES = ("Basic", "Advanced", "Expert", "Master", "Re:Master")
_DIFFICULTY_ALIASES = {
"basic": 0, "bas": 0, "bsc": 0, "0": 0, "00": 0,
"advanced": 1, "adv": 1, "1": 1, "01": 1,
"expert": 2, "exp": 2, "2": 2, "02": 2,
"master": 3, "mas": 3, "mst": 3, "3": 3, "03": 3,
"remaster": 4, "re:master": 4, "remas": 4, "re": 4, "4": 4, "04": 4,
}
def parse_difficulty(text) -> int:
if isinstance(text, int):
if 0 <= text <= 4:
return text
raise ValueError("难度序号必须是 0~4")
key = str(text).strip().lower()
if key in _DIFFICULTY_ALIASES:
return _DIFFICULTY_ALIASES[key]
raise ValueError("无法识别的难度:%s(可用 basic/advanced/expert/master/remaster 或 0~4)" % text)
def find_music_dir(game_path: str) -> str:
"""把「游戏根目录 / Sinmai_Data / StreamingAssets / A000」都归一化到 A000/music。"""
p = os.path.abspath(os.path.expanduser(game_path))
if not os.path.isdir(p):
raise FileNotFoundError("路径不存在:%s" % p)
if os.path.basename(p).lower() == "music" and os.path.basename(os.path.dirname(p)).upper() == "A000":
return p
candidates = [
p,
os.path.join(p, "music"),
os.path.join(p, "A000", "music"),
os.path.join(p, "StreamingAssets", "A000", "music"),
os.path.join(p, "Sinmai_Data", "StreamingAssets", "A000", "music"),
os.path.join(p, "Package", "Sinmai_Data", "StreamingAssets", "A000", "music"),
]
for c in candidates:
if os.path.isdir(c) and os.path.basename(c).lower() == "music":
return c
raise FileNotFoundError("在 %s 下找不到 A000/music 目录" % p)
def music_dir_path(music_dir: str, music_id: int) -> str:
return os.path.join(music_dir, "music%06d" % music_id)
def chart_path(music_dir: str, music_id: int, difficulty: int) -> str:
return os.path.join(music_dir_path(music_dir, music_id), "%06d_%02d.ma2" % (music_id, difficulty))
def parse_music_xml(path: str) -> Dict[str, object]:
"""读取 Music.xml(只用标准库),取出曲名与各难度信息。"""
import xml.etree.ElementTree as ET
info: Dict[str, object] = {"notes": []}
try:
root = ET.parse(path).getroot()
except Exception as exc: # noqa: BLE001
return {"error": str(exc), "notes": []}
def text_of(node, tag):
child = node.find(tag)
if child is None:
return None
if child.text and child.text.strip():
return child.text.strip()
sub = child.find("str")
if sub is not None and sub.text and sub.text.strip():
return sub.text.strip()
return None
info["name"] = text_of(root, "name")
info["artist"] = text_of(root, "artistName")
info["genre"] = text_of(root, "genreName")
info["bpm"] = text_of(root, "bpm")
notes_data = root.find("notesData")
notes = []
if notes_data is not None:
for node in notes_data.findall("Notes"):
f = node.find("file/path")
notes.append({
"file": f.text.strip() if f is not None and f.text else None,
"level": text_of(node, "level"),
"levelDecimal": text_of(node, "levelDecimal"),
"musicLevelID": text_of(node, "musicLevelID"),
"maxNotes": text_of(node, "maxNotes"),
"designer": text_of(node, "notesDesigner"),
"isEnable": text_of(node, "isEnable"),
})
info["notes"] = notes
return info
def level_display(level, level_decimal) -> str:
"""12 + levelDecimal 4 -> '12+';10 + 2 -> '10+';7 + 0 -> '7'。"""
try:
lv = int(level)
except (TypeError, ValueError):
return ""
try:
dec = int(level_decimal or 0)
except (TypeError, ValueError):
dec = 0
if dec >= 3:
return "%d+" % lv
if dec > 0:
return "%d.%d" % (lv, dec)
return "%d" % lv
+846
View File
@@ -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)