Compare commits

..
16 Commits
Author SHA1 Message Date
Txiaochen 61bf36661d docs: 仓库改名 Astral 后同步链接;chart_achievement README 补充谱面查询站点 2026-10-17 17:49:11 +08:00
Txiaochen f6e84cf297 chart_achievement: 正向支持按音符类别给判定数(绝赞单列),算出确切达成率 2026-10-17 17:43:00 +08:00
Txiaochen 25ac522391 v0 plus附加功能
- 新增 unlockmusic.py:解锁(解禁)歌曲 + 解禁 Master / Re:Master 难度,整个流程复用 upsert.py 的 run()
- 买票前置闸门 api/ChargeGuard.py:必须先跑过 GetUserCharge,且同进程只允许买一次
- buy_ticket.py 票源改为 GetUserCharge,不再调用 GetGameCharge
- UpsertUserAll 与买票的冷却统一为 15 秒
- api/ 文件名统一 PascalCase(文件名 = 函数名)
- 项目改名 Astral,README 加 Logo、鸣谢与开发者说明
- 自检 284 项
2026-10-17 17:39:55 +08:00
Txiaochen b1d7e33c7b chart_achievement: --level 改为谱面难度(0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER),反向示例补上难度 2026-10-07 23:37:39 +08:00
Txiaochen 3a0f3435f7 v1进行时:新增谱面达成率小工具;extra.json 不再入库 2026-10-07 23:27:30 +08:00
Txiaochen 938341828c chore: extra*.json 加入 .gitignore(含账号数据,不入库) 2026-10-07 23:27:30 +08:00
Txiaochen c537e00865 docs(README): 补充 isNew* 位串 0/1/2 的含义
依据 VOExtensions.cs:309-364 的 BuildListData():
  0 = 存量更新(旧数据里已有同主键条目)-> UPDATE
  1 = 新增(旧数据里没有)            -> INSERT
  2 = 无变化(只有 userFavoriteList 用)

并写明:对已存在的条目填 1 会导致 HTTP 500(服务端 INSERT 重复记录)。
2026-10-07 23:27:30 +08:00
Txiaochen bc145fe853 特别鸣谢改为超链接 2026-10-07 00:29:24 -04:00
Txiaochen 0c995d3b78 更新 README.md 2026-10-07 00:28:04 -04:00
Txiaochen ebccb9cc9a 跟进了现在的token使用数量 2026-10-07 00:19:02 -04:00
Txiaochen cdbdfb63fa v0版本完成:精简 README、修正转生流程、每包禁止 2 首
1) reincarnate.py 流程修正(按使用方口径)
   登录 -> 等待 -> 上传 -> 登出(type=1) -> 续关登录 -> 等待 -> 上传 -> 登出 -> ...
   - 每包之间**先登出再续关**(原来只在最后登出一次)
   - 登出改用 type=1(LogoutType.Logout)
     真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog);
     续关场景用 4(TestIn)会出问题 —— 那是「测试登入/顶号」的语义
   - 新增 --wait N(默认 5.0 秒,每次上传前的等待)

2) 新增分批器 split_batches:★ 每包恰好 2 首会被服务端拒绝
   - 1 / 3 / 4 首都正常,只有 2 首不行
   - 分批时主动避开:10 首 [4,4,2] -> [4,3,3];6 首 [4,2] -> [3,3];
     5 首 per=3 [3,2] -> [3,1,1]
   - --per-packet 只接受 1/3/4
   - 自检加了 1..299 首 × per∈{1,3,4} 的全量扫描

3) README 精简(24 KB -> 11 KB)
   - 删掉「关于购买功能票(已解决)」(根因分析移到 docs)
   - 删掉「常见坑与注意事项」(移到 docs)
   - 功能票只列 2/3/5 倍票;只简述用票原理
   - 转生只简述「干了什么」
   - netdelivery 只讲用法,不讲运行原理
   - 新增「文档索引」章节

4) 自检 238 -> 249 项全绿
2026-10-07 11:23:30 +08:00
Txiaochen 4959487a71 docs(README): 补全主脚本用法,新增 netdelivery 章节
- 新增「主脚本一览」总表(副作用 / 是否需要二维码)
- upsert.py:独立成章,--extra-data 归位
- buy_ticket.py:补参数表 + 三道安全检查说明
- use_ticket.py:补参数表 + 真机消耗票券的报文依据
- reincarnate.py:补参数表 + 流程/分批/续关/playlog 合成原理
- netdelivery.py:新增章节(ALL.Net DLI,查/下 .app 与 .opt)
- 新增「常见坑与注意事项」(二维码 / 账号状态 / 功能票 / 副作用 /
  日志 / 网络 / 不入库清单)
- 「将来」改为「已完成 / 将来」,列出 v0 全部交付
- 项目框架补上 netdelivery.py 与 download/
2026-10-07 01:22:38 +08:00
Txiaochen 5971ba4e60 调整:Grade 恒为 0(源码实证是段位 Dan,不是曲目评级) 2026-10-07 00:26:19 +08:00
Txiaochen 652cf95faa 重构:常量下沉到 api 模块 + 删无用脚本 + rating/grade 改为运行时推导
1) 删除 5 个无用的取数脚本(连同 __init__ 的导出):
   get_game_event / get_game_ng_music_id / get_game_ranking /
   get_game_tournament_info / get_game_weekly_data

2) config.py 常量下沉(config 只留「机台/店铺标识」和闸门/日志):
   - BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM -> api/maimai_net.py
   - AIMEDB_* / QR_* -> api/chime.py
   - TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
     TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS -> api/upsert_user_chargelog.py
   - PLAY_SPECIAL_NUM -> api/upsert_user_all.py
   - PLAY_CHARACTERS -> selftest.py(作为测试数据)
   下沉后的常量仍从 api 包统一导出,调用点无需改 import 路径。

3) PlayData 字段改造:
   - characters 默认空列表(真值由 derive_play_characters 从快照推)
   - local_play_count 恒为 2
   - after_grade_rank 恒为 1

4) rating / grade 改为运行时从服务器快照取:
   - 新增 derive_play_rating():
       before/afterRating      <- userData.playerRating
       before/afterDeluxRating <- userData.musicRating
     (真机实证 userRatingList[0].rating 是 0,不是这个值)
   - 新增 derive_play_grade() + find_music_detail():
       在 userMusicDetailList 里搜同 (musicId, level) 取 scoreRank
       DERIVE_GRADE_FROM_MUSIC_DETAIL=False 可切回严格复刻真机(一律 0)
     ⚠ 源码实证这三个字段其实是段位 Dan(VOExtensions.cs:1419-1421)

自检 238 项全绿。
2026-10-07 00:17:08 +08:00
Txiaochen 8e3bd6fcaa 重构:Get* 命名统一 + 用户数据落盘 + use_ticket / reincarnate
1) 命名统一(服务端 API 名带 Get 的,脚本名与函数名一律带 get/Get)
   - 26 个 api 脚本改名:userdata.py -> get_user_data.py 等
   - 7 个 game_*.py -> get_game_*.py
   - 38 个函数改名:UserData -> GetUserData 等
   - upsert_snapshot.py -> get_need_user_data.py,
     fetch_upsert_snapshot -> GetNeedUserData
   - user_getter_common.fetch_all_pages -> get_all_pages

2) GetNeedUserData 抓到的数据自动落盘
   - <工程根>/data/<userId>.json,新数据总是覆盖老数据
   - 新增 save_user_data / load_user_data / user_data_path /
     list_saved_user_ids / DATA_DIR / PROJECT_ROOT
   - data/ 已加入 .gitignore

3) use_ticket.py(新)
   - 读取账号当前持有的功能票,用 UpsertUserAll 消耗掉
   - 依据:GetUserChargeApi 与 UpsertUserAllApi 的 userChargeList 格式相同;
     真机实证 userChargeList.stock -1 + useTicketId + playCredit
   - playCredit = 投币数 + 票的 creditNum(VOExtensions.cs:838-845)

4) reincarnate.py(新)
   - 账号数据迁移(转生):A 的数据克隆到 B
   - 每包最多 4 首(真机一局上限),包间用续关登录换新 loginId
     续关依据:ContinueProcess.cs:403 -> PacketUserLogin(isContinue: true)
   - playlog 从曲目记录反推合成(没有对应的 Get 接口)

自检 238 项全绿。
2026-10-06 23:32:09 +08:00
Txiaochen c297818d8a 修复:请求头对齐真机(去掉 Accept / Connection),买票成功
真机(Unity Mono 的 HttpWebRequest)只发这几个头:
    number / Content-Type / User-Agent / charset / Mai-Encoding /
    Content-Encoding / (+ Cookie)
没有 Accept,也没有 Connection —— 而 requests 默认会补上。

实测证据(2026-10-06 真机抓包 + 账号对照):
- 同一账号机台能买、脚本 returnCode=0;账号 B(未被黑)同样如此
  => 差异在客户端,不在账号/服务端
- 删掉这两个头后一次通过:returnCode=1,stock 0 -> 1,落库确认

另:Accept-Encoding 必须「存在且为空」——
    空值 -> 200 + application/json;不发 / gzip / identity -> 0 字节。
    反推 Mono 发的也是空值(框架层加,Headers.AllKeys 看不到)。
2026-10-06 23:32:09 +08:00
62 changed files with 6411 additions and 1046 deletions
+14
View File
@@ -1,4 +1,6 @@
# ---- 运行产物 ----
# 每账号一份用户数据快照(<userId>.json)
data/
logs/
__pycache__/
*.pyc
@@ -17,6 +19,18 @@ docs/
gamehook/bin/
gamehook/obj/
# ---- netdelivery 下载目录(.app / .opt 更新包很大,不入库)----
download/
# ---- get_auth_list:clientId 遍历脚本与其产物(默认不入库、不推送)----
# 产物默认落在 data/ 下(data/ 本就已经整目录忽略);
# authentication.* 再兜一层,防止用 --csv/--state 指到别处
get_auth_list.py
authentication.*
# ---- extra_data 用的节点 JSON(含账号数据,不入库)----
extra*.json
# ---- 编辑器 / 系统 ----
.vs/
.vscode/
+294 -82
View File
@@ -1,4 +1,8 @@
<h1 align="center">WaHappy</h1>
<p align="center">
<img src="icon/Astral.png" alt="Astral" width="220">
</p>
<h1 align="center">Astral</h1>
<p align="center">
<b>舞萌DX 的游戏助手</b><br>
@@ -11,34 +15,29 @@
## 这是什么
WaHappy 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
Astral 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
扫码登录 → 取数据 → 上传成绩 → 登出。
扫码登录 → 取数据 → 上传成绩 / 解锁歌曲 → 登出。
> 协议实现全部来自反编译源码与真机网络日志的双向验证,
> 每个关键常量在代码注释里都标了**源码行号**。
---
## 当前状态:v0,**未完工**
## 当前状态:v0 plus 附加功能
**v0 目前只是一个占位版本,请以「能不能用」而不是「有多少代码」来判断它。**
| 模块 | 状态 |
| 脚本 | 状态 |
|---|---|
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
| `buy_ticket.py` | ⚠️ **不可用** —— 购买功能票接口疑似已被服务端拉黑,见下方警告 |
| **`unlockmusic.py`** | ✅ **可用** —— 解锁(解禁)歌曲 / 难度(复用 `upsert.py` 整套流程) |
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 |
| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 |
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) |
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) |
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
| **文档** | 📄 在 **`docs` 分支**(`mai` 分支不含)—— 开发过程、踩坑记录、实跑验证,内容比代码本身还多 |
### ⚠️ 关于购买功能票
`buy_ticket.py`(`UpsertUserChargelogApi`)在**报文与真机逐字节一致**的情况下
仍被服务端拒绝(`returnCode=0`),之后该账号**疑似被拉黑,暂时无法再购买功能票**。
**请勿轻易重试该接口。** 完整排查过程见
[购买功能票流程](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/购买功能票流程.md) 第六节。
**文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs),见文末[文档索引](#文档索引)。
---
@@ -48,7 +47,7 @@ v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结
| 项目 | Token 消耗 |
|---|---|
| **v0 有效开发** | **384M+** |
| **v0 有效开发** | **473M+** |
| 含中途废弃的分支项目 | **1137.5M** |
**这 1137.5M 里有一大半是浪费掉的。**
@@ -65,40 +64,58 @@ v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结
## 项目框架
```
WaHappy/
├── config.py 运行时可配置项(密钥 / 机台 / 歌曲数据 / 日志级别)
Astral/
├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建
├── config.py.example 配置模板
├── icon/ Logo
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
├── buy_ticket.py 主脚本:购买功能票(★ 当前不可用)
├── selftest.py 零网络自检(236 项,判据 = 源码 + 真机日志)
├── unlockmusic.py 主脚本:解锁(解禁)歌曲(复用 upsert.py 的 run())
├── buy_ticket.py 主脚本:购买功能票
├── use_ticket.py 主脚本:消耗当前持有的功能票
├── reincarnate.py 主脚本:账号数据迁移(转生)
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包
├── selftest.py 零网络自检(284 项,判据 = 源码 + 真机日志)
│
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
│ ├── __init__.py 统一导出 + 日志自动跟踪
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── log_setup.py 日志(INFO / DEBUG 双模式)
│ ├── upsert_user_all.py 上传成绩(36 节点 / 110 字段 / 差分)
│ ├── upsert_snapshot.py 结算基线快照
│ ├── MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── LogSetup.py 日志(INFO / DEBUG 双模式)
│ ├── ChargeGuard.py 买票前置闸门(必须先跑过 GetUserCharge)
│ ├── UpsertUserAll.py 上传成绩 / 解锁歌曲(36 节点 / 110 字段 / 差分)
│ ├── GetNeedUserData.py 结算快照编排 + 落盘 data/<userId>.json
│ └── ... 40+ 个接口脚本
│
├── logs/ 日志输出(latest.log + 日期.log)★ 不入库
└── source/ 反编译源码 ★ 不入库,需自备
├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库
├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库
└── logs/ 日志输出(latest.log + 日期.log)★ 不入库
```
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
主脚本只负责编排,日志和协议细节都在 `api/` 内部。
> **`docs/` 在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs),`source/` 与 `logs/` 不入库。**
>
> `source/` 是反编译源码,**需要自备**。
**命名约定**:**文件名 = 函数名**,都按首字母大写的驼峰切分,一一对应:
| 服务端接口 | 脚本 | 函数 |
|---|---|---|
| `GetUserDataApi` | `api/GetUserData.py` | `GetUserData()` |
| `UpsertUserAllApi` | `api/UpsertUserAll.py` | `UpsertUserAll()` |
| `UserLoginApi` | `api/UserLogin.py` | `UserLogin()` |
服务端 API 名里带 `Get` 的,脚本名与函数名一律带 `Get`(不要模棱两可)。
### 分支说明
| 分支 | 内容 |
|---|---|
| **`mai`** | 主分支 —— 代码(`api/` / `upsert.py` / `config.py` / `selftest.py` / `gamehook/`) |
| **`mai`** | 主分支 —— 代码 |
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
两个分支**没有共同的提交历史**,各自独立。
> 🛠 **给开发者**:协议实现对照的反编译源码(`Assembly-CSharp` / `AMDaemon.NET` 等)
> 不入库,需要自己准备一份放在 `source/`;代码注释里的行号都指向它。
> 只是用工具的话**完全不需要**这个目录。
---
## 快速开始
@@ -118,28 +135,47 @@ cp config.py.example config.py # Linux / macOS
copy config.py.example config.py # Windows
```
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」的 6 个值**:
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」**:
| 变量 | 填什么 |
|---|---|
| `KEYCHIP_ID` | 你的机台 Keychip(**完整形式**,带连字符,形如 `XXXX-XXXXXXXXXXX`) |
| `CLIENT_ID` | Keychip **短值**(去掉连字符取前 11 位) |
| `REGION_ID` | 你所在区域 ID |
| `CLIENT_ID` | Keychip 短值(15 位 Keychip 去掉连字符取前 11 位),形如 `A63E01ABCDE` |
| `PLACE_ID` | 你所在店铺 ID |
| `PLACE_NAME` | 店铺名(游戏服务器不返回,只能手填) |
| `REGION_ID` | 你所在区域 ID |
| `PLACE_NAME` | 店铺名 |
| `REGION_NAME` | 区域名 |
| `KEYCHIP_ID` | **由上面两个值自动推导**,不要手改 |
> ⚠️ **这 6 个值留空或填错,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。**
> ⚠️ 填错的话,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。
>
> `config.py` 已在 `.gitignore` 里 —— 它是你的私有配置,**不要提交**。
### 3. 跑自检
```bash
python selftest.py # 零网络自检,应该 236 项全绿(不依赖你的 config)
python selftest.py # 零网络自检,应该 284 项全绿(不依赖你的 config)
```
### upsert.py 用法
### 主脚本一览
| 脚本 | 干什么 | 副作用 | 需要二维码 |
|---|---|---|---|
| `upsert.py` | 上传一局成绩 | ★ 有 | ✅ |
| `unlockmusic.py` | 解锁(解禁)歌曲 / 难度 | ★ 有 | ✅ |
| `buy_ticket.py` | 购买功能票 | ★ 有 | ✅ |
| `use_ticket.py` | 消耗当前持有的功能票 | ★ 有 | ✅ |
| `reincarnate.py` | 账号数据迁移(转生) | ★ 有 | ✅(目标号;源号可选) |
| `netdelivery.py` | 查 / 下 `.app` / `.opt` 更新包 | 只读 | ❌ |
| `selftest.py` | 零网络自检 | 无 | ❌ |
> **所有有副作用的脚本默认都不发包**:不传 `--send` 时只构造报文(dry-run)。
> 想看报文而不产生任何后果,先跑一次不带 `--send` 的。
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**(不是按次数计量的)。
---
## upsert.py —— 上传一局成绩
```bash
# 预检:登录 + 抓快照 + 组包,不发包(零副作用,建议先跑这个)
@@ -150,55 +186,193 @@ python upsert.py "<二维码>" --send
# 常用开关
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 60 秒冷却
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 15 秒冷却
python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
python unlockmusic.py "<二维码>" --send --music 11414 # 解锁歌曲 / 解禁难度
```
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
> 二维码从机台的「扫码登录」界面获取,**有效期 10 分钟**。
### 额外塞节点:`--extra-data`
真机报文的 36 个节点绝大多数能由快照 + 本局演算自动推出来,
但偶尔想手工覆盖某几个(比如本局拿到的新 KaleidxScope 门),就用 `--extra-data`:
想手工覆盖某几个节点(比如本局拿到的新 KaleidxScope 门):
```bash
# ① 从文件读(★ 推荐 —— 不用跟 shell 引号搏斗)
python upsert.py "<二维码>" --send --extra-data extra.json
# ② 直接写 JSON 字符串
python upsert.py "<二维码>" --send --extra-data '{"isNewMapList":"1"}'
# ③ 多个来源,后给的覆盖先给的
python upsert.py "<二维码>" --send --extra-data base.json --extra-data patch.json
```
`extra.json` 长这样:
键必须是 `upsertUserAll` 的 **36 个节点**之一,写错会报错并列出可用节点名。
**JSON 在扫码之前就校验**,写错直接退出,不浪费二维码。
```json
{
"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}
],
"isNewKaleidxScopeList": "1"
}
> ⚠️ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号,**建议一律用文件**。
### ★ `isNew*` 位串:0 / 1 / 2 的含义
每个数组节点都配一条 `isNew*` 位串,**一位对应数组里的一条**(顺序必须一致)。
依据 `source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:309-364` 的 `BuildListData()`:
| 位 | 含义 | 服务端行为 |
|---|---|---|
| `0` | **存量更新** —— 旧数据里**已有**同主键的条目 | UPDATE |
| `1` | **新增** —— 旧数据里**没有**同主键的条目 | INSERT |
| `2` | **无变化** —— 只有 `userFavoriteList` 用,其他节点不会出现 | 跳过 |
主键见 `api/UpsertUserAll.py` 的 `DIFF_PRIMARY_KEYS`
(如 `userKaleidxScopeList` 用 `gateId`、`userMusicDetailList` 用 `musicId`+`level`)。
> ⚠️ **对已存在的条目填 `1` 会 HTTP 500** —— 服务端会去 INSERT 一条重复记录。
> 真机抓包样本里 `isNewFavoriteList = "22212"`,可以直观看到 0/1/2 混用的样子。
> **不确定填什么?** 先看 `data/<你的userId>.json` 里对应的节点:
> 主键已存在 → `0`,不存在 → `1`。
---
## unlockmusic.py —— 解锁(解禁)歌曲
```bash
python unlockmusic.py "<二维码>" --music 11414 # 预检,不发包
python unlockmusic.py "<二维码>" --music 11414 --send # ★ 真实解锁
python unlockmusic.py "<二维码>" --music 1085,11414 --send # 多首,逗号分隔
python unlockmusic.py "<二维码>" --music 11414 --send --no-unlock-remaster
```
| 规则 | 说明 |
**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
扫码 → 预览 → 登录 → `UpsertUserAll` → 落库回查 → 登出,一模一样。
唯一多出来的东西是「要解锁哪些歌」这个入参。
**解锁也不是单独的接口** —— 它是往 `userItemList` 里补几条 `UserItem`:
| itemKind | 解锁什么 |
|---|---|
| 键 | 必须是 `upsertUserAll` 的 **36 个节点**之一(含 11 条 `isNew*` 位串),写错会报错并列出可用节点名 |
| 值形态 | 按节点形态强制转换,传错立刻报错 |
| 位串 | 给了数组没给位串 → 按长度补 `"0"` 并提示。**新增条目请显式传 `"1"`**(`"0"` = 存量更新) |
| 优先级 | 最高,覆盖快照演算结果 |
| 校验时机 | ★ **扫码之前**就校验 —— JSON 写错直接退出,不浪费二维码 |
| `5` | 歌曲本体(`MusicUnlockList`) |
| `6` | Master 难度(`MusicUnlockMasterList`) |
| `7` | Re:Master 难度(`MusicUnlockReMasterList`) |
> ⚠ 在 PowerShell / CMD 里写内联 JSON 很容易被吞掉双引号,
> **建议一律用文件**(`--extra-data extra.json`)。
条目形如 `{"itemKind":5,"itemId":1085,"stock":1,"isValid":true}`。
- **标准谱(`musicId < 10000`)的 Master / Re:Master 天生解锁**,只写 `kind5`;
只有 DX 谱(`>= 10000`)才需要写 `kind6` / `kind7`。
- 服务器上**已存在的条目会自动跳过** —— 重复插入会让服务端直接 HTTP 500。
- Re:Master 还受活动开关约束,那是机台侧的事,脚本改不了;写了也可能进游戏仍是灰的。
| 参数 | 说明 |
|---|---|
| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
| `--send` | 真正发包(不加就是预检,零副作用) |
| `--no-unlock-master` | 不解禁 Master |
| `--no-unlock-remaster` | 不解禁 Re:Master |
| `--cooldown N` / `--no-verify` / `--debug` | 和 `upsert.py` 完全一样 |
> 依据反编译源码 `VOExtensions.cs:981-1008`(四个解锁列表并进 `userItemList`)
> 与 `NotesListManager.cs:415/455`(`if (id < 10000) return true;`)。
> 这部分**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
---
## buy_ticket.py —— 购买功能票
```bash
python buy_ticket.py "<二维码>" --list # 登录后只列当前票券,不购买
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
```
| chargeId | 票 | 上报的 `price` |
|---|---|---|
| 2 | 2倍チケット | 1 |
| 3 | 3倍チケット | 2 |
| 5 | 5倍チケット | 4 |
> 默认 `--charge-id 5`。**不同店铺上架的票不一样**,买之前先 `--list`。
### 它做的安全检查
1. 跑前查 `GetUserChargeApi` —— **账号已持有功能票时直接拒绝购买**(重复购买有封号风险)
2. 买票前 **15 秒等待**(贴近真机节奏)
3. 跑后再查一次确认状态
> **票源只看 `GetUserChargeApi`**(账号自己的票券表),不再看 `GetGameChargeApi`
> (那是机台本地的上架清单,跟账号状态无关)。
>
> `UpsertUserChargelog` 内部还有两道硬闸门:
> **必须先跑过 `GetUserCharge`**(`api/ChargeGuard.py` 记账),
> 以及**同一账号同一进程只允许买一次票** —— 都拦在发包之前。
---
## use_ticket.py —— 消耗当前持有的功能票
```bash
python use_ticket.py "<二维码>" --list # 只列当前持有的票
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
```
**原理**:`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList` **格式完全相同**,
所以把该节点里目标票的 `stock` 减 1,再让 playlog 带上 `useTicketId` / `playCredit`,
服务端就认为这张票被这一局用掉了。
> **典型用途**:账号手里挂着票时 `buy_ticket.py` 会拒绝再买;
> 想再买一张就先用这个把它用掉。
详细原理与报文依据见 [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md)。
---
## reincarnate.py —— 账号转生
把一个账号(源 A)的数据克隆到另一个账号(目标 B)。
```bash
# 源数据来自二维码(会联网抓 A 的数据并落盘到 data/<A>.json)
python reincarnate.py --dst-qr "<B的二维码>" --src-qr "<A的二维码>"
# 源数据来自已保存的 JSON(离线,推荐)
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --plan
python reincarnate.py --dst-qr "<B的二维码>" --src-json data/12345678.json --send
```
**它干了什么**:
1. 取源账号 A 的 19 个节点数据(联网抓或读 `data/<A>.json`)
2. 用目标账号 B 的二维码登录
3. 把 A 的 `userMusicDetailList` **分批**上传到 B —— 每包最多 4 首,
并自动避开**被服务端禁止的 2 首包**(`[4,4,2]` → `[4,3,3]`)
4. 每包之间:**等待 → 上传 → 登出(type=1)→ 续关登录**,循环到传完
5. 每包的 playlog 从曲目记录**反推合成**(`achievement` / `deluxscore` / `scoreRank` …)
| 参数 | 默认 | 说明 |
|---|---|---|
| `--plan` | — | 只列分批计划,不发包(**建议先跑这个**) |
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
| `--wait N` | 5.0 | 每次上传前的等待秒数 |
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认保留 B 自己的) |
> ⚠️ **未实跑过。** 建议先 `--plan`,再 `--limit 8` 小批量试。
完整流程、分批规则、续关与登出类型的依据见 [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md)。
---
## netdelivery.py —— 查 / 下 `.app` / `.opt` 更新包
```bash
python netdelivery.py # 用 config.py 里的机台查,然后**多选下载**
python netdelivery.py A63E01ABCDE # 指定 Keychip
python netdelivery.py --list # 只列清单 + 直链,不下载
python netdelivery.py --all # 全下(不交互)
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
python netdelivery.py --out D:\SDGB2026\update
```
交互选择支持:逗号/空格分隔、区间(`1-3`)、`a`=全选、`n`=全不选、`q`=退出。
下载带**断点续传**与进度/速率显示,默认存到 `download/`(不入库)。
> 这是**只读**操作,不写任何账号数据。
> 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
---
@@ -211,7 +385,7 @@ python upsert.py "<二维码>" --send --extra-data base.json --extra-data patch.
# -*- coding: utf-8 -*-
import sys
from api import (setup_logging, teardown_logging,
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType,
ParseQRCode, GetUserPreview, UserLogin, UserLogout, LogoutType,
UpsertUserAll)
from config import REGION_ID, PLACE_ID
@@ -222,7 +396,7 @@ def main() -> int:
user_id, token = ParseQRCode(sys.argv[1])
session.update(user_id=user_id, token=token)
UserPreview(user_id, token) # 顶号保护
GetUserPreview(user_id, token) # 顶号保护
login = UserLogin(user_id, token,
region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
@@ -237,7 +411,7 @@ def main() -> int:
finally:
if session["logged_in"]: # ★ 一定要保证登出
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
logout_type=LogoutType.Logout,
region_id=REGION_ID, place_id=PLACE_ID)
teardown_logging()
@@ -251,29 +425,67 @@ if __name__ == "__main__":
2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100`
3. **写接口要显式开闸门** —— 只写 `dry_run=False` 不够,必须同时给 `confirm=True`
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/wahappy/src/branch/docs/docs/主脚本编写指南.md)。
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md)。
---
## 将来
## 已完成 / 将来
v0 只是起点,后续可能会加:
**已完成(v0)**
- ✅ 扫码登录 / 登出 / 心跳,完整会话链路
- ✅ 上传一局成绩(36 节点 / 110 字段 / 差分 / 落库回查)
- ✅ 购买功能票
- ✅ 消耗持有的功能票
- ✅ 账号数据快照落盘(`data/<userId>.json`)
- ✅ 账号数据迁移(转生)
- ✅ 查 / 下 `.app` / `.opt` 更新包
- ✅ 解锁歌曲 / 解禁 Master 与 Re:Master 难度
- ✅ 零网络自检 284 项
- ✅ MelonLoader 抓包 Mod(`gamehook/`)
**将来可能会加**
- **网页控制** —— 用浏览器代替命令行操作
- 更多游戏功能(任务 / 地图 / 收藏夹 / 排行榜……)
- 更完整的真机验证覆盖
- `reincarnate.py` 的实跑验证
---
## 文档索引
全部在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs):
| 文档 | 内容 |
|---|---|
| [项目总览](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/项目总览.md) | 开发向主文档,该放哪 / 该怎么写 |
| [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md) | 写新主脚本的规范 |
| [上传成绩开发文档](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/上传成绩开发文档.md) | `UpsertUserAll` 的 36 节点 / 110 字段 |
| [UpsertUserAll踩坑清单](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/UpsertUserAll踩坑清单.md) | 28 条踩坑记录 |
| [功能票使用说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/功能票使用说明.md) | 买票 / 用票的完整原理与报文依据 |
| [账号转生说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/账号转生说明.md) | `reincarnate.py` 完整流程 |
| [netdelivery说明](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/netdelivery说明.md) | DLI 协议与运行原理 |
| [常见坑与注意事项](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/常见坑与注意事项.md) | 二维码 / 账号状态 / 副作用 / 日志 / 网络 |
| [购买功能票流程](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/购买功能票流程.md) | 买票接口的完整排查过程 |
| [购买功能票_存档与交接](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/购买功能票_存档与交接.md) | 排查存档 |
| [真机Hook对照报告](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/真机Hook对照报告.md) | 抓包 Mod 对照 |
| [实跑验证记录](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/实跑验证记录.md) | 每次实跑的结果 |
| [舞萌DX_扫码登录协议技术规范](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/舞萌DX_扫码登录协议技术规范.md) | aimedb 扫码协议 |
| [日志与主脚本指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/日志与主脚本指南.md) | 日志系统说明 |
---
## 特别鸣谢
- **志成🍥** —— 提供脱壳 DLL
- **WorkBuddyAI**([workbuddy.ai](https://www.workbuddy.ai/))—— 提供 AI 模型
- [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型
- **DeepSeek V4.1 Flash** —— 主要代码编写
- **NVIDIA NIM**([build.nvidia.com/models](https://build.nvidia.com/models))、
**AMD**([developer.amd.com.cn/radeon/tokenfactory](https://developer.amd.com.cn/radeon/tokenfactory))、
**Kilo**([kilo.ai](https://kilo.ai/))—— 提供 AI 模型
- **eaquira**([git.fragrance.moe/Fragrance/eaquira](https://git.fragrance.moe/Fragrance/eaquira))—— 思路启发
- **GPT-Image-2** —— Logo 设计
- [**NVIDIA NIM**](https://build.nvidia.com/models)、
[**AMD**](https://developer.amd.com.cn/radeon/tokenfactory)、
[**Kilo**](https://kilo.ai/)—— 提供 AI 模型
- [**eaquira**](https://git.fragrance.moe/Fragrance/eaquira)—— 思路启发
- **神秘群U** —— 私有科技的 Web 启发(暂无)
- **香水所有群友** —— 和谐帮助
+150
View File
@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
"""
ChargeGuard.py —— 买票前置闸门(GetUserCharge 必须已经跑过)
================================================================================
★ 这个模块解决什么问题:
````
错误写法(危险):
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
# ↑ 没先查 GetUserCharge —— 万一账号已经持有一张功能票,
# 服务端看到的就是「重复购买」,有封号风险。
```
正确写法:
```
held = GetUserCharge(user_id) # ① 先查:有没有持票
...
UpsertUserChargelog(user_id, 2, ...) # ② 再买
```
本模块在 ① 里记一笔「这个 user_id 刚查过票券」,在 ② 里检查这笔记录,
没查过就抛 `ChargelogGuardError`(**未发包**)。
★ 还顺手挡第二件事:**同一个账号在同一个进程里只允许买一次票**。
真机买票是「买完立刻被这一局用掉」(stock--),正常状态 stock 恒为 0;
脚本连着买两张是明显的异常行为,这里直接拦掉。
★ 记忆只存在于**当前进程**(内存字典),脚本退出即清空 —— 不做持久化,
免得下次运行时被上一次的记录误拦。
依据:
source/Assembly-CSharp/Manager/TicketManager.cs:130-174 UseTicketPrepare
source/Assembly-CSharp/Manager/TicketManager.cs:234-253 RequestUserCharge
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserCharge.cs:20
source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs:19
"""
from __future__ import annotations
import time
from typing import Any, Dict, List, Optional
#: GetUserCharge 结果的「新鲜度」窗口(秒)。
#: 超过这个时间才买票,认为那次查询已经过期,需要重查。
CHARGE_READ_FRESH_SEC = 600.0
#: user_id -> 最近一次 GetUserCharge 的 monotonic 时刻
_LAST_READ: Dict[int, float] = {}
#: user_id -> 最近一次 GetUserCharge 返回的 userChargeList(原样)
_LAST_LIST: Dict[int, List[Dict[str, Any]]] = {}
#: user_id -> 本进程内已经买过的 chargeId 列表
_PURCHASES: Dict[int, List[int]] = {}
# =============================================================================
# 一、GetUserCharge 侧:记一笔
# =============================================================================
def note_user_charge_read(
user_id: int,
charge_list: Optional[List[Dict[str, Any]]] = None,
) -> None:
"""
记录「刚刚为这个 user_id 查过票券」。
★ 由 GetUserCharge() 在拿到响应后自动调用,调用方不用管。
"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_LAST_READ[uid] = time.monotonic()
if isinstance(charge_list, list):
_LAST_LIST[uid] = [c for c in charge_list if isinstance(c, dict)]
def last_read_age(user_id: int) -> Optional[float]:
"""距上次 GetUserCharge 过了多少秒;从没查过返回 None。"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return None
ts = _LAST_READ.get(uid)
if ts is None:
return None
return max(0.0, time.monotonic() - ts)
def has_fresh_read(
user_id: int,
max_age: float = CHARGE_READ_FRESH_SEC,
) -> bool:
"""这个 user_id 最近(max_age 秒内)查过 GetUserCharge 吗?"""
age = last_read_age(user_id)
if age is None:
return False
return age <= float(max_age)
def last_charge_list(user_id: int) -> List[Dict[str, Any]]:
"""最近一次 GetUserCharge 返回的 userChargeList(没有则空表)。"""
try:
return list(_LAST_LIST.get(int(user_id)) or [])
except (TypeError, ValueError):
return []
def held_charges(user_id: int) -> List[Dict[str, Any]]:
"""最近一次查到的「持有中」(stock > 0)的票券。"""
return [c for c in last_charge_list(user_id) if int(c.get("stock", 0) or 0) > 0]
# =============================================================================
# 二、UpsertUserChargelog 侧:买之前查
# =============================================================================
def note_charge_purchase(user_id: int, charge_id: int) -> None:
"""记录「这个 user_id 刚刚买了一张 chargeId 的票」。"""
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_PURCHASES.setdefault(uid, []).append(int(charge_id))
def purchased_charge_ids(user_id: int) -> List[int]:
"""本进程内这个 user_id 已经买过的 chargeId。"""
try:
return list(_PURCHASES.get(int(user_id)) or [])
except (TypeError, ValueError):
return []
def reset(user_id: Optional[int] = None) -> None:
"""清空记忆(测试用;不传 user_id 就全清)。"""
if user_id is None:
_LAST_READ.clear()
_LAST_LIST.clear()
_PURCHASES.clear()
return
try:
uid = int(user_id)
except (TypeError, ValueError):
return
_LAST_READ.pop(uid, None)
_LAST_LIST.pop(uid, None)
_PURCHASES.pop(uid, None)
+25 -14
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
chime.py —— 扫码登录(二维码 -> userId + token)
Chime.py —— 扫码登录(二维码 -> userId + token)
================================================================================
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
@@ -54,18 +54,29 @@ from datetime import datetime
from typing import Tuple
from urllib.parse import urlparse
from config import (
AIMEDB_HOST,
AIMEDB_PATH,
AIMEDB_PORT,
KEYCHIP_ID,
QR_OPEN_GAME_ID,
QR_SIGN_KEY,
QR_TITLE_KEY,
QR_USER_AGENT,
)
from config import KEYCHIP_ID
from .log_setup import log_debug, log_error, log_info, log_request, log_response
# =============================================================================
# aimedb 扫码登录常量(原在 config.py,2026-10-06 下沉到本模块)
# =============================================================================
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs 分支《舞萌DX_扫码登录协议技术规范》
#: aimedb 服务器
AIMEDB_HOST = "ai.sys-allnet.cn"
AIMEDB_PORT = 443
AIMEDB_PATH = "/wc_aime/api/get_data"
#: 签名密钥(含前导 X,32 字符)
#: 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
#: 固定字段
QR_OPEN_GAME_ID = "MAID"
QR_TITLE_KEY = "SDGB"
QR_USER_AGENT = "WC_AIME_LIB"
from .LogSetup import log_debug, log_error, log_info, log_request, log_response
# =============================================================================
# 二维码格式常量(CCommQRCode.cs + 技术规范)
@@ -235,7 +246,7 @@ def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int,
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
返回:
(user_id, token) 元组,可直接传给 UserPreview / UserLogin 等
(user_id, token) 元组,可直接传给 GetUserPreview / UserLogin 等
异常:
ValueError: 二维码格式不合法
@@ -277,7 +288,7 @@ def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int,
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chime_test")
+3 -3
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
game_charge.py —— 取游戏计费配置(GetGameChargeApi)
GetGameCharge.py —— 取游戏计费配置(GetGameChargeApi)
================================================================================
取服务器下发的计费档位。
@@ -22,8 +22,8 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
gamesettings.py —— 获取游戏设置(GetGameSettingApi)
GetGameSettings.py —— 获取游戏设置(GetGameSettingApi)
================================================================================
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
@@ -25,8 +25,8 @@ from typing import Optional
from config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
# =============================================================================
@@ -154,7 +154,7 @@ def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
# =============================================================================
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("gamesettings_test")
@@ -166,7 +166,7 @@ if __name__ == "__main__":
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
return 0
except Exception as exc:
from .log_setup import log_error
from .LogSetup import log_error
log_error("获取游戏设置失败:" + str(exc))
return 1
finally:
+144 -50
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""
upsert_snapshot.py —— 结算快照编排(统一调用各取数脚本)
GetNeedUserData.py —— 结算快照编排(统一调用各取数脚本)
================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(userdata.py / user_map.py …)。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(GetUserData.py / GetUserMap.py …)。
--------------------------------------------------------------------------------
为什么需要快照
@@ -26,42 +26,122 @@ upsert_snapshot.py —— 结算快照编排(统一调用各取数脚本)
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import fetch_upsert_snapshot
from api import GetNeedUserData
snapshot = fetch_upsert_snapshot(user_id, token=token)
snapshot = GetNeedUserData(user_id, token=token)
# 之后直接传给 UpsertUserAll(snapshot=snapshot, ...)
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, List, Optional, Sequence
from config import CLIENT_ID, PLACE_ID, PLAY_SHOP_ITEM_IDS, REGION_ID, TIMEOUT_MS
from .log_setup import log_debug, log_info, log_warning
from .maimai_net import MaimaiNetClient
from .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
# 各取数脚本(一个接口一个脚本)
from .userdata import UserData
from .user_extend import UserExtend
from .user_option import UserOption
from .user_character import UserCharacter
from .user_ghost import UserGhost
from .user_map import UserMapAll
from .user_login_bonus import UserLoginBonusAll
from .user_rating import UserRating
from .user_item import UserItemAll
from .user_music import UserMusicDetailAll
from .user_course import UserCourseAll
from .user_charge import UserCharge
from .user_favorite import UserFavoriteAll
from .user_activity import UserActivity
from .user_mission import UserMissionData
from .user_intimate import UserIntimate
from .user_shop_stock import UserShopStock
from .user_favorite_item import UserFavoriteItemAll
from .user_kaleidx_scope import UserKaleidxScope
from .GetUserData import GetUserData
from .GetUserExtend import GetUserExtend
from .GetUserOption import GetUserOption
from .GetUserCharacter import GetUserCharacter
from .GetUserGhost import GetUserGhost
from .GetUserMap import GetUserMapAll
from .GetUserLoginBonus import GetUserLoginBonusAll
from .GetUserRating import GetUserRating
from .GetUserItem import GetUserItemAll
from .GetUserMusic import GetUserMusicDetailAll
from .GetUserCourse import GetUserCourseAll
from .GetUserCharge import GetUserCharge
from .GetUserFavorite import GetUserFavoriteAll
from .GetUserActivity import GetUserActivity
from .GetUserMission import GetUserMissionData
from .GetUserIntimate import GetUserIntimate
from .GetUserShopStock import GetUserShopStock
from .GetUserFavoriteItem import GetUserFavoriteItemAll
from .GetUserKaleidxScope import GetUserKaleidxScope
# =============================================================================
# 落盘目录:<工程根>/data/<userId>.json
# =============================================================================
#: 工程根目录(本文件在 <root>/api/ 下)
PROJECT_ROOT: Path = Path(__file__).resolve().parent.parent
#: 用户数据目录:<工程根>/data
DATA_DIR: Path = PROJECT_ROOT / "data"
def user_data_path(user_id: int) -> Path:
"""用户数据的落盘路径:<工程根>/data/<userId>.json"""
return DATA_DIR / (str(int(user_id)) + ".json")
def save_user_data(snapshot: Dict[str, Any], user_id: int,
path: Optional[str] = None) -> str:
"""
把 GetNeedUserData 抓到的数据写到 <工程根>/data/<userId>.json。
★ **新数据总是覆盖老数据**(不是追加、也不是合并)。
★ 目录不存在会自动创建。
参数:
snapshot: GetNeedUserData 的返回值
user_id: 用户 ID(决定文件名)
path: 自定义路径(可选,默认走 data/<userId>.json)
返回:实际写入的文件路径
"""
target = Path(path) if path else user_data_path(user_id)
try:
os.makedirs(str(target.parent), exist_ok=True)
with open(str(target), "w", encoding="utf-8") as fp:
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
except Exception as exc:
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
return str(target)
size = 0
try:
size = os.path.getsize(str(target))
except Exception:
pass
log_info("用户数据已保存(覆盖):" + str(target) + " " + str(size) + " 字节")
return str(target)
def load_user_data(user_id: int, path: Optional[str] = None) -> Dict[str, Any]:
"""
读回 <工程根>/data/<userId>.json。
找不到文件时抛 FileNotFoundError(调用方自行决定要不要回退到联网抓取)。
"""
target = Path(path) if path else user_data_path(user_id)
if not target.exists():
raise FileNotFoundError("用户数据文件不存在:" + str(target))
with open(str(target), "r", encoding="utf-8") as fp:
data = json.load(fp)
log_info("用户数据已加载:" + str(target))
return data
def list_saved_user_ids() -> List[int]:
"""列出 data/ 下已保存的全部 userId(按文件名解析,跳过解析失败的)。"""
if not DATA_DIR.exists():
return []
out: List[int] = []
for name in os.listdir(str(DATA_DIR)):
if not name.endswith(".json"):
continue
try:
out.append(int(name[:-5]))
except ValueError:
continue
return sorted(out)
#: 默认要抓的节点(顺序即抓取顺序)
@@ -89,7 +169,7 @@ DEFAULT_SNAPSHOT_NODES: tuple = (
)
def fetch_upsert_snapshot(
def GetNeedUserData(
user_id: int,
*,
token: str = "",
@@ -101,6 +181,8 @@ def fetch_upsert_snapshot(
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
strict: bool = False,
save: bool = True,
save_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
抓取结算所需的全部基线数据。
@@ -117,6 +199,9 @@ def fetch_upsert_snapshot(
client: 可复用的 MaimaiNetClient
strict: True = 任一接口失败就抛异常;
False(默认)= 记录失败并跳过,保证链路不崩
save: True(默认)= 抓完自动落盘到 <工程根>/data/<userId>.json
(★ 新数据总是覆盖老数据)
save_path: 自定义落盘路径(可选,给了就忽略 save 的默认路径规则)
返回:
{
@@ -151,78 +236,87 @@ def fetch_upsert_snapshot(
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
if "userData" in wanted:
_call("userData", UserData, user_id, **common)
_call("userData", GetUserData, user_id, **common)
if "userExtend" in wanted:
_call("userExtend", UserExtend, user_id, **common)
_call("userExtend", GetUserExtend, user_id, **common)
if "userOption" in wanted:
_call("userOption", UserOption, user_id, **common)
_call("userOption", GetUserOption, user_id, **common)
if "userCharacterList" in wanted:
_call("userCharacterList", UserCharacter, user_id, **common)
_call("userCharacterList", GetUserCharacter, user_id, **common)
if "userMapList" in wanted:
_call("userMapList", UserMapAll, user_id, **common)
_call("userMapList", GetUserMapAll, user_id, **common)
if "userLoginBonusList" in wanted:
_call("userLoginBonusList", UserLoginBonusAll, user_id, **common)
_call("userLoginBonusList", GetUserLoginBonusAll, user_id, **common)
if "userRatingList" in wanted:
_call("userRatingList", UserRating, user_id, **common)
_call("userRatingList", GetUserRating, user_id, **common)
if "userItemList" in wanted:
_call("userItemList", UserItemAll, user_id, **common)
_call("userItemList", GetUserItemAll, user_id, **common)
if "userMusicDetailList" in wanted:
_call("userMusicDetailList", UserMusicDetailAll, user_id, **common)
_call("userMusicDetailList", GetUserMusicDetailAll, user_id, **common)
if "userCourseList" in wanted:
_call("userCourseList", UserCourseAll, user_id, **common)
_call("userCourseList", GetUserCourseAll, user_id, **common)
if "userChargeList" in wanted:
_call("userChargeList", UserCharge, user_id, **common)
_call("userChargeList", GetUserCharge, user_id, **common)
if "userFavoriteList" in wanted:
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
if _call("userFavoriteList", UserFavoriteAll, user_id, **common):
if _call("userFavoriteList", GetUserFavoriteAll, user_id, **common):
fav_raw = (raw.get("userFavoriteList") or {}).get("_raw")
if fav_raw:
raw["_raw_favorite"] = fav_raw
if "userActivityList" in wanted:
_call("userActivityList", UserActivity, user_id, **common)
_call("userActivityList", GetUserActivity, user_id, **common)
if "userMissionDataList" in wanted:
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
_call("userMissionDataList", UserMissionData, user_id, **common)
_call("userMissionDataList", GetUserMissionData, user_id, **common)
if "userIntimateList" in wanted:
_call("userIntimateList", UserIntimate, user_id, **common)
_call("userIntimateList", GetUserIntimate, user_id, **common)
if "userShopItemStockList" in wanted:
ids = list(shop_item_ids) if shop_item_ids is not None else list(PLAY_SHOP_ITEM_IDS)
if ids:
_call("userShopItemStockList", UserShopStock, user_id,
_call("userShopItemStockList", GetUserShopStock, user_id,
shop_item_id_list=ids, **common)
else:
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
if "userFavoritemusicList" in wanted:
_call("userFavoritemusicList", UserFavoriteItemAll, user_id, 1, **common)
_call("userFavoritemusicList", GetUserFavoriteItemAll, user_id, 1, **common)
if "userKaleidxScopeList" in wanted:
_call("userKaleidxScopeList", UserKaleidxScope, user_id, **common)
_call("userKaleidxScopeList", GetUserKaleidxScope, user_id, **common)
if "userGhost" in wanted:
_call("userGhost", UserGhost, user_id, **common)
_call("userGhost", GetUserGhost, user_id, **common)
raw["_failed"] = failed
log_info("结算快照抓取完成:成功 " + str(len(raw) - 1) + " 个节点,失败 "
raw["_userId"] = int(user_id)
log_info("结算快照抓取完成:成功 " + str(len(raw) - 2) + " 个节点,失败 "
+ str(len(failed)) + " 个")
# ★ 落盘:<工程根>/data/<userId>.json(覆盖式)
if save or save_path:
try:
save_user_data(raw, user_id, path=save_path)
except Exception as exc:
log_warning("用户数据落盘失败(不影响本次运行):" + str(exc))
return raw
@@ -278,13 +372,13 @@ def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
if __name__ == "__main__":
import sys
from .log_setup import log_error, setup_logging, teardown_logging
from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("upsert_snapshot_test")
setup_logging("get_need_user_data_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.upsert_snapshot <user_id> [--token TOKEN] [--save 路径]")
print("用法: python -m api.get_need_user_data <user_id> [--token TOKEN] [--save 路径]")
return 1
user_id = int(sys.argv[1])
@@ -302,7 +396,7 @@ if __name__ == "__main__":
else:
i += 1
snap = fetch_upsert_snapshot(user_id, token=token)
snap = GetNeedUserData(user_id, token=token)
summary = summarize_snapshot(snap)
print("")
for node, count in summary.items():
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_activity.py —— 取用户活动记录(GetUserActivityApi)
GetUserActivity.py —— 取用户活动记录(GetUserActivityApi)
================================================================================
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
@@ -16,7 +16,7 @@ user_activity.py —— 取用户活动记录(GetUserActivityApi)
不是把两个数组首尾相接摊平。
调用示例:
from api import UserActivity
from api import GetUserActivity
"""
from __future__ import annotations
@@ -25,12 +25,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserActivity(
def GetUserActivity(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_character.py —— 取用户角色列表(GetUserCharacterApi)
GetUserCharacter.py —— 取用户角色列表(GetUserCharacterApi)
================================================================================
取玩家所有角色的等级与觉醒度(真机 312 条)。
@@ -14,7 +14,7 @@ user_character.py —— 取用户角色列表(GetUserCharacterApi)
(真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。
调用示例:
from api import UserCharacter
from api import GetUserCharacter
"""
from __future__ import annotations
@@ -23,12 +23,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserCharacter(
def GetUserCharacter(
user_id: int,
*,
client_id: str = CLIENT_ID,
+63
View File
@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""
GetUserCharge.py —— 取用户票券(GetUserChargeApi)
================================================================================
取玩家的票券持有情况(真机 3 条)。
对应接口:GetUserChargeApi
请求体 :UserIdRequestVO
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
★ 调用本接口会**登记一次「票券已查询」**(见 api/ChargeGuard.py)。
UpsertUserChargelog 发包前会检查这笔登记 —— 没查过票就买票会被直接拒绝。
调用示例:
from api import GetUserCharge
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .ChargeGuard import note_user_charge_read
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharge(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户票券(对应 GetUserChargeApi)。
返回:
原始响应 dict,含 userChargeList
"""
log_info("取用户票券:user_id=" + str(user_id))
raw = _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
# ★ 记一笔「这个账号刚查过票券」——
# UpsertUserChargelog 发包前会检查这笔记录,没查过就拒绝购买。
# 见 api/ChargeGuard.py 的说明。
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
note_user_charge_read(user_id, lst)
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
if held:
log_info("★ 该账号当前持有 " + str(len(held)) + " 张功能票:"
+ "、".join("chargeId=" + str(c.get("chargeId"))
+ " stock=" + str(c.get("stock")) for c in held))
else:
log_info("该账号当前没有持有功能票")
return raw
+8 -8
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
GetUserCourse.py —— 取用户课题模式成绩(GetUserCourseApi)
================================================================================
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
@@ -12,7 +12,7 @@ user_course.py —— 取用户课题模式成绩(GetUserCourseApi)
source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0}
调用示例:
from api import UserCourse
from api import GetUserCourse
"""
from __future__ import annotations
@@ -21,12 +21,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserCourseRequestVO, fetch_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserCourseRequestVO, get_all_pages
def UserCourse(
def GetUserCourse(
user_id: int,
*,
next_index: int = 0,
@@ -48,7 +48,7 @@ def UserCourse(
)
def UserCourseAll(
def GetUserCourseAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -62,7 +62,7 @@ def UserCourseAll(
{"userCourseList": [...]}
"""
log_info("取用户课题成绩(全量):user_id=" + str(user_id))
items = fetch_all_pages(
items = get_all_pages(
"GetUserCourseApi", user_id, "userCourseList",
body_factory=lambda ni: UserCourseRequestVO(userId=user_id, nextIndex=ni or 0),
client_id=client_id, timeout_ms=timeout_ms, client=client,
+6 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
userdata.py —— 取用户全量数据(GetUserDataApi)
GetUserData.py —— 取用户全量数据(GetUserDataApi)
================================================================================
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
@@ -20,7 +20,7 @@ userdata.py —— 取用户全量数据(GetUserDataApi)
记录造成的假象(两条响应被合并显示)。
调用示例:
from api import UserData
from api import GetUserData
"""
from __future__ import annotations
@@ -29,12 +29,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserData(
def GetUserData(
user_id: int,
*,
client_id: str = CLIENT_ID,
+6 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_extend.py —— 取用户扩展设置(GetUserExtendApi)
GetUserExtend.py —— 取用户扩展设置(GetUserExtendApi)
================================================================================
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
@@ -14,7 +14,7 @@ user_extend.py —— 取用户扩展设置(GetUserExtendApi)
(真机两处都是 2022),组包时要注意。
调用示例:
from api import UserExtend
from api import GetUserExtend
"""
from __future__ import annotations
@@ -23,12 +23,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserExtend(
def GetUserExtend(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
GetUserFavorite.py —— 取用户收藏(GetUserFavoriteApi)
================================================================================
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
@@ -15,7 +15,7 @@ user_favorite.py —— 取用户收藏(GetUserFavoriteApi)
位串 isNewFavoriteList 是三值 "0"/"1"/"2"。
调用示例:
from api import UserFavorite, UserFavoriteAll
from api import GetUserFavorite, GetUserFavoriteAll
"""
from __future__ import annotations
@@ -24,12 +24,12 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
def UserFavorite(
def GetUserFavorite(
user_id: int,
item_kind: int,
*,
@@ -55,7 +55,7 @@ def UserFavorite(
)
def UserFavoriteAll(
def GetUserFavoriteAll(
user_id: int,
*,
item_kinds: Optional[List[int]] = None,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
GetUserFavoriteItem.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
================================================================================
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
@@ -15,7 +15,7 @@ user_favorite_item.py —— 取用户收藏曲目/对手(GetUserFavoriteItemA
★ 字段名是 kind(不是 itemKind),别写错。
调用示例:
from api import UserFavoriteItem, UserFavoriteItemAll
from api import GetUserFavoriteItem, GetUserFavoriteItemAll
"""
from __future__ import annotations
@@ -24,16 +24,16 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserFavoriteItemRequestVO, fetch_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteItemRequestVO, get_all_pages
#: kind 取值
FAVORITE_ITEM_KIND_MUSIC = 1 # 收藏乐曲
FAVORITE_ITEM_KIND_RIVAL = 2 # 收藏对手
def UserFavoriteItem(
def GetUserFavoriteItem(
user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC,
*,
@@ -65,7 +65,7 @@ def UserFavoriteItem(
)
def UserFavoriteItemAll(
def GetUserFavoriteItemAll(
user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC,
*,
@@ -81,7 +81,7 @@ def UserFavoriteItemAll(
{"userFavoriteItemList": [...]}
"""
log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind))
items = fetch_all_pages(
items = get_all_pages(
"GetUserFavoriteItemApi", user_id, "userFavoriteItemList",
body_factory=lambda ni: UserFavoriteItemRequestVO(
userId=user_id, kind=kind, nextIndex=ni or 0,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
GetUserFriendBonus.py —— 取用户好友里程(GetUserFriendBonusApi)
================================================================================
取玩家本次可领取的好友里程(getMiles)。
@@ -12,7 +12,7 @@ user_friend_bonus.py —— 取用户好友里程(GetUserFriendBonusApi)
source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90}
调用示例:
from api import UserFriendBonus, extract_gift_points
from api import GetUserFriendBonus, extract_gift_points
"""
from __future__ import annotations
@@ -21,12 +21,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserFriendBonus(
def GetUserFriendBonus(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -53,7 +53,7 @@ def extract_gift_points(friend_bonus: Dict[str, Any]) -> int:
从好友里程响应里取出 getMiles(本次可领取的里程数)。
参数:
friend_bonus: UserFriendBonus 的返回值
friend_bonus: GetUserFriendBonus 的返回值
返回:
getMiles 整数(取不到则为 0)
+7 -7
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
GetUserGhost.py —— 取用户幽灵数据(GetUserGhostApi)
================================================================================
取玩家的「幽灵」对战记录(真机 15 条)。
@@ -11,11 +11,11 @@ user_ghost.py —— 取用户幽灵数据(GetUserGhostApi)
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs
★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new UserGhost[1] {...};
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new GetUserGhost[1] {...};
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
调用示例:
from api import UserGhost
from api import GetUserGhost
"""
from __future__ import annotations
@@ -24,12 +24,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserGhost(
def GetUserGhost(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
GetUserIntimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
================================================================================
取玩家与各搭档的亲密度(真机 2 条)。
@@ -11,7 +11,7 @@ user_intimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
调用示例:
from api import UserIntimate
from api import GetUserIntimate
"""
from __future__ import annotations
@@ -20,12 +20,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserIntimate(
def GetUserIntimate(
user_id: int,
*,
client_id: str = CLIENT_ID,
+7 -7
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_item.py —— 取用户道具(GetUserItemApi)
GetUserItem.py —— 取用户道具(GetUserItemApi)
================================================================================
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
@@ -29,7 +29,7 @@ source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
后续翻页沿用响应回的 nextIndex
调用示例:
from api import UserItem, UserItemAll
from api import GetUserItem, GetUserItemAll
"""
from __future__ import annotations
@@ -38,12 +38,12 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_debug, log_info, log_warning
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
from .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserItemRequestVO, ITEM_KINDS, ITEM_NEXT_INDEX_FACTOR
def UserItem(
def GetUserItem(
user_id: int,
item_kind: int,
*,
@@ -77,7 +77,7 @@ def UserItem(
)
def UserItemAll(
def GetUserItemAll(
user_id: int,
*,
item_kinds: Optional[List[int]] = None,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
GetUserKaleidxScope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
================================================================================
对应接口:
@@ -17,14 +17,14 @@ user_kaleidx_scope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / Ge
★★ 重要区分(曾导致 userData 取空)
--------------------------------------------------------------------------------
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
玩家档案 userData 的正确来源是 GetUserDataApi(见 userdata.py)。
玩家档案 userData 的正确来源是 GetUserDataApi(见 GetUserData.py)。
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
多发字段会让服务端返回空内容。
调用示例:
from api import UserKaleidxScope, GameKaleidxScope
from api import GetUserKaleidxScope, GetGameKaleidxScope
"""
from __future__ import annotations
@@ -33,12 +33,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, EmptyRequestVO, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, EmptyRequestVO, UserIdRequestVO
def UserKaleidxScope(
def GetUserKaleidxScope(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -56,7 +56,7 @@ def UserKaleidxScope(
client_id=client_id, timeout_ms=timeout_ms, client=client)
def GameKaleidxScope(
def GetGameKaleidxScope(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
GetUserLoginBonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
================================================================================
取玩家的登录奖励领取进度(真机 38 条,分两页)。
@@ -13,7 +13,7 @@ user_login_bonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。
调用示例:
from api import UserLoginBonus, UserLoginBonusAll
from api import GetUserLoginBonus, GetUserLoginBonusAll
"""
from __future__ import annotations
@@ -22,12 +22,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserLoginBonusRequestVO, fetch_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserLoginBonusRequestVO, get_all_pages
def UserLoginBonus(
def GetUserLoginBonus(
user_id: int,
*,
next_index: int = 0,
@@ -52,7 +52,7 @@ def UserLoginBonus(
)
def UserLoginBonusAll(
def GetUserLoginBonusAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -66,7 +66,7 @@ def UserLoginBonusAll(
{"userLoginBonusList": [...]}
"""
log_info("取用户登录奖励(全量):user_id=" + str(user_id))
items = fetch_all_pages(
items = get_all_pages(
"GetUserLoginBonusApi", user_id, "userLoginBonusList",
body_factory=lambda ni: UserLoginBonusRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=20),
+8 -8
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_map.py —— 取用户地图进度(GetUserMapApi)
GetUserMap.py —— 取用户地图进度(GetUserMapApi)
================================================================================
取玩家各地图的推进距离(真机 104 条)。
@@ -14,7 +14,7 @@ user_map.py —— 取用户地图进度(GetUserMapApi)
(真机:mapId=550003 的 distance 1424000 -> 1435000)。
调用示例:
from api import UserMap / UserMapAll
from api import GetUserMap / GetUserMapAll
"""
from __future__ import annotations
@@ -23,12 +23,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdPageRequestVO, fetch_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdPageRequestVO, get_all_pages
def UserMap(
def GetUserMap(
user_id: int,
*,
next_index: int = 0,
@@ -53,7 +53,7 @@ def UserMap(
)
def UserMapAll(
def GetUserMapAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -67,7 +67,7 @@ def UserMapAll(
{"userMapList": [...]}
"""
log_info("取用户地图进度(全量):user_id=" + str(user_id))
items = fetch_all_pages(
items = get_all_pages(
"GetUserMapApi", user_id, "userMapList",
body_factory=lambda ni: UserIdPageRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=1000),
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
GetUserMission.py —— 取用户任务与周数据(GetUserMissionDataApi)
================================================================================
取玩家的任务进度与周常数据(真机 6 条任务)。
@@ -16,7 +16,7 @@ user_mission.py —— 取用户任务与周数据(GetUserMissionDataApi)
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
调用示例:
from api import UserMissionData
from api import GetUserMissionData
"""
from __future__ import annotations
@@ -25,12 +25,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserMissionData(
def GetUserMissionData(
user_id: int,
*,
client_id: str = CLIENT_ID,
+9 -9
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_music.py —— 取用户曲目成绩(GetUserMusicApi)
GetUserMusic.py —— 取用户曲目成绩(GetUserMusicApi)
================================================================================
取玩家全部曲目的历史成绩归档(真机 739 组)。
@@ -10,7 +10,7 @@ user_music.py —— 取用户曲目成绩(GetUserMusicApi)
响应 :{userId, nextIndex, userMusicList:[{userMusicDetailList:[...], length}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMusicRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserMusic.cs(外层按曲分组)
source/Assembly-CSharp/Net.VO.Mai2/UserMusicDetail.cs(内层按难度)
source/Assembly-CSharp/Net.VO.Mai2/GetUserMusicDetail.cs(内层按难度)
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
按 (musicId, level) 的成绩条目。取数时要摊平。
@@ -18,7 +18,7 @@ user_music.py —— 取用户曲目成绩(GetUserMusicApi)
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
调用示例:
from api import UserMusicDetail, UserMusicDetailFlat
from api import GetUserMusicDetail, UserMusicDetailFlat
"""
from __future__ import annotations
@@ -27,12 +27,12 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserMusicRequestVO, fetch_all_pages
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserMusicRequestVO, get_all_pages
def UserMusicDetail(
def GetUserMusicDetail(
user_id: int,
*,
next_index: int = 0,
@@ -57,7 +57,7 @@ def UserMusicDetail(
)
def UserMusicDetailAll(
def GetUserMusicDetailAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -72,7 +72,7 @@ def UserMusicDetailAll(
每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。
"""
log_info("取用户曲目成绩(全量):user_id=" + str(user_id))
groups = fetch_all_pages(
groups = get_all_pages(
"GetUserMusicApi", user_id, "userMusicList",
body_factory=lambda ni: UserMusicRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=50),
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_new_item.py —— 取新增道具(GetUserNewItemListApi)
GetUserNewItem.py —— 取新增道具(GetUserNewItemListApi)
================================================================================
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
@@ -15,7 +15,7 @@ user_new_item.py —— 取新增道具(GetUserNewItemListApi)
★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。
调用示例:
from api import UserNewItemList
from api import GetUserNewItemList
"""
from __future__ import annotations
@@ -25,8 +25,8 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
@@ -38,7 +38,7 @@ class UserNewItemListRequestVO:
userPlaylogList: List[Any] = field(default_factory=list)
def UserNewItemList(
def GetUserNewItemList(
user_id: int,
*,
version: int = PLAY_VERSION_INT,
+6 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_option.py —— 取用户游戏设置(GetUserOptionApi)
GetUserOption.py —— 取用户游戏设置(GetUserOptionApi)
================================================================================
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
@@ -15,7 +15,7 @@ user_option.py —— 取用户游戏设置(GetUserOptionApi)
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
调用示例:
from api import UserOption
from api import GetUserOption
"""
from __future__ import annotations
@@ -24,12 +24,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserOption(
def GetUserOption(
user_id: int,
*,
client_id: str = CLIENT_ID,
+9 -9
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
userpreview.py —— 用户预览(GetUserPreviewApi)
GetUserPreview.py —— 用户预览(GetUserPreviewApi)
================================================================================
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
@@ -17,8 +17,8 @@ userpreview.py —— 用户预览(GetUserPreviewApi)
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
调用示例:
from api import UserPreview
pv = UserPreview(12345678, "TESTTOKEN0000000000")
from api import GetUserPreview
pv = GetUserPreview(12345678, "TESTTOKEN0000000000")
print(pv.userName, pv.playerRating)
"""
@@ -29,8 +29,8 @@ from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
@@ -74,7 +74,7 @@ class UserPreviewResponseVO:
errorId: int = 0
def UserPreview(
def GetUserPreview(
user_id: int,
token: str,
*,
@@ -133,16 +133,16 @@ def UserPreview(
if __name__ == "__main__":
import sys
from .log_setup import log_error, setup_logging, teardown_logging
from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("userpreview_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.userpreview <user_id> <token> [client_id]")
print("用法: python -m api.get_user_preview <user_id> <token> [client_id]")
return 1
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
pv = UserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
pv = GetUserPreview(int(sys.argv[1]), sys.argv[2], client_id=cid)
print("")
for name in UserPreviewResponseVO.__dataclass_fields__:
print(" " + name.ljust(18) + "= " + str(getattr(pv, name)))
+6 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
GetUserRating.py —— 取用户 Rating 明细(GetUserRatingApi)
================================================================================
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
@@ -14,7 +14,7 @@ user_rating.py —— 取用户 Rating 明细(GetUserRatingApi)
★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。
调用示例:
from api import UserRating
from api import GetUserRating
"""
from __future__ import annotations
@@ -23,12 +23,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserRating(
def GetUserRating(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
GetUserRecommendMusic.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
================================================================================
对应接口:
@@ -14,7 +14,7 @@ user_recommend_music.py —— 取推荐曲目(GetUserRecommendRateMusicApi /
source/2026-10-05.log:79,81
调用示例:
from api import UserRecommendRateMusic, UserRecommendSelectMusic
from api import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
"""
from __future__ import annotations
@@ -23,12 +23,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserRecommendRateMusic(
def GetUserRecommendRateMusic(
user_id: int,
*,
client_id: str = CLIENT_ID,
@@ -46,7 +46,7 @@ def UserRecommendRateMusic(
client_id=client_id, timeout_ms=timeout_ms, client=client)
def UserRecommendSelectMusic(
def GetUserRecommendSelectMusic(
user_id: int,
*,
client_id: str = CLIENT_ID,
+6 -6
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
GetUserRegion.py —— 取用户地区游玩统计(GetUserRegionApi)
================================================================================
取玩家在各地区的游玩次数。
@@ -12,7 +12,7 @@ user_region.py —— 取用户地区游玩统计(GetUserRegionApi)
source/2026-10-05.log:77
调用示例:
from api import UserRegion
from api import GetUserRegion
"""
from __future__ import annotations
@@ -21,12 +21,12 @@ from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def UserRegion(
def GetUserRegion(
user_id: int,
*,
client_id: str = CLIENT_ID,
+10 -10
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
GetUserRival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
================================================================================
取玩家的对手名单及其曲目成绩。
@@ -19,7 +19,7 @@ user_rival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalM
★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。
调用示例:
from api import UserRivalData, UserRivalMusic
from api import GetUserRivalData, GetUserRivalMusic
"""
from __future__ import annotations
@@ -28,18 +28,18 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import (
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import (
_get,
UserRivalRequestVO,
UserRivalMusicRequestVO,
RIVAL_MUSIC_LEVELS,
fetch_all_pages,
get_all_pages,
)
def UserRivalData(
def GetUserRivalData(
user_id: int,
rival_id: int,
*,
@@ -65,7 +65,7 @@ def UserRivalData(
)
def UserRivalMusic(
def GetUserRivalMusic(
user_id: int,
rival_id: int,
*,
@@ -100,7 +100,7 @@ def UserRivalMusic(
)
def UserRivalMusicAll(
def GetUserRivalMusicAll(
user_id: int,
rival_id: int,
*,
@@ -117,7 +117,7 @@ def UserRivalMusicAll(
"""
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id))
items = fetch_all_pages(
items = get_all_pages(
"GetUserRivalMusicApi", user_id, "userRivalMusicList",
body_factory=lambda ni: UserRivalMusicRequestVO(
userId=user_id, rivalId=rival_id, nextIndex=ni or 0,
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
GetUserShopStock.py —— 取用户商店库存(GetUserShopStockApi)
================================================================================
取玩家在各商店商品的已兑换次数。
@@ -15,7 +15,7 @@ user_shop_stock.py —— 取用户商店库存(GetUserShopStockApi)
不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。
调用示例:
from api import UserShopStock
from api import GetUserShopStock
"""
from __future__ import annotations
@@ -24,12 +24,12 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserShopStockRequestVO
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserShopStockRequestVO
def UserShopStock(
def GetUserShopStock(
user_id: int,
*,
shop_item_id_list: Optional[List[int]] = None,
+2 -2
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
LogSetup.py —— 统一日志模块(INFO / DEBUG 双模式)
================================================================================
【两种模式】
@@ -57,7 +57,7 @@ log_setup.py —— 统一日志模块(INFO / DEBUG 双模式)
from api import log_info, log_debug, log_request, log_response
def UserPreview(user_id, token, ...):
def GetUserPreview(user_id, token, ...):
# 函数的第一行 docstring 会成为日志里的「[接口说明]」
# 例如: '''获取用户预览信息'''
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
+64 -17
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
================================================================================
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
@@ -65,12 +65,26 @@ maimai_net.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
--------------------------------------------------------------------------------
二、★ 一个必须注意的坑:Accept-Encoding 必须为空
二、★ 请求头集合:必须与真机一致(2026-10-06 真机抓包实证)
--------------------------------------------------------------------------------
requests 默认会发 Accept-Encoding: gzip, deflate。
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码,会返回空 body。
实测:不发该头 -> HTTP 200 / 0 字节;显式置空 -> 正常返回。
C# 端不需要是因为 HttpWebRequest 默认 AutomaticDecompression = None。
真机(Unity Mono 的 HttpWebRequest)实际发出的头**只有这几个**:
number / Content-Type / User-Agent / charset / Mai-Encoding /
Content-Encoding / (+ 登录后的 Cookie)
**没有 Accept,也没有 Connection。**
requests 默认会补 Accept: * / Connection: keep-alive,
已在 MaimaiNetClient.__init__ 里从 session.headers 删掉。
★ Accept-Encoding 是唯一的例外,必须**存在且为空**:
实测(GetGameSettingApi,2026-10-06):
Accept-Encoding: "" -> 200 + application/json
不发 / "gzip, deflate" / "identity" -> 200 + application/octet-stream(0 字节)
反推:真机能拿到 JSON 响应体,说明 Mono 发的也是**空值**的 Accept-Encoding
(Headers.AllKeys 里看不到它,是框架层加的)。
所以这里固定 Accept-Encoding: ""。
--------------------------------------------------------------------------------
@@ -99,18 +113,27 @@ import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from config import (
AES_KEY,
AES_IV,
BASE_URL,
CLIENT_ID,
MAI_ENCODING,
OBFUSCATE_PARAM,
TIMEOUT_LONG_MS,
TIMEOUT_MS,
)
from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
from .log_setup import log_debug, log_error, log_request, log_response
# =============================================================================
# 协议常量(原在 config.py,2026-10-06 下沉到本模块 —— 它们只服务于网络层)
# =============================================================================
#: 游戏服务器(Maimai2Servlet)
#: 依据 source/Assembly-CSharp/Manager/OperationManager.cs:225,338
#: _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
#: AES 参数,依据 source/Assembly-CSharp/Net/CipherAES.cs:11-12
#: AesKey 32 字节 -> AES-256;AesIV 16 字节
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
AES_IV = b"F>;24DjU9W6ZsRH["
#: API 名混淆盐,依据 source/Assembly-CSharp/Net.Packet/Packet.cs:44
#: 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
OBFUSCATE_PARAM = "8bF76dE9"
from .LogSetup import log_debug, log_error, log_request, log_response
# =============================================================================
# 协议常量
@@ -374,6 +397,30 @@ class MaimaiNetClient:
self.timeout = timeout_ms / 1000.0
self.verify_ssl = verify_ssl
self.session = requests.Session()
# ---------------------------------------------------------------------
# ★ 对齐真机的请求头集合(2026-10-06 真机抓包实证)
# ---------------------------------------------------------------------
# 真机(Unity Mono 的 HttpWebRequest)在 Headers.AllKeys 里**只有**:
# number / Content-Type / User-Agent / charset / Mai-Encoding /
# Content-Encoding / (+ Cookie)
# 没有 Accept,也没有 Connection。
#
# 而 requests 默认会补上:
# Accept: */* <- 删掉(真机不发)
# Connection: keep-alive <- 删掉(真机不发;HTTP/1.1 本来就是长连接)
# Accept-Encoding <- 删掉(真机不发),改由 _build_headers 显式置空
#
# ★ Accept-Encoding 为什么必须**存在且为空**:
# 实测(GetGameSettingApi,2026-10-06):
# Accept-Encoding: "" -> 200 + application/json(384 B)
# 不发该头 / "gzip, deflate" / "identity"
# -> 200 + application/octet-stream(0 B)
# 所以这里从 Session 默认里删掉,再由 _build_headers 显式发一个空值。
self.session.headers.pop("Accept", None)
self.session.headers.pop("Connection", None)
self.session.headers.pop("Accept-Encoding", None)
#: ★ number 头的值(Packet.cs:189 GameManager.PingMsec)。
#: 只由 **Ping 心跳包**更新(对应 PingChecker.cs:227 PingSuccess),
#: 普通业务请求不会改动它 —— 真机整局 53 个请求的 number 恒为 63。
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
ping.py —— 心跳探活(PingMaimaiChn)
Ping.py —— 心跳探活(PingMaimaiChn)
================================================================================
功能:向游戏服务器发一次心跳,确认链路可达。
@@ -41,8 +41,8 @@ from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
def Ping(
@@ -80,7 +80,7 @@ def Ping(
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("ping_test")
+394 -49
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
upsert_user_all.py —— 上传成绩(UpsertUserAllApi)
UpsertUserAll.py —— 上传成绩(UpsertUserAllApi)
================================================================================
功能:把一局游玩的完整结算数据写进服务器。这是**唯一有副作用**的接口。
@@ -85,16 +85,16 @@ source/2026-10-05.log:123 逐节点核对:
--------------------------------------------------------------------------------
五、调用示例
--------------------------------------------------------------------------------
from api import (setup_logging, ParseQRCode, UserPreview, UserLogin,
fetch_upsert_snapshot, UpsertUserAll, build_play_from_config)
from api import (setup_logging, ParseQRCode, GetUserPreview, UserLogin,
GetNeedUserData, UpsertUserAll, build_play_from_config)
setup_logging("结算")
user_id, token = ParseQRCode(qr)
UserPreview(user_id, token)
GetUserPreview(user_id, token)
login = UserLogin(user_id, token)
# 1) 登录后立刻抓基线快照(差分的基准)
snapshot = fetch_upsert_snapshot(user_id, token=token)
snapshot = GetNeedUserData(user_id, token=token)
# 2) 组装本局数据(默认读 config.py 的 PLAY_*)
play = build_play_from_config()
@@ -126,15 +126,40 @@ from config import (
PLACE_ID,
PLACE_NAME,
REGION_ID,
PLAY_SPECIAL_NUM,
REGION_NAME,
TIMEOUT_LONG_MS,
UPSERT_CONFIRM,
UPSERT_SEND_COOLDOWN_SEC,
)
from .log_setup import log_debug, log_error, log_info, log_warning
from .maimai_net import (
# =============================================================================
# 机台硬件标志位(原 PLAY_SPECIAL_NUM,2026-10-06 下沉到本模块)
# =============================================================================
#
# 依据:source/Assembly-CSharp/Manager/GameManager.cs 的 CalcSpecialNum()
# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
# 1024 = 0x400,即除「无条件置位」那一位外全部为 0 —— 正好是一台标准 ACA 机台。
#
# ★ 反推验证:真机 playSpecial = 892014272
# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
PLAY_SPECIAL_NUM = 1024
#: ★ 机台本地游玩计数器:无法从服务器推导,按用户要求**恒为 2**
PLAY_LOCAL_PLAY_COUNT = 2
#: ★ afterGradeRank 恒为 1
PLAY_AFTER_GRADE_RANK = 1
#: ★ 是否在 userMusicDetailList 里搜同 (musicId, level) 来推导 beforeGrade / afterGrade。
#: ⚠ 源码实证这三个字段其实是**段位(Dan)**(VOExtensions.cs:1419-1421),
#: 真机无段位账号一律 0 / 0 / 1。
#: False = **一律 0**(默认,与真机一致 —— 使用方已确认走这条)
#: True = 从曲目记录推(仅调试用)
DERIVE_GRADE_FROM_MUSIC_DETAIL = False
from .LogSetup import log_debug, log_error, log_info, log_warning
from .MaimaiNet import (
MaimaiNetClient,
local_utc_offset_seconds,
login_date_time_now,
@@ -527,7 +552,7 @@ def build_favorite_delta(
"2" = 服务器侧有对应 itemKind(无论内容是否一致)
"1" = 服务器侧查不到该 itemKind
★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 user_favorite.py
★ 关键:**优先用 Get 侧最原始的响应建索引**,不要先用 GetUserFavorite.py
的「补 itemKind」逻辑归一化 —— 真机里 itemKind=10 的响应回的是
{"itemKind":null, "itemIdList":[]},正因为查不到才自然产生 "1"。
@@ -666,13 +691,19 @@ class PlayData:
default_factory=lambda: copy.deepcopy(config.PLAY_JUDGE_COUNTS))
# 评级
before_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_RATING)
after_rating: int = field(default_factory=lambda: config.PLAY_AFTER_RATING)
before_grade: int = field(default_factory=lambda: config.PLAY_BEFORE_GRADE)
after_grade: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE)
after_grade_rank: int = field(default_factory=lambda: config.PLAY_AFTER_GRADE_RANK)
before_delux_rating: int = field(default_factory=lambda: config.PLAY_BEFORE_DELUX_RATING)
after_delux_rating: int = field(default_factory=lambda: config.PLAY_AFTER_DELUX_RATING)
#: ★ 这四个**不再来自 config** —— 运行时由 derive_play_rating() 从
#: GetUserRatingApi 抓到的 userRatingList[0] 填(见该函数)。
#: 没传快照(离线组包)时才保持 0。
before_rating: int = 0
after_rating: int = 0
before_delux_rating: int = 0
after_delux_rating: int = 0
#: ★ 这两个由 derive_play_grade() 从快照的 userMusicDetailList 里
#: 搜同 (musicId, level) 的记录取 scoreRank 得到;搜不到就是 0。
before_grade: int = 0
after_grade: int = 0
#: ★ 恒为 1
after_grade_rank: int = PLAY_AFTER_GRADE_RANK
# 曲种
category_index: int = field(default_factory=lambda: config.PLAY_CATEGORY_INDEX)
@@ -680,8 +711,9 @@ class PlayData:
extra_flag: int = field(default_factory=lambda: config.PLAY_EXTRA_FLAG)
# 角色(游玩时快照,不加经验)
characters: List[Dict[str, int]] = field(
default_factory=lambda: copy.deepcopy(config.PLAY_CHARACTERS))
#: ★ 默认空列表 —— 真正的值由 derive_play_characters() 从服务器快照推。
#: (原 PLAY_CHARACTERS(现在 selftest.py 里) 已移到 selftest.py 作测试数据)
characters: List[Dict[str, int]] = field(default_factory=list)
# 消耗
play_credit: int = field(default_factory=lambda: config.PLAY_CREDIT)
@@ -691,7 +723,8 @@ class PlayData:
is_event_mode: bool = field(default_factory=lambda: config.PLAY_IS_EVENT_MODE)
is_new_free: bool = field(default_factory=lambda: config.PLAY_IS_NEW_FREE)
is_freedom_mode: bool = field(default_factory=lambda: config.PLAY_IS_FREEDOM_MODE)
local_play_count: int = field(default_factory=lambda: config.PLAY_LOCAL_PLAY_COUNT)
#: ★ 恒为 PLAY_LOCAL_PLAY_COUNT(= 2)—— 机台本地计数器,服务器不返回
local_play_count: int = PLAY_LOCAL_PLAY_COUNT
# 演算增量
#: 用户总游玩次数增量(0 = userData.playCount / currentPlayCount 都不变)
@@ -715,6 +748,154 @@ def build_play_from_config() -> PlayData:
return PlayData()
def derive_play_rating(
snapshot: Optional[Any] = None,
play: Optional[PlayData] = None,
) -> PlayData:
"""
从服务器快照里取**真实 rating**,填进 play 的四个 rating 字段。
★ 为什么不再用 config 里的静态值:
rating 会随每局成绩变化,写死会与服务器对不上。
★ 取值依据(真机报文实证,2026-10-06 22:23 那局):
userPlaylog.beforeRating = 15534
userPlaylog.afterRating = 15534
userPlaylog.beforeDeluxRating = 15534
userPlaylog.afterDeluxRating = 15534
userData.playerRating = 15534 ← 与 beforeRating/afterRating 一致
userData.musicRating = 15534 ← 与 beforeDeluxRating/afterDeluxRating 一致
userRatingList[0].rating = 0 ← 实测**不是**这个值,别取错
参数:
snapshot: GetNeedUserData 的返回值
play: 要就地填写的 PlayData(不传则新建)
返回:填好的 PlayData(同一对象)。
"""
p = play or build_play_from_config()
snap = normalize_snapshot(snapshot) if snapshot else {}
ud: Dict[str, Any] = {}
try:
data = snap.get("userData") or []
if isinstance(data, dict):
data = [data]
if isinstance(data, list) and data and isinstance(data[0], dict):
ud = data[0]
except Exception:
ud = {}
def _pick(*names):
for n in names:
v = ud.get(n)
if v not in (None, ""):
try:
return int(v)
except (TypeError, ValueError):
continue
return None
# playerRating 优先;退化时看 userRatingList[0].rating
rating = _pick("playerRating")
if rating is None:
try:
rl = snap.get("userRatingList") or []
if isinstance(rl, dict):
rl = [rl]
if rl and isinstance(rl[0], dict) and rl[0].get("rating") not in (None, ""):
rating = int(rl[0]["rating"])
except Exception:
rating = None
delux = _pick("musicRating")
if rating is not None:
p.before_rating = rating
p.after_rating = rating
if delux is not None:
p.before_delux_rating = delux
p.after_delux_rating = delux
log_info("rating 取自服务器快照:playerRating=" + str(p.before_rating)
+ " musicRating=" + str(p.before_delux_rating))
return p
def find_music_detail(snapshot: Optional[Any], music_id: int, level: int):
"""在快照的 userMusicDetailList 里搜同 (musicId, level) 的那条记录;没有返回 None。"""
snap = normalize_snapshot(snapshot) if snapshot else {}
items = snap.get("userMusicDetailList") or []
if isinstance(items, dict):
items = items.get("userMusicList") or []
for it in items:
if not isinstance(it, dict):
continue
if int(it.get("musicId", -1) or -1) == int(music_id) and int(it.get("level", -1) or -1) == int(level):
return it
return None
def derive_play_grade(
snapshot: Optional[Any] = None,
play: Optional[PlayData] = None,
) -> PlayData:
"""
推导 beforeGrade / afterGrade / afterGradeRank。
★★ 重要:源码里这三个字段是**段位(Dan)**,不是曲目评级 ——
source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:1419-1421
result.beforeGrade = (int)gameScore.PreDanRate; // 打之前的段位 rate
result.afterGrade = (int)gameScore.DanRate; // 打之后的段位 rate
result.afterGradeRank = (int)gameScore.Dan; // 段位等级
真机样本(无段位账号)= beforeGrade 0 / afterGrade 0 / afterGradeRank 1。
本工程的取法(按使用方要求):
before_grade = 在 userMusicDetailList 里搜同 (musicId, level) 的记录,
取它的 scoreRank;搜不到 -> 0
after_grade = 拿本局成绩(play.achievement)与记录里的 achievement 比对,
成绩更好 -> 取本局推导出的评级,否则沿用 before_grade
after_grade_rank = 恒为 1(PLAY_AFTER_GRADE_RANK)
⚠ 想严格复刻真机(无段位账号一律 0)就把 DERIVE_GRADE_FROM_MUSIC_DETAIL 设成 False。
"""
p = play or build_play_from_config()
p.after_grade_rank = PLAY_AFTER_GRADE_RANK
if not DERIVE_GRADE_FROM_MUSIC_DETAIL:
p.before_grade = 0
p.after_grade = 0
log_info("grade:DERIVE_GRADE_FROM_MUSIC_DETAIL=False -> beforeGrade=0 afterGrade=0"
" afterGradeRank=" + str(p.after_grade_rank))
return p
detail = find_music_detail(snapshot, p.music_id, p.music_level)
if detail is None:
p.before_grade = 0
p.after_grade = 0
log_info("grade:快照里没有 musicId=" + str(p.music_id) + " level="
+ str(p.music_level) + " 的记录 -> beforeGrade=0 afterGrade=0")
return p
before = int(detail.get("scoreRank", 0) or 0)
old_ach = int(detail.get("achievement", 0) or 0)
p.before_grade = before
# 本局成绩更好 -> 用本局推导的评级(没有成绩表时退回 before)
if int(p.achievement or 0) > old_ach:
p.after_grade = before
else:
p.after_grade = before
log_info("grade:musicId=" + str(p.music_id) + " level=" + str(p.music_level)
+ " 记录 scoreRank=" + str(before) + " achievement=" + str(old_ach)
+ " / 本局 achievement=" + str(p.achievement)
+ " -> beforeGrade=" + str(p.before_grade)
+ " afterGrade=" + str(p.after_grade)
+ " afterGradeRank=" + str(p.after_grade_rank))
return p
def derive_play_characters(
snapshot: Optional[Any] = None,
play: Optional[PlayData] = None,
@@ -723,18 +904,18 @@ def derive_play_characters(
从**服务器刚下发的快照**推出本局的 5 只角色(含游玩前等级)。
★ 为什么需要它:
config.PLAY_CHARACTERS 是手填的静态值,账号一升级就过期;
PLAY_CHARACTERS(现在 selftest.py 里) 是手填的静态值,账号一升级就过期;
用过期值会得到与 userCharacterList 对不上的 characterLevelN。
快照是登录后立刻抓的服务器真值,永远是最新的。
数据来源(都在 Get 响应里):
userData.charaSlot 本局 5 个角色槽的 characterId(顺序即 slot1..5)
source/Assembly-CSharp/Net.VO.Mai2/UserData.cs
source/Assembly-CSharp/Net.VO.Mai2/GetUserData.cs
userCharacterList 每只角色的 level / awakening
GetUserCharacterApi 响应
参数:
snapshot: fetch_upsert_snapshot 的返回值(或任何含 userData /
snapshot: GetNeedUserData 的返回值(或任何含 userData /
userCharacterList 的字典)。
play: 当前的 PlayData;快照里查不到的槽位回退到它同位置的配置。
@@ -811,7 +992,7 @@ def build_post_play_user_data(
**完全一致**(playCount=1258、point=4460、lastPlayDate=19:22:53.0)。
返回:
游玩后的 userData 字典(可直接放进 [ ] 传给 UserNewItemList)
游玩后的 userData 字典(可直接放进 [ ] 传给 GetUserNewItemList)
"""
p = play or build_play_from_config()
ua = build_user_all(
@@ -979,7 +1160,7 @@ def build_user_playlog(
# userCharacterList : 395=3803 101=4458 137 137 137 ← 游玩后(+1)
#
# 所以 p.characters 里放的必须是**游玩前**的值
# (config.PLAY_CHARACTERS 的注释也是这么写的),这里原样透传即可。
# (PLAY_CHARACTERS(现在 selftest.py 里) 的注释也是这么写的),这里原样透传即可。
# 推荐做法:用 derive_play_characters(snapshot) 直接从服务器刚下发的
# userData.charaSlot + userCharacterList 推出来,省得手填过期。
chars = list(p.characters or [])[:5]
@@ -1131,7 +1312,7 @@ def calc_play_special(calc_special_num: Optional[int] = None) -> int:
参数:
calc_special_num: 机台硬件标志位掩码(GameManager.CalcSpecialNum)。
默认取 config.PLAY_SPECIAL_NUM = 1024。
默认取 PLAY_SPECIAL_NUM = 1024。
返回:
int32 范围的 playSpecial。
@@ -1265,7 +1446,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
"userMapList": <GetUserMapApi 的响应>,
...
}
也接受 fetch_upsert_snapshot() 已经整理好的节点字典(幂等)。
也接受 GetNeedUserData() 已经整理好的节点字典(幂等)。
返回:
节点名 -> 节点值(已裁剪)
@@ -1318,7 +1499,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
# 真机值 = 服务器下发的周界,**不是本地算的**:
# GetGameWeeklyDataApi.updateDate = "2026-09-28 04:00:00"
# Upsert 侧 lastLoginWeek = "2026-09-28 04:00:00" ← 完全一致
# 依据 source/Assembly-CSharp/Manager/UserData.cs:1040-1049
# 依据 source/Assembly-CSharp/Manager/GetUserData.cs:1040-1049
# WeeklyData.LastLoginWeek = gameWeeklyData.updateDate;
# 所以优先用快照里的值;只有拿不到时才退回本地推导。
if "userWeeklyData" not in out and "userMissionDataList" in raw:
@@ -1364,7 +1545,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
# ★ 曲目成绩有两种形态,都要支持:
# a) 接口原始响应:两层嵌套(UserMusic.cs 外层按曲分组,
# 内层 userMusicDetailList 才是 (musicId, level) 条目)
# b) UserMusicDetailAll() 的产物:已摊平成单条
# b) GetUserMusicDetailAll() 的产物:已摊平成单条
# 判据:元素里带 "userMusicDetailList" 键 -> 嵌套;否则已摊平。
groups = src.get("userMusicList") or []
nested = any(
@@ -1382,7 +1563,7 @@ def _extract_from_get_responses(raw: Dict[str, Any]) -> Dict[str, Any]:
else:
items = src.get(list_key)
# ★ 兜底:取数脚本返回的字典有时用**节点名**作键
# (如 UserFavoriteAll -> {"userFavoriteList": [...], "_raw": [...]},
# (如 GetUserFavoriteAll -> {"userFavoriteList": [...], "_raw": [...]},
# 而 GetUserFavoriteApi 的原始响应是 {"userFavorite": {...}})。
if items is None:
items = src.get(node)
@@ -1713,6 +1894,117 @@ def apply_play_effects(
# 十二、组装 upsertUserAll
# =============================================================================
# -----------------------------------------------------------------------------
# ★ 歌曲解锁(解禁):MusicUnlock* 不是独立节点,而是并入 userItemList 的 UserItem
# -----------------------------------------------------------------------------
# 依据 source/Assembly-CSharp/Manager/UserData.cs:132-138 —— 客户端有四个解锁列表:
# MusicUnlockList / MusicUnlockMasterList / MusicUnlockReMasterList / MusicUnlockStrongList
# 它们**没有**对应的 userXxxList 节点,导出时统一并进 userItemList:
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
# MusicUnlockList -> itemKind = 5
# MusicUnlockMasterList -> itemKind = 6
# MusicUnlockReMasterList -> itemKind = 7
# MusicUnlockStrongList -> itemKind = 8
# 条目形如 {"itemKind":5,"itemId":1085,"stock":1,"isValid":true}
# (真机 data/12570152.json 的 userItemList 里 kind5=77 条、kind6=446 条、kind7=34 条)
#
# ★ 难度解锁的「天生免费」规则:
# source/Assembly-CSharp/Manager/NotesListManager.cs:415-453 IsUnlockMaster
# source/Assembly-CSharp/Manager/NotesListManager.cs:455-494 IsUnlockReMaster
# 两个函数开头都有 `if (id < 10000) return true;`
# -> musicId < 10000(标准谱)的 Master / Re:Master **天生解锁**,不必写 kind6/7;
# 只有 musicId >= 10000(DX 谱)才需要额外解锁。
# ★ 另外 Re:Master 还受活动开关约束(IsUnlockReMaster 里的
# `if (!instance.IsOpenEvent(subEventId)) return false;`),
# 那是机台/服务端侧的开关,脚本改不了 —— 写了 kind7 也可能进游戏仍是灰的。
#
# ★ 写 kind5 只是「把这首歌标记为已解锁」,不携带任何成绩;
# 成绩另由 userMusicDetailList 承载,纯解锁不需要打歌。
#: UserItem.itemKind 取值(source/Assembly-CSharp/Manager/UserData.cs:132-138)
ITEM_KIND_MUSIC_UNLOCK = 5
ITEM_KIND_MUSIC_UNLOCK_MASTER = 6
ITEM_KIND_MUSIC_UNLOCK_REMASTER = 7
ITEM_KIND_MUSIC_UNLOCK_STRONG = 8
#: 小于这个 musicId 的谱面 Master / Re:Master 天生解锁(NotesListManager.cs:415/455)
UNLOCK_MASTER_MIN_MUSIC_ID = 10000
def build_unlock_items(
music_ids: Sequence[int],
existing: Optional[Sequence[Any]] = None,
*,
master: bool = True,
remaster: bool = True,
unlock_base: bool = True,
) -> Tuple[List[Dict[str, Any]], List[str]]:
"""
构造「解锁歌曲 / 解禁难度」要追加的 userItemList 条目。
★ 只返回**服务器上还没有**的条目;已存在的跳过(重复插入会让服务端报错)。
参数:
music_ids: 要解锁的 musicId 列表(重复的自动去掉)
existing: 服务器现有的 userItemList(GetUserData 里的那份),
用来判断哪些条目已经存在
master: True = 同时解锁 Master 难度(仅 musicId >= 10000 才写 kind6)
remaster: True = 同时解锁 Re:Master 难度(仅 musicId >= 10000 才写 kind7)
unlock_base: True = 解锁歌曲本体(kind5)
返回:
(条目列表, 说明文字列表)
条目形如 {"itemKind":5,"itemId":1085,"stock":1,"isValid":true}
"""
have: set = set()
for it in existing or []:
if isinstance(it, dict):
try:
have.add((int(it.get("itemKind")), int(it.get("itemId"))))
except (TypeError, ValueError):
continue
ids: List[int] = []
for raw in music_ids or []:
try:
mid = int(raw)
except (TypeError, ValueError):
continue
if mid <= 0 or mid in ids:
continue
ids.append(mid)
items: List[Dict[str, Any]] = []
notes: List[str] = []
def _add(kind: int, mid: int, what: str) -> None:
if (kind, mid) in have:
notes.append("解锁检查:musicId=" + str(mid) + " 的" + what
+ "已经解锁(itemKind=" + str(kind) + "),跳过")
return
have.add((kind, mid))
items.append({"itemKind": kind, "itemId": mid, "stock": 1, "isValid": True})
notes.append("解锁:musicId=" + str(mid) + " " + what
+ "(itemKind=" + str(kind) + ")")
for mid in ids:
if unlock_base:
_add(ITEM_KIND_MUSIC_UNLOCK, mid, "歌曲本体")
if mid < UNLOCK_MASTER_MIN_MUSIC_ID:
# ★ 标准谱(musicId < 10000)Master/Re:Master 天生解锁,不需要条目
if master or remaster:
notes.append("解锁检查:musicId=" + str(mid)
+ " 是标准谱(< " + str(UNLOCK_MASTER_MIN_MUSIC_ID)
+ "),Master/Re:Master 天生解锁,跳过")
continue
if master:
_add(ITEM_KIND_MUSIC_UNLOCK_MASTER, mid, "Master 难度")
if remaster:
_add(ITEM_KIND_MUSIC_UNLOCK_REMASTER, mid, "Re:Master 难度")
return items, notes
def build_user_all(
snapshot: Optional[Any] = None,
play: Optional[PlayData] = None,
@@ -1728,6 +2020,9 @@ def build_user_all(
playlog_id: int = 0,
play_date_time: Optional[int] = None,
extra_data: Optional[Any] = None,
unlock_music_ids: Optional[Sequence[int]] = None,
unlock_master: bool = True,
unlock_remaster: bool = True,
) -> Dict[str, Any]:
"""
组装 upsertUserAll 的 36 个节点。
@@ -1751,6 +2046,10 @@ def build_user_all(
playlog_id: UserLoginApi 的 loginId(用于 userGamePlaylogList)
extra_data: ★ 额外 JSON(dict / JSON 字符串 / 文件路径),
自动匹配插入 36 节点,**优先级高于 patch**。见 merge_extra_data。
unlock_music_ids: ★ 要解锁(解禁)的 musicId 列表。见 build_unlock_items。
写进 userItemList:kind5=歌曲本体、kind6=Master、kind7=Re:Master。
unlock_master: 解锁时是否一并解禁 Master(仅 DX 谱 musicId >= 10000 生效)
unlock_remaster: 解锁时是否一并解禁 Re:Master(同上)
返回:
36 节点的 dict
@@ -1858,6 +2157,34 @@ def build_user_all(
for note in merge_extra_data(user_all, extra_data):
log_info(note)
# ---- ★ 歌曲解锁(解禁)----
# 放在 extra_data 之后:extra_data 可能整体替换了 userItemList,
# 解锁条目追加到最终列表末尾,位串补 "1"(= 服务端侧新增该 item)。
# 依据见本文件「歌曲解锁」注释块。
if unlock_music_ids:
unlock_items, unlock_notes = build_unlock_items(
unlock_music_ids,
existing=user_all.get("userItemList") or [],
master=unlock_master,
remaster=unlock_remaster,
)
for note in unlock_notes:
log_info(note)
if unlock_items:
old_items = list(user_all.get("userItemList") or [])
old_bits = user_all.get("isNewItemList")
if not isinstance(old_bits, str):
old_bits = ""
if len(old_bits) != len(old_items):
# 先对齐到旧列表长度,避免新条目被 sync_bits_to_arrays 补成 "0"
old_bits = (old_bits + "0" * len(old_items))[:len(old_items)]
user_all["userItemList"] = old_items + unlock_items
user_all["isNewItemList"] = old_bits + "1" * len(unlock_items)
log_info("★ 本次解锁共新增 " + str(len(unlock_items))
+ " 个 userItem 条目(位串补 " + "1" * len(unlock_items) + ")")
else:
log_info("解锁检查:指定的歌曲/难度在服务器上都已经解锁,无需改动")
# ---- 清理内部字段 ----
for key in list(user_all.keys()):
if key.startswith("_"):
@@ -2152,7 +2479,7 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any:
★ 为什么要绕这一下:
autowrap() 只替换 api/__init__.py 命名空间里的名字
(见 log_setup.autowrap 的注释),模块内部的直接引用拿到的还是原始函数。
UpsertUserAll 内部要调 fetch_upsert_snapshot / UserNewItemList / UserData,
UpsertUserAll 内部要调 GetNeedUserData / GetUserNewItemList / GetUserData,
绕这一下它们才会像用户直接调用那样打印「=== xxx 开始运行 ===」,
主脚本就完全不用自己写日志了。
"""
@@ -2168,18 +2495,18 @@ def _resolve_api_func(name: str, fallback: Any = None) -> Any:
def _lazy(name: str) -> Any:
"""按名字延迟取 api 子模块里的原始函数(_resolve_api_func 的兜底)。"""
if name == "fetch_upsert_snapshot":
from .upsert_snapshot import fetch_upsert_snapshot
return fetch_upsert_snapshot
if name == "UserNewItemList":
from .user_new_item import UserNewItemList
return UserNewItemList
if name == "UserData":
from .userdata import UserData
return UserData
if name == "UserMusicDetailAll":
from .user_music import UserMusicDetailAll
return UserMusicDetailAll
if name == "GetNeedUserData":
from .GetNeedUserData import GetNeedUserData
return GetNeedUserData
if name == "GetUserNewItemList":
from .GetUserNewItem import GetUserNewItemList
return GetUserNewItemList
if name == "GetUserData":
from .GetUserData import GetUserData
return GetUserData
if name == "GetUserMusicDetailAll":
from .GetUserMusic import GetUserMusicDetailAll
return GetUserMusicDetailAll
raise PatchError("未知的内部 api:" + str(name))
@@ -2227,7 +2554,7 @@ def verify_upsert_landing(
before_ud = (base.get("userData") or [{}])[0] if base.get("userData") else {}
try:
after_raw = _api("UserData")(user_id, client_id=client_id,
after_raw = _api("GetUserData")(user_id, client_id=client_id,
timeout_ms=timeout_ms, client=client)
after_ud = (after_raw.get("userData") or {}) if isinstance(after_raw, dict) else {}
except Exception as exc:
@@ -2245,7 +2572,7 @@ def verify_upsert_landing(
# 本局曲目的 playCount
try:
music_raw = _api("UserMusicDetailAll")(user_id, client_id=client_id,
music_raw = _api("GetUserMusicDetailAll")(user_id, client_id=client_id,
timeout_ms=timeout_ms, client=client)
rows = music_raw.get("userMusicList") or [] if isinstance(music_raw, dict) else []
hit = [m for m in rows
@@ -2297,6 +2624,9 @@ def UpsertUserAll(
auto_characters: bool = True,
play: Optional[PlayData] = None,
extra_data: Optional[Any] = None,
unlock_music_ids: Optional[Sequence[int]] = None,
unlock_master: bool = True,
unlock_remaster: bool = True,
patch: Optional[Dict[str, Any]] = None,
user_playlog_list: Optional[List[Any]] = None,
pre_new_item_list: bool = True,
@@ -2336,7 +2666,7 @@ def UpsertUserAll(
auth_date_time: UserLoginApi 请求里的 dateTime(可选;
userData.dateTime 要用它,真机实测两处一致)
token: 会话令牌(可选)。★ 只在「没传 snapshot 且 auto_snapshot=True」
时需要 —— 内部用它自动调 fetch_upsert_snapshot。
时需要 —— 内部用它自动调 GetNeedUserData。
snapshot: 基线快照(可选,见 normalize_snapshot)。
传了就用它;不传则按 auto_snapshot 自动抓。
★ 服务器对 userData 是整表覆盖、对差分节点是「只回传变化项」,
@@ -2344,7 +2674,7 @@ def UpsertUserAll(
auto_snapshot: True(默认)= 没传 snapshot 时自动抓一份。
auto_characters: True(默认)= 从快照的 userData.charaSlot +
userCharacterList 推出本局 5 只角色(**游玩前**等级)。
这样 config.PLAY_CHARACTERS 过期也不影响。
这样 PLAY_CHARACTERS(现在 selftest.py 里) 过期也不影响。
play: 本局数据(可选,默认按 config.PLAY_*)
extra_data: ★ 额外 JSON,自动匹配插入 upsertUserAll 的 36 个节点。
接受 dict / JSON 字符串 / JSON 文件路径,例如:
@@ -2352,6 +2682,14 @@ def UpsertUserAll(
"isNewKaleidxScopeList": "1"}
优先级最高(覆盖快照演算出的同名字段)。
没给位串时会按数组长度补 "0" 并给出提示。
unlock_music_ids: ★ 要解锁(解禁)的 musicId 列表,例如 [1085, 11414]。
写进 userItemList:kind5=歌曲本体、kind6=Master、kind7=Re:Master。
★ musicId < 10000(标准谱)的 Master/Re:Master 天生解锁,
只写 kind5;只有 DX 谱(>= 10000)才写 kind6/7。
★ 已存在的条目自动跳过(重复插入服务端会报错)。
见 build_unlock_items 上方的源码依据。
unlock_master: 解锁时是否一并解禁 Master 难度(默认 True)
unlock_remaster: 解锁时是否一并解禁 Re:Master 难度(默认 True)
patch: 同 extra_data 的旧接口(只收 dict,不补位串提示)。
user_playlog_list: 本局成绩明细(可选;不传则用 play 自动构造一条)
pre_new_item_list: True(默认)= 发包前先调 GetUserNewItemListApi
@@ -2390,7 +2728,7 @@ def UpsertUserAll(
"推荐传 token=...(自动抓)或 snapshot=...(自己抓好传进来)")
else:
log_info("未传 snapshot,自动抓取结算基线快照 ...")
snapshot = _api("fetch_upsert_snapshot")(
snapshot = _api("GetNeedUserData")(
user_id, token=token, client_id=client_id,
place_id=place_id, region_id=region_id, client=client,
)
@@ -2405,6 +2743,11 @@ def UpsertUserAll(
+ " / ".join(str(c["characterId"]) + ":" + str(c["level"])
for c in derived))
# ★ rating / grade 一律从服务器快照推(不再读 config 的静态值)
if snapshot:
derive_play_rating(snapshot, p)
derive_play_grade(snapshot, p)
# 游玩/上报时刻:不传就取「现在」
play_now = int(play_date_time) if play_date_time else login_date_time_now()
@@ -2426,7 +2769,7 @@ def UpsertUserAll(
region_id=region_id, region_name=region_name, client_id=client_id,
play_date_time=play_now,
)
_api("UserNewItemList")(
_api("GetUserNewItemList")(
user_id, version=p.version_int,
user_data=[post_user_data],
user_playlog_list=list(user_playlog_list),
@@ -2438,6 +2781,8 @@ def UpsertUserAll(
# ---------- 阶段 5:组装 upsertUserAll(快照基线 + 本局演算 + 差分)----------
user_all = build_user_all(
snapshot=snapshot, play=p, patch=patch, extra_data=extra_data,
unlock_music_ids=unlock_music_ids,
unlock_master=unlock_master, unlock_remaster=unlock_remaster,
login_date_time=login_date_time, auth_date_time=auth_date_time,
place_id=place_id, place_name=place_name,
region_id=region_id, region_name=region_name, client_id=client_id,
@@ -2523,7 +2868,7 @@ def UpsertUserAll(
# =============================================================================
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("upsert_user_all_test")
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
r"""
upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
================================================================================
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
@@ -10,7 +10,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
public ulong userId;
public UserChargelog userChargelog;
public UserCharge userCharge;
public GetUserCharge userCharge;
public long loginDateTime;
响应 :{returnCode, apiName}
@@ -26,7 +26,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse
│ └─ 没有 -> 新增一条 stock=1
└─ TicketManager.cs:234 RequestUserCharge(playerId)
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
│ -> 取**购买后**(stock > 0)的那条 UserCharge
│ -> 取**购买后**(stock > 0)的那条 GetUserCharge
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
│ -> chargeId = ticketId
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
@@ -56,7 +56,7 @@ upsert_user_chargelog.py —— 购买功能票 / 上传购买记录(UpsertUse
--------------------------------------------------------------------------------
游戏本地主数据:
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
已抄进 config.TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
已抄进 TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
--------------------------------------------------------------------------------
四、安全闸门
@@ -91,17 +91,64 @@ from config import (
CLIENT_ID,
PLACE_ID,
REGION_ID,
TICKET_CREDIT_NUM,
TICKET_NAMES,
TICKET_VALID_DAYS,
TICKET_VALID_HOUR_OFFSET,
TIMEOUT_MS,
UPSERT_CHARGELOG_CONFIRM,
)
from .log_setup import log_debug, log_error, log_info, log_warning
from .maimai_net import MaimaiNetClient, request_dict
from .upsert_user_all import UpsertResponseVO, resolve_api_func
# =============================================================================
# 功能票(Ticket)主数据(原在 config.py,2026-10-06 下沉到本模块)
# =============================================================================
#
# 依据:游戏本地主数据
# Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml
# 源码侧 Manager.MaiStudio.TicketData.creditNum
#
# ★ 为什么需要它:
# UpsertUserChargelogApi(购买功能票)的 userChargelog.price 取的是
# **ticket.creditNum(票的信用点倍数)**,不是 GetGameChargeApi 里的 money price:
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:410
# result.price = ticket.creditNum;
#
# ticketKind 含义:1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
#: chargeId -> creditNum
TICKET_CREDIT_NUM = {
0: 0,
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
10005: 0, 10105: 0, 10205: 0, # 活动票
11001: 0, # 免费 1.5 倍券
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
}
#: 票名(只用于日志好看,不影响协议)
TICKET_NAMES = {
0: "使用しない",
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
}
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
#: 票有效期算法参数
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00"
TICKET_VALID_HOUR_OFFSET = -4
TICKET_VALID_DAYS = 90
from .ChargeGuard import (
has_fresh_read,
last_read_age,
note_charge_purchase,
purchased_charge_ids,
)
from .LogSetup import log_debug, log_error, log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
from .UpsertUserAll import UpsertResponseVO, resolve_api_func
class ChargelogConfirmError(RuntimeError):
@@ -130,7 +177,7 @@ class ChargelogGuardError(RuntimeError):
@dataclass
class UserChargeVO:
"""UserCharge —— 一张票的持有状态(UserCharge.cs,4 字段)。"""
"""GetUserCharge —— 一张票的持有状态(GetUserCharge.cs,4 字段)。"""
chargeId: int = 0
stock: int = 0
purchaseDate: str = ""
@@ -173,12 +220,12 @@ def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
取 userChargelog.price —— 即 ticket.creditNum。
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
数据来源 config.TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
"""
if override is not None:
return int(override)
if int(charge_id) not in TICKET_CREDIT_NUM:
log_warning("config.TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
log_warning("TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
+ " —— 按 0 处理,建议补进配置")
return 0
return int(TICKET_CREDIT_NUM[int(charge_id)])
@@ -293,6 +340,8 @@ def UpsertUserChargelog(
client_id: str = CLIENT_ID,
guard_no_stock: bool = True,
guard_purchasable: bool = True,
guard_require_charge_read: bool = True,
guard_single_purchase: bool = True,
confirm: Optional[bool] = None,
dry_run: bool = False,
timeout_ms: Optional[int] = None,
@@ -304,14 +353,14 @@ def UpsertUserChargelog(
参数:
user_id: 用户 ID(必填)
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
★ 付费票是 2/3/4/5/6(config.PAID_TICKET_IDS)
★ 付费票是 2/3/4/5/6(PAID_TICKET_IDS)
login_date_time: 登录时刻(必填,★ 必须 == UserLoginApi 的 loginDateTime)
charge_list: 当前票券列表(可选)。不传则自动调 GetUserChargeApi。
用来算「购买后库存」和是否已有该票。
user_data: 当前 userData(可选)。不传则自动调 GetUserDataApi。
用来填 playCount / playerRating。
stock: ★ 购买后该票的库存;不传 = 现有 + 1
price: ★ userChargelog.price;不传 = config.TICKET_CREDIT_NUM[charge_id]
price: ★ userChargelog.price;不传 = TICKET_CREDIT_NUM[charge_id]
purchase_date: 购买时刻字符串;不传 = 现在
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
play_count: 覆盖 playCount(不传则取 userData.playCount)
@@ -327,6 +376,16 @@ def UpsertUserChargelog(
(抛 ChargelogGuardError,未发包)。
理由见 ChargelogGuardError 的 docstring —— 有封号风险。
确认要强行购买才传 False。
guard_require_charge_read:
★ 默认 True = **运行本函数之前必须先调用过 GetUserCharge**
(本进程内、10 分钟内),否则直接拒绝购买。
目的:防止「没查票就买票」造成同一账号重复购买。
显式传了 charge_list 也算「查过了」。
实现见 api/ChargeGuard.py。
guard_single_purchase:
★ 默认 True = **同一账号在同一进程里只允许买一次票**。
真机买完票会立刻被本局用掉(stock 恒为 0),
连着买两张属于异常行为。实现见 api/ChargeGuard.py。
confirm: 副作用闸门;None 时取 config.UPSERT_CHARGELOG_CONFIRM
dry_run: True = 只构造并返回报文,绝不发包
@@ -342,20 +401,48 @@ def UpsertUserChargelog(
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
+ "(" + name + ")dry_run=" + str(dry_run))
if int(charge_id) not in tuple(config.PAID_TICKET_IDS):
log_warning("chargeId=" + str(charge_id) + " 不在 config.PAID_TICKET_IDS="
+ str(tuple(config.PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
if int(charge_id) not in tuple(PAID_TICKET_IDS):
log_warning("chargeId=" + str(charge_id) + " 不在 PAID_TICKET_IDS="
+ str(tuple(PAID_TICKET_IDS)) + " 里 —— 它可能不是可购买的付费票")
# ---------- 0. ★ 前置闸门:必须先跑过 GetUserCharge ----------
# ★ 为什么放在「取当前状态」之前:下面那一步在 charge_list 为空时会
# 自己调一次 GetUserCharge —— 闸门若放在后面,它自己就把自己放行了。
if guard_require_charge_read and charge_list is None:
if not has_fresh_read(user_id):
msg = ("安全检查未通过:本次购买之前**没有调用过 GetUserCharge**。\n"
" 买票前必须先查一次账号当前票券(有没有持票),否则无法排除"
"「重复购买」——服务端看到的是状态矛盾,有**封号风险**。\n"
" 正确顺序:GetUserCharge(user_id) -> UpsertUserChargelog(...)\n"
" 或者直接把查到的票券列表传进来:charge_list=[...]\n"
" 见 api/ChargeGuard.py。")
log_error(msg)
raise ChargelogGuardError(msg)
log_info("前置检查通过:本次购买前已调用 GetUserCharge("
+ str(round(float(last_read_age(user_id) or 0.0), 1)) + " 秒前)")
# ---------- 0.5 ★ 前置闸门:同一账号本进程内只允许买一次 ----------
if guard_single_purchase:
done = purchased_charge_ids(user_id)
if done:
msg = ("安全检查未通过:本进程内该账号(user_id=" + str(user_id)
+ ")**已经买过功能票**(chargeId=" + str(done) + ")。\n"
" 真机买完票会立刻被这一局用掉,正常状态 stock 恒为 0;"
"连着买两张属于异常行为,有**封号风险**。\n"
" 确需强行购买请传 guard_single_purchase=False。")
log_error(msg)
raise ChargelogGuardError(msg)
# ---------- 1. 取当前状态 ----------
if charge_list is None:
log_info("未传 charge_list,自动抓取当前票券 ...")
raw = resolve_api_func("UserCharge")(
raw = resolve_api_func("GetUserCharge")(
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
charge_list = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
if user_data is None:
log_info("未传 user_data,自动抓取当前档案 ...")
raw_ud = resolve_api_func("UserData")(
raw_ud = resolve_api_func("GetUserData")(
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client)
user_data = (raw_ud.get("userData") if isinstance(raw_ud, dict) else None) or {}
@@ -499,6 +586,8 @@ def UpsertUserChargelog(
log_info("购买成功:returnCode=" + str(resp.returnCode) + " apiName=" + resp.apiName)
log_info(" 票名 " + name + " 库存 " + str(old_stock) + " -> " + str(stock))
# ★ 记一笔「这个账号本进程已经买过票」,同进程再买会被前置闸门拦下
note_charge_purchase(user_id, charge_id)
return resp
@@ -507,7 +596,7 @@ def UpsertUserChargelog(
# =============================================================================
if __name__ == "__main__":
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chargelog_test")
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*-
"""
user_getter_common.py —— 各取数脚本的公共底座
UserGetterCommon.py —— 各取数脚本的公共底座
================================================================================
只提供三样东西,取数逻辑都在各自的脚本里:
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
2. _get() 发一次请求并返回**原始响应 dict**
3. fetch_all_pages() 自动翻页
3. get_all_pages() 自动翻页
--------------------------------------------------------------------------------
★ 为什么单独抽出来:各接口的请求体并不统一
@@ -36,8 +36,8 @@ from typing import Any, Callable, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_debug, log_warning
from .maimai_net import MaimaiNetClient, get_default_client
from .LogSetup import log_debug, log_warning
from .MaimaiNet import MaimaiNetClient, get_default_client
# =============================================================================
@@ -187,7 +187,7 @@ def _get(
return raw or {}
def fetch_all_pages(
def get_all_pages(
api_name: str,
user_id: int,
list_key: str,
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
userlogin.py —— 用户正式登录(UserLoginApi)
UserLogin.py —— 用户正式登录(UserLoginApi)
================================================================================
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
@@ -48,8 +48,8 @@ from typing import Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
from .log_setup import log_debug, log_error, log_info
from .maimai_net import (
from .LogSetup import log_debug, log_error, log_info
from .MaimaiNet import (
MaimaiNetClient,
local_utc_offset_seconds,
login_date_time_now,
@@ -198,7 +198,7 @@ def UserLogin(
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogin_test")
+4 -4
View File
@@ -1,6 +1,6 @@
# -*- coding: utf-8 -*-
"""
userlogout.py —— 用户登出(UserLogoutApi)
UserLogout.py —— 用户登出(UserLogoutApi)
================================================================================
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
@@ -39,8 +39,8 @@ from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
from .log_setup import log_debug, log_error, log_info
from .maimai_net import MaimaiNetClient, request_dict
from .LogSetup import log_debug, log_error, log_info
from .MaimaiNet import MaimaiNetClient, request_dict
#: DelayLog.request 的定长(DelayLog.cs:17)
DELAY_LOG_SLOTS = 26
@@ -228,7 +228,7 @@ def UserLogout(
if __name__ == "__main__":
import sys
from .log_setup import setup_logging, teardown_logging
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogout_test")
+222 -156
View File
@@ -20,56 +20,60 @@ api 包 —— 统一导出
目录结构(一个 api 一个脚本)
--------------------------------------------------------------------------------
基础设施
log_setup.py 统一日志(INFO / DEBUG 双模式)
maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
user_getter_common.py 取数脚本公共底座(请求 VO / _get / 翻页)
LogSetup.py 统一日志(INFO / DEBUG 双模式)
MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
UserGetterCommon.py 取数脚本公共底座(请求 VO / _get / 翻页)
ChargeGuard.py ★ 买票前置闸门:买票前必须先跑过 GetUserCharge
会话链路
chime.py 扫码登录 -> userId + token
gamesettings.py 取游戏设置
ping.py 心跳探活
userpreview.py 用户预览
userlogin.py 正式登录 -> loginId + loginDateTime
userlogout.py 登出
Chime.py 扫码登录 -> userId + token
GetGameSettings.py GetGameSettingApi(游戏设置)
Ping.py 心跳探活
GetUserPreview.py GetUserPreviewApi(用户预览)
UserLogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
UserLogout.py UserLogoutApi(登出)
上传成绩
upsert_user_all.py ★ 上传成绩(唯一有副作用的接口)
upsert_snapshot.py 结算快照编排(统一调用下面的取数脚本)
上传 / 结算(有副作用的接口)
UpsertUserAll.py ★ UpsertUserAllApi(上传成绩 + 解锁歌曲/解禁难度)
UpsertUserChargelog.py UpsertUserChargelogApi(购买功能票)
GetNeedUserData.py ★ 结算快照编排(统一调用下面的取数脚本),
并把结果落盘到 <工程根>/data/<userId>.json
取数(一个接口一个脚本)
userdata.py GetUserDataApi
user_extend.py GetUserExtendApi
user_option.py GetUserOptionApi
user_character.py GetUserCharacterApi
user_ghost.py GetUserGhostApi
user_map.py GetUserMapApi
user_login_bonus.py GetUserLoginBonusApi
user_rating.py GetUserRatingApi
user_item.py GetUserItemApi
user_music.py GetUserMusicApi
user_course.py GetUserCourseApi
user_charge.py GetUserChargeApi
user_favorite.py GetUserFavoriteApi
user_activity.py GetUserActivityApi
user_mission.py GetUserMissionDataApi
user_intimate.py GetUserIntimateApi
user_shop_stock.py GetUserShopStockApi
user_favorite_item.py GetUserFavoriteItemApi
user_rival.py GetUserRivalDataApi / GetUserRivalMusicApi
user_friend_bonus.py GetUserFriendBonusApi
user_kaleidx_scope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
user_region.py GetUserRegionApi
user_recommend_music.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
user_new_item.py GetUserNewItemListApi
game_ranking.py GetGameRankingApi
game_event.py GetGameEventApi
game_charge.py GetGameChargeApi
game_weekly_data.py GetGameWeeklyDataApi
game_tournament_info.py GetGameTournamentInfoApi
game_ng_music_id.py GetGameNgMusicIdApi
★ 命名约定:服务端 API 名里带 Get 的,**脚本名与函数名一律带 get / Get**
(不要模棱两可)。所以下面这些文件全是 get_ 开头。
取数 —— GetUser*(一个接口一个脚本)
GetUserData.py GetUserDataApi
GetUserExtend.py GetUserExtendApi
GetUserOption.py GetUserOptionApi
GetUserCharacter.py GetUserCharacterApi
GetUserGhost.py GetUserGhostApi
GetUserMap.py GetUserMapApi
GetUserLoginBonus.py GetUserLoginBonusApi
GetUserRating.py GetUserRatingApi
GetUserItem.py GetUserItemApi
GetUserMusic.py GetUserMusicApi
GetUserCourse.py GetUserCourseApi
GetUserCharge.py GetUserChargeApi
GetUserFavorite.py GetUserFavoriteApi
GetUserActivity.py GetUserActivityApi
GetUserMission.py GetUserMissionDataApi
GetUserIntimate.py GetUserIntimateApi
GetUserShopStock.py GetUserShopStockApi
GetUserFavoriteItem.py GetUserFavoriteItemApi
GetUserRival.py GetUserRivalDataApi / GetUserRivalMusicApi
GetUserFriendBonus.py GetUserFriendBonusApi
GetUserKaleidxScope.py GetUserKaleidxScopeApi / GetGameKaleidxScopeApi
GetUserRegion.py GetUserRegionApi
GetUserRecommendMusic.py GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi
GetUserNewItem.py GetUserNewItemListApi
取数 —— GetGame*
GetGameSettings.py GetGameSettingApi
GetGameCharge.py GetGameChargeApi
"""
from .log_setup import (
from .LogSetup import (
# 会话
setup_logging,
teardown_logging,
@@ -104,7 +108,12 @@ from .log_setup import (
# 1) 网络核心
# ---------------------------------------------------------------------------
from .maimai_net import (
from .MaimaiNet import (
# 协议常量(原在 config.py,已下沉到 MaimaiNet.py)
BASE_URL,
AES_KEY,
AES_IV,
OBFUSCATE_PARAM,
MaimaiNetClient,
MaimaiNetError,
get_default_client,
@@ -133,9 +142,19 @@ from .maimai_net import (
# 2) 会话链路
# ---------------------------------------------------------------------------
from .chime import ParseQRCode
from .Chime import (
ParseQRCode,
# aimedb / QR 常量(原在 config.py,已下沉到 Chime.py)
AIMEDB_HOST,
AIMEDB_PORT,
AIMEDB_PATH,
QR_SIGN_KEY,
QR_OPEN_GAME_ID,
QR_TITLE_KEY,
QR_USER_AGENT,
)
from .gamesettings import (
from .GetGameSettings import (
GetGameSettings,
GameSetting,
GameSettingRequestVO,
@@ -144,21 +163,21 @@ from .gamesettings import (
is_ping_disabled,
)
from .ping import Ping
from .Ping import Ping
from .userpreview import (
UserPreview,
from .GetUserPreview import (
GetUserPreview,
UserPreviewRequestVO,
UserPreviewResponseVO,
)
from .userlogin import (
from .UserLogin import (
UserLogin,
UserLoginRequestVO,
UserLoginResponseVO,
)
from .userlogout import (
from .UserLogout import (
UserLogout,
LogoutType,
DelayLog,
@@ -175,11 +194,18 @@ from .userlogout import (
# 3) 上传成绩
# ---------------------------------------------------------------------------
from .upsert_user_all import (
from .UpsertUserAll import (
UpsertUserAll,
PlayData,
build_play_from_config,
derive_play_characters,
derive_play_rating,
derive_play_grade,
find_music_detail,
PLAY_SPECIAL_NUM,
PLAY_LOCAL_PLAY_COUNT,
PLAY_AFTER_GRADE_RANK,
DERIVE_GRADE_FROM_MUSIC_DETAIL,
UserAllRequestVO,
UpsertResponseVO,
UpsertUserAllError,
@@ -211,6 +237,13 @@ from .upsert_user_all import (
load_extra_data,
merge_extra_data,
verify_upsert_landing,
# ★ 歌曲解锁(解禁):写进 userItemList 的 itemKind 5/6/7
build_unlock_items,
ITEM_KIND_MUSIC_UNLOCK,
ITEM_KIND_MUSIC_UNLOCK_MASTER,
ITEM_KIND_MUSIC_UNLOCK_REMASTER,
ITEM_KIND_MUSIC_UNLOCK_STRONG,
UNLOCK_MASTER_MIN_MUSIC_ID,
default_user_weekly_data,
default_user_2p_playlog,
calc_play_special,
@@ -230,15 +263,44 @@ from .upsert_user_all import (
USER_MISSION_DROP_KEYS,
)
from .upsert_snapshot import (
fetch_upsert_snapshot,
from .GetNeedUserData import (
GetNeedUserData,
save_snapshot,
load_snapshot,
summarize_snapshot,
DEFAULT_SNAPSHOT_NODES,
# ★ 用户数据落盘(<工程根>/data/<userId>.json)
PROJECT_ROOT,
DATA_DIR,
user_data_path,
save_user_data,
load_user_data,
list_saved_user_ids,
)
from .upsert_user_chargelog import (
# ---------------------------------------------------------------------------
# ★ 买票前置闸门:GetUserCharge 必须先跑过(见 api/ChargeGuard.py)
# ---------------------------------------------------------------------------
from .ChargeGuard import (
CHARGE_READ_FRESH_SEC,
has_fresh_read,
held_charges,
last_charge_list,
last_read_age,
note_charge_purchase,
note_user_charge_read,
purchased_charge_ids,
reset as reset_charge_guard,
)
from .UpsertUserChargelog import (
# 票券主数据(原在 config.py,已下沉到 UpsertUserChargelog.py)
TICKET_CREDIT_NUM,
TICKET_NAMES,
PAID_TICKET_IDS,
TICKET_VALID_HOUR_OFFSET,
TICKET_VALID_DAYS,
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
@@ -256,7 +318,7 @@ from .upsert_user_chargelog import (
# 4) 取数公共底座
# ---------------------------------------------------------------------------
from .user_getter_common import (
from .UserGetterCommon import (
EmptyRequestVO as GetterEmptyRequestVO,
UserIdRequestVO as GetterUserIdRequestVO,
UserIdPageRequestVO,
@@ -269,7 +331,7 @@ from .user_getter_common import (
UserShopStockRequestVO,
UserRivalRequestVO,
UserRivalMusicRequestVO,
fetch_all_pages,
get_all_pages,
ITEM_KINDS,
ITEM_NEXT_INDEX_FACTOR,
FAVORITE_ITEM_KINDS,
@@ -280,42 +342,37 @@ from .user_getter_common import (
# 5) 取数脚本(一个接口一个脚本)
# ---------------------------------------------------------------------------
from .userdata import UserData
from .user_extend import UserExtend
from .user_option import UserOption
from .user_character import UserCharacter
from .user_ghost import UserGhost
from .user_map import UserMap, UserMapAll
from .user_login_bonus import UserLoginBonus, UserLoginBonusAll
from .user_rating import UserRating
from .user_item import UserItem, UserItemAll
from .user_music import UserMusicDetail, UserMusicDetailAll
from .user_course import UserCourse, UserCourseAll
from .user_charge import UserCharge
from .user_favorite import UserFavorite, UserFavoriteAll
from .user_activity import UserActivity
from .user_mission import UserMissionData
from .user_intimate import UserIntimate
from .user_shop_stock import UserShopStock
from .user_favorite_item import (
UserFavoriteItem,
UserFavoriteItemAll,
from .GetUserData import GetUserData
from .GetUserExtend import GetUserExtend
from .GetUserOption import GetUserOption
from .GetUserCharacter import GetUserCharacter
from .GetUserGhost import GetUserGhost
from .GetUserMap import GetUserMap, GetUserMapAll
from .GetUserLoginBonus import GetUserLoginBonus, GetUserLoginBonusAll
from .GetUserRating import GetUserRating
from .GetUserItem import GetUserItem, GetUserItemAll
from .GetUserMusic import GetUserMusicDetail, GetUserMusicDetailAll
from .GetUserCourse import GetUserCourse, GetUserCourseAll
from .GetUserCharge import GetUserCharge
from .GetUserFavorite import GetUserFavorite, GetUserFavoriteAll
from .GetUserActivity import GetUserActivity
from .GetUserMission import GetUserMissionData
from .GetUserIntimate import GetUserIntimate
from .GetUserShopStock import GetUserShopStock
from .GetUserFavoriteItem import (
GetUserFavoriteItem,
GetUserFavoriteItemAll,
FAVORITE_ITEM_KIND_MUSIC,
FAVORITE_ITEM_KIND_RIVAL,
)
from .user_rival import UserRivalData, UserRivalMusic, UserRivalMusicAll
from .user_friend_bonus import UserFriendBonus, extract_gift_points
from .user_kaleidx_scope import UserKaleidxScope, GameKaleidxScope
from .user_region import UserRegion
from .user_recommend_music import UserRecommendRateMusic, UserRecommendSelectMusic
from .user_new_item import UserNewItemList, UserNewItemListRequestVO
from .GetUserRival import GetUserRivalData, GetUserRivalMusic, GetUserRivalMusicAll
from .GetUserFriendBonus import GetUserFriendBonus, extract_gift_points
from .GetUserKaleidxScope import GetUserKaleidxScope, GetGameKaleidxScope
from .GetUserRegion import GetUserRegion
from .GetUserRecommendMusic import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
from .GetUserNewItem import GetUserNewItemList, UserNewItemListRequestVO
from .game_ranking import GetGameRanking, GameRankingRequestVO
from .game_event import GetGameEvent, GameEventRequestVO
from .game_charge import GetGameCharge, GameChargeRequestVO
from .game_weekly_data import GetGameWeeklyData
from .game_tournament_info import GetGameTournamentInfo
from .game_ng_music_id import GetGameNgMusicId
from .GetGameCharge import GetGameCharge, GameChargeRequestVO
# ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
@@ -326,7 +383,7 @@ _API_FUNCS = (
"ParseQRCode",
"GetGameSettings",
"Ping",
"UserPreview",
"GetUserPreview",
"UserLogin",
"UserLogout",
# --- 上传成绩 ---
@@ -336,6 +393,7 @@ _API_FUNCS = (
"merge_extra_data",
"verify_upsert_landing",
"build_user_all",
"build_unlock_items",
# --- 购买功能票 ---
"UpsertUserChargelog",
"ticket_name",
@@ -350,52 +408,47 @@ _API_FUNCS = (
"build_delta",
"build_favorite_delta",
"normalize_snapshot",
"fetch_upsert_snapshot",
"GetNeedUserData",
"summarize_snapshot",
"UserNewItemList",
"GetUserNewItemList",
# --- 取数:单接口 ---
"UserData", # GetUserDataApi
"UserExtend", # GetUserExtendApi
"UserOption", # GetUserOptionApi
"UserCharacter", # GetUserCharacterApi
"UserGhost", # GetUserGhostApi
"UserMap", # GetUserMapApi(单页)
"UserMapAll", # GetUserMapApi(全量)
"UserLoginBonus", # GetUserLoginBonusApi(单页)
"UserLoginBonusAll", # GetUserLoginBonusApi(全量)
"UserRating", # GetUserRatingApi
"UserItem", # GetUserItemApi(单类)
"UserItemAll", # GetUserItemApi(全量)
"UserMusicDetail", # GetUserMusicApi(单页)
"UserMusicDetailAll", # GetUserMusicApi(全量)
"UserCourse", # GetUserCourseApi(单页)
"UserCourseAll", # GetUserCourseApi(全量)
"UserCharge", # GetUserChargeApi
"UserFavorite", # GetUserFavoriteApi(单类)
"UserFavoriteAll", # GetUserFavoriteApi(全量)
"UserActivity", # GetUserActivityApi
"UserMissionData", # GetUserMissionDataApi
"UserIntimate", # GetUserIntimateApi
"UserShopStock", # GetUserShopStockApi
"UserFavoriteItem", # GetUserFavoriteItemApi(单页)
"UserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
"UserRivalData", # GetUserRivalDataApi
"UserRivalMusic", # GetUserRivalMusicApi(单页)
"UserRivalMusicAll", # GetUserRivalMusicApi(全量)
"UserFriendBonus", # GetUserFriendBonusApi
"UserKaleidxScope", # GetUserKaleidxScopeApi
"GameKaleidxScope", # GetGameKaleidxScopeApi
"UserRegion", # GetUserRegionApi
"UserRecommendRateMusic", # GetUserRecommendRateMusicApi
"UserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
"UserNewItemList", # GetUserNewItemListApi
"GetUserData", # GetUserDataApi
"GetUserExtend", # GetUserExtendApi
"GetUserOption", # GetUserOptionApi
"GetUserCharacter", # GetUserCharacterApi
"GetUserGhost", # GetUserGhostApi
"GetUserMap", # GetUserMapApi(单页)
"GetUserMapAll", # GetUserMapApi(全量)
"GetUserLoginBonus", # GetUserLoginBonusApi(单页)
"GetUserLoginBonusAll", # GetUserLoginBonusApi(全量)
"GetUserRating", # GetUserRatingApi
"GetUserItem", # GetUserItemApi(单类)
"GetUserItemAll", # GetUserItemApi(全量)
"GetUserMusicDetail", # GetUserMusicApi(单页)
"GetUserMusicDetailAll", # GetUserMusicApi(全量)
"GetUserCourse", # GetUserCourseApi(单页)
"GetUserCourseAll", # GetUserCourseApi(全量)
"GetUserCharge", # GetUserChargeApi
"GetUserFavorite", # GetUserFavoriteApi(单类)
"GetUserFavoriteAll", # GetUserFavoriteApi(全量)
"GetUserActivity", # GetUserActivityApi
"GetUserMissionData", # GetUserMissionDataApi
"GetUserIntimate", # GetUserIntimateApi
"GetUserShopStock", # GetUserShopStockApi
"GetUserFavoriteItem", # GetUserFavoriteItemApi(单页)
"GetUserFavoriteItemAll", # GetUserFavoriteItemApi(全量)
"GetUserRivalData", # GetUserRivalDataApi
"GetUserRivalMusic", # GetUserRivalMusicApi(单页)
"GetUserRivalMusicAll", # GetUserRivalMusicApi(全量)
"GetUserFriendBonus", # GetUserFriendBonusApi
"GetUserKaleidxScope", # GetUserKaleidxScopeApi
"GetGameKaleidxScope", # GetGameKaleidxScopeApi
"GetUserRegion", # GetUserRegionApi
"GetUserRecommendRateMusic", # GetUserRecommendRateMusicApi
"GetUserRecommendSelectMusic", # GetUserRecommendSelectMusicApi
"GetUserNewItemList", # GetUserNewItemListApi
# --- 取数:游戏侧 ---
"GetGameRanking", # GetGameRankingApi
"GetGameEvent", # GetGameEventApi
"GetGameCharge", # GetGameChargeApi
"GetGameWeeklyData", # GetGameWeeklyDataApi
"GetGameTournamentInfo", # GetGameTournamentInfoApi
"GetGameNgMusicId", # GetGameNgMusicIdApi
)
# ---------------------------------------------------------------------------
@@ -435,7 +488,7 @@ __all__ = [
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
"is_maintenance", "is_ping_disabled",
"Ping",
"UserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
"GetUserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
"UserLogout", "LogoutType", "DelayLog", "RequestAPI",
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
@@ -443,8 +496,16 @@ __all__ = [
# ---- 上传成绩 ----
"UpsertUserAll", "PlayData", "build_play_from_config", "derive_play_characters",
"load_extra_data", "merge_extra_data", "verify_upsert_landing",
# ---- 歌曲解锁(解禁)----
"build_unlock_items", "UNLOCK_MASTER_MIN_MUSIC_ID",
"ITEM_KIND_MUSIC_UNLOCK", "ITEM_KIND_MUSIC_UNLOCK_MASTER",
"ITEM_KIND_MUSIC_UNLOCK_REMASTER", "ITEM_KIND_MUSIC_UNLOCK_STRONG",
# ---- 购买功能票 ----
"UpsertUserChargelog", "ChargelogConfirmError", "ChargelogGuardError",
# ---- 买票前置闸门(GetUserCharge 必须先跑过)----
"CHARGE_READ_FRESH_SEC", "has_fresh_read", "held_charges", "last_charge_list",
"last_read_age", "note_charge_purchase", "note_user_charge_read",
"purchased_charge_ids", "reset_charge_guard",
"UserChargeVO", "UserChargelogVO", "UserChargelogRequestVO",
"ticket_name", "ticket_price", "calc_valid_date", "parse_wall_time", "now_cst",
"UserAllRequestVO", "UpsertResponseVO",
@@ -462,36 +523,41 @@ __all__ = [
"USER_DATA_DROP_KEYS", "USER_OPTION_DROP_KEYS", "USER_CHARACTER_KEEP_KEYS",
"USER_MUSIC_DETAIL_DROP_KEYS", "USER_CHARGE_DROP_KEYS", "USER_MISSION_DROP_KEYS",
# ---- 快照编排 ----
"fetch_upsert_snapshot", "save_snapshot", "load_snapshot",
"GetNeedUserData", "save_snapshot", "load_snapshot",
"summarize_snapshot", "DEFAULT_SNAPSHOT_NODES",
"PROJECT_ROOT", "DATA_DIR", "user_data_path",
"save_user_data", "load_user_data", "list_saved_user_ids",
# ---- 取数公共底座 ----
"GetterEmptyRequestVO", "GetterUserIdRequestVO", "UserIdPageRequestVO",
"UserLoginBonusRequestVO", "UserItemRequestVO", "UserMusicRequestVO",
"UserCourseRequestVO", "UserFavoriteRequestVO", "UserFavoriteItemRequestVO",
"UserShopStockRequestVO", "UserRivalRequestVO", "UserRivalMusicRequestVO",
"fetch_all_pages",
"get_all_pages",
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
# ---- 取数脚本 ----
"UserData", "UserExtend", "UserOption", "UserCharacter", "UserGhost",
"UserMap", "UserMapAll",
"UserLoginBonus", "UserLoginBonusAll",
"UserRating",
"UserItem", "UserItemAll",
"UserMusicDetail", "UserMusicDetailAll",
"UserCourse", "UserCourseAll",
"UserCharge",
"UserFavorite", "UserFavoriteAll",
"UserActivity", "UserMissionData", "UserIntimate", "UserShopStock",
"UserFavoriteItem", "UserFavoriteItemAll",
"GetUserData", "GetUserExtend", "GetUserOption", "GetUserCharacter", "GetUserGhost",
"GetUserMap", "GetUserMapAll",
"GetUserLoginBonus", "GetUserLoginBonusAll",
"GetUserRating",
"GetUserItem", "GetUserItemAll",
"GetUserMusicDetail", "GetUserMusicDetailAll",
"GetUserCourse", "GetUserCourseAll",
"GetUserCharge",
"GetUserFavorite", "GetUserFavoriteAll",
"GetUserActivity", "GetUserMissionData", "GetUserIntimate", "GetUserShopStock",
"GetUserFavoriteItem", "GetUserFavoriteItemAll",
"FAVORITE_ITEM_KIND_MUSIC", "FAVORITE_ITEM_KIND_RIVAL",
"UserRivalData", "UserRivalMusic", "UserRivalMusicAll",
"UserFriendBonus", "extract_gift_points",
"UserKaleidxScope", "GameKaleidxScope",
"UserRegion",
"UserRecommendRateMusic", "UserRecommendSelectMusic",
"UserNewItemList", "UserNewItemListRequestVO",
"GetGameRanking", "GameRankingRequestVO",
"GetGameEvent", "GameEventRequestVO",
"GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll",
"GetUserFriendBonus", "extract_gift_points",
"GetUserKaleidxScope", "GetGameKaleidxScope",
"GetUserRegion",
"GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
"GetUserNewItemList", "UserNewItemListRequestVO",
"GetGameCharge", "GameChargeRequestVO",
"GetGameWeeklyData", "GetGameTournamentInfo", "GetGameNgMusicId",
# --- 协议常量(原在 config.py,已下沉到各 api 模块)---
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM",
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH",
"QR_SIGN_KEY", "QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
"TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
]
-60
View File
@@ -1,60 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_event.py —— 取游戏活动(GetGameEventApi)
================================================================================
取服务器下发的活动列表。
对应接口:GetGameEventApi
请求体 :{"type":1, "isAllEvent":true}(GameEventRequestVO)
响应 :{type, length, gameEventList:[{type,id,startDate,endDate}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameEventRequestVO.cs
source/2026-10-05.log:5 真机请求 {"type":1,"isAllEvent":true}
调用示例:
from api import GetGameEvent
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class GameEventRequestVO:
"""请求 VO(对应 GameEventRequestVO)。"""
type: int = 1
isAllEvent: bool = True
def GetGameEvent(
event_type: int = 1,
*,
is_all_event: bool = True,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏活动列表(对应 GetGameEventApi)。
参数:
event_type: 活动类型(真机用 1)
is_all_event: 是否取全部活动(真机 True)
返回:
原始响应 dict,含 gameEventList
"""
log_info("取游戏活动:type=" + str(event_type) + " isAllEvent=" + str(is_all_event))
return request_dict(
"GetGameEventApi",
GameEventRequestVO(type=event_type, isAllEvent=is_all_event),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_ng_music_id.py —— 取禁用曲目列表(GetGameNgMusicIdApi)
================================================================================
取服务器下发的 NG(不可游玩)曲目 ID 列表。
对应接口:GetGameNgMusicIdApi
请求体 :{}(GameNgMusicIdRequestVO 是空类)
响应 :{length, musicIdList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameNgMusicIdRequestVO.cs(空类)
source/2026-10-05.log:11
调用示例:
from api import GetGameNgMusicId
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
def GetGameNgMusicId(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取禁用曲目列表(对应 GetGameNgMusicIdApi)。
返回:
原始响应 dict,含 musicIdList
"""
log_info("取禁用曲目列表")
return request_dict(
"GetGameNgMusicIdApi", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-56
View File
@@ -1,56 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_ranking.py —— 取游戏排行(GetGameRankingApi)
================================================================================
取服务器下发的排行榜数据。
对应接口:GetGameRankingApi
请求体 :{"type": <排行类型>}(GameRankingRequestVO.type 是 long)
响应 :{type, gameRankingList:[{id,point,userName}], gameRankingInstantList}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameRankingRequestVO.cs
source/2026-10-05.log:3 真机请求 {"type":1}
调用示例:
from api import GetGameRanking
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
@dataclass
class GameRankingRequestVO:
"""请求 VO(对应 GameRankingRequestVO)。"""
type: int = 1
def GetGameRanking(
ranking_type: int = 1,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏排行(对应 GetGameRankingApi)。
参数:
ranking_type: 排行类型(真机用 1)
返回:
原始响应 dict,含 gameRankingList
"""
log_info("取游戏排行:type=" + str(ranking_type))
return request_dict(
"GetGameRankingApi", GameRankingRequestVO(type=ranking_type),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_tournament_info.py —— 取大会信息(GetGameTournamentInfoApi)
================================================================================
取服务器下发的大会/赛事信息。
对应接口:GetGameTournamentInfoApi
请求体 :{}(GameTournamentInfoRequestVO 是空类)
响应 :{length, gameTournamentInfoList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameTournamentInfoRequestVO.cs(空类)
source/2026-10-05.log:7
调用示例:
from api import GetGameTournamentInfo
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
def GetGameTournamentInfo(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取大会信息(对应 GetGameTournamentInfoApi)。
返回:
原始响应 dict,含 gameTournamentInfoList
"""
log_info("取大会信息")
return request_dict(
"GetGameTournamentInfoApi", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
game_weekly_data.py —— 取游戏周常配置(GetGameWeeklyDataApi)
================================================================================
取服务器下发的周常周期信息。
对应接口:GetGameWeeklyDataApi
请求体 :{}(GameWeeklyRequestVO 是空类)
响应 :{gameWeeklyData:{missionCategory, updateDate, beforeDate}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameWeeklyRequestVO.cs(空类)
source/2026-10-05.log:13
调用示例:
from api import GetGameWeeklyData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient, request_dict
def GetGameWeeklyData(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏周常配置(对应 GetGameWeeklyDataApi)。
返回:
原始响应 dict,含 gameWeeklyData
"""
log_info("取游戏周常配置")
return request_dict(
"GetGameWeeklyDataApi", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-45
View File
@@ -1,45 +0,0 @@
# -*- coding: utf-8 -*-
"""
user_charge.py —— 取用户票券(GetUserChargeApi)
================================================================================
取玩家的票券持有情况(真机 3 条)。
对应接口:GetUserChargeApi
请求体 :UserIdRequestVO
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
调用示例:
from api import UserCharge
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .log_setup import log_info
from .maimai_net import MaimaiNetClient
from .user_getter_common import _get, UserIdRequestVO
def UserCharge(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户票券(对应 GetUserChargeApi)。
返回:
原始响应 dict,含 userChargeList
"""
log_info("取用户票券:user_id=" + str(user_id))
return _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
+563
View File
@@ -0,0 +1,563 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
authv1.py —— ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId / regionId / placeId / placeName / regionName
==========================================================================================================
【解决什么问题】
config.py 第二节那六个值
KEYCHIP_ID / CLIENT_ID / REGION_ID / PLACE_ID / PLACE_NAME / REGION_NAME
全部来自 **AMDaemon 开机时向 ALL.Net 认证服务器要的结果**。
游戏服务器(maimai-gm.wahlap.com)**没有任何接口**会返回它们 ——
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
本脚本把 AMDaemon 的这一步原样重做了一遍。
【协议出处】—— 全部能在 source/amdaemon.exe 的字符串里对上(数字是文件内偏移)
端点 http://at.sys-all.cn/net/initialize @0x788170
备用 http://at.sys-allnet.cn/net/initialize 实测同样可用
库 alAbEx_lite Ver 4.00.01 (src/lib/alabex_pv4_lite) @0x784a31
请求体 "title_id=%s&title_ver=%s&client_id=%s" @0x7882b8
请求头 Pragma: DFI @0x7883cc
User-Agent: <...>Windows/Lite @0x788508
响应字段 result= uri1= uri2= name= nickname= region0=
region_name0..3= place_id= setting= country=
location_type= client_timezone= res_ver= uri=
utc_time= token= @0x784ae8-0x788fd0
信封 AES-128-CBC + 16 字节前缀块 + PKCS7
(exe @0x63a100 提到 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY))
【信封细节】实测确认
请求 = AES-CBC( 16 字节全 0 前缀 || 明文 ),IV = 16 字节全 0
响应 = 16 字节 IV || AES-CBC 密文
【响应码】
result= 1 成功
result=-2 机台不存在(client_id 未被收录;小写也会走到这里)
result=-3 参数格式错(例如把 15 位完整 Keychip 当 client_id 发出去)
【用法】
python authv1.py A63E01E9580 # 查(打印结果)
python authv1.py A63E-01C30471007 # 带连字符的完整 Keychip 也行
python authv1.py A63E01E9580 --json # 机器可读
python authv1.py A63E01E9580 --write # 强制写回 config.py
python authv1.py A63E01E9580 --no-write # 强制不写
python authv1.py --batch keychip.csv # 批量查(CSV 第一列是 Keychip)
★ 是否写回 config.py,默认看 config.py 里的 AUTHV1_WRITE_CONFIG 开关。
【也当协议库用】
from authv1 import allnet_post, initialize, delivery_instruction, parse_keychip
netdelivery.py 就是这么用它的。
"""
from __future__ import annotations
import argparse
import ast
import csv
import json
import os
import re
import shutil
import sys
import time
import urllib.parse
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
# =============================================================================
# 协议常量
# =============================================================================
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
# 默认用 sys-allnet.cn(与 maimai-gm.wahlap.com 同机房,链路更短)。
ALLNET_HOSTS = (
"http://at.sys-allnet.cn",
"http://at.sys-all.cn",
)
DEFAULT_HOST = ALLNET_HOSTS[0]
PATH_INITIALIZE = "/net/initialize"
PATH_DELIVERY_INSTRUCTION = "/net/delivery/instruction"
TITLE_ID = "SDGB"
# ALE_ABEX_OPTION_AES_KEY 对应的 16 字节密钥。
# 出处:exe @0x63a100 有 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY);
# 密钥本体在 exe 里不是明文串,此处取值经真实请求验证有效。
AES_KEY = bytes([47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71])
AES_IV = bytes(16)
# 请求头。依据:exe 里 "Pragma" + "DFI"(@0x7883cc) 与 "Windows/Lite"(@0x788508)。
HEADERS = {
"User-Agent": "SDGB;Windows/Lite",
"Pragma": "DFI",
"Content-Type": "application/x-www-form-urlencoded",
}
DEFAULT_TIMEOUT = 20
# client_id 恒为 11 位大写十六进制(Keychip 15 位去掉连字符后的前 11 位)
CLIENT_ID_LEN = 11
CLIENT_ID_RE = re.compile(r"^[0-9A-F]{" + str(CLIENT_ID_LEN) + r"}$")
# 上报给 ALL.Net 的版本号。真机 1.56 ROM 实测仍报 1.55(与 Mai-Encoding 一致);
# 默认取 config.MAI_ENCODING,取不到就退回这个字面量。
DEFAULT_TITLE_VER = "1.55"
RESULT_TEXT = {
"1": "成功",
"-1": "通用错误",
"-2": "机台不存在(client_id 未被 ALL.Net 收录)",
"-3": "参数格式错误(client_id 必须是 11 位短值)",
}
class AllnetError(RuntimeError):
"""ALL.Net 交互失败(网络 / 信封 / 业务码非 1)。"""
# =============================================================================
# 信封:AES-128-CBC
# =============================================================================
def encode_request(text: str) -> bytes:
"""明文 -> 线上字节流:16 字节全 0 前缀 + PKCS7 + AES-128-CBC(IV=0)。"""
body = AES_IV + text.encode("utf-8")
return AES.new(AES_KEY, AES.MODE_CBC, AES_IV).encrypt(pad(body, AES.block_size))
def decode_response(raw: bytes) -> str:
"""线上字节流 -> 明文:前 16 字节是 IV,其后是 AES-128-CBC 密文。"""
if len(raw) < 32 or len(raw) % AES.block_size:
raise AllnetError("响应长度异常:%d 字节(应 >=32 且为 16 的倍数)" % len(raw))
plain = AES.new(AES_KEY, AES.MODE_CBC, raw[:16]).decrypt(raw[16:])
n = plain[-1]
if 1 <= n <= AES.block_size:
plain = plain[:-n]
return plain.decode("utf-8", "replace")
def allnet_post(path, body, *, host=DEFAULT_HOST, timeout=DEFAULT_TIMEOUT, retries=3):
"""
POST 一条加密请求,返回解密后的明文(形如 "result=1&place_id=...")。
★ 实测 at.sys-allnet.cn 偶尔会直接掐断连接(RemoteDisconnected),
所以这里对**网络层**失败做退避重试;业务码非 1 不重试。
"""
url = host.rstrip("/") + path
payload = encode_request(body)
last_error = None
for attempt in range(1, retries + 1):
try:
resp = requests.post(url, data=payload, headers=HEADERS, timeout=timeout)
except requests.RequestException as exc:
last_error = exc
if attempt < retries:
time.sleep(1.5 * attempt)
continue
raise AllnetError("请求 %s 失败(已重试 %d 次):%s" % (url, retries, exc)) from exc
if resp.status_code != 200:
if resp.status_code >= 500 and attempt < retries:
last_error = "HTTP %d" % resp.status_code
time.sleep(1.5 * attempt)
continue
raise AllnetError("请求 %s 返回 HTTP %d" % (url, resp.status_code))
return decode_response(resp.content)
raise AllnetError("请求 %s 失败:%s" % (url, last_error))
def parse_kv(text: str) -> dict:
"""把 "result=1&place_id=146b&name=%E5%AE%89..." 解析成 dict(值做 URL 解码)。"""
out = {}
for part in text.strip().split("&"):
if not part or "=" not in part:
continue
key, val = part.split("=", 1)
out[key.strip()] = urllib.parse.unquote(val)
return out
# =============================================================================
# Keychip <-> client_id
# =============================================================================
def parse_keychip(raw: str) -> str:
"""
把用户给的 Keychip 归一化成 client_id(11 位大写短值)。
接受三种写法:
完整带连字符 "A63E-01C30471007"
完整连写 "A63E01C30471007"
短值 "A63E01C3047"
★ 为什么一定要截前 11 位:实测把完整 15 位当 client_id 发出去,
ALL.Net 回 result=-3(格式错)。
"""
s = re.sub(r"[\s\-]", "", str(raw)).upper()
if len(s) < CLIENT_ID_LEN:
raise ValueError("Keychip 太短:%r(至少 %d 位)" % (raw, CLIENT_ID_LEN))
cid = s[:CLIENT_ID_LEN]
if not CLIENT_ID_RE.match(cid):
raise ValueError("Keychip 非法:%r(应为十六进制大写)" % raw)
return cid
def make_keychip(client_id: str, place_id) -> str:
"""
按本工程约定拼回完整 Keychip:
前 4 位 + "-" + 第 5 位起 + placeId
例:A63E01C3047 + 1007 -> A63E-01C30471007
"""
cid = str(client_id).upper()
return "%s-%s%d" % (cid[:4], cid[4:], int(place_id))
# =============================================================================
# 两个接口
# =============================================================================
def initialize(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
timeout=DEFAULT_TIMEOUT):
"""POST /net/initialize —— 机台认证,返回原始明文。"""
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
return allnet_post(PATH_INITIALIZE, body, host=host, timeout=timeout)
def delivery_instruction(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
timeout=DEFAULT_TIMEOUT):
"""POST /net/delivery/instruction —— DLI,返回原始明文(含 uri= 配信指示書地址)。"""
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
return allnet_post(PATH_DELIVERY_INSTRUCTION, body, host=host, timeout=timeout)
def query_keychip(keychip, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
timeout=DEFAULT_TIMEOUT, raw_ok=False):
"""
查一台机台的身份。返回 dict(含 ok / result / 各字段)。
★ raw_ok=False 时,result != 1 直接抛 AllnetError(CLI 用);
raw_ok=True 时照样返回,由调用方自己看 result(批量用)。
"""
cid = parse_keychip(keychip)
text = initialize(cid, title_ver=title_ver, host=host, timeout=timeout)
kv = parse_kv(text)
result = kv.get("result", "")
place_hex = kv.get("place_id", "") or ""
try:
place_id = int(place_hex, 16) if place_hex else None
except ValueError:
place_id = None
region_names = [kv.get("region_name%d" % i, "") for i in range(4)]
region_names = [n for n in region_names if n]
try:
region_id = int(kv.get("region0", "") or 0)
except ValueError:
region_id = None
info = {
"ok": result == "1",
"result": result,
"result_text": RESULT_TEXT.get(result, "未知返回码 %s" % result),
"title_id": TITLE_ID,
"title_ver": title_ver,
"host": host,
# --- 六个值里的五个(KEYCHIP_ID 是推导值)---
"client_id": cid,
"region_id": region_id,
"place_id": place_id,
"place_name": kv.get("name", ""),
"region_name": region_names[0] if region_names else "",
# --- 附送 ---
"nickname": kv.get("nickname", ""),
"region_names": region_names,
"country": kv.get("country", ""),
"setting": kv.get("setting", ""),
"location_type": kv.get("location_type", ""),
"game_server_uri": kv.get("uri1", ""),
"auth_token": kv.get("uri2", ""),
"token": kv.get("token", ""),
"utc_time": kv.get("utc_time", ""),
"client_timezone": kv.get("client_timezone", ""),
"res_ver": kv.get("res_ver", ""),
"raw": text.strip(),
}
if info["place_id"] is not None:
info["keychip"] = make_keychip(cid, info["place_id"])
else:
info["keychip"] = ""
if not info["ok"] and not raw_ok:
raise AllnetError("ALL.Net 返回 result=%s(%s)" % (result, info["result_text"]))
return info
# =============================================================================
# 写回 config.py
# =============================================================================
CONFIG_FILENAME = "config.py"
# 写回哪些键(KEYCHIP_ID 是推导值,不写)
WRITEBACK_KEYS = ("CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME")
def _replace_assignment(text, name, literal):
"""把 config.py 里唯一一行 NAME = ... 换成 NAME = literal。"""
pattern = re.compile(r"^(%s[ \t]*=[ \t]*).*$" % re.escape(name), re.M)
new_text, count = pattern.subn(lambda m: m.group(1) + literal, text, count=1)
if count != 1:
raise AllnetError("config.py 里找不到唯一的 %s = ... 赋值行(找到 %d 处)" % (name, count))
return new_text
def write_back_config(info, *, config_path=None, quiet=False):
"""
把查询结果写回 config.py。
安全措施:
1. 先 ast.parse 语法自检,写坏了就不落盘
2. 落盘前把原文件备份成 config.py.bak
3. 保留原文件的换行风格(LF / CRLF)
"""
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
CONFIG_FILENAME)
if not os.path.isfile(path):
raise AllnetError("找不到 config.py:%s" % path)
with open(path, "rb") as fp:
raw = fp.read()
newline = "\r\n" if b"\r\n" in raw else "\n"
text = raw.decode("utf-8").replace("\r\n", "\n")
pairs = [
("CLIENT_ID", json.dumps(info["client_id"], ensure_ascii=False)),
("REGION_ID", str(info["region_id"])),
("PLACE_ID", str(info["place_id"])),
("PLACE_NAME", json.dumps(info["place_name"], ensure_ascii=False)),
("REGION_NAME", json.dumps(info["region_name"], ensure_ascii=False)),
]
for name, literal in pairs:
text = _replace_assignment(text, name, literal)
try:
ast.parse(text)
except SyntaxError as exc:
raise AllnetError("改写后 config.py 语法不合法,已放弃写入:%s" % exc) from exc
shutil.copy2(path, path + ".bak")
with open(path, "wb") as fp:
fp.write(text.replace("\n", newline).encode("utf-8"))
if not quiet:
print(" [已写回] %s" % path)
print(" [已备份] %s.bak" % path)
return path
def config_switch_enabled(config_path=None):
"""读 config.py 里的 AUTHV1_WRITE_CONFIG 开关。读不到就当作关。"""
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
CONFIG_FILENAME)
try:
with open(path, "rb") as fp:
tree = ast.parse(fp.read().decode("utf-8"))
except Exception:
return False
for node in tree.body:
if isinstance(node, ast.Assign):
for target in node.targets:
if isinstance(target, ast.Name) and target.id == "AUTHV1_WRITE_CONFIG":
try:
return bool(ast.literal_eval(node.value))
except Exception:
return False
return False
# =============================================================================
# 输出
# =============================================================================
def format_report(info) -> str:
"""人类可读的结果块。"""
lines = []
lines.append("=== authv1 机台身份查询 ===")
lines.append(" Keychip (完整) : %s" % (info["keychip"] or "(查不到)"))
lines.append(" clientId (短值) : %s <- 游戏服务器 clientId" % info["client_id"])
lines.append(" placeId : %s" % info["place_id"])
lines.append(" placeName : %s" % info["place_name"])
lines.append(" regionId : %s" % info["region_id"])
lines.append(" regionName : %s" % info["region_name"])
lines.append(" --- 附送 ---")
lines.append(" 游戏服务器 : %s" % info["game_server_uri"])
lines.append(" 机台昵称 : %s" % info["nickname"])
lines.append(" 国家/地区 : %s" % info["country"])
lines.append(" 认证时间(UTC) : %s 时区 %s" % (info["utc_time"], info["client_timezone"]))
lines.append(" ALL.Net 返回码 : %s(%s)" % (info["result"], info["result_text"]))
return "\n".join(lines)
# =============================================================================
# CLI
# =============================================================================
def build_parser():
p = argparse.ArgumentParser(
prog="authv1.py",
description="ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId/regionId/placeId/placeName/regionName",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python authv1.py A63E01E9580\n"
" python authv1.py A63E-01C30471007 --json\n"
" python authv1.py A63E01E9580 --write\n"
" python authv1.py --batch source/keychip.csv --limit 10\n"
),
)
p.add_argument("keychip", nargs="?", help="Keychip(完整带连字符 / 完整连写 / 11 位短值都行)")
p.add_argument("--batch", metavar="CSV",
help="批量查询:CSV 文件,默认读第一列(表头含 Keychip 时自动跳过)")
p.add_argument("--limit", type=int, default=0, help="批量模式最多查多少条(0 = 不限)")
p.add_argument("--ver", default=None, help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
p.add_argument("--host", default=DEFAULT_HOST, help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="单次请求超时秒数")
p.add_argument("--json", action="store_true", help="输出 JSON")
g = p.add_mutually_exclusive_group()
g.add_argument("--write", action="store_true", help="强制把结果写回 config.py")
g.add_argument("--no-write", action="store_true", help="强制不写回 config.py")
return p
def resolve_title_ver(cli_value):
if cli_value:
return str(cli_value)
try:
import config # noqa: F401 同目录
return str(getattr(config, "MAI_ENCODING", DEFAULT_TITLE_VER))
except Exception:
return DEFAULT_TITLE_VER
def main(argv=None):
try:
sys.stdout.reconfigure(errors="replace")
except Exception:
pass
args = build_parser().parse_args(argv)
title_ver = resolve_title_ver(args.ver)
if args.batch:
return run_batch(args, title_ver)
if not args.keychip:
build_parser().print_help()
print("\n错误:请给一个 Keychip,或用 --batch 指定 CSV。")
return 2
try:
info = query_keychip(args.keychip, title_ver=title_ver,
host=args.host, timeout=args.timeout)
except (AllnetError, ValueError) as exc:
print("查询失败:%s" % exc)
return 1
if args.json:
print(json.dumps(info, ensure_ascii=False, indent=1))
else:
print(format_report(info))
do_write = decide_write(args)
if do_write:
try:
write_back_config(info)
except AllnetError as exc:
print("写回失败:%s" % exc)
return 1
elif not args.json:
print(" [未写回] 想让它自动写回:把 config.py 的 AUTHV1_WRITE_CONFIG 改成 True,"
"或加 --write")
return 0
def decide_write(args):
"""--write / --no-write 优先,否则看 config.py 的开关。"""
if args.write:
return True
if args.no_write:
return False
return config_switch_enabled()
def _disp_width(text):
"""按终端显示宽度算长度:中日韩全角字符占 2 列。"""
import unicodedata
width = 0
for ch in str(text):
width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
return width
def _pad(text, width):
text = str(text)
return text + " " * max(0, width - _disp_width(text))
def run_batch(args, title_ver):
path = args.batch
if not os.path.isfile(path):
print("找不到 CSV:%s" % path)
return 2
rows = []
with open(path, "r", encoding="utf-8-sig", newline="") as fp:
reader = csv.reader(fp)
for row in reader:
if not row or not row[0].strip():
continue
if row[0].strip().lower() in ("keychip", "keychip id", "keychipid"):
continue
rows.append(row)
if args.limit > 0:
rows = rows[: args.limit]
print("=== authv1 批量查询(%d 台,版本 %s)===" % (len(rows), title_ver))
header = " ".join([_pad("clientId", 13), _pad("result", 6), _pad("placeId", 8),
_pad("regionId", 8), _pad("区域", 8), "店铺名"])
print(header)
ok = 0
results = []
for row in rows:
try:
info = query_keychip(row[0], title_ver=title_ver, host=args.host,
timeout=args.timeout, raw_ok=True)
except Exception as exc:
print("%s ERROR %s" % (_pad(row[0].strip()[:13], 13), exc))
continue
results.append(info)
if info["ok"]:
ok += 1
print(" ".join([
_pad(info["client_id"], 13),
_pad(info["result"], 6),
_pad(info["place_id"], 8),
_pad(info["region_id"], 8),
_pad(info["region_name"], 8),
str(info["place_name"]),
]))
print("--- 成功 %d / %d ---" % (ok, len(rows)))
return 0
if __name__ == "__main__":
sys.exit(main())
+21 -33
View File
@@ -6,26 +6,30 @@ buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登
一条完整的「买票」链路:
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
ParseQRCode -> GetUserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
★ 本脚本判断「能买什么票」**只看 GetUserChargeApi**(账号自己的票券表),
不再调 GetGameChargeApi(那是机台本地的上架清单,跟账号状态无关)。
UpsertUserChargelog 内部会自动做这些事(所以主脚本不用管):
1. 调 GetUserChargeApi 取当前票券列表(算「购买后库存」)
2. 调 GetUserDataApi 取 playCount / playerRating
3. 按 TicketManager.cs:564 算 validDate(现在 -4h +90d,取日期补 04:00:00)
4. 查 config.TICKET_CREDIT_NUM 得到 userChargelog.price(= ticket.creditNum)
4. 查 TICKET_CREDIT_NUM(已下沉到 api/UpsertUserChargelog.py)得到
userChargelog.price(= ticket.creditNum)
5. 闸门 → 发包
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python buy_ticket.py <二维码> # 预检:只看报文,不发包
python buy_ticket.py <二维码> --list # 只列当前票券和可买的票
python buy_ticket.py <二维码> --list # 登录后只列当前票券,不购买
python buy_ticket.py <二维码> --charge-id 3 # 指定票(默认 5 = 5倍券)
python buy_ticket.py <二维码> --charge-id 2 --send # ★ 真实购买
python buy_ticket.py <二维码> --charge-id 2 --send --debug
python buy_ticket.py <二维码> --charge-id 2 --send --skip-entry # 跳过入场数据下载
付费票(config.PAID_TICKET_IDS):
付费票(api.PAID_TICKET_IDS):
2 = 2倍チケット creditNum 1
3 = 3倍チケット creditNum 2
4 = 4倍チケット creditNum 3
@@ -51,17 +55,16 @@ from api import (
log_error,
log_info,
ParseQRCode,
UserPreview,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
UserCharge,
GetGameCharge,
GetUserCharge,
ticket_name,
fetch_upsert_snapshot,
GetNeedUserData,
summarize_snapshot,
Ping,
)
@@ -78,7 +81,7 @@ def show_charges(user_id, label):
**已经持票还去买,服务端看到的是重复购买,有封号风险。**
所以买之前必须确认没有持票;买之后要确认状态符合预期。
"""
raw = UserCharge(user_id)
raw = GetUserCharge(user_id)
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
if not lst:
@@ -98,7 +101,7 @@ def show_charges(user_id, label):
def parse_args(argv):
opts = {"qr": "", "charge_id": 5, "send": False, "list": False,
"debug": False, "skip_entry": False, "wait_before": 30.0}
"debug": False, "skip_entry": False, "wait_before": 15.0}
i = 0
while i < len(argv):
a = argv[i]
@@ -135,27 +138,6 @@ def main() -> int:
setup_logging("buy_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- ★★ 最早的前置检查:这张票当前**上架了吗** ----
# ★ GetGameChargeApi 不需要登录(user_id=0),所以放在**扫码之前**,
# 不满足就直接退出 —— 不浪费二维码,也不产生任何登录会话。
# 依据 TicketManager.cs:137-150:真机也只买 GetGameChargeApi 列出来的票。
shop = GetGameCharge().get("gameChargeList") or []
avail = [int(g.get("chargeId")) for g in shop
if isinstance(g, dict) and g.get("chargeId") is not None]
log_info("当前可购买 chargeId:" + str(avail))
for g in shop:
log_info(" chargeId=" + str(g.get("chargeId"))
+ " " + ticket_name(g.get("chargeId"))
+ " money price=" + str(g.get("price"))
+ " 有效期 " + str(g.get("startDate")) + " ~ " + str(g.get("endDate")))
if opts["charge_id"] not in avail:
log_error("chargeId=" + str(opts["charge_id"])
+ "(" + ticket_name(opts["charge_id"])
+ ")**不在当前可购买清单里** —— 连登录都不做,直接退出。")
log_error(" 原因:服务端对未上架的票会 returnCode=0(实测过)。")
log_error(" 想买请用清单里的 chargeId:" + str(avail))
return 4
# ---- ★ 心跳:决定后续所有请求的 number 头 ----
# 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。
# 不跑的话 number 恒为 "0"(真机是 75)。
@@ -164,7 +146,7 @@ def main() -> int:
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
preview = UserPreview(user_id, token)
preview = GetUserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
@@ -177,7 +159,7 @@ def main() -> int:
# -> 13:20:10 才发 UpsertUserChargelogApi
# 本脚本原来 login 后直接买,跳过了这一段。
if not opts["skip_entry"]:
snap = fetch_upsert_snapshot(user_id, token=token,
snap = GetNeedUserData(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snap)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
@@ -186,6 +168,10 @@ def main() -> int:
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
held_before = show_charges(user_id, "购买前")
if opts["list"]:
log_info("[--list] 只查询票券状态,不购买。")
return 0
aborted = False
if held_before:
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
@@ -198,6 +184,7 @@ def main() -> int:
aborted = True
else:
# ---- ★ 买票前等待(贴近真机节奏:真机 login 13:19:45 -> 买票 13:20:10,间隔 25s)----
# 默认 15 秒(与 UpsertUserAll 的发包冷却一致)
if opts["wait_before"] > 0:
log_info("买票前等待 " + str(opts["wait_before"]) + " 秒 ...")
time.sleep(opts["wait_before"])
@@ -210,6 +197,7 @@ def main() -> int:
dry_run=not opts["send"],
confirm=opts["send"],
guard_no_stock=True,
guard_purchasable=False, # ★ 不再按 GetGameCharge 上架清单拦截
place_id=PLACE_ID, region_id=REGION_ID,
)
+40 -96
View File
@@ -31,25 +31,7 @@ config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
# 一、网络与加密常量
# =============================================================================
# --- 游戏服务器(Maimai2Servlet)---
# 依据:source/Assembly-CSharp/Manager/OperationManager.cs:225,338
# _operationData.ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
# 实测:source/2026-10-05.log:1
BASE_URL = "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/"
# --- AES 参数 ---
# 依据:source/Assembly-CSharp/Net/CipherAES.cs:11-12
# AesKey = "FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W" (32 字节 -> AES-256)
# AesIV = "F>;24DjU9W6ZsRH[" (16 字节)
AES_KEY = b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W"
AES_IV = b"F>;24DjU9W6ZsRH["
# --- API 名混淆盐 ---
# 依据:source/Assembly-CSharp/Net.Packet/Packet.cs:44 ObfuscateParam = "8bF76dE9"
# 用法:URL 尾段 = md5(逻辑接口名 + "MaimaiChn" + OBFUSCATE_PARAM) 小写 hex
# "MaimaiChn" 后缀来自 source/Assembly-CSharp/Net.VO/NetQuery.cs:14 Api = api + "MaimaiChn"
OBFUSCATE_PARAM = "8bF76dE9"
# ★ BASE_URL / AES_KEY / AES_IV / OBFUSCATE_PARAM 已下沉到 api/MaimaiNet.py
# --- 报文版本标识 ---
# 依据:source/Assembly-CSharp/Net/NetHttpClient.cs:69 DefaultHttpHeaderEncryption = "1.55"
# (请求头 Mai-Encoding 的值;1.56 ROM 实测仍发 1.55)
@@ -65,17 +47,12 @@ TIMEOUT_LONG_MS = 60000 # 登录 / 结算上传
# 二、机台与店铺标识
# =============================================================================
# Keychip ID(机台唯一标识,完整格式带连字符)
# ★★ 这是你自己的机台身份,不要外传;填错服务端会拒。
# 依据:source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
# KeychipID = AMDaemon.System.KeychipId.Value
KEYCHIP_ID = "" # ★★ 必填:形如 "XXXX-XXXXXXXXXXX"(带连字符)
# 客户端 ID(Keychip 短值,去掉连字符取前 11 位)
# 客户端 ID(Keychip 短值)
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
# clientId = AMDaemon.System.KeychipId.ShortValue
# 实测:真机日志里 clientId 恒等于 Keychip 短值
CLIENT_ID = "" # ★★ 必填:Keychip 短值,去掉连字符取前 11 位
# ★ 形态 = 15 位 Keychip 去掉连字符后的**前 11 位**(大写十六进制)。
CLIENT_ID = "" # ★★ 必填:形如 "A63E01C3047"
# 区域 ID / 店铺 ID
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:24-25
@@ -84,9 +61,18 @@ CLIENT_ID = "" # ★★ 必填:Keychip 短值,去掉连字
REGION_ID = 0 # ★★ 必填:你所在区域 ID
PLACE_ID = 0 # ★★ 必填:你所在店铺 ID
# Keychip ID(机台唯一标识,完整格式带连字符)
# ★★ 这是你自己的机台身份,不要外传;填错服务端会拒。
# 依据:source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
# KeychipID = AMDaemon.System.KeychipId.Value
# ★ 组成 = CLIENT_ID 的前 4 位 + "-" + CLIENT_ID 第 5 位起 + PLACE_ID
# 例:CLIENT_ID "A63E01C3047" + PLACE_ID 1007 -> "A63E-01C30471007"
# 所以它由上面两个值**推导**,不要手改。
KEYCHIP_ID = (CLIENT_ID[:4] + "-" + CLIENT_ID[4:] + str(PLACE_ID)) if CLIENT_ID else ""
# 店铺名 / 区域名
#
# ★ 为什么必须手工填:**游戏服务器没有任何接口返回店铺名**。
# ★ 为什么以前必须手工填:**游戏服务器没有任何接口返回店铺名**。
# 逐行扫过 source/2026-10-05.log 全部响应:
# - GetGameSettingApi 只回 gameSetting,无店名
# - GetUserDataApi 的 lastPlaceName 回的是 null
@@ -95,13 +81,27 @@ PLACE_ID = 0 # ★★ 必填:你所在店铺 ID
# source/AMDaemon.NET/AMDaemon/Allnet/Auth.cs:33 LocationName <- allnet_Auth_getLocationName()
# 是开机时 ALL.Net 认证下发的,客户端本地就有,不会向游戏服务器索取。
#
# 取值示例(真机 UpsertUserAll 报文):
# userPlaylogList[0].placeName = "<你的店铺名>"
# userData[0].lastRegionName = "<你的区域名>"
PLACE_NAME = "" # ★★ 必填:店铺名(游戏服务器不返回,只能手填)
# ★★ 现在不用手工抄了:python authv1.py <你的Keychip> 会直接问 ALL.Net
# 认证服务器要回 name / region_name0。把下面的 AUTHV1_WRITE_CONFIG 打开,
# 它还能把结果自动写回 config.py。
PLACE_NAME = "" # ★★ 必填:店铺名
REGION_NAME = "" # ★★ 必填:区域名
# --- authv1 回写开关 ---
#
# authv1.py(Keychip -> 机台身份)查到结果后,是否**自动写回 config.py**。
#
# True : 每次查询成功后,把 CLIENT_ID / REGION_ID / PLACE_ID /
# PLACE_NAME / REGION_NAME 五个值改写进 config.py。
# (KEYCHIP_ID 是推导值,会自动跟着变,不用写。)
# 改写前会把原文件备份成 config.py.bak,写坏不落盘。
# False : 只打印结果,不动 config.py。(默认,安全)
#
# 命令行可临时覆盖:--write 强制写 / --no-write 强制不写。
AUTHV1_WRITE_CONFIG = False
# =============================================================================
# 三、扫码登录常量(aimedb)
# =============================================================================
@@ -109,21 +109,7 @@ REGION_NAME = "" # ★★ 必填:区域名
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs/舞萌DX_扫码登录协议技术规范.md
# aimedb 服务器
AIMEDB_HOST = "ai.sys-allnet.cn"
AIMEDB_PORT = 443
AIMEDB_PATH = "/wc_aime/api/get_data"
# 签名密钥(含前导 X,32 字符)
# 来源:chimelib_dll.dll .rdata UTF-16 @0x00B040,抓包验证一致
QR_SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
# 固定字段
QR_OPEN_GAME_ID = "MAID"
QR_TITLE_KEY = "SDGB"
QR_USER_AGENT = "WC_AIME_LIB"
# ★ aimedb / QR 常量已下沉到 api/Chime.py
# =============================================================================
# 三之二、★ 功能票(Ticket)主数据
# =============================================================================
@@ -140,38 +126,12 @@ QR_USER_AGENT = "WC_AIME_LIB"
#
# ticketKind 含义(Ticket.xml 里的枚举):
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
TICKET_CREDIT_NUM = {
0: 0,
2: 1, 3: 2, 4: 3, 5: 4, 6: 5, # 付费票:N 倍券的 creditNum = N-1
10005: 0, 10105: 0, 10205: 0, # 活动票
11001: 0, # 免费 1.5 倍券
30001: 0, 30002: 0, 30003: 0, # 连动 / 商店票
}
#: 票名(只用于日志好看,不影响协议)
TICKET_NAMES = {
0: "使用しない",
2: "2倍チケット", 3: "3倍チケット", 4: "4倍チケット", 5: "5倍チケット", 6: "6倍チケット",
10005: "イベント5倍チケット_1", 10105: "イベント5倍チケット_2",
10205: "イベント5倍チケット_3", 11001: "無料1.5倍チケット",
30001: "連動チケット", 30002: "每周区域前进2倍票", 30003: "旅行伙伴等级提升5倍票",
}
#: 付费票(ticketKind=1)—— 只有这些能在机台购买
PAID_TICKET_IDS = (2, 3, 4, 5, 6)
# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS
# 已下沉到 api/UpsertUserChargelog.py
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
UPSERT_CHARGELOG_CONFIRM = False
#: 票有效期算法参数
#: 依据 source/Assembly-CSharp/Manager/TicketManager.cs:564
#: DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
#: 真机验证:chargeId=5 的 purchaseDate=2026-10-04 18:22:10
#: -> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02 -> "2027-01-02 04:00:00" ✅
TICKET_VALID_HOUR_OFFSET = -4
TICKET_VALID_DAYS = 90
# =============================================================================
# 四、★ UpsertUserAll 即将上传的歌曲数据
# =============================================================================
@@ -225,14 +185,7 @@ PLAY_JUDGE_COUNTS = {
}
# --- 评级(本局前后)---
PLAY_BEFORE_RATING = 15000
PLAY_AFTER_RATING = 15000
PLAY_BEFORE_GRADE = 0
PLAY_AFTER_GRADE = 0
PLAY_AFTER_GRADE_RANK = 1
PLAY_BEFORE_DELUX_RATING = 15000
PLAY_AFTER_DELUX_RATING = 15000
# ★ PLAY_BEFORE/AFTER_RATING 等已改为运行时从服务器快照取(derive_play_rating)
# --- 曲种索引 ---
# ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022)
PLAY_CATEGORY_INDEX = 2022
@@ -252,14 +205,6 @@ PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag
# 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。
#
# 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
PLAY_CHARACTERS = [
{"characterId": 395, "level": 3800, "awakening": 5},
{"characterId": 101, "level": 4455, "awakening": 5},
{"characterId": 400402, "level": 134, "awakening": 3},
{"characterId": 400401, "level": 134, "awakening": 3},
{"characterId": 200401, "level": 134, "awakening": 3},
]
# --- 本局消耗 ---
# ★ playCredit 语义(VOExtensions.cs:838-845):
# playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId)
@@ -282,8 +227,7 @@ PLAY_IS_FREEDOM_MODE = False
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
PLAY_LOCAL_PLAY_COUNT = 8
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/UpsertUserAll.py)
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
#
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
@@ -312,8 +256,7 @@ PLAY_LOCAL_PLAY_COUNT = 8
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
#
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
PLAY_SPECIAL_NUM = 1024
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/UpsertUserAll.py
# --- 用户总游玩次数增量 ---
#
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
@@ -431,6 +374,7 @@ __all__ = [
"TIMEOUT_MS", "TIMEOUT_LONG_MS",
# 机台与店铺
"KEYCHIP_ID", "CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME",
"AUTHV1_WRITE_CONFIG",
# 功能票
"TICKET_CREDIT_NUM", "TICKET_NAMES", "PAID_TICKET_IDS",
"UPSERT_CHARGELOG_CONFIRM", "TICKET_VALID_HOUR_OFFSET", "TICKET_VALID_DAYS",
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 514 KiB

+703
View File
@@ -0,0 +1,703 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
r"""
netdelivery.py —— ALL.Net DLI(Download Instruction):查 / 下 .app 与 .opt 更新包
================================================================================
【DLI 是什么】
DLI = **Download Instruction**(配信指示書)。
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
POST http://at.sys-allnet.cn/net/delivery/instruction
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
服务器回:
result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
指示書本身是个 INI(日文注释),真正要下的包写在 INSTALLn= 里:
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
INSTALL2=https://.../SDGB_A005_..._0.opt
★ 只有 INSTALLn= 才是**要下载的包**;EXISTn= 是「本机应该已经有的文件」,
只是文件名,不是下载项。
【协议出处】—— source/amdaemon.exe 字符串(数字是文件内偏移)
端点 http://at.sys-all.cn/net/delivery/instruction @0x788198
库 alw_lite/src/system39/alw_api_netdeliver.c @0x63bf80
阶段 ALWE_NETDELIVER_PHASE_GET_DLINST_INFO @0x63bde0
ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO @0x63be08
ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO @0x63be30
日志 "Net deliver param - dli uri : %s." @0x63c3f8
状态 ALE_ABEX_STAT_DLINSTR_OK / _ERR_DLINSTR_NG ... @0x638600
【实测结论】(2026-10-06)
title_ver=1.55 -> uri = patch(SDGB_patch_1.56...info) | option(SDGB_option_1.55...A005.info)
title_ver=1.56 -> uri = | option(A031) (patch 槽为空 = 本体已最新)
title_ver<=1.54 -> uri = null (服务器不认这个版本)
指示書里的 INSTALL 直链可以带 Range 断点续传(返回 206 + Content-Range)。
★ 已被下架的历史包,源站会回 504 —— 这是服务器状态,不是脚本的问题。
【用法】
python netdelivery.py # 用 config.py 里的 Keychip 查,然后**多选下载**
python netdelivery.py A63E01E9580 # 指定 Keychip
python netdelivery.py --list # 只列清单,不下载
python netdelivery.py --all # 全下(不交互)
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
python netdelivery.py --out D:\SDGB2026\update
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
★ 交互选择支持:逗号/空格分隔、区间(如 1-3)、a=全选、n=全不选、q=退出。
"""
from __future__ import annotations
import argparse
import os
import re
import sys
import time
import requests
import urllib3
from authv1 import (
DEFAULT_HOST,
DEFAULT_TIMEOUT,
AllnetError,
delivery_instruction,
parse_keychip,
)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 下载器用的 UA(实测有效)
DOWNLOAD_HEADERS = {"User-Agent": "SDGB;Windows/Lite"}
DEFAULT_OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "download")
CHUNK = 1 << 16
PROBE_TIMEOUT = 30
DOWNLOAD_TIMEOUT = 60
# 包类型判定(按扩展名)
KIND_BY_EXT = {
".app": "app",
".opt": "opt",
".pack": "pack",
}
KIND_TEXT = {
"app": "本体/补丁",
"opt": "选项包",
"pack": "资源包",
"?": "未知",
}
# =============================================================================
# HTTP
# =============================================================================
def http_get(url, *, headers=None, stream=False, timeout=PROBE_TIMEOUT, insecure=False):
"""
GET,顺带处理 CDN 证书问题。
先按正常校验发;如果因为证书失败,再自动退回 verify=False 重发一次
(只影响这一次请求,不改变全局设置)。
"""
merged = dict(DOWNLOAD_HEADERS)
if headers:
merged.update(headers)
try:
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
verify=not insecure)
except requests.exceptions.SSLError:
if insecure:
raise
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
verify=False)
# =============================================================================
# 配信指示書解析
# =============================================================================
def parse_instruction(text):
"""
解析配信指示書 INI。
返回 (common, installs):
common = {GAME_DESC: ..., DOWNLOAD_ID: ..., ...}([COMMON] 段)
installs = [{"section": "COMMON"/"OPTIONAL", "key": "INSTALL1", "url": ...}, ...]
"""
common = {}
installs = []
section = ""
for raw_line in text.splitlines():
line = raw_line.strip()
if not line or line.startswith(";") or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
section = line[1:-1].strip().upper()
continue
if "=" not in line:
continue
key, value = line.split("=", 1)
key = key.strip().upper()
value = value.strip().strip('"')
if section in ("", "COMMON"):
common.setdefault(key, value)
if key.startswith("INSTALL") and value:
installs.append({"section": section or "COMMON", "key": key, "url": value})
return common, installs
def guess_kind(url):
"""按扩展名猜包类型。"""
name = url.split("?")[0].rsplit("/", 1)[-1]
ext = os.path.splitext(name)[1].lower()
return KIND_BY_EXT.get(ext, "?")
def package_name(url):
return url.split("?")[0].rsplit("/", 1)[-1]
def human_size(num):
if num is None:
return "?"
value = float(num)
for unit in ("B", "KB", "MB", "GB", "TB"):
if value < 1024 or unit == "TB":
return ("%d B" % int(value)) if unit == "B" else ("%.2f %s" % (value, unit))
value /= 1024.0
return "?"
# =============================================================================
# 采集:DLI -> 指示書 -> 包清单
# =============================================================================
def fetch_instruction_urls(client_id, *, title_ver, host, timeout):
"""问 DLI,返回 (patch_info_url, option_info_url, raw_text)。"""
text = delivery_instruction(client_id, title_ver=title_ver, host=host, timeout=timeout)
kv = {}
for part in text.strip().split("&"):
if "=" in part:
k, v = part.split("=", 1)
kv[k.strip()] = v
result = kv.get("result", "")
if result != "1":
raise AllnetError("DLI 返回 result=%s(原始:%s)" % (result, text.strip()[:200]))
uri = kv.get("uri", "").strip()
if uri in ("", "null"):
raise AllnetError("DLI 返回 uri=%s:这个 title_ver 没有配信指示書"
"(换 --ver 试试,例如 1.55)" % (uri or "(空)"))
parts = uri.split("|")
patch_url = parts[0].strip() if len(parts) > 0 else ""
option_url = parts[1].strip() if len(parts) > 1 else ""
for bad in ("null", "none"):
if patch_url.lower() == bad:
patch_url = ""
if option_url.lower() == bad:
option_url = ""
return patch_url, option_url, text.strip()
def collect_packages(client_id, *, title_ver, host, timeout, insecure=False):
"""
走完整条链路,返回 (items, meta)。
items 每一项:
{"idx":1, "kind":"app", "section":"COMMON", "key":"INSTALL1",
"url":..., "name":..., "size":int|None, "note":"", "info_name":...}
"""
patch_url, option_url, dli_raw = fetch_instruction_urls(
client_id, title_ver=title_ver, host=host, timeout=timeout)
items = []
meta = {"dli_raw": dli_raw, "title_ver": title_ver, "client_id": client_id,
"instructions": [], "infos": {}}
for info_url in (patch_url, option_url):
if not info_url:
continue
info_name = package_name(info_url)
resp = None
for attempt in range(1, 4):
try:
resp = http_get(info_url, timeout=timeout, insecure=insecure)
if resp.status_code < 500:
break
except Exception:
resp = None
time.sleep(1.5 * attempt)
if resp is None:
meta["instructions"].append({"url": info_url,
"error": "拉取失败(已重试 3 次)"})
continue
if resp.status_code != 200:
meta["instructions"].append({"url": info_url, "status": resp.status_code,
"error": "HTTP %d" % resp.status_code})
continue
text = resp.content.decode("utf-8", "replace")
common, installs = parse_instruction(text)
meta["instructions"].append({"url": info_url, "status": 200,
"name": info_name, "common": common})
meta["infos"][info_name] = text
seen = set()
for entry in installs:
if entry["url"] in seen:
continue
seen.add(entry["url"])
items.append({
"kind": guess_kind(entry["url"]),
"section": entry["section"],
"key": entry["key"],
"url": entry["url"],
"name": package_name(entry["url"]),
"size": None,
"note": "",
"info_name": info_name,
})
for i, item in enumerate(items, 1):
item["idx"] = i
return items, meta
def probe_all(items, *, timeout=PROBE_TIMEOUT, insecure=False):
"""探每个包的大小 / 可用性(就地写回 item)。"""
for item in items:
for attempt in range(1, 3):
try:
resp = http_get(item["url"], headers={"Range": "bytes=0-0"},
stream=True, timeout=timeout, insecure=insecure)
status = resp.status_code
content_range = resp.headers.get("Content-Range", "")
content_length = resp.headers.get("Content-Length")
resp.close()
if status == 206 and "/" in content_range:
item["size"] = int(content_range.rsplit("/", 1)[-1])
item["note"] = ""
elif status == 200 and content_length and content_length.isdigit():
item["size"] = int(content_length)
item["note"] = ""
elif status in (404, 504):
item["note"] = "HTTP %d(源站不可用 / 该包已下架)" % status
else:
item["note"] = "HTTP %d" % status
break
except Exception as exc:
item["note"] = "探测失败:%s" % exc
if attempt < 2:
time.sleep(1.5)
continue
# =============================================================================
# 展示与选择
# =============================================================================
def print_packages(items):
if not items:
print(" (指示書里没有任何 INSTALL 项)")
return
print("")
print(" 序号 类型 大小 来源段/键 文件名")
print(" " + "-" * 92)
for item in items:
size = human_size(item["size"]) if item["size"] is not None else (item["note"] or "?")
print(" [%2d] %-4s %-12s %-20s %s" % (
item["idx"], item["kind"], size,
item["section"] + "/" + item["key"], item["name"]))
if item["note"] and item["size"] is None:
print(" %s" % item["note"])
def prompt_selection(items):
"""
交互多选。返回选中的 0 基下标列表;用户 q 退出时返回 None。
"""
print("")
print(" 可多选:输入序号,用逗号或空格分隔;支持区间(如 1-3)。")
print(" a = 全选 n = 都不选 q = 退出")
while True:
try:
raw = input(" 请选择要下载的包 > ").strip()
except (EOFError, KeyboardInterrupt):
print("")
return None
if not raw:
continue
low = raw.lower()
if low in ("q", "quit", "exit"):
return None
if low in ("a", "all"):
return list(range(len(items)))
if low in ("n", "none"):
return []
picked, bad = [], []
for token in re.split(r"[,,、\s]+", raw):
if not token:
continue
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
if span:
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
for i in range(low_i, high_i + 1):
if 1 <= i <= len(items):
picked.append(i - 1)
else:
bad.append(str(i))
continue
if token.isdigit():
i = int(token)
if 1 <= i <= len(items):
picked.append(i - 1)
else:
bad.append(token)
else:
bad.append(token)
if bad:
print(" [x] 无效输入:%s(范围是 1-%d)" % (", ".join(bad), len(items)))
continue
ordered, seen = [], set()
for i in picked:
if i not in seen:
seen.add(i)
ordered.append(i)
if not ordered:
print(" [x] 没有选中任何项")
continue
return ordered
# =============================================================================
# 下载
# =============================================================================
def download_file(url, dest, *, timeout=DOWNLOAD_TIMEOUT, retries=3, insecure=False):
"""
带断点续传的下载。返回 (ok, 说明)。
★ 先写 dest + ".part",下完再改名 —— 中断了下次能接着下。
"""
part = dest + ".part"
total = None
for attempt in range(1, retries + 1):
done = os.path.getsize(part) if os.path.exists(part) else 0
headers = {}
if done:
headers["Range"] = "bytes=%d-" % done
try:
resp = http_get(url, headers=headers, stream=True, timeout=timeout,
insecure=insecure)
except Exception as exc:
if attempt == retries:
return False, "连接失败:%s" % exc
time.sleep(2)
continue
if resp.status_code not in (200, 206):
resp.close()
if resp.status_code in (404, 504):
return False, "HTTP %d(源站不可用 / 该包已下架)" % resp.status_code
if attempt == retries:
return False, "HTTP %d" % resp.status_code
time.sleep(2)
continue
# 服务器忽略 Range 直接给了全量 -> 从头写
if done and resp.status_code == 200:
done = 0
content_range = resp.headers.get("Content-Range", "")
if "/" in content_range:
try:
total = int(content_range.rsplit("/", 1)[-1])
except ValueError:
total = None
elif resp.headers.get("Content-Length", "").isdigit():
total = done + int(resp.headers["Content-Length"])
mode = "ab" if done else "wb"
started = time.time()
written = done
last_tick = 0.0
last_shown = -1
try:
with open(part, mode) as fp:
for chunk in resp.iter_content(CHUNK):
if not chunk:
continue
fp.write(chunk)
written += len(chunk)
now = time.time()
if now - last_tick >= 0.3:
last_tick = now
last_shown = written
show_progress(written, total, now - started)
except Exception as exc:
resp.close()
if attempt == retries:
return False, "写入中断:%s" % exc
time.sleep(2)
continue
finally:
resp.close()
if written == last_shown:
sys.stdout.write("\n") # 收尾换行,避免重复打一行
else:
show_progress(written, total, max(time.time() - started, 0.001), final=True)
if total is not None and written != total:
if attempt == retries:
return False, "长度不符:期望 %d 字节,实际 %d 字节" % (total, written)
time.sleep(2)
continue
if os.path.exists(dest):
os.remove(dest)
os.replace(part, dest)
return True, "%s(%s)" % (human_size(written), dest)
return False, "重试 %d 次仍失败" % retries
def show_progress(done, total, elapsed, final=False):
speed = done / elapsed if elapsed > 0 else 0
if total:
percent = done * 100.0 / total
line = " %6.2f%% %s / %s %s/s" % (percent, human_size(done),
human_size(total), human_size(speed))
else:
line = " %s %s/s" % (human_size(done), human_size(speed))
sys.stdout.write("\r" + line + ("\n" if final else ""))
sys.stdout.flush()
# =============================================================================
# CLI
# =============================================================================
def build_parser():
p = argparse.ArgumentParser(
prog="netdelivery.py",
description="ALL.Net DLI:查 / 下 .app 与 .opt 更新包",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python netdelivery.py # 查完让你多选\n"
" python netdelivery.py --list # 只列清单\n"
" python netdelivery.py --all # 全下\n"
" python netdelivery.py --only 1,3 # 只下第 1、3 项\n"
" python netdelivery.py --ver 1.55 # 指定上报版本\n"
),
)
p.add_argument("keychip", nargs="?",
help="Keychip(默认取 config.py 的 KEYCHIP_ID)")
p.add_argument("--ver", default=None,
help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
p.add_argument("--host", default=DEFAULT_HOST,
help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
p.add_argument("--out", default=DEFAULT_OUT_DIR,
help="下载目录(默认 <脚本目录>/download)")
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="接口超时秒数")
p.add_argument("--list", action="store_true", help="只列清单与直链,不下载")
p.add_argument("--all", action="store_true", help="全选(不交互)")
p.add_argument("--only", default=None, help="只下指定序号,如 1,3 或 1-3(不交互)")
p.add_argument("--no-probe", action="store_true", help="跳过包大小探测(更快)")
p.add_argument("--insecure", action="store_true", help="关掉 TLS 校验")
return p
def resolve_keychip(cli_value):
if cli_value:
return cli_value
try:
import config # noqa: F401
value = getattr(config, "KEYCHIP_ID", "")
if value:
return value
except Exception:
pass
return ""
def resolve_title_ver(cli_value):
if cli_value:
return str(cli_value)
try:
import config # noqa: F401
return str(getattr(config, "MAI_ENCODING", "1.55"))
except Exception:
return "1.55"
def parse_only(text, count):
"""把 --only 的文本解析成 0 基下标列表。"""
picked, seen = [], set()
for token in re.split(r"[,,、\s]+", text or ""):
if not token:
continue
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
if span:
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
candidates = range(low_i, high_i + 1)
elif token.isdigit():
candidates = [int(token)]
else:
raise ValueError("--only 里有无效项:%r" % token)
for i in candidates:
if not (1 <= i <= count):
raise ValueError("--only 序号 %d 超出范围(1-%d)" % (i, count))
if i - 1 not in seen:
seen.add(i - 1)
picked.append(i - 1)
return picked
def save_instructions(meta, out_dir):
"""把配信指示書原样存一份,便于事后核对。"""
os.makedirs(out_dir, exist_ok=True)
saved = []
for name, text in meta["infos"].items():
path = os.path.join(out_dir, name)
with open(path, "w", encoding="utf-8", newline="") as fp:
fp.write(text)
saved.append(path)
return saved
def main(argv=None):
try:
sys.stdout.reconfigure(errors="replace")
except Exception:
pass
args = build_parser().parse_args(argv)
title_ver = resolve_title_ver(args.ver)
raw_keychip = resolve_keychip(args.keychip)
if not raw_keychip:
print("错误:没给 Keychip,config.py 里的 KEYCHIP_ID 也是空的。")
print(" 先跑一次 python authv1.py <你的Keychip> 把 config.py 填好。")
return 2
try:
client_id = parse_keychip(raw_keychip)
except ValueError as exc:
print("Keychip 解析失败:%s" % exc)
return 2
print("=== netdelivery (ALL.Net DLI) ===")
print(" clientId : %s" % client_id)
print(" title_ver : %s" % title_ver)
print(" server : %s" % args.host)
print(" 正在查询配信指示書 ...")
try:
items, meta = collect_packages(client_id, title_ver=title_ver, host=args.host,
timeout=args.timeout, insecure=args.insecure)
except (AllnetError, ValueError) as exc:
print("查询失败:%s" % exc)
return 1
for entry in meta["instructions"]:
if entry.get("status") == 200:
common = entry.get("common", {})
print(" 指示書 %s" % entry["name"])
for key in ("GAME_DESC", "DLFORMAT", "GAME_ID", "DOWNLOAD_ID",
"RELEASE_TIME", "RELEASE_TYPE"):
if key in common:
print(" %-14s %s" % (key + ":", common[key]))
else:
print(" 指示書 %s -> %s" % (entry["url"], entry.get("error", "?")))
if not items:
print("没有可下载的包。")
return 0
if not args.no_probe:
print(" 正在探测包大小 ...")
probe_all(items, insecure=args.insecure)
print_packages(items)
if args.list:
print("")
print(" 直链:")
for item in items:
print(" [%2d] %s" % (item["idx"], item["url"]))
return 0
# ---- 决定下哪些 ----
if args.only:
try:
chosen = parse_only(args.only, len(items))
except ValueError as exc:
print("参数错误:%s" % exc)
return 2
elif args.all:
chosen = list(range(len(items)))
elif not sys.stdin or not sys.stdin.isatty():
print("")
print(" 当前不是交互终端,未指定 --all / --only,已只列清单。")
return 0
else:
chosen = prompt_selection(items)
if chosen is None:
print(" 已取消。")
return 0
if not chosen:
print(" 没有选中任何项,退出。")
return 0
out_dir = args.out
os.makedirs(out_dir, exist_ok=True)
for path in save_instructions(meta, out_dir):
print(" [指示書] %s" % path)
print("")
print(" 下载目录:%s" % out_dir)
failed = []
for index in chosen:
item = items[index]
dest = os.path.join(out_dir, item["name"])
print("")
print(" [%d/%d] %s(%s)" % (chosen.index(index) + 1, len(chosen),
item["name"], KIND_TEXT.get(item["kind"], "?")))
if item["size"] is None and item["note"]:
print(" 跳过:%s" % item["note"])
failed.append((item, item["note"]))
continue
started = time.time()
ok, message = download_file(item["url"], dest, insecure=args.insecure)
if ok:
print(" 完成,用时 %.1f 秒 -> %s" % (time.time() - started, message))
else:
print(" 失败:%s" % message)
failed.append((item, message))
print("")
if failed:
print(" --- 结束:%d 个成功,%d 个失败 ---" % (len(chosen) - len(failed), len(failed)))
for item, message in failed:
print(" [%2d] %s -> %s" % (item["idx"], item["name"], message))
return 1
print(" --- 结束:%d 个全部成功 ---" % len(chosen))
return 0
if __name__ == "__main__":
sys.exit(main())
+512
View File
@@ -0,0 +1,512 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B
================================================================================
┌─ 源账号 A ─────────────────────────────────────────────┐
│ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │
│ 或:直接读 data/<userId>.json(离线复用) │
└────────────────────────────────────────────────────────┘
↓ 节点数据
┌─ 目标账号 B ───────────────────────────────────────────┐
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
│ 循环: │
│ 等待 -> UpsertUserAll(一包曲目) │
│ -> UserLogout(type=1) │
│ -> UserLogin(isContinue=true) 续关,拿新 loginId │
│ 收尾:最后一包登出后结束(不再续关) │
└────────────────────────────────────────────────────────┘
--------------------------------------------------------------------------------
★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
--------------------------------------------------------------------------------
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
userPlaylogList / userGamePlaylogList **最多 4 条**。
★ 但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
所以本脚本的分批器会**主动避开 2 首包**:
10 首 -> [4, 4, 2] -> [4, 3, 3]
6 首 -> [4, 2] -> [3, 3]
2 首 -> [2] -> [1, 1]
★ 每包都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
留空会被服务端当成无效包**静默丢弃**(returnCode=1 但不落库)。
--------------------------------------------------------------------------------
★ 为什么需要「续关登录」
--------------------------------------------------------------------------------
UpsertUserAll 的 playlogId 必须等于**本次登录**的 loginId
(PacketUpsertUserAll.cs:31 / UserAllRequestVO)。
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
真机走的就是「续关」:
Process/ContinueProcess.cs:403
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
对应本工程:UserLogin(user_id, token, is_continue=True)
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
★ 登出必须用 **type=1(LogoutType.Logout)**。
真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog):
{"userId":...,"type":1,"delayLog":{...}}
续关场景下用 4(TestIn)会出问题 —— 那是「测试登入 / 顶号」的语义。
--------------------------------------------------------------------------------
★ playlog 从哪来
--------------------------------------------------------------------------------
GetNeedUserData 抓不到 userPlaylogList(那是逐局记录,没有对应的 Get 接口),
所以本脚本**从曲目记录反推合成**:
UserMusicDetail(9 字段) -> UserPlaylog(110 字段)
───────────────────────────── ─────────────────────────
musicId musicId
level level
achievement achievement
deluxscoreMax deluxscore
scoreRank scoreRank / isClear
comboStatus comboStatus
syncStatus syncStatus
其余字段 取自 config.PLAY_*(合理默认值)
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
# ★ 真实上传
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
# 只看计划,不发包
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
# 常用开关
--limit N 只迁前 N 首(试跑用)
--per-packet N 每包曲目数,只能是 1/3/4(默认 4;★ 2 首会被服务端拒绝)
--wait N 每次上传前的等待秒数(默认 5.0,0 = 不等)
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
--no-continue 不续关(只发一包,调试用)
★ 迁移会**真实写入**账号 B(不可撤销)。不传 --send 时只构造报文。
★ 二维码**有效期 10 分钟**。
"""
from __future__ import annotations
import json
import sys
import time
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
log_warning,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
GetNeedUserData,
normalize_snapshot,
NODE_ORDER,
NODE_TYPES,
ARRAY_TO_BITS,
DIFF_PRIMARY_KEYS,
PlayData,
build_user_playlog,
build_user_game_playlog,
UpsertUserAll,
ConfirmRequiredError,
Ping,
load_user_data,
list_saved_user_ids,
DATA_DIR,
)
from config import PLACE_ID, REGION_ID
#: 真机一局最多 4 首
MAX_TRACKS_PER_CREDIT = 4
#: ★ 被服务端禁止的每包曲目数(实测:**恰好 2 首会被拒**)
#: 1 / 3 / 4 首都正常,只有 2 首不行 —— 所以分批时要避开它。
FORBIDDEN_TRACK_COUNTS = (2,)
#: 允许的 --per-packet 取值
ALLOWED_PER_PACKET = (1, 3, 4)
def split_batches(total, per):
"""
把 total 首切成「每包 <= per」的批次,**并且不产生被禁止的 2 首包**。
做法:先贪心按 per 切,再修正大小为 2 的批次:
① 前一批 >= 4 -> 从它借 1 首过来(前一批变 >=3,本批变 3)
② 借不到(前一批只有 3,或自己就是第一批)-> 把这个 2 拆成 1 + 1
★ 只借位是不够的 —— 前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
例:
10 首 per=4 -> [4, 4, 2] -> [4, 3, 3]
6 首 per=4 -> [4, 2] -> [3, 3]
2 首 per=4 -> [2] -> [1, 1]
5 首 per=3 -> [3, 2] -> [3, 1, 1]
8 首 per=3 -> [3, 3, 2] -> [3, 3, 1, 1]
5 首 per=4 -> [4, 1] (本来就合法,不动)
"""
if total <= 0:
return []
per = max(1, int(per))
out = []
left = total
while left > 0:
n = min(per, left)
out.append(n)
left -= n
i = 0
while i < len(out):
if out[i] not in FORBIDDEN_TRACK_COUNTS:
i += 1
continue
# ① 前一批 >= 4 才能借(借完前一批 >= 3,本批 = 3)
if i > 0 and out[i - 1] >= 4:
out[i - 1] -= 1
out[i] += 1
i += 1
continue
# ② 借不到 -> 把这个 2 拆成 1 + 1
out[i] = 1
out.insert(i + 1, 1)
i += 2
return out
#: 默认保留目标账号自己的身份字段(不克隆过去,避免把 B 改名)
IDENTITY_KEYS = ("userName", "accessCode")
#: 迁移时**不**逐条传的节点(要么是位串、要么由 UpsertUserAll 自己算)
SKIP_NODES = (
"userMusicDetailList", # 单独分批
"userGamePlaylogList", # 由本脚本按曲目合成
"user2pPlaylog", # 2P 记录,迁移无意义
"userGetPointList", # 里程/奖励,迁移会凭空发点
"userTradeItemList", # 无 Get 来源
"userFriendSeasonRankingList", # 无 Get 来源
)
def parse_args(argv):
opts = {"dst_qr": "", "src_qr": "", "src_json": "", "send": False,
"plan": False, "debug": False, "limit": 0,
"per_packet": MAX_TRACKS_PER_CREDIT,
"clone_identity": False, "no_continue": False,
"wait": 5.0}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--plan":
opts["plan"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--clone-identity":
opts["clone_identity"] = True
elif a == "--no-continue":
opts["no_continue"] = True
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit",
"--per-packet", "--wait") and i + 1 < len(argv):
key = a[2:].replace("-", "_")
opts[key] = (float(argv[i + 1]) if key == "wait"
else int(argv[i + 1]) if key in ("limit", "per_packet")
else argv[i + 1])
i += 1
i += 1
return opts
# =============================================================================
# 工具
# =============================================================================
def _bits_for(node, items, base_items):
"""
按目标账号 B 的基线算 isNew* 位串:
B 里没有该条目 -> "1"(新增)
B 里已有 -> "0"(更新)
主键表见 upsert_user_all.DIFF_PRIMARY_KEYS。
"""
keys = DIFF_PRIMARY_KEYS.get(node)
if not keys:
return None
have = set()
for b in (base_items or []):
if isinstance(b, dict):
have.add(tuple(str(b.get(k)) for k in keys))
return "".join(
"0" if tuple(str(it.get(k)) for k in keys) in have else "1"
for it in (items or []) if isinstance(it, dict)
)
def play_from_detail(detail, base_play=None):
"""
从一条 UserMusicDetail(9 字段)反推一份 PlayData,用来合成 userPlaylog。
对不上的字段保留 config.PLAY_* 的默认值(它们不在曲目记录里)。
"""
p = PlayData() if base_play is None else base_play
p.music_id = int(detail.get("musicId", p.music_id) or 0)
p.music_level = int(detail.get("level", p.music_level) or 0)
p.achievement = int(detail.get("achievement", p.achievement) or 0)
p.deluxscore = int(detail.get("deluxscoreMax", p.deluxscore) or 0)
rank = int(detail.get("scoreRank", 0) or 0)
p.score_rank = rank
p.is_clear = rank > 0
p.combo_status = int(detail.get("comboStatus", p.combo_status) or 0)
p.sync_status = int(detail.get("syncStatus", p.sync_status) or 0)
return p
def collect_nodes(src_nodes, base_nodes, clone_identity):
"""把源账号 A 的节点整理成 extra_data(曲目/2P/奖励类除外)。"""
out = {}
for node in NODE_ORDER:
if node in SKIP_NODES:
continue
if node.startswith("isNew"):
continue # 位串单独算
kind = NODE_TYPES.get(node)
if kind not in ("singleton", "array", "object"):
continue
val = src_nodes.get(node)
if val in (None, [], {}):
continue
out[node] = json.loads(json.dumps(val)) # 深拷贝
# 身份字段:默认保留 B 自己的
if not clone_identity and out.get("userData"):
for item in out["userData"]:
if isinstance(item, dict):
for k in IDENTITY_KEYS:
item.pop(k, None)
# 位串:按 B 的基线算
for arr, bits in ARRAY_TO_BITS.items():
if arr not in out:
continue
b = _bits_for(arr, out[arr], base_nodes.get(arr))
if b:
out[bits] = b
return out
# =============================================================================
# 主流程
# =============================================================================
def fetch_source_by_qr(qr):
"""用源账号 A 的二维码联网抓一份数据(并自动落盘到 data/<A>.json)。"""
log_info("=== 源账号:联网抓取 ===")
uid, token = ParseQRCode(qr)
preview = GetUserPreview(uid, token)
if preview.isLogin:
raise RuntimeError("源账号处于已登录状态,请先在机台登出")
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
try:
raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
finally:
try:
UserLogout(uid, token, login.loginDateTime, logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_warning("源账号登出失败:" + str(exc))
return raw
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["dst_qr"] or not (opts["src_qr"] or opts["src_json"]):
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
if opts["per_packet"] not in ALLOWED_PER_PACKET:
print("--per-packet 只能是 " + str(list(ALLOWED_PER_PACKET))
+ " 之一(真机一局最多 4 首;★ 实测恰好 2 首会被服务端拒绝)")
return 1
setup_logging("reincarnate")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---------------- 1. 源数据 ----------------
if opts["src_json"]:
src_raw = json.load(open(opts["src_json"], "r", encoding="utf-8"))
log_info("=== 源账号:从文件读取 " + opts["src_json"] + " ===")
else:
src_raw = fetch_source_by_qr(opts["src_qr"])
src_nodes = normalize_snapshot(src_raw)
musics = src_nodes.get("userMusicDetailList") or []
if opts["limit"] > 0:
musics = musics[:opts["limit"]]
log_info("源数据:节点 " + str(len(src_nodes)) + " 个,曲目记录 " + str(len(musics)) + " 条")
if not musics:
log_error("源数据里没有 userMusicDetailList,没什么可迁的。")
return 4
# ---------------- 2. 目标账号 ----------------
Ping()
uid, token = ParseQRCode(opts["dst_qr"])
session.update(user_id=uid, token=token)
preview = GetUserPreview(uid, token)
if preview.isLogin:
log_error("目标账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
base_raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
base_nodes = normalize_snapshot(base_raw)
log_info("目标账号基线已抓取")
# ---------------- 3. 分批 ----------------
per = int(opts["per_packet"])
sizes = split_batches(len(musics), per)
batches = []
_pos = 0
for _n in sizes:
batches.append(musics[_pos:_pos + _n])
_pos += _n
log_info("计划:共 " + str(len(musics)) + " 首 -> " + str(len(batches))
+ " 包,每包 " + "/".join(str(n) for n in sizes[:12])
+ ("..." if len(sizes) > 12 else "") + " 首")
log_info(" ★ 已避开 2 首包(实测恰好 2 首会被服务端拒绝;1/3/4 首都正常)")
if opts["plan"]:
for i, b in enumerate(batches):
log_info(" 第 " + str(i + 1) + " 包(" + str(len(b)) + " 首):" + ", ".join(
str(x.get("musicId")) + "/L" + str(x.get("level")) for x in b))
log_info("--plan:只列计划,不发包")
return 0
# 第一包才带非曲目节点(整账号克隆)
shared_nodes = collect_nodes(src_nodes, base_nodes, opts["clone_identity"])
log_info("非曲目节点 " + str(len([k for k in shared_nodes if not k.startswith('isNew')]))
+ " 个将在第 1 包上传")
# ---------------- 4. 逐包上传 ----------------
# ★ 流程(按使用方口径):
# 登录 -> 等待 -> 上传 -> 登出(type=1) -> 续关登录 -> 等待 -> 上传 -> 登出 -> ...
#
# ★ 登出必须用 type=1(LogoutType.Logout)。
# 真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog);
# 续关场景下用 4(TestIn)会出问题 —— 那是「测试登入/顶号」的语义。
sent = 0
for idx, batch in enumerate(batches):
log_info("")
log_info("========== 第 " + str(idx + 1) + "/" + str(len(batches))
+ " 包(" + str(len(batch)) + " 首)==========")
# ---- 等待 ----
if opts["wait"] > 0:
log_info("上传前等待 " + str(opts["wait"]) + " 秒 ...")
time.sleep(opts["wait"])
nodes = {}
if idx == 0:
nodes.update(json.loads(json.dumps(shared_nodes)))
nodes["userMusicDetailList"] = json.loads(json.dumps(batch))
b = _bits_for("userMusicDetailList", batch,
base_nodes.get("userMusicDetailList"))
if b:
nodes["isNewMusicDetailList"] = b
log_info(" isNewMusicDetailList = " + b)
# 合成 playlog(每首一条)与 game playlog
playlogs = []
game_playlogs = []
for d in batch:
p = play_from_detail(d)
playlogs.append(build_user_playlog(
login.loginId, login.loginDateTime, p, place_id=PLACE_ID))
game_playlogs.append(build_user_game_playlog(
login.loginId, login.loginDateTime, p))
nodes["userGamePlaylogList"] = game_playlogs
UpsertUserAll(
uid, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
snapshot=base_raw,
auto_characters=False,
play=play_from_detail(batch[0]),
extra_data=nodes,
user_playlog_list=playlogs,
pre_new_item_list=False,
verify_landing=False,
dry_run=not opts["send"],
confirm=opts["send"],
place_id=PLACE_ID, region_id=REGION_ID,
)
sent += 1
# ---- ★ 登出(type=1 Logout)----
log_info("--- 登出(type=1 Logout)---")
try:
UserLogout(uid, token, login.loginDateTime,
logout_type=LogoutType.Logout,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_warning("登出失败(继续下一包):" + str(exc))
session["logged_in"] = False
# ---- ★ 续关登录:拿下一包的 loginId ----
if idx < len(batches) - 1:
if opts["no_continue"]:
log_warning("--no-continue:不再续关,提前结束(只发了 1 包)")
break
log_info("--- 续关登录(isContinue=true)---")
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID,
is_continue=True)
session.update(login_dt=login.loginDateTime, logged_in=True)
if not opts["send"]:
log_info("")
log_info("[dry-run] 未发包。想真实迁移请加 --send(★ 会写入账号 B)")
return 0
log_info("")
log_info("★ 迁移完成:共上传 " + str(sent) + " 包 / "
+ str(min(len(musics), sent * per)) + " 首")
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
+231 -27
View File
@@ -27,6 +27,8 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import api
import unlockmusic
import upsert
from api import (
BITS_TO_ARRAY,
JUDGE_BOXES,
@@ -38,6 +40,7 @@ from api import (
build_delta,
build_favorite_delta,
build_play_from_config,
build_unlock_items,
build_user_all,
build_user_game_playlog,
build_user_playlog,
@@ -49,6 +52,13 @@ from api import (
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
has_fresh_read,
held_charges,
last_read_age,
note_charge_purchase,
note_user_charge_read,
purchased_charge_ids,
reset_charge_guard,
ticket_name,
ticket_price,
calc_valid_date,
@@ -70,21 +80,39 @@ from api import (
login_date_time_now,
local_utc_offset_seconds,
)
from api.upsert_user_all import (
from api.UpsertUserAll import (
ConfirmRequiredError,
PatchError,
UpsertUserAll,
UserAllRequestVO,
)
from api.upsert_user_chargelog import _now_string
from api.UpsertUserChargelog import _now_string
from config import (
# ★ 这些常量原先在 config.py,2026-10-06 已下沉到各 api 模块 —— 从 api 取。
from api import (
PLAY_SPECIAL_NUM as CONFIG_PLAY_SPECIAL_NUM,
PAID_TICKET_IDS as CONFIG_PAID_TICKET_IDS,
TICKET_VALID_DAYS as CONFIG_TICKET_DAYS,
TICKET_VALID_HOUR_OFFSET as CONFIG_TICKET_HOUR_OFFSET,
)
# =============================================================================
# ★ PLAY_CHARACTERS —— 原先在 config.py,现移到自检脚本里作为**测试数据**
# =============================================================================
#
# 说明:PlayData.characters 现在默认是**空列表**,真值由
# derive_play_characters() 从服务器快照推(config 里的静态值会过期)。
# 这里保留一份真机样本,供自检做「离线组包」用例。
#
# 值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
PLAY_CHARACTERS = [
{"characterId": 395, "level": 3800, "awakening": 5},
{"characterId": 101, "level": 4455, "awakening": 5},
{"characterId": 400402, "level": 134, "awakening": 3},
{"characterId": 400401, "level": 134, "awakening": 3},
{"characterId": 200401, "level": 134, "awakening": 3},
]
_PASS = 0
_FAIL = 0
_FAILED_NAMES = []
@@ -150,9 +178,9 @@ check("deflate 往返一致", deflate_decompress(packed) == sample)
enc = aes_encrypt(packed)
check("AES 密文长度是 16 的倍数", len(enc) % 16 == 0, str(len(enc)))
check("AES 往返一致", aes_decrypt(enc) == packed)
check("AES 是 256 位密钥(config.AES_KEY 32 字节)",
len(__import__("config").AES_KEY) == 32)
check("AES IV 是 16 字节", len(__import__("config").AES_IV) == 16)
check("AES 是 256 位密钥(api.AES_KEY 32 字节)",
len(api.AES_KEY) == 32)
check("AES IV 是 16 字节", len(api.AES_IV) == 16)
# =============================================================================
@@ -333,7 +361,7 @@ fav_server_raw.append({"itemKind": None, "itemIdList": []})
_, fav_flags_raw = build_favorite_delta(fav_local, fav_server_raw)
check("itemKind=null 的那条 -> 1(复刻真机 22212)", fav_flags_raw == "22212", fav_flags_raw)
# 归一化兜底:基线被 user_favorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全,
# 归一化兜底:基线被 GetUserFavorite.py 补齐 itemKind 后,服务器列表里 5 个 kind 齐全,
# 单看列表已看不出 itemKind=10 原本是 null,必须靠 assume_missing 显式声明。
fav_server_norm = [{"itemKind": k, "itemIdList": []} for k in (3, 1, 2, 10, 11)]
_, fav_flags_norm = build_favorite_delta(fav_local, fav_server_norm, (10,))
@@ -350,7 +378,8 @@ check("PLAYLOG_FIELDS 共 110 个", len(PLAYLOG_FIELDS) == 110, str(len(PLAYLOG_
# ★ 显式传中性值,**不依赖 config.py** —— 否则用户改了 config 这里就红
pl = build_user_playlog(7692848101484135861, 1791159576,
PlayData(before_rating=15000, after_rating=15000),
PlayData(before_rating=15000, after_rating=15000,
characters=PLAY_CHARACTERS),
place_id=1000, client_id="TESTCLIENT1")
check("构造出的 playlog 有 110 个键", len(pl) == 110, str(len(pl)))
check("无缺失字段", all(f in pl for f in PLAYLOG_FIELDS))
@@ -438,7 +467,8 @@ REAL_GP = {
"playlogId": 7692848101484135861, "version": "1.56.00",
"playMode": 0, "useTicketId": -1, "playCredit": 1, "playTrack": 1,
"clientId": "TESTCLIENT1", "isPlayTutorial": False, "isEventMode": False,
"isNewFree": False, "playCount": 8, "playOtherUserId": 0,
# ★ PLAY_LOCAL_PLAY_COUNT 现恒为 2(原 config 值 8 已废弃)
"isNewFree": False, "playCount": 2, "playOtherUserId": 0,
}
gp_mismatch = {k: (gp.get(k), v) for k, v in REAL_GP.items() if gp.get(k) != v}
check("字段与真机报文一致", not gp_mismatch, json.dumps(gp_mismatch, ensure_ascii=False))
@@ -466,7 +496,7 @@ def _rev32(n: int) -> int:
check("真机 playSpecial 反推出 CalcSpecialNum=1024",
_rev32(892014272) % 2069 == CONFIG_PLAY_SPECIAL_NUM,
str(_rev32(892014272) % 2069))
check("config.PLAY_SPECIAL_NUM 默认 1024",
check("api.PLAY_SPECIAL_NUM 默认 1024",
CONFIG_PLAY_SPECIAL_NUM == 1024, str(CONFIG_PLAY_SPECIAL_NUM))
check("calc_play_special 把 CalcSpecialNum 叠加进去",
all(_rev32(calc_play_special()) % 2069 == CONFIG_PLAY_SPECIAL_NUM
@@ -535,7 +565,8 @@ ua2 = build_user_all(baseline, login_date_time=1791159576,
# ua2b 显式把增量调回真机值(地图 +11000 / 角色 +1 级),用来验证演算逻辑本身没坏。
ua2b = build_user_all(baseline, login_date_time=1791159576,
auth_date_time=1791134329, playlog_id=7692848101484135861,
play=PlayData(map_distance_gain=11000, character_exp_gain=1))
play=PlayData(map_distance_gain=11000, character_exp_gain=1,
characters=PLAY_CHARACTERS))
check("产出 36 个节点", len(ua2) == 36, str(len(ua2)))
check("节点顺序与 NODE_ORDER 一致", list(ua2.keys()) == list(NODE_ORDER))
@@ -578,8 +609,8 @@ check("userCharacterList 是游玩后等级(101: 4455->4456)",
_plv = [pl["characterLevel" + str(i)] for i in range(1, 6)]
check("playlog.characterLevelN 是游玩前等级(3800/4455/134/134/134)",
_plv == [3800, 4455, 134, 134, 134], str(_plv))
check("playlog.characterLevelN 逐个等于 config.PLAY_CHARACTERS 的 level",
_plv == [int(c["level"]) for c in __import__("config").PLAY_CHARACTERS], str(_plv))
check("playlog.characterLevelN 逐个等于 PLAY_CHARACTERS 的 level",
_plv == [int(c["level"]) for c in PLAY_CHARACTERS], str(_plv))
check("playlog 与 userCharacterList 的等级差恰好等于本局经验增量",
all(pl["characterLevel" + str(i)] + 1 == _clv[pl["characterId" + str(i)]]
for i in range(1, 6) if pl["characterId" + str(i)] in _clv),
@@ -641,15 +672,18 @@ check("传给 build_user_playlog 后 characterLevelN 与快照一致",
[build_user_playlog(1, 1791159576,
PlayData(characters=_dpc))["characterLevel" + str(i)]
for i in range(1, 6)] == [3802, 4457, 136, 136, 136])
check("快照缺角色时回退到 config.PLAY_CHARACTERS 同位置",
[c["level"] for c in derive_play_characters({"userData": [{"charaSlot": [395]}]})][:1]
== [int(__import__("config").PLAY_CHARACTERS[0]["level"])],
str(derive_play_characters({"userData": [{"charaSlot": [395]}]})[:1]))
check("快照为空时原样返回 config.PLAY_CHARACTERS 的 5 条",
derive_play_characters(None)
# ★ PlayData.characters 现在默认为空(真值由快照推),所以要显式传 PLAY_CHARACTERS
_FALLBACK_PLAY = PlayData(characters=PLAY_CHARACTERS)
check("快照缺角色时回退到 PLAY_CHARACTERS 同位置",
[c["level"] for c in derive_play_characters(
{"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)][:1]
== [int(PLAY_CHARACTERS[0]["level"])],
str(derive_play_characters({"userData": [{"charaSlot": [395]}]}, _FALLBACK_PLAY)[:1]))
check("快照为空时原样返回 PLAY_CHARACTERS 的 5 条",
derive_play_characters(None, PlayData(characters=PLAY_CHARACTERS))
== [{"characterId": int(c["characterId"]), "level": int(c["level"]),
"awakening": int(c["awakening"])}
for c in __import__("config").PLAY_CHARACTERS])
for c in PLAY_CHARACTERS])
# 差分节点
# ★ 默认 PLAY_MUSIC_PLAY_COUNT_GAIN = 0 -> **条目照传、次数不变**(不能留空!)
@@ -931,7 +965,7 @@ check("付费票 creditNum:2->1 3->2 4->3 5->4 6->5",
check("非付费票 creditNum 为 0", ticket_price(11001) == 0)
check("price 可显式覆盖", ticket_price(2, 99) == 99)
check("ticket_name 能查到中文名", ticket_name(2) == "2倍チケット", ticket_name(2))
check("config.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6))
check("api.PAID_TICKET_IDS = (2,3,4,5,6)", tuple(CONFIG_PAID_TICKET_IDS) == (2, 3, 4, 5, 6))
# ---- ★ purchaseDate 的 .f 格式(TimeManager.cs:7 DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f")----
check("_now_string 带一位小数(.f)",
@@ -1078,6 +1112,149 @@ except ChargelogConfirmError:
check("买票未开闸门抛 ChargelogConfirmError(未发包)", True)
# =============================================================================
# 13.7 歌曲解锁(解禁)
# =============================================================================
section("13.7 歌曲解锁(解禁)")
# 依据 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
# kind5 = MusicUnlockList(歌曲本体)
# kind6 = MusicUnlockMasterList(Master)
# kind7 = MusicUnlockReMasterList(Re:Master)
# 依据 source/Assembly-CSharp/Manager/NotesListManager.cs:415/455
# `if (id < 10000) return true;` -> 标准谱 Master/Re:Master 天生解锁
_unl_std, _unl_std_notes = build_unlock_items([1085])
check("标准谱(<10000)只写 kind5,不写 kind6/7",
[it["itemKind"] for it in _unl_std] == [5], str([it["itemKind"] for it in _unl_std]))
check("解锁条目形态 = {itemKind,itemId,stock,isValid}",
_unl_std[0] == {"itemKind": 5, "itemId": 1085, "stock": 1, "isValid": True},
str(_unl_std[0]))
check("标准谱给出「天生解锁」说明",
any("天生解锁" in n for n in _unl_std_notes), str(_unl_std_notes))
_unl_dx, _ = build_unlock_items([11414])
check("DX 谱写 kind5 + kind6 + kind7",
[it["itemKind"] for it in _unl_dx] == [5, 6, 7], str([it["itemKind"] for it in _unl_dx]))
_unl_m, _ = build_unlock_items([11414], master=False)
check("master=False 不写 kind6", [it["itemKind"] for it in _unl_m] == [5, 7],
str([it["itemKind"] for it in _unl_m]))
_unl_r, _ = build_unlock_items([11414], remaster=False)
check("remaster=False 不写 kind7", [it["itemKind"] for it in _unl_r] == [5, 6],
str([it["itemKind"] for it in _unl_r]))
_unl_dup, _ = build_unlock_items([11414], existing=[{"itemKind": 5, "itemId": 11414}])
check("已存在的条目自动跳过(不重复插入)",
[it["itemKind"] for it in _unl_dup] == [6, 7], str([it["itemKind"] for it in _unl_dup]))
_unl_all, _ = build_unlock_items(
[11414], existing=[{"itemKind": k, "itemId": 11414} for k in (5, 6, 7)])
check("全都有时返回空列表", _unl_all == [], str(_unl_all))
check("musicId 重复只写一次", len(build_unlock_items([1085, 1085])[0]) == 1)
_ua_unl = build_user_all(baseline, login_date_time=1791159576, playlog_id=1,
unlock_music_ids=[11414])
check("build_user_all 把解锁条目追加进 userItemList",
[it["itemKind"] for it in _ua_unl["userItemList"]] == [5, 6, 7],
str(_ua_unl["userItemList"]))
check("解锁条目的位串全是 1(新增)", _ua_unl["isNewItemList"] == "111",
_ua_unl["isNewItemList"])
check("位串长度与 userItemList 条数一致",
len(_ua_unl["isNewItemList"]) == len(_ua_unl["userItemList"]))
check("不给 unlock_music_ids 时不产生 userItem 条目",
build_user_all(baseline, login_date_time=1791159576,
playlog_id=1)["userItemList"] == [])
_ua_unl3 = build_user_all(
baseline, login_date_time=1791159576, playlog_id=1,
extra_data={"userItemList": [{"itemKind": 5, "itemId": 1085,
"stock": 1, "isValid": True}],
"isNewItemList": "1"},
unlock_music_ids=[11414])
check("extra_data 给了 userItemList 时解锁条目追加在其后",
[it["itemId"] for it in _ua_unl3["userItemList"]] == [1085, 11414, 11414, 11414],
str([it["itemId"] for it in _ua_unl3["userItemList"]]))
check("已有条目的位串保留、新条目补 1", _ua_unl3["isNewItemList"] == "1111",
_ua_unl3["isNewItemList"])
# =============================================================================
# 13.8 unlockmusic.py 的参数解析(主脚本复用 upsert.run)
# =============================================================================
section("13.8 unlockmusic.py 参数解析")
_uopts = unlockmusic.parse_args(["QRCODE", "--music", "11414", "--send"])
check("--music 解析成 int 列表", _uopts["unlock_music"] == [11414],
str(_uopts["unlock_music"]))
check("二维码取第一个非 -- 参数", _uopts["qr"] == "QRCODE", _uopts["qr"])
check("--send 置位", _uopts["send"] is True)
_uopts = unlockmusic.parse_args(["QR", "--music", "1085,11414", "--music", "7"])
check("逗号(含中文逗号)+ 可重复都认",
_uopts["unlock_music"] == [1085, 11414, 7], str(_uopts["unlock_music"]))
_uopts = unlockmusic.parse_args(["QR", "--unlock-music", "11414",
"--no-unlock-master", "--no-unlock-remaster"])
check("--unlock-music 是 --music 的别名", _uopts["unlock_music"] == [11414])
check("两个难度开关默认 True、可关掉",
_uopts["unlock_master"] is False and _uopts["unlock_remaster"] is False)
_uopts = unlockmusic.parse_args(["QR", "--music", "abc"])
check("非法 musicId 记进 unlock_bad", _uopts.get("unlock_bad") == "abc")
check("upsert.parse_args 不再认识 --unlock-music",
"unlock_music" not in upsert.parse_args(["QR", "--unlock-music", "11414"]))
check("upsert.run 存在且可被别的脚本复用", callable(getattr(upsert, "run", None)))
# =============================================================================
# 13.9 买票前置闸门(必须先跑 GetUserCharge)
# =============================================================================
section("13.9 买票前置闸门(必须先跑 GetUserCharge)")
reset_charge_guard()
check("没查过票时 has_fresh_read 为 False", has_fresh_read(55555) is False)
check("没查过票时 last_read_age 为 None", last_read_age(55555) is None)
note_user_charge_read(55555, [{"chargeId": 2, "stock": 0},
{"chargeId": 5, "stock": 1}])
check("查过之后 has_fresh_read 为 True", has_fresh_read(55555) is True)
check("held_charges 只挑 stock>0 的",
[c["chargeId"] for c in held_charges(55555)] == [5], str(held_charges(55555)))
try:
UpsertUserChargelog(66666, 2, 1791159576, user_data={},
guard_purchasable=False, dry_run=True)
check("没查票就买票应抛 ChargelogGuardError", False, "未抛异常")
except ChargelogGuardError as exc:
check("没查票就买票抛 ChargelogGuardError(未发包)", True)
check("报错信息里点明要先调 GetUserCharge",
"GetUserCharge" in str(exc), str(exc)[:120])
check("显式传 charge_list 视为已查票(可通过)",
UpsertUserChargelog(66666, 2, 1791159576, charge_list=[], user_data={},
guard_purchasable=False,
dry_run=True).userCharge.chargeId == 2)
note_charge_purchase(77777, 2)
check("purchased_charge_ids 记录已买", purchased_charge_ids(77777) == [2])
note_user_charge_read(77777, [])
try:
UpsertUserChargelog(77777, 2, 1791159576, charge_list=[], user_data={},
guard_purchasable=False, dry_run=True)
check("同进程重复买票应被拦下", False, "未抛异常")
except ChargelogGuardError as exc:
check("同进程重复买票抛 ChargelogGuardError", True)
check("报错信息点明已经买过", "已经买过" in str(exc), str(exc)[:120])
reset_charge_guard()
check("reset 之后记忆清空",
has_fresh_read(55555) is False and purchased_charge_ids(77777) == [])
# =============================================================================
# 14. 副作用闸门
# =============================================================================
@@ -1111,13 +1288,13 @@ check("dry_run 未联网(能正常返回即证明)", req2.playlogId == 76928
section("15. config 与源码常量")
import config
check("AES_KEY 来自 CipherAES.cs", config.AES_KEY == b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W")
check("AES_IV 来自 CipherAES.cs", config.AES_IV == b"F>;24DjU9W6ZsRH[")
check("混淆盐来自 Packet.cs", config.OBFUSCATE_PARAM == "8bF76dE9")
check("AES_KEY 来自 CipherAES.cs", api.AES_KEY == b"FKM2JX:VjZNK6hc:A0<JU:i5oR7LA]9W")
check("AES_IV 来自 CipherAES.cs", api.AES_IV == b"F>;24DjU9W6ZsRH[")
check("混淆盐来自 Packet.cs", api.OBFUSCATE_PARAM == "8bF76dE9")
check("Mai-Encoding 来自 NetHttpClient.cs", config.MAI_ENCODING == "1.55")
check("BASE_URL 指向 Maimai2Servlet",
config.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/")
check("扫码签名密钥 32 字符", len(config.QR_SIGN_KEY) == 32)
api.BASE_URL == "https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/")
check("扫码签名密钥 32 字符", len(api.QR_SIGN_KEY) == 32)
check("LOG_LEVEL 合法", config.LOG_LEVEL in ("info", "debug"), config.LOG_LEVEL)
check("UPSERT_CONFIRM 默认 False(安全)", config.UPSERT_CONFIRM is False)
@@ -1137,10 +1314,37 @@ check("UpsertUserAll 已被包装", getattr(api.UpsertUserAll, "__mai_logged__",
check("已挂载 api 数量 >= 40", len(api.get_called_apis()) == 0 or True)
check("会话链路 6 个函数都在",
all(hasattr(api, n) for n in
("ParseQRCode", "GetGameSettings", "Ping", "UserPreview",
("ParseQRCode", "GetGameSettings", "Ping", "GetUserPreview",
"UserLogin", "UserLogout")))
# =============================================================================
# 19. reincarnate 分批器(★ 每包恰好 2 首会被服务端拒绝)
# =============================================================================
section("19. reincarnate 分批器")
from reincarnate import split_batches as _split # noqa: E402
for _t, _p, _want in [(10, 4, [4, 3, 3]), (6, 4, [3, 3]), (2, 4, [1, 1]),
(5, 3, [3, 1, 1]), (8, 3, [3, 3, 1, 1]), (5, 4, [4, 1]),
(4, 4, [4]), (1, 4, [1]), (7, 4, [4, 3])]:
_got = _split(_t, _p)
check("分批 " + str(_t) + " 首 per=" + str(_p) + " -> " + str(_want),
_got == _want, str(_got))
_bad = []
for _t in range(1, 300):
for _p in (1, 3, 4):
_g = _split(_t, _p)
if sum(_g) != _t or 2 in _g or any(x > _p or x < 1 for x in _g):
_bad.append((_t, _p, _g))
check("1..299 首 × per∈{1,3,4} 都不产生 2 首包", not _bad, str(_bad[:3]))
check("--per-packet 允许值 = (1, 3, 4)",
__import__("reincarnate").ALLOWED_PER_PACKET == (1, 3, 4))
# =============================================================================
# 汇总
# =============================================================================
+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
```
---
+201
View File
@@ -0,0 +1,201 @@
# 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` 自动找谱面 |
> 🔎 **不知道 musicId / 定数?** [maimai.lxns.net/songs](https://maimai.lxns.net/songs)
> 可以在线查询谱面信息(曲名、难度、定数等),查到 `musicId` 和难度后再回来跑脚本。
### 难度(--level)
难度用**游戏里规定的数字**表示,也就是谱面文件名后缀 `_00` ~ `_04` 的那个数字
(对应游戏主数据 `musicDifficulty/MusicDifficulty00000X` 的 `<id>`):
| 数字 | 难度 | 谱面文件名 |
| --- | --- | --- |
| 0 | BASIC | `<id>_00.ma2` |
| 1 | ADVANCED | `<id>_01.ma2` |
| 2 | EXPERT | `<id>_02.ma2` |
| 3 | MASTER | `<id>_03.ma2` |
| 4 | RE:MASTER | `<id>_04.ma2` |
`--level 0` ~ `--level 4` 或 `--level BASIC` / `ADVANCED` / `EXPERT` / `MASTER` / `RE:MASTER`
(大小写随意;`--difficulty` 是同一个参数的别名)都行,**默认 3(MASTER)**;
不给时会在 stderr 提示用了默认值。
注意:这里的「等级」指**谱面难度**。谱面的数值等级(如 `12+`)是另一回事,
由 `Music.xml` 自动读出,只用于显示。
## 正向:已知判定数 → 达成率 + DX 分
达成率是**按音符权重加权**的(Tap 500 / Hold 1000 / Slide 1500 / Break 2500),
所以有两种给判定数的方式:
### ① 精确:按音符类别给(推荐,得到确切数值)
```powershell
python chart_achievement.py --game "<...>\Sinmai_Data" --music-id 8 --level 3 --tap 260/0/3 --break 6 # 3 = MASTER
```
每个类别写 `CP/Perfect/Great/Good/Miss` 五个数(`/`、`,` 或空格分隔,后面可以省略),
**没给满的部分按 CP 算**:
| 参数 | 类别 | 权重 |
| --- | --- | --- |
| `--tap` | Tap / Star / Touch / EX | 500 |
| `--hold` | Hold 系 | 1000 |
| `--slide` | Slide 系 | 1500 |
| `--break` | 绝赞(Break 系) | 2500 |
绝赞的 Great / Perfect 本身还有档位,需要时可以再细分(不给就按 Great 失分 20%、Perfect 奖励 75 算):
| 参数 | 含义 |
| --- | --- |
| `--break-great40 N` | 绝赞里 N 个 Great 是「失分 40%」档(Great2nd) |
| `--break-great50 N` | 绝赞里 N 个 Great 是「失分 50%」档(Great3rd) |
| `--break-perfect50 N` | 绝赞里 N 个 Perfect 是「奖励分 50」档(Perfect2nd) |
输出就是一个**确切**的达成率,并附上失分 / 绝赞奖励分的明细:
```
判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)
Tap/Star/Touch/EX (500) 263 个:CP 260 · Great 3
Hold (1000) 14 个:CP 14
Slide (1500) 19 个:CP 19
Break (2500) 6 个:CP 6
结果
达成率 100.8413% (确切值)
DX 分 900 / 906 (99.34%)
```
### ② 汇总:只给总判定数(得到估计值 + 区间)
```powershell
python chart_achievement.py --chart "<...>\000008_03.ma2" --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 --level 3 --achievement 100.75 # 3 = MASTER
python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --level 2 --achievement 99 --mode range # 2 = EXPERT
```
两种模式(`--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` | 命令行界面 / 交互向导 |
## 已知局限
- 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布。绝赞的 Great 档位(失分 20% / 40% / 50%)
和 Perfect 档位(奖励 75 / 50)需要自己用 `--break-great40` / `--break-great50` /
`--break-perfect50` 指定,不给就按 20% / 75 算。
- 汇总模式(只给总判定数)只能给区间——这不是精度问题,是信息量不够:同样的判定总数,
落在绝赞上和落在 Tap 上扣的分不同。要确切值就按类别给(`--tap` / `--hold` / `--slide` / `--break`)。
- 精确反向只验证坏音符数 ≤ 12 的组合(见上文)。
- 若 `.ma2` 里的 `T_NUM_ALL` 与解析出的计分音符数不一致,会在输出里给警告
(全部 5419 个游戏谱面实测一致)。
@@ -0,0 +1,651 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""舞萌DX 谱面达成率 / DX 分计算器。
正向:给 谱面 + 各判定数量 -> 算达成率与 DX 分
反向:给 谱面 + 目标达成率 -> 倒算所有可行的判定组合与 DX 分
只依赖 Python 标准库。
"""
from __future__ import annotations
import argparse
import json
import os
import re
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 (CLASS_LABEL, CLASS_ORDER, CLASS_WEIGHT, DX_PER, JUDGE_LABEL,
JUDGMENTS, Theory, class_capacity, dx_score,
reference_achievement, solve_exact, solve_forward,
solve_reverse, theory_from_chart)
try: # 中文输出到管道时避免 UnicodeEncodeError
sys.stdout.reconfigure(errors="replace")
sys.stderr.reconfigure(errors="replace")
except Exception: # noqa: BLE001
pass
LINE = "-" * 60
def pct(value) -> str:
return "%.4f%%" % float(value)
def die(msg: str, code: int = 2) -> "None":
print("[错误] " + msg, file=sys.stderr)
raise SystemExit(code)
# --------------------------------------------------------------- 载入谱面
def _enrich(chart: Chart, path: str) -> Chart:
"""从同目录的 Music.xml 补全曲名 / 难度 / 等级(拿不到就跳过)。"""
base = os.path.basename(path)
stem = os.path.splitext(base)[0]
if "_" in stem:
left, right = stem.split("_", 1)
try:
chart.music_id = int(left)
chart.difficulty_index = int(right)
chart.difficulty_name = DIFFICULTY_NAMES[chart.difficulty_index]
except (ValueError, IndexError):
pass
xml_path = os.path.join(os.path.dirname(path), "Music.xml")
if os.path.isfile(xml_path):
info = parse_music_xml(xml_path)
chart.song_name = info.get("name") or chart.song_name
notes = info.get("notes") or []
idx = chart.difficulty_index
if idx is not None and 0 <= idx < len(notes):
entry = notes[idx]
chart.level_text = level_display(entry.get("level"), entry.get("levelDecimal"))
try:
chart.max_notes_xml = int(entry.get("maxNotes") or 0)
except (TypeError, ValueError):
chart.max_notes_xml = None
return chart
def load_from_file(path: str) -> Chart:
path = os.path.abspath(os.path.expanduser(path))
if not os.path.isfile(path):
die("谱面文件不存在:%s" % path)
return _enrich(parse_ma2(path), path)
def load_from_game(game_path: str, music_id: int, difficulty: int) -> Chart:
music_dir = find_music_dir(game_path)
path = chart_path(music_dir, music_id, difficulty)
if not os.path.isfile(path):
alt = os.path.join(music_dir, "music%06d" % music_id)
hint = ""
if os.path.isdir(alt):
have = sorted(f for f in os.listdir(alt) if f.lower().endswith(".ma2"))
hint = ";该曲目现有谱面:" + (", ".join(have) if have else "无")
die("找不到谱面:%s%s" % (path, hint))
chart = parse_ma2(path)
chart.music_id = music_id
chart.difficulty_index = difficulty
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
chart = _enrich(chart, path)
chart.difficulty_index = difficulty
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
return chart
# ------------------------------------------------------------------ 展示
def print_chart(chart: Chart, th: Theory) -> None:
print(LINE)
print("谱面")
print(" 文件 %s" % chart.path)
if chart.song_name:
print(" 曲目 %s" % chart.song_name)
if chart.music_id is not None:
print(" 编号 %d" % chart.music_id)
if chart.difficulty_name:
print(" 难度 %s (%d)" % (chart.difficulty_name.upper(),
chart.difficulty_index if chart.difficulty_index is not None else 0))
if chart.level_text:
print(" 等级 %s" % chart.level_text)
parts = []
for g in GROUP_ORDER:
n = th.counts.get(g, 0)
if n:
parts.append("%s %d" % (GROUP_LABEL[g], n))
print(" 音符 %s" % " · ".join(parts))
print(" 合计 %d 个计分音符" % th.total_notes)
if chart.connect_count:
print(" (另有 %d 个接续滑条节点,不计分)" % chart.connect_count)
bonus = " + 绝赞奖励 %d" % th.break_bonus_score if th.break_bonus_score else ""
print(" 理论分 %d%s" % (th.all_perfect_score, bonus))
print(" 理论DX %d" % th.max_dx)
print(" 满分 %s" % pct(th.max_achievement))
for w in chart.warnings:
print(" [警告] %s" % w)
declared = chart.totals.get("TTM_SCR_SS")
if declared is not None and declared != th.all_perfect_score:
print(" [警告] 谱面自带 TTM_SCR_SS=%d 与计算出的理论分 %d 不一致"
% (declared, th.all_perfect_score))
def print_forward(chart: Chart, result) -> None:
th = result.theory
print(LINE)
print("判定输入(汇总)")
for j in JUDGMENTS:
print(" %-18s %d" % (JUDGE_LABEL[j], result.judges.get(j, 0)))
print(" %-18s %d / %d" % ("合计", sum(result.judges.values()), th.total_notes))
print(LINE)
print("结果")
exact = result.achievement_min == result.achievement_max
if exact:
print(" 达成率 %s (确切值)" % pct(result.achievement_min))
else:
print(" 达成率 %s (按音符类别比例分摊的估计值)" % pct(result.achievement_ref))
print(" 区间 %s ~ %s"
% (pct(result.achievement_min), pct(result.achievement_max)))
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 not exact:
print()
print(" 说明:只给汇总判定数时,达成率取决于 Great/Good/Miss 落在哪类音符上——")
print(" 区间下界 = 扣分都落在权重最高的音符上,上界 = 都落在权重最低的音符上。")
print(" 想要一个确切数值:用 --tap / --hold / --slide / --break 按类别给判定数。")
print(LINE)
_EXACT_KEYS = ("cp", "perfect", "perfect50", "great", "great40", "great50", "good", "miss")
_EXACT_KEY_LABEL = {
"cp": "CP", "perfect": "Perfect", "perfect50": "Perfect(50)",
"great": "Great", "great40": "Great(40%)", "great50": "Great(50%)",
"good": "Good", "miss": "Miss",
}
# 打印时用纯 ASCII,避免中文宽字符把表格撑歪
_EXACT_CLASS_TITLE = {
"tap": "Tap/Star/Touch/EX", "hold": "Hold", "slide": "Slide", "break": "Break",
}
def print_exact(chart: Chart, result) -> None:
th = result.theory
print(LINE)
print("判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)")
for cls in CLASS_ORDER:
cap = class_capacity(th, cls)
if not cap:
continue
parts = []
for key in _EXACT_KEYS:
n = result.spec.get(cls, {}).get(key, 0)
if n:
parts.append("%s %d" % (_EXACT_KEY_LABEL[key], n))
print(" %-24s %4d 个:%s"
% ("%s (%d)" % (_EXACT_CLASS_TITLE[cls], CLASS_WEIGHT[cls]), cap,
" · ".join(parts) if parts else "-"))
print(LINE)
print("结果")
print(" 达成率 %s (确切值)" % pct(result.achievement))
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 th.all_perfect_score:
print(" 失分 %d / %d (%.4f%%)"
% (result.loss_total, th.all_perfect_score,
100.0 * result.loss_total / th.all_perfect_score))
if th.break_bonus_score:
print(" 绝赞奖励 %d / %d (%.4f%%)"
% (result.bonus_total, th.break_bonus_score,
100.0 * result.bonus_total / th.break_bonus_score))
print(LINE)
def print_reverse(chart: Chart, outcome) -> None:
th = outcome["theory"]
rows = outcome["rows"]
mode = outcome.get("mode", "exact")
print(LINE)
print("目标达成率 %s [%s]" % (pct(outcome["target"]),
"精确模式" if mode == "exact" else "区间模式"))
note = outcome.get("note")
if note:
print(" [说明] %s" % note)
if not rows:
if mode == "exact":
cap = outcome.get("exact_cap") or 0
print(" (在严格验证范围内没有找到解:坏音符 ≤ %d 的组合里没有能精确打出该值的)" % cap)
if outcome.get("more_unverified"):
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
else:
print(" (无解:该达成率在这个谱面上打不出来)")
print(LINE)
return
if mode == "exact":
print(" Miss Good Great CP+P CP 范围 DX 分范围")
else:
print(" Miss Good Great CP+P CP 范围 达成率范围 DX 分范围")
for r in rows:
cp_range = "%d~%d" % (r.cp_min, r.cp_max) if r.cp_min != r.cp_max else str(r.cp_min)
dx_range = "%d~%d" % (r.dx_min, r.dx_max) if r.dx_min != r.dx_max else str(r.dx_min)
if mode == "exact":
print(" %4d %4d %5d %4d %-11s %s"
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, dx_range))
else:
ach = "%s ~ %s" % (pct(r.achievement_min), pct(r.achievement_max))
print(" %4d %4d %5d %4d %-11s %-24s %s"
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, ach, dx_range))
print()
if mode == "exact":
print(" 共 %d 组能精确打出 %s 的判定组合(CP+P = Critical Perfect 与 Perfect 之和)"
% (len(rows), pct(outcome["target"])))
cap = outcome.get("exact_cap") or 0
print(" 已逐个严格验证 Miss+Good+Great ≤ %d 的组合;每个组合都真的能打出该达成率" % cap)
if outcome.get("unverified"):
print(" [注意] 另有 %d 组候选(坏音符超过 %d 个)未做精确验证,未列入结果"
% (outcome["unverified"], cap))
if outcome.get("more_unverified"):
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
else:
print(" 共 %d 组可行组合(CP+P = Critical Perfect 与 Perfect 之和)" % len(rows))
print(" 区间模式:只保证达成率落在这个范围内,不保证能恰好打出目标值")
if outcome["truncated"]:
print(" [注意] 结果被 --limit / 扫描上限截断,可能还有更多组合")
print(" DX 分 = 3*CP + 2*Perfect + 1*Great;CP 越靠范围上端,DX 分越高")
print(LINE)
# ------------------------------------------------------------------ JSON
def to_json(chart: Chart, th: Theory, payload: Dict) -> str:
data = {
"chart": {
"path": chart.path,
"music_id": chart.music_id,
"song_name": chart.song_name,
"difficulty": chart.difficulty_name,
"difficulty_index": chart.difficulty_index,
"level_text": chart.level_text,
"notes": th.counts,
"total_notes": th.total_notes,
"connect_slide": chart.connect_count,
"all_perfect_score": th.all_perfect_score,
"break_bonus_score": th.break_bonus_score,
"max_dx": th.max_dx,
"max_achievement": float(th.max_achievement),
"warnings": chart.warnings,
},
}
data.update(payload)
return json.dumps(data, ensure_ascii=False, indent=2)
# ------------------------------------------------------------------ 交互
def ask(prompt: str, default: str = "") -> str:
try:
text = input(prompt).strip()
except EOFError:
text = ""
return text or default
def ask_int(prompt: str, default: Optional[int] = None) -> Optional[int]:
while True:
raw = ask(prompt, "" if default is None else str(default))
if raw == "" and default is None:
return None
try:
return int(raw)
except ValueError:
print(" 请输入整数")
def interactive(args) -> None:
print("=" * 60)
print("舞萌DX 谱面达成率 / DX 分 计算器")
print("=" * 60)
chart = None
while chart is None:
mode = ask("谱面来源:1) .ma2 文件路径 2) 游戏路径 + musicid [1]: ", "1")
if mode.startswith("2"):
game = ask("游戏路径(Sinmai_Data 目录): ")
if not game:
continue
mid = ask_int("musicid: ")
if mid is None:
continue
diff = ask("难度 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER [3]: ", "3")
try:
chart = load_from_game(game, mid, parse_difficulty(diff))
except SystemExit:
chart = None
else:
path = ask("谱面文件路径: ")
if not path:
continue
try:
chart = load_from_file(path)
except SystemExit:
chart = None
th = theory_from_chart(chart)
print_chart(chart, th)
mode = ask("计算模式:1) 汇总判定数(估计值 + 区间) 2) 按音符类别给判定数(确切值)"
" 3) 由达成率倒算判定数 [1]: ", "1")
if mode.startswith("3"):
while True:
raw = ask("目标达成率(如 99.5 或 100.5): ")
if not raw:
return
try:
target = Fraction(raw.rstrip("%"))
break
except (ValueError, ZeroDivisionError):
print(" 请输入数字")
mode = ask("反算方式:1) 精确(只列真能打出该达成率的组合) 2) 区间(判定数只是估算时用) [1]: ", "1")
args.mode = "range" if mode.startswith("2") else "exact"
run_reverse(chart, th, target, args)
elif mode.startswith("2"):
spec = {}
for cls in CLASS_ORDER:
cap = class_capacity(th, cls)
if not cap:
continue
raw = ask(" %s(%d 个)CP/Perfect/Great/Good/Miss [%d/0/0/0/0]: "
% (CLASS_LABEL[cls], cap, cap))
if not raw:
continue
try:
spec[cls] = parse_class_counts(raw, cls)
except ValueError as exc:
print(" [错误] %s" % exc)
run_exact(chart, th, spec, args)
else:
values = {}
for j in JUDGMENTS:
if j == "miss":
continue
values[j] = ask_int(" %s 数 [0]: " % JUDGE_LABEL[j], 0) or 0
values["miss"] = ask_int(" Miss 数(留空 = 总数减去以上判定): ")
run_forward(chart, th, values, args)
# ------------------------------------------------------------------ 执行
def normalize_judges(th: Theory, raw: Dict[str, Optional[int]]) -> Dict[str, int]:
given = {j: int(raw.get(j) or 0) for j in JUDGMENTS}
if raw.get("miss") is None:
given["miss"] = th.total_notes - sum(given[j] for j in JUDGMENTS if j != "miss")
if given["miss"] < 0:
die("CP+Perfect+Great+Good 合计 %d 超过了音符总数 %d"
% (sum(given[j] for j in JUDGMENTS if j != "miss"), th.total_notes))
total = sum(given.values())
if total != th.total_notes:
die("判定数之和 %d 与音符总数 %d 不一致(Miss 请留空自动补足)" % (total, th.total_notes))
return given
_CLASS_KEYS = ("cp", "perfect", "great", "good", "miss")
def parse_class_counts(text: str, cls: str) -> Dict[str, int]:
"""把 "CP/Perfect/Great/Good/Miss"(斜杠、逗号或空格分隔)解析成判定数字典。"""
parts = [p for p in re.split(r"[\/,\s]+", str(text).strip()) if p]
if len(parts) > len(_CLASS_KEYS):
raise ValueError("%s 最多 %d 个数(CP/Perfect/Great/Good/Miss):%s"
% (CLASS_LABEL[cls], len(_CLASS_KEYS), text))
out: Dict[str, int] = {}
for key, raw in zip(_CLASS_KEYS, parts):
try:
n = int(raw)
except ValueError:
raise ValueError("%s 里的「%s」不是整数:%s" % (CLASS_LABEL[cls], raw, text))
if n < 0:
raise ValueError("%s 里不能有负数:%s" % (CLASS_LABEL[cls], text))
if n:
out[key] = n
return out
def build_spec(args) -> Dict[str, Dict[str, int]]:
"""把 --tap/--hold/--slide/--break 以及绝赞档位参数拼成 solve_exact 的输入。"""
spec: Dict[str, Dict[str, int]] = {}
for cls in CLASS_ORDER:
text = getattr(args, cls, None)
if text:
spec[cls] = parse_class_counts(text, cls)
brk = dict(spec.get("break") or {})
perfect_total = brk.get("perfect", 0)
great_total = brk.get("great", 0)
p50 = int(args.break_perfect50 or 0)
g40 = int(args.break_great40 or 0)
g50 = int(args.break_great50 or 0)
if p50 or g40 or g50:
if p50 > perfect_total:
raise ValueError("--break-perfect50 %d 超过绝赞 Perfect 总数 %d"
"(先在 --break 里写 CP/Perfect/Great/Good/Miss)" % (p50, perfect_total))
if g40 + g50 > great_total:
raise ValueError("--break-great40 + --break-great50 = %d 超过绝赞 Great 总数 %d"
% (g40 + g50, great_total))
if p50:
brk["perfect"] = perfect_total - p50
brk["perfect50"] = p50
if g40:
brk["great40"] = g40
if g50:
brk["great50"] = g50
if great_total - g40 - g50:
brk["great"] = great_total - g40 - g50
else:
brk.pop("great", None)
brk = {k: v for k, v in brk.items() if v}
spec["break"] = brk
return spec
def run_exact(chart: Chart, th: Theory, spec: Dict[str, Dict[str, int]], args) -> None:
try:
result = solve_exact(th, spec)
except ValueError as exc:
die(str(exc))
if args.json:
print(to_json(chart, th, {
"mode": "forward_exact",
"judges_by_class": result.spec,
"achievement": float(result.achievement),
"dx_score": result.dx,
"loss_total": result.loss_total,
"bonus_total": result.bonus_total,
}))
return
print_chart(chart, th)
print_exact(chart, result)
def run_forward(chart: Chart, th: Theory,
raw: Dict[str, Optional[int]], args) -> None:
judges = normalize_judges(th, raw)
result = solve_forward(th, judges)
if args.json:
print(to_json(chart, th, {
"mode": "forward",
"judges": judges,
"achievement_min": float(result.achievement_min),
"achievement_max": float(result.achievement_max),
"achievement_reference": float(result.achievement_ref),
"dx_score": result.dx,
}))
return
print_chart(chart, th)
print_forward(chart, result)
def run_reverse(chart: Chart, th: Theory,
target: Fraction, args) -> None:
outcome = solve_reverse(th, target, limit=args.limit, mode=args.mode)
if args.json:
print(to_json(chart, th, {
"mode": "reverse",
"search_mode": outcome["mode"],
"target": float(target),
"truncated": outcome["truncated"],
"exact_cap": outcome.get("exact_cap"),
"unverified": outcome.get("unverified", 0),
"more_unverified": outcome.get("more_unverified", False),
"note": outcome.get("note"),
"solutions": [{
"miss": r.miss, "good": r.good, "great": r.great,
"critical_perfect_plus_perfect": r.cp_plus_perfect,
"critical_perfect_min": r.cp_min, "critical_perfect_max": r.cp_max,
"achievement_min": float(r.achievement_min),
"achievement_max": float(r.achievement_max),
"dx_min": r.dx_min, "dx_max": r.dx_max,
} for r in outcome["rows"]],
}))
return
print_chart(chart, th)
print_reverse(chart, outcome)
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="chart_achievement.py",
description="舞萌DX 谱面达成率 / DX 分计算器(正向计算 + 由达成率倒算判定数)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""示例:
# 直接给谱面文件,正向计算(汇总判定数 -> 估计值 + 区间)
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --cp 290 --perfect 10 --great 2
# 按音符类别给判定数 -> 确切的达成率(CP/Perfect/Great/Good/Miss,可省略后面的)
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --tap 260/0/3 --break 6
# 给游戏路径 + musicid + 难度(难度用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --cp 300 --perfect 2
# 由达成率倒算判定组合(精确模式:只列真能打出该值的组合)
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5
# 判定数只是估算时,用区间模式
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5 --mode range
""")
src = p.add_argument_group("谱面来源(二选一)")
src.add_argument("--chart", metavar="PATH", help=".ma2 谱面文件路径")
src.add_argument("--game", metavar="PATH", help="游戏目录(Sinmai_Data / StreamingAssets / A000 / music 均可)")
src.add_argument("--music-id", type=int, metavar="ID", help="曲目编号(配合 --game 使用)")
src.add_argument("--level", "--difficulty", dest="level", default=None, metavar="D",
help="谱面难度,用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT "
"3=MASTER 4=RE:MASTER(也可直接写名字;默认 3=MASTER)")
fw = p.add_argument_group("正向计算(汇总):只给总判定数,得到估计值 + 区间")
fw.add_argument("--cp", type=int, metavar="N", help="Critical Perfect 个数")
fw.add_argument("--perfect", type=int, metavar="N", help="Perfect 个数")
fw.add_argument("--great", type=int, metavar="N", help="Great 个数")
fw.add_argument("--good", type=int, metavar="N", help="Good 个数")
fw.add_argument("--miss", type=int, metavar="N", help="Miss 个数(留空 = 总数减去其它判定)")
ex = p.add_argument_group(
"正向计算(精确):按音符类别给判定数,得到确切的达成率",
"每个类别写 CP/Perfect/Great/Good/Miss 五个数(斜杠或空格分隔,后面可以省略),"
"没给满的部分按 CP 算。")
ex.add_argument("--tap", metavar="CP/P/G/GD/M",
help="Tap/Star/Touch/EX 类(权重 500)")
ex.add_argument("--hold", metavar="CP/P/G/GD/M", help="Hold 系(权重 1000)")
ex.add_argument("--slide", metavar="CP/P/G/GD/M", help="Slide 系(权重 1500)")
ex.add_argument("--break", dest="break", metavar="CP/P/G/GD/M",
help="绝赞(权重 2500;Great/Perfect 档位见下面三个参数)")
ex.add_argument("--break-perfect50", type=int, metavar="N",
help="绝赞 Perfect 里奖励分只有 50 的个数(Perfect2nd)")
ex.add_argument("--break-great40", type=int, metavar="N",
help="绝赞 Great 里失分 40%% 的个数(Great2nd)")
ex.add_argument("--break-great50", type=int, metavar="N",
help="绝赞 Great 里失分 50%% 的个数(Great3rd)")
rv = p.add_argument_group("反向计算:已知达成率")
rv.add_argument("--achievement", metavar="A", help="目标达成率(如 99.5 / 100.5)")
rv.add_argument("--mode", choices=("exact", "range"), default="exact",
help="exact(默认):只列真的能打出该达成率的组合;"
"range:列出达成率区间覆盖该值的组合(更快,输入不精确时用)")
rv.add_argument("--limit", type=int, default=200, metavar="N",
help="反向计算最多输出多少组解(默认 200)")
p.add_argument("--json", action="store_true", help="以 JSON 输出结果")
return p
def main(argv: Optional[List[str]] = None) -> int:
args = build_parser().parse_args(argv)
if args.chart and args.game:
die("--chart 与 --game 只能给一个")
if not args.chart and not args.game:
interactive(args)
return 0
if args.chart:
chart = load_from_file(args.chart)
if args.level is not None:
try:
want = parse_difficulty(args.level)
except ValueError as exc:
die(str(exc))
if chart.difficulty_index is not None and want != chart.difficulty_index:
print("[注意] --level 给的是 %s,但谱面文件名里是 %s,按文件名算"
% (DIFFICULTY_NAMES[want], DIFFICULTY_NAMES[chart.difficulty_index]),
file=sys.stderr)
else:
if args.music_id is None:
die("使用 --game 时必须同时给 --music-id")
if args.level is None:
print("[提示] 没有给 --level,按默认难度 3(MASTER)计算"
"(0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)", file=sys.stderr)
try:
difficulty = parse_difficulty(args.level if args.level is not None else "master")
except ValueError as exc:
die(str(exc))
chart = load_from_game(args.game, args.music_id, difficulty)
th = theory_from_chart(chart)
if th.total_notes == 0:
die("谱面里没有解析到任何计分音符:%s" % chart.path)
if args.achievement:
try:
target = Fraction(str(args.achievement).rstrip("%"))
except (ValueError, ZeroDivisionError):
die("--achievement 需要是数字,如 99.5")
if target > th.max_achievement:
die("目标达成率 %s 超过该谱面上限 %s" % (pct(target), pct(th.max_achievement)))
if target < 0:
die("目标达成率不能为负")
run_reverse(chart, th, target, args)
return 0
class_flags = {"tap": args.tap, "hold": args.hold, "slide": args.slide,
"break": getattr(args, "break", None),
"perfect50": args.break_perfect50,
"great40": args.break_great40, "great50": args.break_great50}
if any(v for v in class_flags.values()):
if any(v is not None for v in (args.cp, args.perfect, args.great, args.good, args.miss)):
die("汇总判定数(--cp/--perfect/--great/--good/--miss)"
"和按类别判定数(--tap/--hold/--slide/--break)只能给一种")
try:
spec = build_spec(args)
except ValueError as exc:
die(str(exc))
run_exact(chart, th, spec, 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,"
"精确用 --tap/--hold/--slide/--break;或用 --achievement 做反向计算")
run_forward(chart, th, raw, args)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+367
View File
@@ -0,0 +1,367 @@
# -*- 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(可用 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER,"
"或直接写名字)" % 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
+949
View File
@@ -0,0 +1,949 @@
# -*- 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),
)
# ------------------------------------------------------------------ 正向:按音符类别精确计算
# 达成率是按「音符权重」加权的,只给汇总判定数时只能得到区间;
# 把判定数按权重类别分开给(绝赞单独一列),就能算出确切的数值。
CLASS_ORDER = ("tap", "hold", "slide", "break")
CLASS_LABEL = {
"tap": "Tap/Star/Touch/EX",
"hold": "Hold 系",
"slide": "Slide 系",
"break": "绝赞(Break 系)",
}
CLASS_WEIGHT = {"tap": 500, "hold": 1000, "slide": 1500, "break": 2500}
CLASS_GROUPS = {
"tap": (GROUP_TAP, GROUP_EX),
"hold": (GROUP_HOLD,),
"slide": (GROUP_SLIDE,),
"break": (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK),
}
# 每类里每个判定键的 (失分, 奖励分);绝赞的 Great / Perfect 有多个档位
CLASS_OPTIONS = {
"tap": {"cp": (0, 0), "perfect": (0, 0), "great": (100, 0),
"good": (250, 0), "miss": (500, 0)},
"hold": {"cp": (0, 0), "perfect": (0, 0), "great": (200, 0),
"good": (500, 0), "miss": (1000, 0)},
"slide": {"cp": (0, 0), "perfect": (0, 0), "great": (300, 0),
"good": (750, 0), "miss": (1500, 0)},
"break": {"cp": (0, 100), "perfect": (0, 75), "perfect50": (0, 50),
"great": (500, 40), "great40": (1000, 40), "great50": (1250, 40),
"good": (1500, 30), "miss": (2500, 0)},
}
_JUDGE_OF_KEY = {
"cp": "cp", "perfect": "perfect", "perfect50": "perfect",
"great": "great", "great40": "great", "great50": "great",
"good": "good", "miss": "miss",
}
def class_capacity(th: Theory, cls: str) -> int:
"""这一类音符在谱面里有多少个。"""
return sum(th.counts.get(g, 0) for g in CLASS_GROUPS[cls])
@dataclass
class ExactResult:
theory: Theory
spec: Dict[str, Dict[str, int]] # 实际参与计算的判定数(已补足 CP)
achievement: Fraction
dx: int
loss_total: int
bonus_total: int
breakdown: Dict[str, Tuple[int, int, int]] # 类别 -> (音符数, 失分, 奖励分)
@property
def score(self) -> int:
return self.theory.all_perfect_score - self.loss_total
def solve_exact(th: Theory, spec: Dict[str, Dict[str, int]]) -> ExactResult:
"""按音符类别给判定数,算出**确切**的达成率与 DX 分。
spec: {"tap": {"cp": 250, "great": 2}, "break": {"cp": 4, "great": 1}, ...}
每类没给满的部分按 CP 补足(非绝赞 CP 与 Perfect 同分;绝赞按 CP 计奖励 100)。
"""
filled: Dict[str, Dict[str, int]] = {}
loss_total = 0
bonus_total = 0
dx = 0
breakdown: Dict[str, Tuple[int, int, int]] = {}
for cls in CLASS_ORDER:
cap = class_capacity(th, cls)
given = {k: int(v) for k, v in (spec.get(cls) or {}).items() if int(v)}
for key in given:
if key not in CLASS_OPTIONS[cls]:
raise ValueError("「%s」没有「%s」这个判定" % (CLASS_LABEL[cls], key))
used = sum(given.values())
if used > cap:
raise ValueError("%s 只有 %d 个音符,但给了 %d 个判定"
% (CLASS_LABEL[cls], cap, used))
if cap > used:
given["cp"] = given.get("cp", 0) + (cap - used)
filled[cls] = given
cls_loss = 0
cls_bonus = 0
for key, n in given.items():
dl, db = CLASS_OPTIONS[cls][key]
cls_loss += dl * n
cls_bonus += db * n
dx += DX_PER[_JUDGE_OF_KEY[key]] * n
loss_total += cls_loss
bonus_total += cls_bonus
breakdown[cls] = (cap, cls_loss, cls_bonus)
w = th.all_perfect_score
if w <= 0:
raise ValueError("谱面里没有计分音符")
achievement = Fraction(100) - Fraction(100 * loss_total, w)
if th.break_bonus_score:
achievement += Fraction(bonus_total, th.break_bonus_score)
return ExactResult(
theory=th, spec=filled, achievement=achievement, dx=dx,
loss_total=loss_total, bonus_total=bonus_total, breakdown=breakdown,
)
# ------------------------------------------------------------------ 反向:工具函数
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)
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
unlockmusic.py —— 主脚本:扫码登录 → 解锁(解禁)歌曲 → 登出
================================================================================
**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
扫码 → 预览 → 登录 → UpsertUserAll → 落库回查 → 登出,一模一样。
唯一多出来的东西是"要解锁哪些歌"这个入参。
--------------------------------------------------------------------------------
为什么解锁不用单独的接口
--------------------------------------------------------------------------------
游戏里解锁不是一次独立的网络请求,而是**往 userItemList 里补几条 UserItem**:
itemKind = 5 歌曲本体解锁(MusicUnlockList)
itemKind = 6 Master 难度解锁(MusicUnlockMasterList)
itemKind = 7 Re:Master 难度解锁(MusicUnlockReMasterList)
依据 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
(UserData.cs:132-138 那四个解锁列表不是独立节点,是并进 userItemList 的)。
条目长这样:
{"itemKind": 5, "itemId": 11414, "stock": 1, "isValid": true}
★ 标准谱(musicId < 10000)的 Master / Re:Master **天生解锁**,不会写 kind6/7
(source/Assembly-CSharp/Manager/NotesListManager.cs:415/455 开头就是
`if (id < 10000) return true;`);只有 DX 谱(>= 10000)才需要额外解禁。
★ 服务器上已经有的条目**自动跳过** —— 重复插入会让服务端直接 HTTP 500。
★ Re:Master 还受活动开关约束(IsUnlockReMaster 里的 IsOpenEvent),
那是机台侧开关,脚本改不了;写了也可能进游戏仍是灰的。
★ 本功能**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python unlockmusic.py <二维码> --music 11414 # 预检,不发包
python unlockmusic.py <二维码> --music 11414 --send # ★ 真实解锁
python unlockmusic.py <二维码> --music 1085,11414 --send # 多首,逗号分隔
python unlockmusic.py <二维码> --music 11414 --send --no-unlock-remaster
# 只要歌曲本体,不解禁任何难度
python unlockmusic.py <二维码> --music 11414 --send --no-unlock-master --no-unlock-remaster
# 和其它主脚本一样的通用开关
python unlockmusic.py <二维码> --music 11414 --send --debug
python unlockmusic.py <二维码> --music 11414 --send --cooldown 0
python unlockmusic.py <二维码> --music 11414 --send --no-verify
| 参数 | 说明 |
|---|---|
| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
| `--send` | 真正发包(不加就是预检,零副作用) |
| `--no-unlock-master` | 不解禁 Master |
| `--no-unlock-remaster` | 不解禁 Re:Master |
| `--cooldown N` | 发包前冷却秒数,默认取 config 的 15 秒 |
| `--no-verify` | 跳过上传后的落库回查 |
| `--debug` | 本次运行打印完整收发报文 |
日志同样是 logs/latest.log(覆盖)+ logs/YYYY-MM-DD.log(追加)。
"""
from __future__ import annotations
import sys
import upsert
from api import log_error, setup_logging, teardown_logging
def parse_args(argv):
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
"cooldown": None, "extra_data": [],
"unlock_music": [], "unlock_master": True, "unlock_remaster": True}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--no-verify":
opts["no_verify"] = True
elif a == "--extra-data" and i + 1 < len(argv):
opts["extra_data"].append(argv[i + 1])
i += 1
elif a == "--cooldown" and i + 1 < len(argv):
opts["cooldown"] = float(argv[i + 1])
i += 1
elif a in ("--music", "--unlock-music") and i + 1 < len(argv):
# 可重复;也支持逗号分隔(中英文逗号都认)
for part in str(argv[i + 1]).replace(",", ",").split(","):
part = part.strip()
if not part:
continue
try:
opts["unlock_music"].append(int(part))
except ValueError:
opts["unlock_bad"] = part
i += 1
elif a == "--no-unlock-master":
opts["unlock_master"] = False
elif a == "--no-unlock-remaster":
opts["unlock_remaster"] = False
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"] or not opts["unlock_music"]:
# 没有二维码、或者没有指定要解锁哪首 —— 直接把用法打出来
print(__doc__)
return 1
if opts.get("unlock_bad"):
# 参数本身就不对,连扫码都不用做
setup_logging("unlockmusic")
log_error("--music 里有非法 musicId:" + str(opts["unlock_bad"]))
teardown_logging()
return 6
# ★ 整个流程复用 upsert.run(),这里只多传三个解锁参数
return upsert.run(
opts,
unlock_music=opts["unlock_music"],
unlock_master=opts["unlock_master"],
unlock_remaster=opts["unlock_remaster"],
log_name="unlockmusic",
)
if __name__ == "__main__":
sys.exit(main())
+41 -8
View File
@@ -29,9 +29,12 @@ upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文
python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 60 秒冷却
python upsert.py <二维码> --send --cooldown 0 # 跳过发包前的 15 秒冷却
python upsert.py <二维码> --send --no-verify # 跳过上传后的落库回查
# ★ 解锁(解禁)歌曲请用另一个主脚本(它复用本脚本的整套流程)
python unlockmusic.py <二维码> --send --music 11414
# ★ 额外塞节点(--extra-data,可重复,后给的覆盖先给的)
python upsert.py <二维码> --send --extra-data extra.json
python upsert.py <二维码> --send --extra-data '{"isNewMapList":"1"}'
@@ -83,6 +86,17 @@ upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
新增条目请显式传 "1","0" 表示存量更新。
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
--------------------------------------------------------------------------------
★ 这个文件同时是一个可复用的模块
--------------------------------------------------------------------------------
`run(opts, ...)` 就是整套流程(扫码 → 预览 → 登录 → UpsertUserAll → 登出),
`main()` 只做参数解析然后调它。所以别的主脚本可以直接:
import upsert
sys.exit(upsert.run({"qr": qr, "send": True, ...}, log_name="unlockmusic"))
unlockmusic.py 就是这么写的 —— 它只多了一个"要解锁哪些歌"的入参。
── 在代码里直接调 api 的等价写法 ──
UpsertUserAll(
@@ -106,7 +120,7 @@ from api import (
log_warning,
log_error,
ParseQRCode,
UserPreview,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
@@ -146,19 +160,24 @@ def parse_args(argv):
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"]:
def run(opts, *, unlock_music=None, unlock_master=True, unlock_remaster=True,
log_name="upsert") -> int:
"""跑完整套流程。upsert.py 自己用,unlockmusic.py 也复用它。
opts 里必须有的键:qr / send / debug / no_verify / cooldown / extra_data
unlock_music / unlock_master / unlock_remaster 三个是解锁用的,upsert.py 不传。
"""
if not opts.get("qr"):
print(__doc__)
return 1
# --debug 只影响本次运行,不改 config.py
if opts["debug"]:
if opts.get("debug"):
import config as _cfg
_cfg.LOG_LEVEL = "debug"
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
setup_logging("upsert")
setup_logging(log_name)
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
extra = None
@@ -186,6 +205,13 @@ def main() -> int:
extra = merged
log_info("--extra-data 合计 " + str(len(extra)) + " 个节点:" + ", ".join(extra))
# ---- 解锁参数校验(同样放在扫码之前)----
if unlock_music:
log_info("要解锁 " + str(len(unlock_music)) + " 首:"
+ ", ".join(str(m) for m in unlock_music)
+ "(Master=" + str(unlock_master)
+ " / Re:Master=" + str(unlock_remaster) + ")")
# 会话状态:保证异常时也能登出(否则账号会卡在 isLogin=True,下次登录 returnCode=100)
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
@@ -194,7 +220,7 @@ def main() -> int:
session.update(user_id=user_id, token=token)
# ---- 预览:顺便挡住「账号已在游戏中」的情况,避免顶号 ----
preview = UserPreview(user_id, token)
preview = GetUserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
@@ -210,6 +236,9 @@ def main() -> int:
auth_date_time=login.dateTime,
token=token,
extra_data=extra,
unlock_music_ids=unlock_music or None,
unlock_master=unlock_master,
unlock_remaster=unlock_remaster,
dry_run=not opts["send"],
confirm=opts["send"],
cooldown_sec=opts["cooldown"],
@@ -235,5 +264,9 @@ def main() -> int:
teardown_logging()
def main() -> int:
return run(parse_args(sys.argv[1:]), log_name="upsert")
if __name__ == "__main__":
sys.exit(main())
+287
View File
@@ -0,0 +1,287 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll 表达「用票打了一局」)
================================================================================
一条完整的「用票」链路:
ParseQRCode -> GetUserPreview -> UserLogin -> GetUserCharge
-> GetNeedUserData(基线快照)-> UpsertUserAll -> GetUserCharge(复查)-> UserLogout
--------------------------------------------------------------------------------
★ 为什么可以用 UpsertUserAll 消耗票券
--------------------------------------------------------------------------------
GetUserChargeApi 与 UpsertUserAllApi 里的 userChargeList **格式完全相同**
(UserCharge.cs:chargeId / stock / purchaseDate / validDate),
所以只要把 UpsertUserAll 的 userChargeList 节点改成「该票 stock - 1」,
服务端就会认为这张票被这一局用掉了。
真机实证(gamehook 抓包 2026-10-06 22:23 那一局,正好用了 2 倍券):
userChargeList: [
{"chargeId":2,"stock":0,...}, <- 买来时是 1,本局用掉变成 0
{"chargeId":3,"stock":0,...},
{"chargeId":5,"stock":0,...}
]
userGamePlaylogList[0].useTicketId = 2 <- ★ 用了哪张票
userGamePlaylogList[0].playCredit = 2 <- ★ 本局消耗的 credit
userPlaylogList[0].playCredit = 2
userData.lastPlayCredit = 2
★ playCredit 的算法(source/.../Net.VO.Mai2/VOExtensions.cs:838-845):
playCredit = Credit.GameCostEnoughPlay() // 投币数,普通局 = 1
+ TicketManager.GetTicketCredit(ticketId) // 票的 creditNum
2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致)
creditNum 表见 TICKET_CREDIT_NUM。
★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里),
所以这里也不需要给位串。
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python use_ticket.py <二维码> # 预检:只看报文,不发包
python use_ticket.py <二维码> --list # 只列当前持有的票,不消耗
python use_ticket.py <二维码> --ticket-id 2 # 指定要消耗哪张(默认第一张持有的)
python use_ticket.py <二维码> --ticket-id 2 --send # ★ 真实消耗
python use_ticket.py <二维码> --send --music-id 143 --level 4
python use_ticket.py <二维码> --send --skip-entry # 跳过入场数据下载
★ 消耗会写入服务器(不可撤销)。不传 --send 时只构造报文。
★ 二维码**有效期 10 分钟**。
★ 账号当前**没有**持有功能票时直接拒绝(没有可消耗的东西)。
"""
from __future__ import annotations
import sys
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
log_warning,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
GetUserCharge,
GetNeedUserData,
summarize_snapshot,
UpsertUserAll,
PlayData,
ConfirmRequiredError,
ticket_name,
Ping,
# ★ 原先在 config.py,已下沉到 api/UpsertUserChargelog.py
TICKET_CREDIT_NUM,
)
from config import (
PLACE_ID,
REGION_ID,
PLAY_MUSIC_ID,
PLAY_MUSIC_LEVEL,
PLAY_CREDIT,
)
def read_charges(user_id, label):
"""调 GetUserChargeApi,打印票券状态并返回 (全部, 持有的)。"""
raw = GetUserCharge(user_id)
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
if not lst:
log_info(" (空)")
for c in lst:
cid = c.get("chargeId")
stock = int(c.get("stock", 0) or 0)
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
+ " stock=" + str(stock)
+ " purchaseDate=" + str(c.get("purchaseDate"))
+ " validDate=" + str(c.get("validDate"))
+ (" ← ★ 持有中" if stock > 0 else ""))
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
return lst, held
def parse_args(argv):
opts = {"qr": "", "ticket_id": None, "send": False, "list": False,
"debug": False, "skip_entry": False,
"music_id": PLAY_MUSIC_ID, "level": PLAY_MUSIC_LEVEL}
i = 0
while i < len(argv):
a = argv[i]
if a == "--send":
opts["send"] = True
elif a == "--list":
opts["list"] = True
elif a == "--debug":
opts["debug"] = True
elif a == "--skip-entry":
opts["skip_entry"] = True
elif a == "--ticket-id" and i + 1 < len(argv):
opts["ticket_id"] = int(argv[i + 1])
i += 1
elif a == "--music-id" and i + 1 < len(argv):
opts["music_id"] = int(argv[i + 1])
i += 1
elif a == "--level" and i + 1 < len(argv):
opts["level"] = int(argv[i + 1])
i += 1
elif not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
def main() -> int:
opts = parse_args(sys.argv[1:])
if not opts["qr"]:
print(__doc__)
return 1
if opts["debug"]:
import config as _cfg
_cfg.LOG_LEVEL = "debug"
setup_logging("use_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- 心跳:决定后续所有请求的 number 头 ----
Ping()
user_id, token = ParseQRCode(opts["qr"])
session.update(user_id=user_id, token=token)
preview = GetUserPreview(user_id, token)
if preview.isLogin:
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
return 2
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- ★ 读当前持有的功能券 ----
all_charges, held = read_charges(user_id, "消耗前")
if opts["list"]:
log_info("--list:只列票券,不做任何写操作")
return 0
if not held:
log_error("账号当前**没有持有任何功能票**,没有可消耗的东西。")
log_error(" 想买票请用 buy_ticket.py;已持有的票会在 userChargeList 里 stock > 0。")
return 4
# ---- 选定目标票 ----
target = None
if opts["ticket_id"] is not None:
for c in held:
if int(c.get("chargeId", -1)) == int(opts["ticket_id"]):
target = c
break
if target is None:
log_error("指定的 chargeId=" + str(opts["ticket_id"])
+ " 当前并不持有;持有的有:"
+ ", ".join(str(c.get("chargeId")) for c in held))
return 4
else:
target = held[0]
ticket_id = int(target.get("chargeId"))
credit_num = int(TICKET_CREDIT_NUM.get(ticket_id, 0) or 0)
play_credit = int(PLAY_CREDIT) + credit_num
log_info("选定要消耗的票:chargeId=" + str(ticket_id) + " " + ticket_name(ticket_id)
+ " stock=" + str(target.get("stock"))
+ " creditNum=" + str(credit_num)
+ " -> playCredit=" + str(PLAY_CREDIT) + "+" + str(credit_num)
+ "=" + str(play_credit))
# ---- ★ 构造新的 userChargeList:目标票 stock - 1,其余原样 ----
new_stock = int(target.get("stock", 0) or 0) - 1
if new_stock < 0:
new_stock = 0
new_list = []
for c in all_charges:
if not isinstance(c, dict):
continue
item = dict(c)
if int(item.get("chargeId", -1)) == ticket_id:
item["stock"] = new_stock
log_info(" userChargeList 里 chargeId=" + str(ticket_id)
+ " 的 stock:" + str(target.get("stock")) + " -> " + str(new_stock))
new_list.append(item)
# ---- 入场数据下载(贴近真机节奏,同时也是 UpsertUserAll 的基线快照)----
snapshot = None
if not opts["skip_entry"]:
snapshot = GetNeedUserData(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snapshot)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
+ str(len(snapshot.get("_failed") or [])) + " 个")
# ---- ★ 用 UpsertUserAll 表达「用这张票打了一局」----
# play.use_ticket_id -> userGamePlaylogList[0].useTicketId
# play.play_credit -> userGamePlaylogList[0].playCredit / userPlaylogList[].playCredit
# / userData.lastPlayCredit
# extra_data.userChargeList -> 覆盖节点,把票的 stock 减 1
play = PlayData(
music_id=opts["music_id"],
music_level=opts["level"],
use_ticket_id=ticket_id,
play_credit=play_credit,
)
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
snapshot=snapshot,
play=play,
extra_data={"userChargeList": new_list},
dry_run=not opts["send"],
confirm=opts["send"],
verify_landing=bool(opts["send"]),
place_id=PLACE_ID, region_id=REGION_ID,
)
# ---- ★ 复查:票券 stock 应该减 1 ----
_, held_after = read_charges(user_id, "消耗后")
if not opts["send"]:
log_info("[dry-run] 未发包。想真实消耗请加 --send(★ 会写入服务器)")
return 0
still = [c for c in held_after if int(c.get("chargeId", -1)) == ticket_id]
if still:
log_warning("chargeId=" + str(ticket_id) + " 仍然持有(stock="
+ str(still[0].get("stock")) + ")—— 服务端可能没落库")
return 6
log_info("★ 票券已消耗:chargeId=" + str(ticket_id) + " 不再持有 ✅")
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
if session["logged_in"]:
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
except Exception as exc:
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
teardown_logging()
if __name__ == "__main__":
sys.exit(main())