Compare commits

3 Commits
Author SHA1 Message Date
Txiaochen 3e30dffb31 v0版本完成:新增 4 篇详细文档(README 精简内容的落点)
README 为了防冗余做了精简,被移出的详细内容写成文档放这里:

- 功能票使用说明.md      买票/用票的完整原理、报文依据、playCredit 公式、实测记录
- 账号转生说明.md        reincarnate 完整流程、分批规则(避开 2 首)、
                          续关登录依据、登出 type=1 依据、playlog 合成、迁移范围
- 常见坑与注意事项.md    二维码 / 账号状态 / 功能票 / 副作用 / 日志 / 网络 /
                          不入库清单 + .gitignore 行尾注释的坑
- netdelivery说明.md     DLI 协议、amdaemon.exe 字符串出处、用法与实测结论
2026-10-07 11:23:47 +08:00
Txiaochen ddacc97b18 docs: 新增《购买功能票_存档与交接》
买票功能暂停,归档全部有用信息以便将来恢复:
- 协议事实(源码依据 + 真机 328 字节抓包原文)
- 完整实验时间线(8 次失败)与 12 项已排除假设
- ★ 同一账号对照实验证明差异在客户端(机台能买、脚本不能买)
- 别人的可用实现(截图转录)+ 12 项差异对照 + 6 个疑惑
- 账号风险提示、恢复工作的步骤与命令
2026-10-06 16:49:41 +08:00
Txiaochen 41909b9169 docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括:
- 项目总览 / 主脚本编写指南 / 日志指南
- 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条)
- 真机 Hook 对照报告 / 实跑验证记录
- 购买功能票流程(含被服务端拒绝的排查过程)
  · 扫码登录协议技术规范
2026-10-06 14:42:32 +08:00
79 changed files with 35133 additions and 17062 deletions
-7
View File
@@ -1,7 +0,0 @@
# 统一换行符,避免 Windows/Linux 来回转换把每个文件都标成 modified
* text=auto
*.py text eol=lf
*.md text eol=lf
*.cs text eol=crlf
*.csproj text eol=crlf
*.json text eol=lf
-40
View File
@@ -1,40 +0,0 @@
# ---- 运行产物 ----
# 每账号一份用户数据快照(<userId>.json)
data/
logs/
__pycache__/
*.pyc
*.pyo
# ---- 本地查证用(含 SEGA 二进制 / 反编译代码 / 真机日志,不入库)----
source/
# ---- 私有配置:只提交 config.py.example,不提交 config.py ----
config.py
# ---- 文档单独放 docs 分支,主分支不含 ----
docs/
# ---- .NET 构建产物 ----
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/
.idea/
*.user
Thumbs.db
Desktop.ini
-499
View File
@@ -1,499 +0,0 @@
<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>
宗旨:让舞萌DX的玩家能够更好地游玩游戏,获得快乐。
</p>
<p align="center">AI真的很好用你知道吗</p>
---
## 这是什么
Astral 是一个用 **Python 纯网络方式**与舞萌DX(SDGB 国服)游戏服务器交互的工具集。
它不修改游戏、不注入进程,只是把游戏客户端做的事用脚本重做一遍:
扫码登录 → 取数据 → 上传成绩 / 解锁歌曲 → 登出。
> 协议实现全部来自反编译源码与真机网络日志的双向验证,
> 每个关键常量在代码注释里都标了**源码行号**。
---
## 当前状态:v0 plus 附加功能
| 脚本 | 状态 |
|---|---|
| **`upsert.py`** | ✅ **可用** —— 扫码登录 → 上传一局成绩 → 落库回查 → 登出 |
| **`unlockmusic.py`** | ✅ **可用** —— 解锁(解禁)歌曲 / 难度(复用 `upsert.py` 整套流程) |
| **`buy_ticket.py`** | ✅ **可用** —— 购买功能票 |
| **`use_ticket.py`** | ✅ **可用** —— 消耗当前持有的功能票 |
| **`reincarnate.py`** | 🔧 已实现(**未实跑**)—— 账号数据迁移(转生) |
| **`netdelivery.py`** | ✅ **可用** —— 查 / 下 `.app` / `.opt` 更新包(只读) |
| `api/` 下 40+ 个接口模块 | 🔧 已实现并通过零网络自检,但**未全部在真机上验证过** |
**文档**在 [`docs` 分支](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs),见文末[文档索引](#文档索引)。
---
## 一个教训:别把一件事分段交给 AI
v0 的开发过程中,我反复把同一个目标拆成小段丢给 AI,结果:
| 项目 | Token 消耗 |
|---|---|
| **v0 有效开发** | **473M+** |
| 含中途废弃的分支项目 | **1137.5M** |
**这 1137.5M 里有一大半是浪费掉的。**
真正的教训是:
> **不要把一个东西分段交给 AI,而是直接设定一个目标,然后让它自己跑完。**
分段交接会让 AI 反复丢失上下文、重复劳动、在已经解决的问题上打转 ——
而一个清晰的目标 + 足够长的执行窗口,反而更快也更省。
---
## 项目框架
```
Astral/
├── config.py 运行时可配置项(机台标识 / 歌曲数据 / 日志级别)★ 需自建
├── config.py.example 配置模板
├── icon/ Logo
├── upsert.py ★ 主脚本:扫码登录 → 上传成绩 → 登出
├── unlockmusic.py 主脚本:解锁(解禁)歌曲(复用 upsert.py 的 run())
├── buy_ticket.py 主脚本:购买功能票
├── use_ticket.py 主脚本:消耗当前持有的功能票
├── reincarnate.py 主脚本:账号数据迁移(转生)
├── netdelivery.py 主脚本:查 / 下 .app / .opt 更新包
├── selftest.py 零网络自检(284 项,判据 = 源码 + 真机日志)
│
├── api/ ★ 所有与服务器交互的模块(一个接口一个脚本)
│ ├── __init__.py 统一导出 + 日志自动跟踪
│ ├── MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── LogSetup.py 日志(INFO / DEBUG 双模式)
│ ├── ChargeGuard.py 买票前置闸门(必须先跑过 GetUserCharge)
│ ├── UpsertUserAll.py 上传成绩 / 解锁歌曲(36 节点 / 110 字段 / 差分)
│ ├── GetNeedUserData.py 结算快照编排 + 落盘 data/<userId>.json
│ └── ... 40+ 个接口脚本
│
├── data/ 每账号一份用户数据快照(<userId>.json)★ 不入库
├── download/ netdelivery.py 下载的 .app / .opt ★ 不入库
└── logs/ 日志输出(latest.log + 日期.log)★ 不入库
```
**设计约定**:`api/` 里每个脚本 = 一个接口,对外暴露一个**能传参的函数**。
主脚本只负责编排,日志和协议细节都在 `api/` 内部。
**命名约定**:**文件名 = 函数名**,都按首字母大写的驼峰切分,一一对应:
| 服务端接口 | 脚本 | 函数 |
|---|---|---|
| `GetUserDataApi` | `api/GetUserData.py` | `GetUserData()` |
| `UpsertUserAllApi` | `api/UpsertUserAll.py` | `UpsertUserAll()` |
| `UserLoginApi` | `api/UserLogin.py` | `UserLogin()` |
服务端 API 名里带 `Get` 的,脚本名与函数名一律带 `Get`(不要模棱两可)。
### 分支说明
| 分支 | 内容 |
|---|---|
| **`mai`** | 主分支 —— 代码 |
| **`docs`** | 孤儿分支 —— 只有 `docs/`,开发文档与踩坑记录 |
两个分支**没有共同的提交历史**,各自独立。
> 🛠 **给开发者**:协议实现对照的反编译源码(`Assembly-CSharp` / `AMDaemon.NET` 等)
> 不入库,需要自己准备一份放在 `source/`;代码注释里的行号都指向它。
> 只是用工具的话**完全不需要**这个目录。
---
## 快速开始
### 1. 装依赖
```bash
pip install requests pycryptodome
```
### 2. ★ 先填 `config.py`
仓库里只有模板 `config.py.example`,先复制一份:
```bash
cp config.py.example config.py # Linux / macOS
copy config.py.example config.py # Windows
```
然后**必须**填好 **`config.py` 第二节「机台与店铺标识」**:
| 变量 | 填什么 |
|---|---|
| `CLIENT_ID` | Keychip 短值(15 位 Keychip 去掉连字符取前 11 位),形如 `A63E01ABCDE` |
| `PLACE_ID` | 你所在店铺 ID |
| `REGION_ID` | 你所在区域 ID |
| `PLACE_NAME` | 店铺名 |
| `REGION_NAME` | 区域名 |
| `KEYCHIP_ID` | **由上面两个值自动推导**,不要手改 |
> ⚠️ 填错的话,发出去的请求带的就是别人的机台身份,服务端会直接拒绝。
>
> `config.py` 已在 `.gitignore` 里 —— 它是你的私有配置,**不要提交**。
### 3. 跑自检
```bash
python selftest.py # 零网络自检,应该 284 项全绿(不依赖你的 config)
```
### 主脚本一览
| 脚本 | 干什么 | 副作用 | 需要二维码 |
|---|---|---|---|
| `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
# 预检:登录 + 抓快照 + 组包,不发包(零副作用,建议先跑这个)
python upsert.py "<二维码>"
# 真实上传一局成绩(★ 有副作用,不可撤销)
python upsert.py "<二维码>" --send
# 常用开关
python upsert.py "<二维码>" --send --debug # 日志里带完整收发报文
python upsert.py "<二维码>" --send --cooldown 0 # 跳过发包前 15 秒冷却
python upsert.py "<二维码>" --send --no-verify # 跳过上传后的落库回查
python unlockmusic.py "<二维码>" --send --music 11414 # 解锁歌曲 / 解禁难度
```
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`,不用动任何代码。**
### 额外塞节点:`--extra-data`
想手工覆盖某几个节点(比如本局拿到的新 KaleidxScope 门):
```bash
python upsert.py "<二维码>" --send --extra-data extra.json
```
键必须是 `upsertUserAll` 的 **36 个节点**之一,写错会报错并列出可用节点名。
**JSON 在扫码之前就校验**,写错直接退出,不浪费二维码。
> ⚠️ 在 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 | 解锁什么 |
|---|---|
| `5` | 歌曲本体(`MusicUnlockList`) |
| `6` | Master 难度(`MusicUnlockMasterList`) |
| `7` | Re:Master 难度(`MusicUnlockReMasterList`) |
条目形如 `{"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 协议层。
---
## 主脚本怎么写
**核心原则:不要为每个 api 调用写日志。** 日志已经在 `api/` 里了。
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from api import (setup_logging, teardown_logging,
ParseQRCode, GetUserPreview, UserLogin, UserLogout, LogoutType,
UpsertUserAll)
from config import REGION_ID, PLACE_ID
def main() -> int:
setup_logging("我的脚本") # ← 只需要这一句
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
user_id, token = ParseQRCode(sys.argv[1])
session.update(user_id=user_id, token=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)
# ★ 一个函数跑完整条结算链路
UpsertUserAll(user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
dry_run=False, confirm=True)
return 0
finally:
if session["logged_in"]: # ★ 一定要保证登出
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.Logout,
region_id=REGION_ID, place_id=PLACE_ID)
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
```
三条铁律:
1. **不要写日志** —— 每个 api 函数会自动打印 `=== 函数名 开始运行 ===` / `[运行结果]`
2. **一定要登出** —— 账号卡在 `isLogin=True` 时下次登录会 `returnCode=100`
3. **写接口要显式开闸门** —— 只写 `dry_run=False` 不够,必须同时给 `confirm=True`
完整指南见 [主脚本编写指南](https://git.fragrance.moe/Txiaochen/Astral/src/branch/docs/docs/主脚本编写指南.md)。
---
## 已完成 / 将来
**已完成(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
- [**WorkBuddy AI**](https://www.workbuddy.ai/)—— 提供 AI 模型
- **DeepSeek V4.1 Flash** —— 主要代码编写
- **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
@@ -1,150 +0,0 @@
# -*- 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)
-311
View File
@@ -1,311 +0,0 @@
# -*- coding: utf-8 -*-
"""
Chime.py —— 扫码登录(二维码 -> userId + token)
================================================================================
功能:把微信二维码串(或二维码 URL)交给 aimedb 服务器,换取 userId 与 token。
对应接口:POST https://ai.sys-allnet.cn/wc_aime/api/get_data
--------------------------------------------------------------------------------
协议依据
--------------------------------------------------------------------------------
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
CCommGetUserData_Create(strGameID, strChipID, strCommonKey,
strQRData, strTitleKey, serverURLIndex)
strGameID = "MAID"
strChipID = KeychipID
strQRData = 二维码串[20:] <- 见 CCommQRCode.EraseHashIdentifier()
strTitleKey= "SDGB"
source/ChimeLib.NET/ChimeLib/NET/COMM/CCommQRCode.cs
IsValid() : 前缀 "SGWC"(Chime.WechatID) + 偏移 4 处为 gameID
GetTimeStamp() : [8:20] 12 位 yymmddHHMMss
EraseHashIdentifier() : [20:] 64 位载荷
source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250-255
KeychipID = AMDaemon.System.KeychipId.Value
TitleKey = AMDaemon.System.GameId
GameID = "MAID"
docs/舞萌DX_扫码登录协议技术规范.md
- 签名 key = SHA-256(chipID + timestamp + SIGN_KEY) 大写 hex
- ★ 请求头【绝不能】带 Content-Type,否则服务端返回 errorID=57
- qrCode 字段传的是 64 位载荷,不是完整 84 字符串
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import ParseQRCode
user_id, token = ParseQRCode("SGWCMAID2610022219463A...")
# 也可以直接传二维码 URL
user_id, token = ParseQRCode("https://wq.wahlap.net/qrcode/req/MAID261002....html?l=...")
"""
from __future__ import annotations
import hashlib
import http.client
import json
import re
import ssl
from datetime import datetime
from typing import Tuple
from urllib.parse import urlparse
from config import KEYCHIP_ID
# =============================================================================
# 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 + 技术规范)
# =============================================================================
QR_PREFIX = "SGWC" # Chime.WechatID
QR_GAME_ID = "MAID"
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
QR_PAYLOAD_LEN = 64
QR_TOTAL_LEN = QR_PREFIX_LEN + QR_PAYLOAD_LEN # 84
#: 默认机台 chipID
#:
#: ★★ 必须用**完整形式**(带连字符),不是短值 —— 这是个容易搞错的点:
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:250
#: KeychipID = AMDaemon.System.KeychipId.Value; // 完整形式
#: source/Assembly-CSharp/Manager/ChimeReaderManager.cs:255
#: Chime.Instance.Init(GameID, KeychipID, _chimeDevice, TitleKey);
#: 而游戏服务器那边用的是短值:
#: source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
#: clientId = AMDaemon.System.KeychipId.ShortValue; // 短值
#:
#: 即:aimedb 扫码用完整 KeychipId,游戏服务器用 ShortValue。
#: docs 分支的《扫码登录协议技术规范》§3.4 里也有对应的抓包验证向量。
#: token 与 chipID 绑定,用错会导致后续接口「returnCode=1 但不落库」。
DEFAULT_CHIP_ID = KEYCHIP_ID
# =============================================================================
# 内部工具
# =============================================================================
def _extract_qr_from_url(url: str) -> str:
"""
从二维码 URL 提取完整 84 字符串。
URL 形如:
https://wq.wahlap.net/qrcode/req/MAID2610022219463A....html?l=...&t=...&d=...
取 /qrcode/req/ 与 .html 之间的 80 字符,前面补 "SGWC"。
"""
path = urlparse(url).path
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
if not match:
raise ValueError("URL 路径格式不符合预期,无法提取二维码数据:" + url)
return QR_PREFIX + match.group(1)
def _normalize_qr(qr_input: str) -> str:
"""URL 或字符串 -> 标准 84 字符二维码串(含格式校验)。"""
qr = (qr_input or "").strip()
if qr.startswith("http://") or qr.startswith("https://"):
log_debug("输入是 URL,先提取二维码字符串")
qr = _extract_qr_from_url(qr)
if len(qr) != QR_TOTAL_LEN:
raise ValueError(
"二维码长度应为 " + str(QR_TOTAL_LEN) + " 字符,实际 " + str(len(qr)) + " 字符:" + qr
)
if not qr.startswith(QR_PREFIX):
raise ValueError("二维码必须以 '" + QR_PREFIX + "' 开头:" + qr)
if qr[4:8] != QR_GAME_ID:
raise ValueError(
"二维码 GameID 应为 '" + QR_GAME_ID + "',实际 '" + qr[4:8] + "':" + qr
)
if not all(c in "0123456789ABCDEF" for c in qr[QR_PREFIX_LEN:]):
raise ValueError("二维码载荷必须为大写十六进制:" + qr)
log_debug(
"二维码校验通过:timestamp=" + qr[8:20]
+ " payload_len=" + str(len(qr[QR_PREFIX_LEN:]))
)
return qr
def _build_body(qr_string: str, chip_id: str) -> Tuple[bytes, str, str]:
"""
构造请求体。
签名(技术规范 §4.1):
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
★ 拼接里不含 qrCode、不含 titlekey
qrCode 字段(CCommQRCode.EraseHashIdentifier):
qr_string[20:] 即 64 位载荷
返回:
(body_bytes, timestamp, key)
"""
timestamp = datetime.now().strftime("%y%m%d%H%M%S")
key = hashlib.sha256((chip_id + timestamp + QR_SIGN_KEY).encode()).hexdigest().upper()
body_dict = {
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
}
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode("utf-8")
return body_bytes, timestamp, key
def _send(body_bytes: bytes) -> dict:
"""
发送 HTTPS POST 并解析 JSON 响应。
★ 请求头只放 Connection / User-Agent / Content-Length。
绝不能加 Content-Type —— 加了会得到 errorID=57(技术规范 §3.3)。
"""
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
headers = {
"Connection": "Keep-Alive",
"User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes)),
# ★ 此处刻意不加 Content-Type
}
conn = http.client.HTTPSConnection(AIMEDB_HOST, AIMEDB_PORT, context=ctx, timeout=20)
try:
conn.request("POST", AIMEDB_PATH, body=body_bytes, headers=headers)
resp = conn.getresponse()
text = resp.read().decode("utf-8")
status = resp.status
finally:
conn.close()
log_response(status=status, body=text, api_name="aimedb/get_data")
try:
return json.loads(text)
except Exception as exc:
raise RuntimeError("aimedb 响应不是合法 JSON:" + text[:300]) from exc
def _parse(resp: dict) -> Tuple[int, str]:
"""解析响应,返回 (user_id, token)。"""
error_id = resp.get("errorID", -1)
user_id = resp.get("userID", -1)
token = resp.get("token", "") or ""
if error_id != 0:
raise RuntimeError(
"扫码登录失败:errorID=" + str(error_id) + ",响应=" + json.dumps(resp, ensure_ascii=False)
+ "\n 提示:errorID=57 通常是二维码已失效/已被使用,或请求头带了 Content-Type"
)
if user_id == -1 or not token:
raise RuntimeError(
"扫码登录失败:userID=" + str(user_id) + ", token='" + token + "'"
)
return int(user_id), str(token)
# =============================================================================
# 公开 API
# =============================================================================
def ParseQRCode(qr_input: str, *, chip_id: str = DEFAULT_CHIP_ID) -> Tuple[int, str]:
"""
解析二维码并换取 userId 与 token。
参数:
qr_input: 84 字符二维码串(SGWCMAID...),或二维码 URL
chip_id: 机台 Keychip 短值(可选,默认取 config.CLIENT_ID)
返回:
(user_id, token) 元组,可直接传给 GetUserPreview / UserLogin 等
异常:
ValueError: 二维码格式不合法
RuntimeError: 服务器返回错误或网络异常
"""
log_info("解析二维码登录:chip_id=" + chip_id)
qr_string = _normalize_qr(qr_input)
body_bytes, timestamp, key = _build_body(qr_string, chip_id)
log_request(
url="https://" + AIMEDB_HOST + ":" + str(AIMEDB_PORT) + AIMEDB_PATH,
body={
"chipID": chip_id,
"openGameID": QR_OPEN_GAME_ID,
"key": key,
"qrCode": qr_string[QR_PREFIX_LEN:],
"timestamp": timestamp,
"titlekey": QR_TITLE_KEY,
},
headers={"Connection": "Keep-Alive", "User-Agent": QR_USER_AGENT,
"Content-Length": str(len(body_bytes))},
api_name="aimedb/get_data",
)
log_info("发送登录请求:timestamp=" + timestamp + " host=" + AIMEDB_HOST + AIMEDB_PATH)
resp = _send(body_bytes)
user_id, token = _parse(resp)
log_info("扫码登录成功:user_id=" + str(user_id) + " token=" + token[:8] + "...(长度 "
+ str(len(token)) + ")")
return user_id, token
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
import sys
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chime_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.chime <二维码串或URL> [chip_id]")
return 1
chip = sys.argv[2] if len(sys.argv) > 2 else DEFAULT_CHIP_ID
user_id, token = ParseQRCode(sys.argv[1], chip_id=chip)
print("")
print("user_id =", user_id)
print("token =", token)
return 0
except Exception as exc:
log_error("扫码登录失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-53
View File
@@ -1,53 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetGameCharge.py —— 取游戏计费配置(GetGameChargeApi)
================================================================================
取服务器下发的计费档位。
对应接口:GetGameChargeApi
请求体 :{"isAll": false}(GameChargeRequestVO)
响应 :{length, gameChargeList:[{orderId,chargeId,price,startDate,endDate}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/GameChargeRequestVO.cs
source/2026-10-05.log:9 真机请求 {"isAll":false}
调用示例:
from api import GetGameCharge
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
class GameChargeRequestVO:
"""请求 VO(对应 GameChargeRequestVO)。"""
isAll: bool = False
def GetGameCharge(
*,
is_all: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取游戏计费配置(对应 GetGameChargeApi)。
返回:
原始响应 dict,含 gameChargeList
"""
log_info("取游戏计费配置:isAll=" + str(is_all))
return request_dict(
"GetGameChargeApi", GameChargeRequestVO(isAll=is_all),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-175
View File
@@ -1,175 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetGameSettings.py —— 获取游戏设置(GetGameSettingApi)
================================================================================
功能:取服务器下发的运行时配置(维护窗口、心跳间隔、包超时、各类上限)。
对应接口:GetGameSettingApi
请求体 :{"placeId": <店铺ID>, "clientId": "<机台短值>"}
source/Assembly-CSharp/Net.VO.Mai2/GameSettingRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:21-24
★ 真机调用时机:会话最开始、Ping 之前(source/2026-10-05.log:1)。
调用示例:
from api import GetGameSettings
resp = GetGameSettings()
print(resp.gameSetting.isMaintenance, resp.gameSetting.requestInterval)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Optional
from config import CLIENT_ID, PLACE_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
# =============================================================================
# VO(字段对照 source/Assembly-CSharp/Net.VO.Mai2/GameSetting.cs 与真机响应)
# =============================================================================
@dataclass
class GameSetting:
"""游戏运行时配置(对应 Net.VO.Mai2.GameSetting)。"""
isMaintenance: bool = False
requestInterval: int = 0
rebootStartTime: str = ""
rebootEndTime: str = ""
rebootInterval: int = 0
movieUploadLimit: int = 0
movieStatus: int = 0
movieServerUri: str = ""
deliverServerUri: str = ""
oldServerUri: str = ""
usbDlServerUri: str = ""
maxCountRivalMusic: int = 0
replicationDelayLimit: int = 0
exclusionStartTime: str = ""
exclusionEndTime: str = ""
pingDisable: bool = False
packetTimeout: int = 0
packetTimeoutLong: int = 0
packetRetryCount: int = 0
userDataDlErrTimeout: int = 0
userDataDlErrRetryCount: int = 0
userDataDlErrSamePacketRetryCount: int = 0
userDataUpSkipTimeout: int = 0
userDataUpSkipRetryCount: int = 0
iconPhotoDisable: bool = False
uploadPhotoDisable: bool = False
photoDisableArea: Optional[str] = None
collectDelayInfoDisable: bool = False
maxCountMusic: int = 0
maxCountItem: int = 0
nameEntryDisable: bool = False
packetRecreateCount: int = 0
@dataclass
class GameSettingRequestVO:
"""请求 VO(对应 GameSettingRequestVO)。"""
placeId: int = 0
clientId: str = ""
@dataclass
class GameSettingResponseVO:
"""响应 VO(对应 GameSettingResponseVO)。"""
isAouAccession: bool = False
gameSetting: GameSetting = field(default_factory=GameSetting)
# =============================================================================
# 公开 API
# =============================================================================
def GetGameSettings(
*,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> GameSettingResponseVO:
"""
获取游戏设置(服务器下发的运行时配置)。
参数:
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
client_id: 机台短值(可选,默认 config.CLIENT_ID)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
GameSettingResponseVO
"""
log_info("获取游戏设置:place_id=" + str(place_id) + " client_id=" + client_id)
raw = request_dict(
"GetGameSettingApi",
GameSettingRequestVO(placeId=place_id, clientId=client_id),
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
gs_raw = raw.get("gameSetting") or {}
known = {f for f in GameSetting.__dataclass_fields__}
gs = GameSetting(**{k: v for k, v in gs_raw.items() if k in known})
resp = GameSettingResponseVO(
isAouAccession=bool(raw.get("isAouAccession", False)),
gameSetting=gs,
)
if gs.isMaintenance:
log_warning("服务器处于维护状态(isMaintenance=True)")
if gs.pingDisable:
log_info("服务器已禁用心跳(pingDisable=True)")
log_info(
"游戏设置获取完成:isMaintenance=" + str(gs.isMaintenance)
+ " requestInterval=" + str(gs.requestInterval)
+ " packetTimeout=" + str(gs.packetTimeout)
+ " packetTimeoutLong=" + str(gs.packetTimeoutLong)
+ " pingDisable=" + str(gs.pingDisable)
)
return resp
def is_maintenance(settings: GameSettingResponseVO) -> bool:
"""便捷判断:服务器是否处于维护状态。"""
return bool(settings.gameSetting.isMaintenance)
def is_ping_disabled(settings: GameSettingResponseVO) -> bool:
"""便捷判断:是否已禁用心跳。"""
return bool(settings.gameSetting.pingDisable)
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("gamesettings_test")
try:
resp = GetGameSettings()
gs = resp.gameSetting
print("")
for name in GameSetting.__dataclass_fields__:
print(" " + name.ljust(34) + "= " + str(getattr(gs, name)))
return 0
except Exception as exc:
from .LogSetup import log_error
log_error("获取游戏设置失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-413
View File
@@ -1,413 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetNeedUserData.py —— 结算快照编排(统一调用各取数脚本)
================================================================================
功能:一键抓取 UpsertUserAll 需要的全部基线数据,整理成「节点字典」。
★ 本模块**只做编排**,取数逻辑都在各自的 api 脚本里(GetUserData.py / GetUserMap.py …)。
--------------------------------------------------------------------------------
为什么需要快照
--------------------------------------------------------------------------------
服务器对 userData 是**整表覆盖**语义,对差分节点是**只回传变化项**语义。
要把本局的变化正确表达出来,必须先有一份「登录时的服务器状态」作基线,
再把本局的影响叠加到基线上 —— 这就是快照的作用。
真机对照(source/2026-10-05.log):
| 节点 | Get 侧(基线) | Upsert 侧(本局后) |
|---------------------|-------------------|----------------------|
| userData.playCount | 1257 | 1258 (+1) |
| userMapList | 104 条 | 仅 1 条(distance 变)|
| userMusicDetailList | 739 组 | 仅 1 条(playCount+1)|
| userItemList | 1070 条 | [](无变化) |
--------------------------------------------------------------------------------
调用示例
--------------------------------------------------------------------------------
from api import GetNeedUserData
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 .LogSetup import log_debug, log_info, log_warning
from .MaimaiNet import MaimaiNetClient
# 各取数脚本(一个接口一个脚本)
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)
#: 默认要抓的节点(顺序即抓取顺序)
#: ★ 只列 UpsertUserAll 真正需要的;纯展示类接口(排行/活动/大会…)不在此列。
DEFAULT_SNAPSHOT_NODES: tuple = (
"userData",
"userExtend",
"userOption",
"userCharacterList",
"userMapList",
"userLoginBonusList",
"userRatingList",
"userItemList",
"userMusicDetailList",
"userCourseList",
"userChargeList",
"userFavoriteList",
"userActivityList",
"userMissionDataList",
"userIntimateList",
"userShopItemStockList",
"userFavoritemusicList",
"userKaleidxScopeList",
"userGhost",
)
def GetNeedUserData(
user_id: int,
*,
token: str = "",
client_id: str = CLIENT_ID,
place_id: int = PLACE_ID,
region_id: int = REGION_ID,
shop_item_ids: Optional[Sequence[int]] = None,
nodes: Optional[Sequence[str]] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
strict: bool = False,
save: bool = True,
save_path: Optional[str] = None,
) -> Dict[str, Any]:
"""
抓取结算所需的全部基线数据。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(可选,多数取数接口不需要)
client_id: 机台短值
place_id: 店铺 ID
region_id: 区域 ID
shop_item_ids: 商店商品清单(可选,默认 config.PLAY_SHOP_ITEM_IDS)
nodes: 要抓的节点(可选,默认 DEFAULT_SNAPSHOT_NODES)
timeout_ms: 超时毫秒
client: 可复用的 MaimaiNetClient
strict: True = 任一接口失败就抛异常;
False(默认)= 记录失败并跳过,保证链路不崩
save: True(默认)= 抓完自动落盘到 <工程根>/data/<userId>.json
(★ 新数据总是覆盖老数据)
save_path: 自定义落盘路径(可选,给了就忽略 save 的默认路径规则)
返回:
{
"userData": <GetUserDataApi 原始响应>,
"userExtend": <GetUserExtendApi 原始响应>,
"userMapList": {"userMapList": [...]}, # 已摊平/翻页
"userMusicDetailList": {"userMusicList": [...]},# 已摊平成单条
...
"_failed": [ {"node":.., "error":..}, ... ]
}
这个结构可以直接传给 UpsertUserAll(snapshot=...),也可 json 落盘。
"""
wanted = list(nodes) if nodes else list(DEFAULT_SNAPSHOT_NODES)
log_info("抓取结算快照:user_id=" + str(user_id) + " 节点数=" + str(len(wanted)))
raw: Dict[str, Any] = {}
failed: List[Dict[str, str]] = []
def _call(node: str, fn, *args, **kwargs):
"""逐节点容错:单个接口失败不影响整体。"""
try:
raw[node] = fn(*args, **kwargs)
return True
except Exception as exc:
failed.append({"node": node, "error": type(exc).__name__ + ": " + str(exc)})
log_warning("节点 " + node + " 抓取失败:" + type(exc).__name__ + ": " + str(exc))
if strict:
raise
return False
common = dict(client_id=client_id, timeout_ms=timeout_ms, client=client)
# ---- 逐个抓取(顺序与 DEFAULT_SNAPSHOT_NODES 一致)----
if "userData" in wanted:
_call("userData", GetUserData, user_id, **common)
if "userExtend" in wanted:
_call("userExtend", GetUserExtend, user_id, **common)
if "userOption" in wanted:
_call("userOption", GetUserOption, user_id, **common)
if "userCharacterList" in wanted:
_call("userCharacterList", GetUserCharacter, user_id, **common)
if "userMapList" in wanted:
_call("userMapList", GetUserMapAll, user_id, **common)
if "userLoginBonusList" in wanted:
_call("userLoginBonusList", GetUserLoginBonusAll, user_id, **common)
if "userRatingList" in wanted:
_call("userRatingList", GetUserRating, user_id, **common)
if "userItemList" in wanted:
_call("userItemList", GetUserItemAll, user_id, **common)
if "userMusicDetailList" in wanted:
_call("userMusicDetailList", GetUserMusicDetailAll, user_id, **common)
if "userCourseList" in wanted:
_call("userCourseList", GetUserCourseAll, user_id, **common)
if "userChargeList" in wanted:
_call("userChargeList", GetUserCharge, user_id, **common)
if "userFavoriteList" in wanted:
# ★ 该接口额外带一份**原始** itemKind 副本(_raw),
# 供 UpsertUserAll 生成 isNewFavoriteList 位串用
# —— 真机 itemKind=10 回的是 null,正是它产生了位串里的 "1"。
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", GetUserActivity, user_id, **common)
if "userMissionDataList" in wanted:
# ★ 该接口的响应里同时带着 userWeeklyData(周界),
# 它是 Upsert 侧 userWeeklyData 节点的来源,会被自动提取。
_call("userMissionDataList", GetUserMissionData, user_id, **common)
if "userIntimateList" in wanted:
_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", GetUserShopStock, user_id,
shop_item_id_list=ids, **common)
else:
log_debug("未配置 PLAY_SHOP_ITEM_IDS,跳过商店库存节点")
if "userFavoritemusicList" in wanted:
_call("userFavoritemusicList", GetUserFavoriteItemAll, user_id, 1, **common)
if "userKaleidxScopeList" in wanted:
_call("userKaleidxScopeList", GetUserKaleidxScope, user_id, **common)
if "userGhost" in wanted:
_call("userGhost", GetUserGhost, user_id, **common)
raw["_failed"] = failed
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
def save_snapshot(snapshot: Dict[str, Any], path: str) -> str:
"""把快照写到 JSON 文件(便于离线复用 / 人工核对)。"""
with open(path, "w", encoding="utf-8") as fp:
json.dump(snapshot, fp, ensure_ascii=False, indent=2, default=str)
log_info("快照已保存:" + path)
return path
def load_snapshot(path: str) -> Dict[str, Any]:
"""从 JSON 文件读回快照。"""
with open(path, "r", encoding="utf-8") as fp:
data = json.load(fp)
log_info("快照已加载:" + path)
return data
def summarize_snapshot(snapshot: Dict[str, Any]) -> Dict[str, int]:
"""
统计快照各节点的条数,便于快速核对(返回 {节点名: 条数})。
条数含义:
userData 这类单元素数组 -> 1
userMusicDetailList 这类列表 -> 摊平后的条数
"""
out: Dict[str, int] = {}
for node, value in (snapshot or {}).items():
if node.startswith("_"):
continue
if isinstance(value, list):
out[node] = len(value)
elif isinstance(value, dict):
# 从响应里挑出列表
found = False
for key, sub in value.items():
if isinstance(sub, list):
out[node] = len(sub)
found = True
break
if not found:
out[node] = 1
else:
out[node] = 0
return out
# =============================================================================
# 单独运行(调试)
# =============================================================================
if __name__ == "__main__":
import sys
from .LogSetup import log_error, setup_logging, teardown_logging
def _main() -> int:
setup_logging("get_need_user_data_test")
try:
if len(sys.argv) < 2:
print("用法: python -m api.get_need_user_data <user_id> [--token TOKEN] [--save 路径]")
return 1
user_id = int(sys.argv[1])
token = ""
save_path = ""
args = sys.argv[2:]
i = 0
while i < len(args):
if args[i] == "--token" and i + 1 < len(args):
token = args[i + 1]
i += 2
elif args[i] == "--save" and i + 1 < len(args):
save_path = args[i + 1]
i += 2
else:
i += 1
snap = GetNeedUserData(user_id, token=token)
summary = summarize_snapshot(snap)
print("")
for node, count in summary.items():
print(" " + node.ljust(28) + "= " + str(count) + " 条")
if save_path:
save_snapshot(snap, save_path)
return 0
except Exception as exc:
log_error("抓取快照失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-48
View File
@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserActivity.py —— 取用户活动记录(GetUserActivityApi)
================================================================================
取玩家的活动/成就记录(playList 与 musicList 两个列表)。
对应接口:GetUserActivityApi
请求体 :UserIdRequestVO
响应 :{userActivity:{playList:[...], musicList:[...]}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserActivityRequestVO.cs
★ 形状特例(36 个 Upsert 节点里唯一一个 Get 与 Upsert 形状不同的):
Get 侧 :单个对象 userActivity = {playList, musicList}
Upsert 侧:**单元素数组** userActivityList = [ {playList, musicList} ]
不是把两个数组首尾相接摊平。
调用示例:
from api import GetUserActivity
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserActivity(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户活动记录(对应 GetUserActivityApi)。
返回:
原始响应 dict,含 userActivity 子对象
"""
log_info("取用户活动记录:user_id=" + str(user_id))
return _get("GetUserActivityApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-46
View File
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserCharacter.py —— 取用户角色列表(GetUserCharacterApi)
================================================================================
取玩家所有角色的等级与觉醒度(真机 312 条)。
对应接口:GetUserCharacterApi
请求体 :UserIdRequestVO
响应 :{userId, length, userCharacterList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCharacterRequestVO.cs
★ 回传 Upsert 时只保留 characterId / level / awakening / useCount 四个字段
(真机 source/2026-10-05.log:123 的 userCharacterList 就是这 4 个键)。
调用示例:
from api import GetUserCharacter
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserCharacter(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户角色列表(对应 GetUserCharacterApi)。
返回:
原始响应 dict,含 userCharacterList
"""
log_info("取用户角色列表:user_id=" + str(user_id))
return _get("GetUserCharacterApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-63
View File
@@ -1,63 +0,0 @@
# -*- 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
-71
View File
@@ -1,71 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserCourse.py —— 取用户课题模式成绩(GetUserCourseApi)
================================================================================
取玩家的 COURSE(课题)模式通关记录(真机 16 条)。
对应接口:GetUserCourseApi
请求体 :UserCourseRequestVO(userId + nextIndex,**没有 maxCount**)
响应 :{userId, length, nextIndex, userCourseList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserCourseRequestVO.cs
source/2026-10-05.log:55 请求体就是 {"userId":..,"nextIndex":0}
调用示例:
from api import GetUserCourse
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserCourseRequestVO, get_all_pages
def GetUserCourse(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户课题模式成绩(单页,对应 GetUserCourseApi)。
返回:
原始响应 dict,含 userCourseList
"""
log_info("取用户课题成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserCourseApi",
UserCourseRequestVO(userId=user_id, nextIndex=next_index),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserCourseAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户课题成绩(自动翻页取全)。
返回:
{"userCourseList": [...]}
"""
log_info("取用户课题成绩(全量):user_id=" + str(user_id))
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,
)
log_info("课题成绩获取完成:" + str(len(items)) + " 条")
return {"userCourseList": items}
-58
View File
@@ -1,58 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserData.py —— 取用户全量数据(GetUserDataApi)
================================================================================
取玩家档案(86 个字段的大对象),是 upsertUserAll.userData 的**唯一正确来源**。
对应接口:GetUserDataApi
请求体 :UserIdRequestVO(只有 userId)
响应 :{userId, userData:{86 键}, banState}
依据 :source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserData.cs:17
source/Assembly-CSharp/Net.VO.Mai2/UserDetailResponseVO.cs
★ banState 在 Get 侧是**顶层**字段(UserDetailResponseVO.cs:15),
回传 Upsert 时要搬进 userData 内部(真机 source/2026-10-05.log:123 的 userData 里有 banState)。
⚠ 常见误区:有资料认为 userData 由 GetGameKaleidxScopeApi 下发。
但 GameKaleidxScopeResponseVO.cs 里只有 gameKaleidxScopeList 字段,根本没有 userData。
真机日志里那一次「GetGameKaleidxScope 的响应带 userData」,是抓包工具按时间相邻
记录造成的假象(两条响应被合并显示)。
调用示例:
from api import GetUserData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserData(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全量数据(对应 GetUserDataApi)。
参数:
user_id: 用户 ID(必填)
client_id: 机台短值(可选)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
原始响应 dict:{"userId":.., "userData":{..}, "banState":..}
"""
log_info("取用户全量数据:user_id=" + str(user_id))
return _get("GetUserDataApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-46
View File
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserExtend.py —— 取用户扩展设置(GetUserExtendApi)
================================================================================
取玩家上次的选曲 / 分类 / 卡片槽等扩展状态。
对应接口:GetUserExtendApi
请求体 :UserIdRequestVO
响应 :{userId, userExtend:{...}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserExtendRequestVO.cs
★ 其中 categoryIndex(曲种索引)与 userPlaylog 的 extNum4 必须一致
(真机两处都是 2022),组包时要注意。
调用示例:
from api import GetUserExtend
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserExtend(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户扩展设置(对应 GetUserExtendApi)。
返回:
原始响应 dict,含 userExtend 子对象
"""
log_info("取用户扩展设置:user_id=" + str(user_id))
return _get("GetUserExtendApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-102
View File
@@ -1,102 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserFavorite.py —— 取用户收藏(GetUserFavoriteApi)
================================================================================
取玩家各类收藏夹(称号 / 头像 / 相框 / 搭档 / 背景…)。
对应接口:GetUserFavoriteApi
请求体 :UserFavoriteRequestVO(userId + itemKind)
响应 :{userId, userFavorite:{itemKind, itemIdList:[...]}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteRequestVO.cs
source/2026-10-05.log:59-68 真机依次请求 itemKind = 3,1,2,10,11
★ 回传 Upsert 时 userFavoriteList 是**恒 5 条**的完整数组(不做剔除),
位串 isNewFavoriteList 是三值 "0"/"1"/"2"。
调用示例:
from api import GetUserFavorite, GetUserFavoriteAll
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserFavoriteRequestVO, FAVORITE_ITEM_KINDS
def GetUserFavorite(
user_id: int,
item_kind: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户某一类收藏(对应 GetUserFavoriteApi)。
参数:
user_id: 用户 ID(必填)
item_kind: 收藏类别(必填):3=头像 1=称号 2=名牌 10=搭档 11=相框
返回:
原始响应 dict,含 userFavorite
"""
log_info("取用户收藏:user_id=" + str(user_id) + " itemKind=" + str(item_kind))
return _get(
"GetUserFavoriteApi",
UserFavoriteRequestVO(userId=user_id, itemKind=item_kind),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserFavoriteAll(
user_id: int,
*,
item_kinds: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全部收藏(按真机顺序请求 5 个类别)。
参数:
item_kinds: 类别列表(可选,默认 FAVORITE_ITEM_KINDS = (3,1,2,10,11))
返回:
{"userFavoriteList": [{itemKind, itemIdList}, ...]},恒 5 条
"""
kinds = list(item_kinds) if item_kinds else list(FAVORITE_ITEM_KINDS)
log_info("取用户全部收藏:user_id=" + str(user_id) + " kinds=" + str(kinds))
result: List[Dict[str, Any]] = []
raw_items: List[Dict[str, Any]] = []
for kind in kinds:
resp = _get(
"GetUserFavoriteApi",
UserFavoriteRequestVO(userId=user_id, itemKind=kind),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
fav = resp.get("userFavorite") or {}
if not fav:
log_warning("itemKind=" + str(kind) + " 未返回 userFavorite,按空处理")
fav = {"itemKind": kind, "itemIdList": []}
# ★ 真机里 itemKind=10 的响应回的是 {"itemKind":null,"itemIdList":[]}(服务端怪癖)。
# 保留一份**原始**副本供 Upsert 差分用 —— 位串里的 "1" 正是它产生的。
raw_items.append(dict(fav))
# 回传用的副本按请求值补齐 itemKind,避免报文里出现 null
filled = dict(fav)
if not filled.get("itemKind"):
filled["itemKind"] = kind
result.append(filled)
log_info("收藏获取完成:" + str(len(result)) + " 类")
return {"userFavoriteList": result, "_raw": raw_items}
-92
View File
@@ -1,92 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserFavoriteItem.py —— 取用户收藏曲目/对手(GetUserFavoriteItemApi)
================================================================================
取玩家收藏的乐曲(kind=1)或收藏的对手(kind=2)。
对应接口:GetUserFavoriteItemApi
请求体 :UserFavoriteItemRequestVO(userId + kind + nextIndex + maxCount=100
+ isAllFavoriteItem)
响应 :{userId, kind, length, nextIndex, userFavoriteItemList:[{orderId,id}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFavoriteItemRequestVO.cs
source/2026-10-05.log:93,107 真机 kind=2 与 kind=1 各一次
★ 字段名是 kind(不是 itemKind),别写错。
调用示例:
from api import GetUserFavoriteItem, GetUserFavoriteItemAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
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 GetUserFavoriteItem(
user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC,
*,
next_index: int = 0,
is_all_favorite_item: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户收藏项(单页,对应 GetUserFavoriteItemApi)。
参数:
user_id: 用户 ID(必填)
kind: 1=收藏乐曲 2=收藏对手(默认 1)
返回:
原始响应 dict,含 userFavoriteItemList
"""
log_info("取用户收藏项:user_id=" + str(user_id) + " kind=" + str(kind)
+ " next_index=" + str(next_index))
return _get(
"GetUserFavoriteItemApi",
UserFavoriteItemRequestVO(
userId=user_id, kind=kind, nextIndex=next_index,
maxCount=100, isAllFavoriteItem=is_all_favorite_item,
),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserFavoriteItemAll(
user_id: int,
kind: int = FAVORITE_ITEM_KIND_MUSIC,
*,
is_all_favorite_item: bool = False,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户收藏项(自动翻页取全)。
返回:
{"userFavoriteItemList": [...]}
"""
log_info("取用户收藏项(全量):user_id=" + str(user_id) + " kind=" + str(kind))
items = get_all_pages(
"GetUserFavoriteItemApi", user_id, "userFavoriteItemList",
body_factory=lambda ni: UserFavoriteItemRequestVO(
userId=user_id, kind=kind, nextIndex=ni or 0,
maxCount=100, isAllFavoriteItem=is_all_favorite_item),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("收藏项获取完成:" + str(len(items)) + " 条")
return {"userFavoriteItemList": items}
-64
View File
@@ -1,64 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserFriendBonus.py —— 取用户好友里程(GetUserFriendBonusApi)
================================================================================
取玩家本次可领取的好友里程(getMiles)。
对应接口:GetUserFriendBonusApi
请求体 :UserIdRequestVO
响应 :{returnCode, userId, getMiles}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserFriendBonusRequestVO.cs
source/2026-10-05.log:111 真机响应 {"returnCode":1,"userId":..,"getMiles":90}
调用示例:
from api import GetUserFriendBonus, extract_gift_points
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserFriendBonus(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户好友里程(对应 GetUserFriendBonusApi)。
返回:
原始响应 dict:{"returnCode":1, "userId":.., "getMiles":..}
"""
log_info("取用户好友里程:user_id=" + str(user_id))
resp = _get("GetUserFriendBonusApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
if int(resp.get("returnCode", 0) or 0) != 1:
log_warning("好友里程接口返回 returnCode=" + str(resp.get("returnCode")))
return resp
def extract_gift_points(friend_bonus: Dict[str, Any]) -> int:
"""
从好友里程响应里取出 getMiles(本次可领取的里程数)。
参数:
friend_bonus: GetUserFriendBonus 的返回值
返回:
getMiles 整数(取不到则为 0)
"""
try:
return int((friend_bonus or {}).get("getMiles", 0) or 0)
except Exception:
return 0
-47
View File
@@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserGhost.py —— 取用户幽灵数据(GetUserGhostApi)
================================================================================
取玩家的「幽灵」对战记录(真机 15 条)。
对应接口:GetUserGhostApi
请求体 :UserIdRequestVO
响应 :{length, userGhostList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserGhostRequestVO.cs
★ Upsert 侧 userGhost 默认留空:源码 VOExtensions.cs:235 是
dst.userGhost = (!src.MyGhost.NeedSave) ? null : new GetUserGhost[1] {...};
只有「本局产生了幽灵」才回传,纯网络脚本复刻不出幽灵数据。
调用示例:
from api import GetUserGhost
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserGhost(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户幽灵数据(对应 GetUserGhostApi)。
返回:
原始响应 dict,含 userGhostList
"""
log_info("取用户幽灵数据:user_id=" + str(user_id))
return _get("GetUserGhostApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-43
View File
@@ -1,43 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserIntimate.py —— 取用户搭档亲密度(GetUserIntimateApi)
================================================================================
取玩家与各搭档的亲密度(真机 2 条)。
对应接口:GetUserIntimateApi
请求体 :UserIdRequestVO
响应 :{userId, length, userIntimateList:[{partnerId,intimateLevel,intimateCountRewarded}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserIntimateRequestVO.cs
调用示例:
from api import GetUserIntimate
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserIntimate(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户搭档亲密度(对应 GetUserIntimateApi)。
返回:
原始响应 dict,含 userIntimateList
"""
log_info("取用户搭档亲密度:user_id=" + str(user_id))
return _get("GetUserIntimateApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-124
View File
@@ -1,124 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserItem.py —— 取用户道具(GetUserItemApi)
================================================================================
取玩家各类道具的库存(真机按 kind 分 12 次请求)。
对应接口:GetUserItemApi
请求体 :UserItemRequestVO(userId + nextIndex + maxCount=100)
响应 :{userId, length, nextIndex, itemKind, userItemList:[{itemKind,itemId,stock,isValid}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserItemRequestVO.cs
--------------------------------------------------------------------------------
★★ 首发 nextIndex 必须带哨兵值(最容易踩的坑)
--------------------------------------------------------------------------------
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
public PacketGetUserItem(ulong userId, long itemId, ...)
{
Create(GetNetQuery(userId, itemId * 10000000000L)); // <- 首包哨兵
}
// Proc(): nextIndex != 0 时继续翻页
真机日志 source/2026-10-05.log:32 也是 nextIndex=10000000000。
传 0 会被服务端拒绝(HTTP 500)。
★ 请求体里**没有 itemKind 字段** —— 类别是靠 nextIndex 的哨兵值表达的:
第 1 页 nextIndex = kind * 1e10
后续翻页沿用响应回的 nextIndex
调用示例:
from api import GetUserItem, GetUserItemAll
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
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 GetUserItem(
user_id: int,
item_kind: int,
*,
next_index: Optional[int] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户某一类道具(对应 GetUserItemApi)。
参数:
user_id: 用户 ID(必填)
item_kind: 道具类别(必填):1=名牌 2=称号 3=头像 4=礼物 5=乐曲 …
next_index: 分页索引;**None(默认)表示首页**,会自动换算成
item_kind * 10000000000 的哨兵值。
返回:
原始响应 dict,含 userItemList
"""
if next_index is None:
next_index = item_kind * ITEM_NEXT_INDEX_FACTOR
log_info("取用户道具:user_id=" + str(user_id) + " item_kind=" + str(item_kind)
+ " nextIndex=" + str(next_index))
return _get(
"GetUserItemApi",
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserItemAll(
user_id: int,
*,
item_kinds: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户全部道具(按 ITEM_KINDS 逐个类别抓取,并自动翻页)。
参数:
item_kinds: 类别列表(可选,默认 ITEM_KINDS)
返回:
{"userItemList": [...]},已合并所有类别与分页
"""
kinds = list(item_kinds) if item_kinds else list(ITEM_KINDS)
log_info("取用户全部道具:user_id=" + str(user_id) + " kinds=" + str(kinds))
merged: List[Dict[str, Any]] = []
for kind in kinds:
next_index: Optional[int] = kind * ITEM_NEXT_INDEX_FACTOR
page = 0
while next_index:
page += 1
resp = _get(
"GetUserItemApi",
UserItemRequestVO(userId=user_id, nextIndex=next_index, maxCount=100),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
items = resp.get("userItemList") or []
if isinstance(items, dict):
items = [items]
merged.extend(items)
next_index = int(resp.get("nextIndex") or 0)
log_debug("item_kind=" + str(kind) + " 第 " + str(page) + " 页:"
+ str(len(items)) + " 条,nextIndex=" + str(next_index))
if page >= 200:
log_warning("item_kind=" + str(kind) + " 翻页超过 200 页,已中止")
break
log_info("道具获取完成:" + str(len(merged)) + " 条")
return {"userItemList": merged}
-75
View File
@@ -1,75 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserKaleidxScope.py —— 取 KALEIDX 门进度(GetUserKaleidxScopeApi / GetGameKaleidxScopeApi)
================================================================================
对应接口:
GetUserKaleidxScopeApi 请求体 UserIdRequestVO(userId)
响应 {userId, userKaleidxScopeList:[...]}
GetGameKaleidxScopeApi 请求体 **空对象 {}**(GameKaleidxScopeRequestVO 没有任何字段)
响应 {gameKaleidxScopeList:[{gateId, phaseId}]}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserKaleidxScopeRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeRequestVO.cs(空类)
source/Assembly-CSharp/Net.VO.Mai2/GameKaleidxScopeResponseVO.cs
source/2026-10-05.log:15,119
★★ 重要区分(曾导致 userData 取空)
--------------------------------------------------------------------------------
GameKaleidxScopeResponseVO 里**只有** gameKaleidxScopeList 字段,没有 userData。
玩家档案 userData 的正确来源是 GetUserDataApi(见 GetUserData.py)。
真机日志里「GetGameKaleidxScope 的响应带 userData」是抓包工具把时间相邻的
两条响应合并显示造成的假象 —— 不要据此给 GetGameKaleidxScopeApi 发 userId,
多发字段会让服务端返回空内容。
调用示例:
from api import GetUserKaleidxScope, GetGameKaleidxScope
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, EmptyRequestVO, UserIdRequestVO
def GetUserKaleidxScope(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取玩家 KALEIDX 门进度(对应 GetUserKaleidxScopeApi)。
返回:
原始响应 dict,含 userKaleidxScopeList
"""
log_info("取玩家 KALEIDX 门进度:user_id=" + str(user_id))
return _get("GetUserKaleidxScopeApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
def GetGameKaleidxScope(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取全服 KALEIDX 门开放进度(对应 GetGameKaleidxScopeApi)。
★ 请求体是空对象 {} —— 该 RequestVO 一个字段都没有,**不要传 userId**。
返回:
原始响应 dict,含 gameKaleidxScopeList
"""
log_info("取全服 KALEIDX 门进度")
return _get("GetGameKaleidxScopeApi", EmptyRequestVO(), 0,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-76
View File
@@ -1,76 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserLoginBonus.py —— 取用户登录奖励(GetUserLoginBonusApi)
================================================================================
取玩家的登录奖励领取进度(真机 38 条,分两页)。
对应接口:GetUserLoginBonusApi
请求体 :UserLoginBonusRequestVO(userId + nextIndex + maxCount=20)
响应 :{userId, length, nextIndex, userLoginBonusList:[{bonusId,point,isCurrent,isComplete}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserLoginBonusRequestVO.cs
★ 回传 Upsert 时只回传 isCurrent=true 的那一条(本次签到的那条)。
调用示例:
from api import GetUserLoginBonus, GetUserLoginBonusAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserLoginBonusRequestVO, get_all_pages
def GetUserLoginBonus(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户登录奖励(单页,对应 GetUserLoginBonusApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userLoginBonusList
"""
log_info("取用户登录奖励:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserLoginBonusApi",
UserLoginBonusRequestVO(userId=user_id, nextIndex=next_index, maxCount=20),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserLoginBonusAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户登录奖励(自动翻页取全)。
返回:
{"userLoginBonusList": [...]}
"""
log_info("取用户登录奖励(全量):user_id=" + str(user_id))
items = get_all_pages(
"GetUserLoginBonusApi", user_id, "userLoginBonusList",
body_factory=lambda ni: UserLoginBonusRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=20),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("登录奖励获取完成:" + str(len(items)) + " 条")
return {"userLoginBonusList": items}
-77
View File
@@ -1,77 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserMap.py —— 取用户地图进度(GetUserMapApi)
================================================================================
取玩家各地图的推进距离(真机 104 条)。
对应接口:GetUserMapApi
请求体 :UserMapRequestVO(userId + nextIndex + maxCount=1000)
响应 :{userId, length, nextIndex, userMapList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMapRequestVO.cs
★ Get 侧下发 100+ 条;Upsert 只回传「本次距离变化」的那 1 条
(真机:mapId=550003 的 distance 1424000 -> 1435000)。
调用示例:
from api import GetUserMap / GetUserMapAll
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdPageRequestVO, get_all_pages
def GetUserMap(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地图进度(单页,对应 GetUserMapApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userMapList
"""
log_info("取用户地图进度:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserMapApi",
UserIdPageRequestVO(userId=user_id, nextIndex=next_index, maxCount=1000),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserMapAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地图进度(自动翻页取全)。
返回:
{"userMapList": [...]}
"""
log_info("取用户地图进度(全量):user_id=" + str(user_id))
items = get_all_pages(
"GetUserMapApi", user_id, "userMapList",
body_factory=lambda ni: UserIdPageRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=1000),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("地图进度获取完成:" + str(len(items)) + " 条")
return {"userMapList": items}
-48
View File
@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserMission.py —— 取用户任务与周数据(GetUserMissionDataApi)
================================================================================
取玩家的任务进度与周常数据(真机 6 条任务)。
对应接口:GetUserMissionDataApi
请求体 :UserIdRequestVO
响应 :{userId, userWeeklyData:{...}, userMissionDataList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserMissionRequestVO.cs
★ 一个接口同时下发两个 Upsert 节点:
userMissionDataList 任务列表
userWeeklyData 周常数据(★ 是**对象**不是数组)
★ 两者回传 Upsert 时都要丢掉各自的 userId 字段。
调用示例:
from api import GetUserMissionData
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserMissionData(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户任务与周数据(对应 GetUserMissionDataApi)。
返回:
原始响应 dict:{"userId":.., "userWeeklyData":{..}, "userMissionDataList":[..]}
"""
log_info("取用户任务与周数据:user_id=" + str(user_id))
return _get("GetUserMissionDataApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-90
View File
@@ -1,90 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserMusic.py —— 取用户曲目成绩(GetUserMusicApi)
================================================================================
取玩家全部曲目的历史成绩归档(真机 739 组)。
对应接口:GetUserMusicApi
请求体 :UserMusicRequestVO(userId + nextIndex + maxCount=50)
响应 :{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/GetUserMusicDetail.cs(内层按难度)
★ 响应是**两层嵌套**:外层每元素是一首歌,内层的 userMusicDetailList 才是
按 (musicId, level) 的成绩条目。取数时要摊平。
★ 回传 Upsert 时只回传本局玩的那一条(playCount +1),其余不动。
调用示例:
from api import GetUserMusicDetail, UserMusicDetailFlat
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserMusicRequestVO, get_all_pages
def GetUserMusicDetail(
user_id: int,
*,
next_index: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户曲目成绩(单页,对应 GetUserMusicApi)。
参数:
next_index: 分页起始索引(0 = 首页)
返回:
原始响应 dict,含 userMusicList(两层嵌套)
"""
log_info("取用户曲目成绩:user_id=" + str(user_id) + " next_index=" + str(next_index))
return _get(
"GetUserMusicApi",
UserMusicRequestVO(userId=user_id, nextIndex=next_index, maxCount=50),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserMusicDetailAll(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户曲目成绩(自动翻页取全,已摊平成单层)。
返回:
{"userMusicList": [ {"musicId":..,"level":..,"playCount":..}, ... ]}
每元素是**单条 (musicId, level) 成绩**,不是按曲分组的嵌套结构。
"""
log_info("取用户曲目成绩(全量):user_id=" + str(user_id))
groups = get_all_pages(
"GetUserMusicApi", user_id, "userMusicList",
body_factory=lambda ni: UserMusicRequestVO(
userId=user_id, nextIndex=ni or 0, maxCount=50),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
flat: List[Dict[str, Any]] = []
for group in groups:
if isinstance(group, dict):
details = group.get("userMusicDetailList") or []
if isinstance(details, dict):
details = [details]
flat.extend(details)
log_info("曲目成绩获取完成:" + str(len(flat)) + " 条(原始 "
+ str(len(groups)) + " 组)")
return {"userMusicList": flat}
-84
View File
@@ -1,84 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserNewItem.py —— 取新增道具(GetUserNewItemListApi)
================================================================================
上传结算后查询「本次新增了哪些道具」(真机响应 userItemList 为空)。
对应接口:GetUserNewItemListApi
请求体 :UserNewItemListRequestVO(userId + version + userData[] + userPlaylogList[])
响应 :{userId, userItemList:[...]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserNewItemListRequestVO.cs
source/2026-10-05.log:121 真机请求带 userData 与 userPlaylogList
★ 这是**结算之后**才调的接口,用于回查本局获得的新道具。
★ 请求体里的 userData / userPlaylogList 需要传,内容与 UpsertUserAll 一致。
调用示例:
from api import GetUserNewItemList
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_VERSION_INT, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
class UserNewItemListRequestVO:
"""请求 VO(对应 UserNewItemListRequestVO)。"""
userId: int
version: int = PLAY_VERSION_INT
userData: List[Any] = field(default_factory=list)
userPlaylogList: List[Any] = field(default_factory=list)
def GetUserNewItemList(
user_id: int,
*,
version: int = PLAY_VERSION_INT,
user_data: Optional[List[Any]] = None,
user_playlog_list: Optional[List[Any]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取本次新增道具(对应 GetUserNewItemListApi)。
★ 这是**结算流程的前置接口**,真机在 UpsertUserAll 之前约 8 秒调用
(source/2026-10-05-new.log: 19:22:53 -> 19:23:02)。
★★ 传入的 user_data 必须是**游玩后**的档案,不是登录时的旧值。
真机实证(source/2026-10-05-new.log 的该请求):
userData[0].playCount = 1258 (登录时是 1258,本局后 1259)
userData[0].point = 4460 (登录时是 4110,本局后 4460)
userData[0].lastPlayDate = "2026-10-05 19:22:53.0"
即:**与 UpsertUserAll 里的 userData 完全一致**。
所以正确用法是先把本局演算跑完,再拿演算后的 userData 传进来。
参数:
user_id: 用户 ID(必填)
version: ROM 版本整数(默认取 config.PLAY_VERSION_INT)
user_data: 玩家档案数组(通常传 [游玩后的 userData])
user_playlog_list: 本局成绩数组(通常传 [playlog])
返回:
原始响应 dict,含 userItemList
"""
log_info("取新增道具:user_id=" + str(user_id) + " version=" + str(version))
return request_dict(
"GetUserNewItemListApi",
UserNewItemListRequestVO(
userId=user_id, version=version,
userData=list(user_data or []),
userPlaylogList=list(user_playlog_list or []),
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
-47
View File
@@ -1,47 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserOption.py —— 取用户游戏设置(GetUserOptionApi)
================================================================================
取玩家的音符速度、判定显示、音量等游戏内设置(48 个字段)。
对应接口:GetUserOptionApi
请求体 :UserIdRequestVO
响应 :userOption 的字段直接平铺在响应顶层
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserOptionRequestVO.cs
★ 形态提醒:Get 侧响应是**平铺**的(顶层直接就是 optionKind/noteSpeed/...),
而回传 Upsert 时要包成单元素数组 userOption:[{...}]。
裁剪时要丢掉 tempoVolume(真机 Upsert 报文里没有这个字段)。
调用示例:
from api import GetUserOption
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserOption(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户游戏设置(对应 GetUserOptionApi)。
返回:
原始响应 dict,设置字段平铺在顶层
"""
log_info("取用户游戏设置:user_id=" + str(user_id))
return _get("GetUserOptionApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-156
View File
@@ -1,156 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserPreview.py —— 用户预览(GetUserPreviewApi)
================================================================================
功能:刷卡后欢迎界面的数据(昵称、Rating、是否已登录、封禁状态…)。
对应接口:GetUserPreviewApi
请求体 :{"userId":.., "segaIdAuthKey":"", "token":"..", "clientId":".."}
source/Assembly-CSharp/Net.VO.Mai2/UserPreviewRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserPreview.cs:20-25
响应 :见 UserPreviewResponseVO(23 字段,含 errorId)
★ 真机调用时机:扫码拿到 userId/token 之后、UserLoginApi 之前
(source/2026-10-05.log:23)。
★ 客户端行为:isLogin=True 时拒绝继续登录,避免顶号
(source/Assembly-CSharp/Process.Entry.State/ConfirmPlay.cs:151)。
调用示例:
from api import GetUserPreview
pv = GetUserPreview(12345678, "TESTTOKEN0000000000")
print(pv.userName, pv.playerRating)
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
@dataclass
class UserPreviewRequestVO:
"""请求 VO(对应 UserPreviewRequestVO)。"""
userId: int
segaIdAuthKey: str = ""
token: str = ""
clientId: str = ""
@dataclass
class UserPreviewResponseVO:
"""
响应 VO(对应 UserPreviewResponseVO + 真机响应字段)。
★ 字段顺序按 source/Assembly-CSharp/Net.VO.Mai2/UserPreviewResponseVO.cs
与 source/2026-10-05.log:24 的真机响应合并。
"""
userId: int = 0
userName: str = ""
isLogin: bool = False
lastGameId: Optional[str] = None
lastRomVersion: str = ""
lastDataVersion: str = ""
lastLoginDate: str = ""
lastPlayDate: str = ""
playerRating: int = 0
nameplateId: int = 0
iconId: int = 0
trophyId: int = 0
partnerId: int = 0
frameId: int = 0
dispRate: int = 0
totalAwake: int = 0
isNetMember: int = 0
dailyBonusDate: str = ""
headPhoneVolume: Optional[int] = None
isInherit: bool = False
banState: int = 0
errorId: int = 0
def GetUserPreview(
user_id: int,
token: str,
*,
client_id: str = CLIENT_ID,
sega_id_auth_key: str = "",
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserPreviewResponseVO:
"""
获取用户预览信息。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填,来自 ParseQRCode)
client_id: 机台短值(可选)
sega_id_auth_key: SEGA ID 鉴权 key(可选,通常为空)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserPreviewResponseVO
"""
log_info("获取用户预览:user_id=" + str(user_id) + " client_id=" + client_id)
raw = request_dict(
"GetUserPreviewApi",
UserPreviewRequestVO(
userId=user_id,
segaIdAuthKey=sega_id_auth_key,
token=token,
clientId=client_id,
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
known = {f for f in UserPreviewResponseVO.__dataclass_fields__}
resp = UserPreviewResponseVO(**{k: v for k, v in raw.items() if k in known})
if resp.errorId != 0:
log_warning("服务器返回 errorId=" + str(resp.errorId))
if resp.banState != 0:
log_warning("账号可能被封禁:banState=" + str(resp.banState))
if resp.isLogin:
log_warning("账号处于已登录状态(isLogin=True),继续登录会顶号")
log_info(
"用户预览获取完成:userName=" + resp.userName
+ " playerRating=" + str(resp.playerRating)
+ " isLogin=" + str(resp.isLogin)
+ " isNetMember=" + str(resp.isNetMember)
)
return resp
if __name__ == "__main__":
import sys
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.get_user_preview <user_id> <token> [client_id]")
return 1
cid = sys.argv[3] if len(sys.argv) > 3 else CLIENT_ID
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)))
return 0
except Exception as exc:
log_error("获取用户预览失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-46
View File
@@ -1,46 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserRating.py —— 取用户 Rating 明细(GetUserRatingApi)
================================================================================
取玩家的 Rating 构成(旧谱 / 新谱 / 下一档候选 + 段位)。
对应接口:GetUserRatingApi
请求体 :UserIdRequestVO
响应 :{userId, userRating:{rating, ratingList, newRatingList, nextRatingList,
nextNewRatingList, udemae}}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRatingRequestVO.cs
★ 回传 Upsert 时是**单元素数组** userRatingList:[{...}],不是对象。
调用示例:
from api import GetUserRating
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRating(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户 Rating 明细(对应 GetUserRatingApi)。
返回:
原始响应 dict,含 userRating 子对象
"""
log_info("取用户 Rating 明细:user_id=" + str(user_id))
return _get("GetUserRatingApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-64
View File
@@ -1,64 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserRecommendMusic.py —— 取推荐曲目(GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi)
================================================================================
对应接口:
GetUserRecommendRateMusicApi 请求体 UserIdRequestVO
响应 {userId, userRecommendRateMusicIdList:[...]}
GetUserRecommendSelectMusicApi 请求体 UserIdRequestVO
响应 {userId, userRecommendSelectionMusicIdList:[...]}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendRateMusicRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserRecommendSelectionMusicRequestVO.cs
source/2026-10-05.log:79,81
调用示例:
from api import GetUserRecommendRateMusic, GetUserRecommendSelectMusic
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRecommendRateMusic(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取推荐上分曲目(对应 GetUserRecommendRateMusicApi)。
返回:
原始响应 dict,含 userRecommendRateMusicIdList
"""
log_info("取推荐上分曲目:user_id=" + str(user_id))
return _get("GetUserRecommendRateMusicApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
def GetUserRecommendSelectMusic(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取推荐选曲(对应 GetUserRecommendSelectMusicApi)。
返回:
原始响应 dict,含 userRecommendSelectionMusicIdList
"""
log_info("取推荐选曲:user_id=" + str(user_id))
return _get("GetUserRecommendSelectMusicApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-44
View File
@@ -1,44 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserRegion.py —— 取用户地区游玩统计(GetUserRegionApi)
================================================================================
取玩家在各地区的游玩次数。
对应接口:GetUserRegionApi
请求体 :UserIdRequestVO
响应 :{userId, length, userRegionList:[{regionId,playCount,created}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserRegionRequestVO.cs
source/2026-10-05.log:77
调用示例:
from api import GetUserRegion
"""
from __future__ import annotations
from typing import Any, Dict, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserIdRequestVO
def GetUserRegion(
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户地区游玩统计(对应 GetUserRegionApi)。
返回:
原始响应 dict,含 userRegionList
"""
log_info("取用户地区统计:user_id=" + str(user_id))
return _get("GetUserRegionApi", UserIdRequestVO(userId=user_id), user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
-128
View File
@@ -1,128 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserRival.py —— 取用户对手数据(GetUserRivalDataApi / GetUserRivalMusicApi)
================================================================================
取玩家的对手名单及其曲目成绩。
对应接口:
GetUserRivalDataApi 请求体 UserRivalRequestVO(userId + rivalId)
响应 {userId, userRivalData:{rivalId, rivalName}}
GetUserRivalMusicApi 请求体 UserRivalMusicRequestVO(userId + rivalId + nextIndex
+ userRivalMusicLevelList)
响应 {userId, rivalId, length, nextIndex, userRivalMusicList}
依据:
source/Assembly-CSharp/Net.VO.Mai2/UserRivalRequestVO.cs
source/Assembly-CSharp/Net.VO.Mai2/UserRivalMusicRequestVO.cs
source/2026-10-05.log:95,101
★ 对手 ID 需要调用方提供(先由 GetUserFavoriteItemApi 的 kind=2 取到)。
调用示例:
from api import GetUserRivalData, GetUserRivalMusic
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import (
_get,
UserRivalRequestVO,
UserRivalMusicRequestVO,
RIVAL_MUSIC_LEVELS,
get_all_pages,
)
def GetUserRivalData(
user_id: int,
rival_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手资料(对应 GetUserRivalDataApi)。
参数:
user_id: 用户 ID(必填)
rival_id: 对手 ID(必填)
返回:
原始响应 dict,含 userRivalData
"""
log_info("取对手资料:user_id=" + str(user_id) + " rival_id=" + str(rival_id))
return _get(
"GetUserRivalDataApi",
UserRivalRequestVO(userId=user_id, rivalId=rival_id),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserRivalMusic(
user_id: int,
rival_id: int,
*,
next_index: int = 0,
levels: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手曲目成绩(单页,对应 GetUserRivalMusicApi)。
参数:
user_id: 用户 ID(必填)
rival_id: 对手 ID(必填)
next_index: 分页索引(0 = 首页)
levels: 难度档位列表(可选,默认 0/1/2/3/4/10)
返回:
原始响应 dict,含 userRivalMusicList
"""
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
log_info("取对手曲目成绩:user_id=" + str(user_id) + " rival_id=" + str(rival_id)
+ " levels=" + str(lv))
return _get(
"GetUserRivalMusicApi",
UserRivalMusicRequestVO(
userId=user_id, rivalId=rival_id, nextIndex=next_index,
userRivalMusicLevelList=[{"level": x} for x in lv],
),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
def GetUserRivalMusicAll(
user_id: int,
rival_id: int,
*,
levels: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取对手曲目成绩(自动翻页取全)。
返回:
{"userRivalMusicList": [...]}
"""
lv = list(levels) if levels else list(RIVAL_MUSIC_LEVELS)
log_info("取对手曲目成绩(全量):user_id=" + str(user_id) + " rival_id=" + str(rival_id))
items = get_all_pages(
"GetUserRivalMusicApi", user_id, "userRivalMusicList",
body_factory=lambda ni: UserRivalMusicRequestVO(
userId=user_id, rivalId=rival_id, nextIndex=ni or 0,
userRivalMusicLevelList=[{"level": x} for x in lv]),
client_id=client_id, timeout_ms=timeout_ms, client=client,
)
log_info("对手曲目获取完成:" + str(len(items)) + " 条")
return {"userRivalMusicList": items}
-58
View File
@@ -1,58 +0,0 @@
# -*- coding: utf-8 -*-
"""
GetUserShopStock.py —— 取用户商店库存(GetUserShopStockApi)
================================================================================
取玩家在各商店商品的已兑换次数。
对应接口:GetUserShopStockApi
请求体 :UserShopStockRequestVO(userId + shopItemIdList)
响应 :{userId, userShopStockList:[{shopItemId,tradeCount}]}
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserShopStockRequestVO.cs
source/2026-10-05.log:117 真机请求体带 100+ 个 shopItemId
★ shopItemIdList 需要调用方提供(来自游戏 Master 数据,脚本无法自行推导)。
不传时按空列表发送,服务端返回空 userShopStockList —— 这是合法的。
调用示例:
from api import GetUserShopStock
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLAY_SHOP_ITEM_IDS, TIMEOUT_MS
from .LogSetup import log_info
from .MaimaiNet import MaimaiNetClient
from .UserGetterCommon import _get, UserShopStockRequestVO
def GetUserShopStock(
user_id: int,
*,
shop_item_id_list: Optional[List[int]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
取用户商店库存(对应 GetUserShopStockApi)。
参数:
user_id: 用户 ID(必填)
shop_item_id_list: 商品 ID 清单(可选,默认取 config.PLAY_SHOP_ITEM_IDS)
返回:
原始响应 dict,含 userShopStockList
"""
ids = list(shop_item_id_list) if shop_item_id_list is not None \
else list(PLAY_SHOP_ITEM_IDS)
log_info("取用户商店库存:user_id=" + str(user_id) + " 商品数=" + str(len(ids)))
return _get(
"GetUserShopStockApi",
UserShopStockRequestVO(userId=user_id, shopItemIdList=ids),
user_id, client_id=client_id, timeout_ms=timeout_ms, client=client,
)
-704
View File
@@ -1,704 +0,0 @@
# -*- coding: utf-8 -*-
"""
LogSetup.py —— 统一日志模块(INFO / DEBUG 双模式)
================================================================================
【两种模式】
INFO (默认) 只展示 api 脚本的运行情况和结果:
=== ParseQRCode 开始运行 ===
[接口说明] ...
[调用参数] ...
[运行结果] ...
=== ParseQRCode 运行结束 ===
DEBUG 在 INFO 基础上,额外展示「向 api 发送的数据」和「api 返回的数据」:
-> 请求 GetUserPreviewApi
URL : https://...
请求头 : {...}
请求体 : {...JSON 全文...}
<- 响应 GetUserPreviewApi
状态码 : 200
响应体 : {...JSON 全文...}
【模式从哪来】
优先级: setup_logging(level=...) 显式传参 > config.py 的 LOG_LEVEL > INFO
日常只改 config.py 一行:
LOG_LEVEL = "info" # 只看流程
LOG_LEVEL = "debug" # 连收发数据一起看
【日志文件(两种形式同时存在、同时写入)】
logs/latest.log 覆盖模式("w")—— 只包含【当次】主脚本运行产出的日志,
下一次运行主脚本时被覆盖
logs/YYYY-MM-DD.log 追加模式("a")—— 存放【当日】产生的所有日志
每行都带 [YYYY-MM-DD HH:MM:SS] 时间戳。
【主脚本怎么用】
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
def main():
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
try:
user_id, token = ParseQRCode(qr) # 自动产出「开始运行 / 运行结束」
login = UserLogin(user_id, token)
finally:
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
api 函数不需要自己写 print,也不需要装饰器 —— api/__init__.py 在导入时就
统一套上了日志外壳(见 autowrap)。
【api 脚本怎么用】
from api import log_info, log_debug, log_request, log_response
def GetUserPreview(user_id, token, ...):
# 函数的第一行 docstring 会成为日志里的「[接口说明]」
# 例如: '''获取用户预览信息'''
log_info(f"请求用户预览 user_id={user_id}") # 可选,想额外记话时才写
log_request(url=url, body=req, headers=hdr, api_name="GetUserPreviewApi")
raw = _http_post(...)
log_response(status=200, body=raw, headers=hdr, api_name="GetUserPreviewApi")
return parsed
"""
from __future__ import annotations
import atexit
import functools
import json
import os
import platform
import time
from datetime import datetime
from typing import Any, Callable, Iterable, Optional
# =============================================================================
# 常量与全局状态
# =============================================================================
LEVEL_INFO = "info"
LEVEL_DEBUG = "debug"
_LEVEL_RANK = {LEVEL_INFO: 20, LEVEL_DEBUG: 10}
# 项目根目录(本文件在 api/ 下,根目录是上一级)
_ROOT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
_LOGS_DIR = os.path.join(_ROOT_DIR, "logs")
_SEPARATOR = "=" * 66
_INDENT = " "
# 全局状态
_session_started = False
_session_script_name = ""
_current_level = LEVEL_INFO
_value_max = 0
_date_log_path: Optional[str] = None
_latest_log_path: Optional[str] = None
_date_log_file = None
_latest_log_file = None
# 被自动跟踪的 api 函数名(供横幅展示)
_tracked_api_names: list = []
# 本次会话实际调用过的 api(按首次调用顺序)
_called_apis: list = []
# =============================================================================
# 模式判定
# =============================================================================
def _normalize_level(level: Any) -> str:
"""把各种写法的模式名归一化成 'info' / 'debug'。"""
if level is None:
return LEVEL_INFO
text = str(level).strip().lower()
if text in ("debug", "dbg", "verbose", "详细", "调试"):
return LEVEL_DEBUG
return LEVEL_INFO
def _config_level() -> str:
"""从 config.py 读 LOG_LEVEL;读不到就用 INFO。"""
try:
import config # type: ignore
return _normalize_level(getattr(config, "LOG_LEVEL", LEVEL_INFO))
except Exception:
return LEVEL_INFO
def _config_value_max() -> int:
"""从 config.py 读 LOG_VALUE_MAX(0 = 不截断)。"""
try:
import config # type: ignore
return int(getattr(config, "LOG_VALUE_MAX", 0) or 0)
except Exception:
return 0
def get_level() -> str:
"""返回当前日志模式('info' / 'debug')。"""
return _current_level
def is_debug() -> bool:
"""当前是否 DEBUG 模式。"""
return _LEVEL_RANK.get(_current_level, 20) <= _LEVEL_RANK[LEVEL_DEBUG]
def set_level(level: Any) -> str:
"""运行时切换模式(一般不用,改 config.py 即可)。返回切换后的模式。"""
global _current_level
_current_level = _normalize_level(level)
return _current_level
# =============================================================================
# 日志文件
# =============================================================================
def _ensure_logs_dir() -> None:
os.makedirs(_LOGS_DIR, exist_ok=True)
def _get_date_log_path() -> str:
return os.path.join(_LOGS_DIR, datetime.now().strftime("%Y-%m-%d") + ".log")
def _get_latest_log_path() -> str:
return os.path.join(_LOGS_DIR, "latest.log")
def _write_files(message: str) -> None:
"""把一段文本写进两个日志文件(每行带时间戳)。"""
timestamp = datetime.now().strftime("[%Y-%m-%d %H:%M:%S]")
lines = message.splitlines() or [""]
payload = "".join(timestamp + line + "\n" for line in lines)
for handle in (_date_log_file, _latest_log_file):
if handle:
try:
handle.write(payload)
handle.flush()
except Exception:
pass
def _emit(message: str = "", *, console: bool = True) -> None:
"""统一出口:控制台 + 两个日志文件。"""
if console:
try:
print(message)
except Exception:
try:
print(message.encode("utf-8", "replace").decode("utf-8", "replace"))
except Exception:
pass
_write_files(message)
def _close_log_files() -> None:
global _date_log_file, _latest_log_file
for name in ("_date_log_file", "_latest_log_file"):
handle = globals()[name]
if handle:
try:
handle.close()
except Exception:
pass
globals()[name] = None
# =============================================================================
# 自动跟踪:给 api 函数套日志外壳
# =============================================================================
#
# 目标:主脚本只写 setup_logging("名字") + 业务代码,
# 调用的每个 api 函数自动产出「===函数名 开始运行=== / ===函数名 运行结束===」,
# 调用方不必自己写装饰器,也不必自己埋点。
#
# 原理:api/__init__.py 在导入完各 api 脚本后调用 autowrap(globals(), _API_FUNCS),
# 把命名空间里的函数替换成带日志的版本 —— 于是 from api import ParseQRCode
# 拿到的已经是包装后的函数。
def _describe(func: Callable) -> str:
"""取函数 docstring 首行,作为「[接口说明]」。"""
doc = (func.__doc__ or "").strip()
if not doc:
return ""
return doc.splitlines()[0].strip()
def _make_tracked(func: Callable) -> Callable:
"""生成带日志外壳的包装函数。"""
name = func.__name__
summary = _describe(func)
@functools.wraps(func)
def tracked(*args, **kwargs) -> Any:
_note_api_call(name)
_emit("")
_emit("=== " + name + " 开始运行 ===")
if summary:
_emit(_INDENT + "[接口说明] " + summary)
# 调用参数:DEBUG 展开多行 JSON,INFO 记单行摘要
if args:
if is_debug():
_emit(_INDENT + "[位置参数]")
_emit(_fmt_pretty(list(args)))
else:
_emit(_INDENT + "[位置参数] " + ", ".join(_fmt(a) for a in args))
if kwargs:
shown = {k: v for k, v in kwargs.items() if v is not None}
if shown:
if is_debug():
_emit(_INDENT + "[关键字参数]")
_emit(_fmt_pretty(shown))
else:
_emit(_INDENT + "[关键字参数] "
+ ", ".join(k + "=" + _fmt(v) for k, v in shown.items()))
t0 = time.monotonic()
try:
result = func(*args, **kwargs)
except BaseException as exc:
ms = int((time.monotonic() - t0) * 1000)
_emit(_INDENT + "[运行结果] 异常 " + type(exc).__name__ + ": "
+ str(exc) + "(耗时 " + str(ms) + " ms)")
_emit("=== " + name + " 运行结束 ===")
_emit("")
raise
ms = int((time.monotonic() - t0) * 1000)
_emit(_INDENT + "[运行结果] 成功 " + _fmt_result(result)
+ "(耗时 " + str(ms) + " ms)")
if is_debug() and result is not None:
_emit(_INDENT + "[返回数据]")
_emit(_fmt_pretty(result))
_emit("=== " + name + " 运行结束 ===")
_emit("")
return result
tracked.__mai_logged__ = True # type: ignore[attr-defined]
tracked.__mai_original__ = func # type: ignore[attr-defined]
return tracked
def autowrap(namespace: dict, names: Iterable[str]) -> list:
"""
给命名空间里的函数套上日志外壳(导入时调用,幂等)。
参数:
namespace: 目标命名空间,通常是 api/__init__.py 的 globals()
names: 要包装的函数名序列
返回:
实际完成包装的函数名列表
"""
wrapped = []
for name in names:
obj = namespace.get(name)
if obj is None or not callable(obj):
continue
if getattr(obj, "__mai_logged__", False):
wrapped.append(name)
continue
try:
namespace[name] = _make_tracked(obj)
wrapped.append(name)
except Exception:
continue
for n in wrapped:
if n not in _tracked_api_names:
_tracked_api_names.append(n)
return wrapped
def _note_api_call(name: str) -> None:
"""记录主脚本调用过的 api(去重,保留首次调用顺序)。"""
if name not in _called_apis:
_called_apis.append(name)
def get_called_apis() -> list:
"""返回本次会话实际调用过的 api 名称(按首次调用顺序)。"""
return list(_called_apis)
# =============================================================================
# 会话:初始化 / 收尾
# =============================================================================
def setup_logging(script_name: str, level: Any = None) -> str:
"""
初始化日志会话(主脚本开头调用一次)。
做三件事:
1. 确定日志模式(level 显式传参 > config.LOG_LEVEL > INFO)
2. 打开两个日志文件:日期日志(追加)+ latest.log(覆盖)
3. 打印会话横幅
参数:
script_name: 脚本名,显示在横幅上
level: 日志模式;None(默认)= 读 config.py 的 LOG_LEVEL
返回:
实际生效的模式('info' / 'debug')
"""
global _session_started, _session_script_name, _value_max
global _date_log_path, _latest_log_path, _date_log_file, _latest_log_file
set_level(level if level is not None else _config_level())
_value_max = _config_value_max()
if _session_started:
return _current_level
_ensure_logs_dir()
_session_script_name = script_name
_date_log_path = _get_date_log_path()
_latest_log_path = _get_latest_log_path()
_called_apis.clear()
_date_log_file = open(_date_log_path, "a", encoding="utf-8") # 追加:当日全部
_latest_log_file = open(_latest_log_path, "w", encoding="utf-8") # 覆盖:仅当次
_print_banner(script_name)
_session_started = True
return _current_level
def _print_banner(script_name: str) -> None:
mode_text = "DEBUG(含收发数据)" if is_debug() else "INFO(仅运行情况与结果)"
lines = [
_SEPARATOR,
_INDENT + "日志会话开始 —— " + script_name,
_SEPARATOR,
_INDENT * 2 + "主脚本 : " + script_name,
_INDENT * 2 + "开始时间 : " + datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
_INDENT * 2 + "日志模式 : " + mode_text,
_INDENT * 2 + "Python : " + platform.python_version()
+ " (" + platform.python_implementation() + " " + platform.machine() + ")",
_INDENT * 2 + "工作目录 : " + os.getcwd(),
_INDENT * 2 + "已挂载 api : " + str(len(_tracked_api_names))
+ " 个(自动跟踪,主脚本无需写日志代码)",
_INDENT * 2 + "当次日志 : " + str(_latest_log_path),
_INDENT * 2 + "日期日志 : " + str(_date_log_path),
_SEPARATOR,
]
_emit("\n".join(lines))
def teardown_logging() -> None:
"""结束日志会话(主脚本结尾调用;不调用也会在退出时自动收尾)。"""
global _session_started
if not _session_started:
return
lines = [_SEPARATOR, _INDENT + "本次运行调用的 api", _SEPARATOR]
if _called_apis:
for i, name in enumerate(_called_apis, 1):
lines.append(_INDENT * 2 + str(i).rjust(2) + ". " + name)
else:
lines.append(_INDENT * 2 + "(未调用任何 api)")
lines.append(_SEPARATOR)
lines.append(_INDENT + "日志会话结束 —— " + _session_script_name)
lines.append(_SEPARATOR)
_emit("\n".join(lines))
_close_log_files()
_session_started = False
# =============================================================================
# 值格式化
# =============================================================================
def _truncate(text: str) -> str:
if _value_max and len(text) > _value_max:
return text[:_value_max] + "...(共 " + str(len(text)) + " 字符)"
return text
def _fmt(value: Any) -> str:
"""把一个值渲染成紧凑单行文本。"""
if isinstance(value, str):
return _truncate(value)
if value is None or isinstance(value, (int, float, bool)):
return repr(value)
if isinstance(value, (dict, list, tuple)):
try:
return _truncate(json.dumps(value, ensure_ascii=False,
separators=(",", ":"), default=str))
except Exception:
return _truncate(repr(value))
if hasattr(value, "__dict__"):
try:
data = {k: v for k, v in vars(value).items() if not k.startswith("_")}
return _truncate(json.dumps(data, ensure_ascii=False,
separators=(",", ":"), default=str))
except Exception:
return _truncate(repr(value))
return _truncate(repr(value))
def _to_plain(value: Any) -> Any:
"""把 dataclass / 普通对象递归转成可 JSON 化的结构。"""
if value is None or isinstance(value, (int, float, bool, str)):
return value
if isinstance(value, dict):
return {k: _to_plain(v) for k, v in value.items()}
if isinstance(value, (list, tuple, set)):
return [_to_plain(v) for v in value]
if hasattr(value, "__dataclass_fields__"):
return {k: _to_plain(getattr(value, k, None)) for k in value.__dataclass_fields__}
if hasattr(value, "__dict__"):
return {k: _to_plain(v) for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def _fmt_pretty(value: Any, indent: str = " ") -> str:
"""把 dict / list / VO 渲染成多行缩进的 JSON。DEBUG 下完整输出,不折叠。"""
data = _to_plain(value)
if isinstance(data, str):
stripped = data.strip()
if stripped[:1] in ("{", "["):
try:
data = json.loads(stripped)
except Exception:
return "\n".join(indent + line for line in data.splitlines())
try:
text = json.dumps(data, ensure_ascii=False, indent=2, default=str)
except Exception:
text = repr(data)
if _value_max and len(text) > _value_max:
text = text[:_value_max] + "\n" + indent + "...(共 " + str(len(text)) + " 字符,已截断)"
return "\n".join(indent + line for line in text.splitlines())
def _fmt_result(result: Any) -> str:
"""结果的紧凑摘要(INFO 用)。"""
if isinstance(result, list):
return "list(" + str(len(result)) + " 项)"
if isinstance(result, tuple):
return "tuple(" + str(len(result)) + " 项)"
if isinstance(result, dict):
return "dict(" + str(len(result)) + " 键)"
if isinstance(result, str):
return repr(result) if len(result) <= 60 else "str(" + str(len(result)) + " 字符)"
if hasattr(result, "__dataclass_fields__"):
return type(result).__name__ + "(" + str(len(result.__dataclass_fields__)) + " 字段)"
return repr(result)
# =============================================================================
# 流程日志(INFO 可见)
# =============================================================================
def log_info(message: str) -> None:
"""普通信息 —— 相当于「会进日志文件的 print」。"""
_emit(str(message))
def log_step(title: str, *, index: int = 0, total: int = 0) -> None:
"""记一个运行步骤:log_step("扫码登录", index=1, total=5) -> [步骤 1/5] 扫码登录"""
if index and total:
_emit("[步骤 " + str(index) + "/" + str(total) + "] " + title)
elif index:
_emit("[步骤 " + str(index) + "] " + title)
else:
_emit("[步骤] " + title)
def log_result(message: Any) -> None:
"""记一条运行结果。"""
_emit("[结果] " + (message if isinstance(message, str) else _fmt(message)))
def log_warning(message: str) -> None:
"""警告(不影响流程,但需要留意)。"""
_emit("[警告] " + str(message))
def log_error(message: str) -> None:
"""错误。"""
_emit("[错误] " + str(message))
def log_section(title: str) -> None:
"""记一个分节(带分隔线),用于主脚本分段。"""
_emit("\n" + "-" * 60 + "\n" + title + "\n" + "-" * 60)
def log_field(name: str, value: Any, *, debug_only: bool = False) -> None:
"""
记一个「字段: 值」;DEBUG 下 dict / list / VO 自动多行展开。
log_field("user_id", 12345)
log_field("请求体", {"userId": 12345}, debug_only=True) # 只在 DEBUG 出现
"""
if debug_only and not is_debug():
return
if is_debug() and (isinstance(value, (dict, list)) or hasattr(value, "__dataclass_fields__")):
_emit(name + ":")
_emit(_fmt_pretty(value))
return
_emit(name + ": " + _fmt(value))
def log_debug(message: Any) -> None:
"""调试信息 —— 仅 DEBUG 模式输出。"""
if not is_debug():
return
_emit("[调试] " + _fmt(message))
# =============================================================================
# 收发数据日志(DEBUG 专属)—— api 脚本用它记录「发送的数据 / 返回的数据」
# =============================================================================
def log_request(
url: str = "",
body: Any = None,
headers: Any = None,
*,
api_name: str = "",
) -> None:
"""
记录一次「向 api 发送的数据」(仅 DEBUG 模式输出)。
参数:
url: 完整请求地址
body: 请求体(dict / VO / JSON 字符串),展开成 JSON 全文
headers: 请求头(dict)
api_name: 接口名
"""
if not is_debug():
return
_emit("")
_emit("-> 请求 " + api_name if api_name else "-> 请求")
if url:
_emit(" URL : " + str(url))
if headers:
_emit(" 请求头 : " + _fmt(headers))
if body is not None:
_emit(" 请求体 :")
_emit(_fmt_pretty(body))
def log_response(
status: Any = None,
body: Any = None,
headers: Any = None,
*,
api_name: str = "",
elapsed_ms: Optional[int] = None,
) -> None:
"""
记录一次「api 返回的数据」(仅 DEBUG 模式输出)。
参数:
status: HTTP 状态码
body: 响应体(JSON 字符串 / dict / VO)
headers: 响应头(dict)
api_name: 接口名
elapsed_ms: 耗时(毫秒)
"""
if not is_debug():
return
_emit("")
_emit("<- 响应 " + api_name if api_name else "<- 响应")
if status is not None:
_emit(" 状态码 : " + str(status))
if elapsed_ms is not None:
_emit(" 耗时 : " + str(elapsed_ms) + " ms")
if headers:
_emit(" 响应头 : " + _fmt(headers))
if body is not None:
_emit(" 响应体 :")
_emit(_fmt_pretty(body))
def log_payload(label: str, data: Any) -> None:
"""记录任意报文 / 数据结构(仅 DEBUG 模式输出)。"""
if not is_debug():
return
_emit("")
_emit(" " + label + " :")
_emit(_fmt_pretty(data))
# =============================================================================
# 兼容:装饰器与 logger 对象
# =============================================================================
def api_log(func: Callable) -> Callable:
"""
api 函数装饰器 —— 与自动跟踪等价(重复包装会被跳过)。
一般【不需要】手写:api/__init__.py 的 autowrap 已经统一套好外壳。
这里保留是为了让 api 脚本单独运行(python -m api.xxx)时也能记录。
"""
if getattr(func, "__mai_logged__", False):
return func
return _make_tracked(func)
class _Logger:
"""兼容旧写法的 logger 对象;新代码直接用模块级函数即可。"""
def info(self, message: str) -> None:
log_info(message)
def warning(self, message: str) -> None:
log_warning(message)
def error(self, message: str) -> None:
log_error(message)
def debug(self, message: Any) -> None:
log_debug(message)
def step(self, title: str, **kwargs) -> None:
log_step(title, **kwargs)
def result(self, message: Any) -> None:
log_result(message)
_logger_instance = _Logger()
def get_logger() -> _Logger:
"""获取全局 logger 实例(等价于直接用模块级函数)。"""
return _logger_instance
# =============================================================================
# 退出时自动收尾
# =============================================================================
def _auto_teardown() -> None:
try:
if _session_started:
teardown_logging()
else:
_close_log_files()
except Exception:
pass
atexit.register(_auto_teardown)
-820
View File
@@ -1,820 +0,0 @@
# -*- coding: utf-8 -*-
"""
MaimaiNet.py —— 舞萌DX 网络核心层(纯 HTTP 复刻)
================================================================================
本模块是【唯一】直接和游戏服务器说话的地方,其余 api 脚本都通过它收发报文。
--------------------------------------------------------------------------------
一、协议依据(全部来自 source/ 反编译源码)
--------------------------------------------------------------------------------
1) 服务器基址与 API 路径
source/Assembly-CSharp/Manager/OperationManager.cs:225,338
ServerUri = Auth.GameServerUri + MovieServerUri("mE2s3Jhd/")
source/Assembly-CSharp/Net.Packet/Packet.cs:116
Client = NetHttpClient.Create(BaseUrl + Obfuscator(query.Api))
source/Assembly-CSharp/Net.Packet/Packet.cs:485
Obfuscator(s) = md5_hex_lower(s + "8bF76dE9")
source/Assembly-CSharp/Net.VO/NetQuery.cs:14
Api = api + "MaimaiChn"
=> 最终 URL = BASE_URL + md5(逻辑接口名 + "MaimaiChn" + "8bF76dE9")
已用真实日志验证:md5("GetGameSettingApiMaimaiChn" + "8bF76dE9")
= 83a5d5a20b062c5c2ad817460b8f7f76
(source/2026-10-05.log:1 的请求就是打到这个尾段)
2) 请求体封装(三段:JSON -> deflate -> AES)
source/Assembly-CSharp/Net/NetHttpClient.cs:262-300 RequestCallback
a. DeflateStream(CompressionMode.Compress) 压缩 JSON 字节
b. 手工拼 zlib 头 0x78 0x9C + 压缩数据 + Adler32(4 字节大端)
c. CipherAES.Encrypt(...) 整体 AES 加密
source/Assembly-CSharp/Net/CipherAES.cs:11-30
AES-256-CBC / PKCS7 / Key 32B / IV 16B
3) 响应体解封装(逆过程)
source/Assembly-CSharp/Net/NetHttpClient.cs:355-380 Decrypt + Decompress
a. AES 解密
b. 去掉前 2 字节 zlib 头与后 4 字节 Adler32
c. Deflate 解压 -> UTF-8 JSON
4) 请求头
source/Assembly-CSharp/Net/NetHttpClient.cs:174-190 Request()
Content-Type : application/json
charset : UTF-8
Mai-Encoding : 1.55 (DefaultHttpHeaderEncryption)
Content-Encoding: deflate (有请求体时)
User-Agent : <混淆后的API名>#<userId 或 clientId>
(source/Assembly-CSharp/Net/NetPacketUtil.cs:12-19)
number : <ping 毫秒>(非 Ping 接口;Packet.cs:189)
★ number 的语义:GameManager.PingMsec,只由 Ping 心跳包刷新
(PingChecker.cs:227 <- PacketPing.cs:34 的 sw.ElapsedMilliseconds)。
真机整局 53 个业务请求的 number 恒为同一个值(实测 63)。
所以调用方**应当先跑一次 Ping()**,之后的请求才会带上真实 number;
没跑过时 number = 0,与真机开机后第一屏的行为一致。
★ Cookie:★ UserLoginApi 的响应会下发
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
(Packet.cs:228-234 只在 UserLogin 响应上把它收进 CookieContainer,
之后 Packet.cs:190-193 每个带 userId 的请求都挂回去)。
Ping 等接口的响应头里没有 Set-Cookie,所以必须跑过登录才会拿到。
这是**真实的会话粘滞**,必须带着。
requests.Session 会自动收下并自动带上,无需额外代码 ——
但前提是**全程复用同一个 MaimaiNetClient**(用 get_default_client())。
--------------------------------------------------------------------------------
二、★ 请求头集合:必须与真机一致(2026-10-06 真机抓包实证)
--------------------------------------------------------------------------------
真机(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: ""。
--------------------------------------------------------------------------------
三、对外接口
--------------------------------------------------------------------------------
MaimaiNetClient 客户端类(连接复用 + Cookie 管理)
get_default_client() 取全局默认客户端
request(...) 便捷函数:发一次请求并返回原始 JSON 字符串
request_vo(...) 便捷函数:发一次请求并返回映射后的 dataclass
加解密工具:aes_encrypt / aes_decrypt / deflate_compress / deflate_decompress
协议工具 :obfuscate_api_name / build_api_url / adler32
"""
from __future__ import annotations
import hashlib
import json
import time
import zlib
from dataclasses import dataclass, field, is_dataclass
from typing import Any, Dict, List, Optional, Type, TypeVar
from urllib.parse import urljoin
import requests
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from config import CLIENT_ID, MAI_ENCODING, TIMEOUT_LONG_MS, TIMEOUT_MS
# =============================================================================
# 协议常量(原在 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
# =============================================================================
# 协议常量
# =============================================================================
#: API 名后缀(NetQuery.cs:14 Api = api + "MaimaiChn")
API_SUFFIX = "MaimaiChn"
#: HTTP 头(NetHttpClient.cs:174-190)
HEADER_CONTENT_TYPE = "application/json"
HEADER_CHARSET = "UTF-8"
HEADER_CONTENT_ENCODING = "deflate"
#: AES 参数
AES_BLOCK_SIZE = 16
T = TypeVar("T")
# =============================================================================
# 一、协议工具
# =============================================================================
def obfuscate_api_name(api_name: str) -> str:
"""
对逻辑接口名做 MD5 混淆,得到 URL 尾段(对应 Packet.Obfuscator)。
逻辑名 "GetUserPreviewApi" -> "GetUserPreviewApiMaimaiChn"
-> md5(上面 + "8bF76dE9")
参数:
api_name: 逻辑接口名,**不要**自己带 MaimaiChn 后缀
返回:
32 位小写十六进制字符串
"""
full_name = api_name if api_name.endswith(API_SUFFIX) else api_name + API_SUFFIX
return hashlib.md5((full_name + OBFUSCATE_PARAM).encode("utf-8")).hexdigest()
def build_api_url(api_name: str) -> str:
"""构建完整的请求 URL。"""
return urljoin(BASE_URL, obfuscate_api_name(api_name))
def build_user_agent(api_name: str, user_id: int = 0, client_id: str = CLIENT_ID) -> str:
"""
构建 User-Agent(对应 NetPacketUtil.GetUserAgent)。
userId != 0 -> "<混淆API名>#<userId>"
userId == 0 -> "<混淆API名>#<clientId>"
"""
tail = user_id if user_id else client_id
return obfuscate_api_name(api_name) + "#" + str(tail)
def adler32(data: bytes) -> int:
"""
计算 Adler-32 校验和(对应 NetHttpClient.Adler32:437-452)。
★ 注意与 zlib.adler32 的区别:C# 端是自己实现的,
返回值是 (b << 16) + a,与 zlib.adler32 一致,这里保留原实现以便对照。
"""
a = 1
b = 0
for byte in data:
a = (a + byte) % 65521
b = (b + a) % 65521
return (b << 16) | a
# =============================================================================
# 二、加解密 / 压缩
# =============================================================================
def aes_encrypt(data: bytes) -> bytes:
"""AES-256-CBC 加密(PKCS7 填充)。对应 CipherAES.Encrypt。"""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
return cipher.encrypt(pad(data, AES_BLOCK_SIZE))
def aes_decrypt(data: bytes) -> bytes:
"""AES-256-CBC 解密(PKCS7 去填充)。对应 CipherAES.Decrypt。"""
cipher = AES.new(AES_KEY, AES.MODE_CBC, AES_IV)
return unpad(cipher.decrypt(data), AES_BLOCK_SIZE)
def deflate_compress(data: bytes) -> bytes:
"""
原始 deflate 压缩 + 手工拼 zlib 头与 Adler32。
对应 NetHttpClient.cs:262-300 RequestCallback:
memoryStream.WriteByte(120); // 0x78
memoryStream.WriteByte(156); // 0x9C
... 原始 deflate 数据 ...
... Adler32 大端 4 字节 ...
★ C# 的 DeflateStream 产生的是**原始 deflate**(无 zlib 头),
所以 Python 侧要用 compressobj(wbits=-zlib.MAX_WBITS)。
"""
compressor = zlib.compressobj(wbits=-zlib.MAX_WBITS)
compressed = compressor.compress(data) + compressor.flush()
checksum = adler32(data)
return bytes([0x78, 0x9C]) + compressed + checksum.to_bytes(4, "big")
def deflate_decompress(data: bytes) -> bytes:
"""
去掉 zlib 头与 Adler32 后做原始 deflate 解压。
对应 NetHttpClient.cs:355-380 Decompress:
_temporaryStream.Position = 2; // 跳过 2 字节头
_temporaryStream.SetLength(_temporaryStream.Length - 4); // 去掉 4 字节校验
"""
if len(data) < 6:
return b""
return zlib.decompress(data[2:-4], -zlib.MAX_WBITS)
# =============================================================================
# 三、真机时间戳
# =============================================================================
def local_utc_offset_seconds() -> int:
"""取本机时区相对 UTC 的偏移秒数(东八区 = +28800)。"""
offset = -time.timezone
if time.daylight and time.localtime().tm_isdst > 0:
offset = -time.altzone
return int(offset)
def login_date_time_now() -> int:
"""
生成真机 UserLoginApi 的 loginDateTime。
★ 真机算法(source/Assembly-CSharp/MAI2/DateTime.cs + PacketUserLogin.cs:29):
MAI2.DateTime.Now 返回 DateTime.UtcNow + LocalOffset,Kind = Unspecified;
new DateTimeOffset(该 Unspecified 值) 会再按本地时区加一次偏移,
最终 ToUnixTimeSeconds() = 标准 Unix 秒 + 本地时区偏移。
东八区即 now + 28800。
★ 为什么必须一致:
PacketUserLogin.cs:43 会把请求值回填进响应,
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
与登录时不一致会被服务端拒绝。
"""
return int(time.time()) + local_utc_offset_seconds()
# =============================================================================
# 四、VO 序列化 / 反序列化
# =============================================================================
def vo_to_json(obj: Any) -> str:
"""
把请求对象序列化成紧凑 JSON(对应 VOSerializer.Serialize)。
规则:
- dataclass -> 按字段声明顺序,跳过值为 None 的字段
- 带 __dict__ 的对象 -> 同上
- dict / 其他 -> 直接 json.dumps
"""
if obj is None:
return "{}"
if is_dataclass(obj) and not isinstance(obj, type):
data = {k: getattr(obj, k) for k in obj.__dataclass_fields__}
data = {k: v for k, v in data.items() if v is not None}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
if isinstance(obj, dict):
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
if hasattr(obj, "__dict__"):
data = {k: v for k, v in vars(obj).items() if v is not None and not k.startswith("_")}
return json.dumps(data, separators=(",", ":"), ensure_ascii=False, default=_json_default)
return json.dumps(obj, separators=(",", ":"), ensure_ascii=False, default=_json_default)
def _json_default(value: Any) -> Any:
"""json.dumps 的兜底转换:dataclass / 普通对象 -> dict。"""
if is_dataclass(value) and not isinstance(value, type):
return {k: getattr(value, k) for k in value.__dataclass_fields__}
if hasattr(value, "__dict__"):
return {k: v for k, v in vars(value).items() if not k.startswith("_")}
return str(value)
def _convert_value(value: Any, target_type: Any) -> Any:
"""按目标类型递归转换嵌套 dataclass。"""
if value is None:
return None
if isinstance(target_type, type) and is_dataclass(target_type) and isinstance(value, dict):
kwargs = {}
for fname, fdef in target_type.__dataclass_fields__.items():
if fname in value:
kwargs[fname] = _convert_value(value[fname], fdef.type)
return target_type(**kwargs)
origin = getattr(target_type, "__origin__", None)
if origin is list and isinstance(value, list):
args = getattr(target_type, "__args__", None)
if args and isinstance(args[0], type) and is_dataclass(args[0]):
return [_convert_value(v, args[0]) for v in value]
return value
return value
def vo_from_json(cls: Type[T], json_str: str) -> T:
"""
把响应 JSON 反序列化成 dataclass(对应 VOSerializer.Deserialize)。
★ 只填充类里声明过的字段 —— 服务端多给的字段会被忽略,不会报错。
"""
data = json.loads(json_str)
if not isinstance(data, dict):
return data # type: ignore[return-value]
if is_dataclass(cls):
kwargs = {}
for fname, fdef in cls.__dataclass_fields__.items():
if fname in data:
kwargs[fname] = _convert_value(data[fname], fdef.type)
return cls(**kwargs)
obj = cls() # type: ignore[call-arg]
for k, v in data.items():
if hasattr(obj, k):
setattr(obj, k, v)
return obj
# =============================================================================
# 五、网络客户端
# =============================================================================
class MaimaiNetError(RuntimeError):
"""网络层 / 协议层错误。"""
class MaimaiNetClient:
"""
舞萌DX 游戏服务器客户端。
一次完整请求的流程:
请求方向:VO -> 紧凑 JSON -> 原始 deflate -> 0x78 0x9C + Adler32 -> AES-256-CBC -> HTTP POST
响应方向:HTTP Body -> AES 解密 -> 去 2 字节头 / 4 字节尾 -> deflate 解压 -> JSON -> VO
Cookie 由 requests.Session 自动管理;登录接口返回的 Set-Cookie 会被保存,
后续请求自动带上(对应 C# 的 CookieContainer,Packet.cs:183-190)。
"""
def __init__(
self,
base_url: str = BASE_URL,
timeout_ms: int = TIMEOUT_MS,
verify_ssl: bool = False,
) -> None:
self.base_url = base_url.rstrip("/") + "/"
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。
self.ping_msec: int = 0
if not verify_ssl:
try:
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
except Exception:
pass
# -------------------------------------------------------------------------
# 内部
# -------------------------------------------------------------------------
def _build_headers(
self,
api_name: str,
user_id: int = 0,
client_id: str = CLIENT_ID,
has_body: bool = True,
ping_msec: Optional[int] = None,
) -> Dict[str, str]:
"""
构建请求头(对应 NetHttpClient.Request:174-190 + Packet.ProcImpl:166-176)。
★ 关于 number 头:
source/Assembly-CSharp/Net.Packet/Packet.cs:187-194
if (!IsPingPacket())
{
Client.AddHeader("number", GameManager.PingMsec.ToString());
if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); }
}
即**除 Ping 外的每个接口**都会带一个 number 头,值是本机到服务器的
ping 毫秒数(GameManager.PingMsec)。
★ number 的来源(源码链路,逐条可查):
Main/PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
Net.Packet.Mai2/PacketPing.cs:34
_onDone((int)sw.ElapsedMilliseconds)
→ 值 = **Ping 包的往返毫秒**
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
所以 number **只由 Ping 心跳刷新**,普通业务请求不会改它。
实测(gamehook 抓真机一局 70 个请求):
#1 ~ #10 number = 0 ← 还没跑过 Ping
#11 ~ #17 Ping 心跳(不带 number 头)
#18 ~ #70 number = 63 ← Ping 往返 63ms,此后恒定
本类在 request() 里对 Ping 包做同样的事:把往返毫秒写进 self.ping_msec。
★ 关于 Cookie(Packet.cs:190-193 + 228-234):
Client.AddCookie(Query.UserId) 把「登录时服务端 Set-Cookie 存下来的
CookieContainer」挂到本次请求上(NetHttpClient.cs:170-177)。
★ 实测 **UserLoginApi 的响应头里确实有**:
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
而 Ping 等接口的响应头里没有 Set-Cookie(只有 Date / Content-Type /
Content-Length / Connection / Pragma / Cache-Control)。
所以「登录前没有 Cookie、登录后一直带着 JSESSIONID」正是真机行为。
Cookie 由 requests.Session 自动管理:收到就存、后续自动带 —— 无需额外代码,
但**必须全程复用同一个 MaimaiNetClient**,否则会丢掉 JSESSIONID。
"""
headers = {
"Content-Type": HEADER_CONTENT_TYPE,
"charset": HEADER_CHARSET,
"Mai-Encoding": MAI_ENCODING,
"User-Agent": build_user_agent(api_name, user_id, client_id),
# ★ 必须显式置空,见模块 docstring 第二节
"Accept-Encoding": "",
}
if has_body:
headers["Content-Encoding"] = HEADER_CONTENT_ENCODING
# ★ number 头(Packet.cs:189):除 Ping 外的接口都要带
if not api_name.startswith("Ping"):
value = self.ping_msec if ping_msec is None else ping_msec
headers["number"] = str(int(value))
return headers
@property
def last_ping_msec(self) -> int:
"""number 头的当前值(= 最近一次 Ping 的往返毫秒)。兼容旧字段名。"""
return self.ping_msec
def _encrypt_request(self, json_str: str) -> bytes:
"""JSON -> deflate(zlib头+Adler32) -> AES。"""
return aes_encrypt(deflate_compress(json_str.encode("utf-8")))
def _decrypt_response(self, encrypted: bytes) -> str:
"""AES -> 去头尾 -> deflate -> UTF-8 字符串。"""
return deflate_decompress(aes_decrypt(encrypted)).decode("utf-8")
# -------------------------------------------------------------------------
# 公开
# -------------------------------------------------------------------------
def request(
self,
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> str:
"""
发送一次请求,返回**原始 JSON 响应字符串**。
参数:
api_name: 逻辑接口名(如 "GetUserPreviewApi",不要带 MaimaiChn)
request_vo: 请求体对象(dataclass / dict / None 表示空对象 {})
user_id: 用户 ID(0 表示未登录请求;影响 User-Agent 与 Cookie)
client_id: 客户端 ID
timeout_ms: 超时覆盖(毫秒)
extra_headers: 额外请求头(如 number)
返回:
响应 JSON 字符串
异常:
MaimaiNetError: 网络异常 / 空响应 / 解密或解压失败
"""
url = urljoin(self.base_url, obfuscate_api_name(api_name))
timeout = (timeout_ms or self.timeout * 1000) / 1000.0
request_json = vo_to_json(request_vo)
body = self._encrypt_request(request_json)
headers = self._build_headers(api_name, user_id, client_id, has_body=True)
if extra_headers:
headers.update(extra_headers)
# ★ DEBUG 模式:记录「向 api 发送的数据」
log_request(url=url, body=request_json, headers=headers, api_name=api_name)
t0 = time.monotonic()
try:
response = self.session.post(
url,
data=body,
headers=headers,
timeout=timeout,
verify=self.verify_ssl,
)
except requests.exceptions.RequestException as exc:
log_error("接口 " + api_name + " 请求失败:" + str(exc))
raise MaimaiNetError("接口 " + api_name + " 请求失败:" + str(exc)) from exc
elapsed_ms = int((time.monotonic() - t0) * 1000)
# ★ 只有 Ping 包会刷新 number(对应 PingChecker.cs:224-229 PingSuccess)。
# 普通业务包不动它,否则 number 会随每个包的耗时抖动,与真机不符。
if api_name.startswith("Ping"):
self.ping_msec = elapsed_ms
content = response.content
# 0 字节响应:请求未进入业务层
if len(content) == 0:
detail = (
"接口 " + api_name + " 返回 HTTP " + str(response.status_code)
+ ",响应体为 0 字节(空响应)。\n"
" 含义:服务端没有返回任何内容,请求很可能未进入业务层。\n"
" 排查:userId/token 是否有效、接口名与请求体字段是否与源码一致、"
"Accept-Encoding 是否被置空。\n"
" 响应头:" + repr(dict(response.headers))
)
log_error(detail)
raise MaimaiNetError(detail)
try:
response_json = self._decrypt_response(content)
except ValueError as exc:
detail = (
"接口 " + api_name + " 响应解密失败(HTTP " + str(response.status_code)
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
" 含义:服务器返回了内容,但不是合法的 AES 密文,或长度不是 16 的倍数。"
)
log_error(detail)
raise MaimaiNetError(detail) from exc
except zlib.error as exc:
detail = (
"接口 " + api_name + " 响应解压失败(HTTP " + str(response.status_code)
+ ",body " + str(len(content)) + " 字节):" + str(exc) + "\n"
" 含义:AES 解密成功但 deflate 解压失败,响应不是预期的压缩格式"
"(可能是网关/防火墙返回的页面)。"
)
log_error(detail)
raise MaimaiNetError(detail) from exc
if not response_json:
detail = (
"接口 " + api_name + " 返回空明文(HTTP " + str(response.status_code)
+ ",密文 " + str(len(content)) + " 字节,解密后为空串)。\n"
" 含义:链路与加密都正常,但服务端没有返回内容。\n"
" 排查:userId / token 是否失效、keychip 与该卡是否匹配、该卡是否正在游戏中。"
)
log_error(detail)
raise MaimaiNetError(detail)
# ★ DEBUG 模式:记录「api 返回的数据」
try:
shown_body: Any = json.loads(response_json)
except Exception:
shown_body = response_json
log_response(
status=response.status_code,
body=shown_body,
headers={k: v for k, v in response.headers.items()},
api_name=api_name,
elapsed_ms=elapsed_ms,
)
return response_json
def request_vo(
self,
api_name: str,
request_vo: Any,
response_cls: Type[T],
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> T:
"""发送请求并把响应映射成 dataclass。"""
raw = self.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
return vo_from_json(response_cls, raw)
def request_dict(
self,
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""
发送请求并返回**原始响应 dict**。
★ 取数脚本一律用这个:upsertUserAll 需要响应原文,
映射成 dataclass 会丢掉服务端多给的字段。
"""
raw = self.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
data = json.loads(raw)
return data if isinstance(data, dict) else {"_data": data}
def close(self) -> None:
"""关闭底层会话。"""
try:
self.session.close()
except Exception:
pass
def __enter__(self) -> "MaimaiNetClient":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()
# =============================================================================
# 六、全局默认客户端
# =============================================================================
_default_client: Optional[MaimaiNetClient] = None
def get_default_client() -> MaimaiNetClient:
"""获取(必要时创建)全局默认客户端 —— 连接与 Cookie 会自动复用。"""
global _default_client
if _default_client is None:
_default_client = MaimaiNetClient()
return _default_client
def close_default_client() -> None:
"""关闭全局默认客户端(一般不需要手动调用)。"""
global _default_client
if _default_client is not None:
_default_client.close()
_default_client = None
def request(
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> str:
"""便捷函数:用默认客户端发一次请求,返回原始 JSON 字符串。"""
net = client or get_default_client()
return net.request(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
def request_dict(
api_name: str,
request_vo: Any = None,
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
"""便捷函数:用默认客户端发一次请求,返回原始响应 dict。"""
net = client or get_default_client()
return net.request_dict(
api_name, request_vo,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
def request_vo(
api_name: str,
request_vo: Any,
response_cls: Type[T],
*,
user_id: int = 0,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
extra_headers: Optional[Dict[str, str]] = None,
) -> T:
"""便捷函数:用默认客户端发一次请求,返回映射后的 dataclass。"""
net = client or get_default_client()
return net.request_vo(
api_name, request_vo, response_cls,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms, extra_headers=extra_headers,
)
# =============================================================================
# 七、通用请求 / 响应 VO
# =============================================================================
@dataclass
class EmptyRequestVO:
"""空请求体 —— 对应 C# 里没有任何字段的 RequestVO(如 PingRequestVO)。"""
@dataclass
class UserIdRequestVO:
"""只有 userId 的请求体(对应 UserDetailRequestVO / UserCharacterRequestVO 等)。"""
userId: int
@dataclass
class ReturnCodeVO:
"""通用「只有 returnCode」的响应体。"""
returnCode: int = 0
# =============================================================================
# 单独运行:协议自检(不发业务请求)
# =============================================================================
if __name__ == "__main__":
print("=== maimai_net 协议自检 ===")
print("BASE_URL :", BASE_URL)
print("GetGameSettingApi :", build_api_url("GetGameSettingApi"))
print(" 期望尾段 : 83a5d5a20b062c5c2ad817460b8f7f76")
print("User-Agent 示例 :", build_user_agent("UserLoginApi", 12345678))
sample = '{"userId":12345678,"token":"abc"}'
packed = deflate_compress(sample.encode("utf-8"))
print("deflate 头/尾 :", packed[:2].hex(), packed[-4:].hex(),
" Adler32 =", hex(adler32(sample.encode("utf-8"))))
assert deflate_decompress(packed).decode("utf-8") == sample
enc = aes_encrypt(packed)
assert aes_decrypt(enc) == packed
print("AES 往返 : OK(密文", len(enc), "字节,16 的倍数:", len(enc) % 16 == 0, ")")
print("自检通过")
-95
View File
@@ -1,95 +0,0 @@
# -*- coding: utf-8 -*-
"""
Ping.py —— 心跳探活(PingMaimaiChn)
================================================================================
功能:向游戏服务器发一次心跳,确认链路可达。
对应接口:PingMaimaiChn
请求体 :{}(source/Assembly-CSharp/Net.VO.Mai2/PingRequestVO.cs 是空类)
响应 :{"result": "Pong"}
★ 接口名(容易写错,注意):
source/Assembly-CSharp/Net.Packet.Mai2/PacketPing.cs:22
new NetQuery<PingRequestVO, PingResponseVO>("Ping", 0uL);
客户端传的逻辑名是 **"Ping"**,NetQuery.cs:14 再补上 "MaimaiChn" 后缀,
所以混淆用的全名是 "PingMaimaiChn":
md5("PingMaimaiChn" + "8bF76dE9") = 3bae8c1162b3148abcfc38a89c03e3e7
本模块传 "Ping" 即可,obfuscate_api_name 会自动补后缀。
(Packet.cs:96 用 Query.Api.StartsWith("Ping") 判断是否心跳包,
也是基于补完后缀的 "PingMaimaiChn"。)
★ 为什么要跑它(不只是探活):
Packet.cs:189 非 Ping 接口都会带 HTTP 头 number = GameManager.PingMsec
PingChecker.cs:227 GameManager.PingMsec = Ping 包的往返毫秒
所以 **Ping 是 number 头的唯一来源**。先 Ping 一次,后面的业务请求才会
带上真实 number(真机实测 63ms);不 Ping 则 number = 0,
与真机开机后第一屏(还没跑过心跳)的行为一致。
MaimaiNetClient 在收到 Ping 响应后会自动把往返毫秒记进 client.ping_msec。
调用示例:
from api import Ping
if Ping():
print("服务器可达")
"""
from __future__ import annotations
from typing import Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_info, log_warning
from .MaimaiNet import MaimaiNetClient, request_dict
def Ping(
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> bool:
"""
心跳探活。
参数:
client_id: 机台短值(可选)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
True = 服务器返回 "Pong"
"""
log_info("发送心跳探活")
raw = request_dict(
"Ping", {},
user_id=0, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
result = raw.get("result", "")
ok = (result == "Pong")
if ok:
log_info("心跳正常:result=Pong")
else:
log_warning("心跳返回异常:result=" + str(result))
return ok
if __name__ == "__main__":
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("ping_test")
try:
ok = Ping()
print("")
print("服务器可达:" + str(ok))
return 0 if ok else 1
finally:
teardown_logging()
raise SystemExit(_main())
-2893
View File
File diff suppressed because it is too large Load Diff
-620
View File
@@ -1,620 +0,0 @@
# -*- coding: utf-8 -*-
r"""
UpsertUserChargelog.py —— 购买功能票 / 上传购买记录(UpsertUserChargelogApi)
================================================================================
功能:把「买了一张功能票」这件事上报给服务器,并把该票的持有数 +1。
对应接口:UpsertUserChargelogApi
请求体 :UserChargelogRequestVO(4 字段,顺序照 C# 声明)
source/Assembly-CSharp/Net.VO.Mai2/UserChargelogRequestVO.cs
public ulong userId;
public UserChargelog userChargelog;
public GetUserCharge userCharge;
public long loginDateTime;
响应 :{returnCode, apiName}
--------------------------------------------------------------------------------
一、真机的购买流程(源码逐行可查)
--------------------------------------------------------------------------------
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
├─ ticket.ticketKind.id == 1(付费票)
├─ 从 OperationManager.GetGameChargeDataList() 找该 chargeId 的 price
│ (= GetGameChargeApi 的响应)
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
│ └─ 没有 -> 新增一条 stock=1
└─ TicketManager.cs:234 RequestUserCharge(playerId)
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
│ -> 取**购买后**(stock > 0)的那条 GetUserCharge
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
│ -> chargeId = ticketId
│ price = DataManager.GetTicket(ticketId).creditNum ★ 不是 money price
│ purchaseDate = TimeManager.GetNowDateString()
│ playCount = 玩家总游玩次数
│ playerRating = 玩家 Rating
│ placeId / regionId / clientId = 当前机台
└─ 发 UpsertUserChargelogApi
★ 成功后 TicketManager.cs:255 RequestSuccess 会把本地 stock 再 --,
因为那张票马上就被这一局用掉了。本模块只负责「买」,
所以默认传的是**购买后**的库存(现有 + 1)。
--------------------------------------------------------------------------------
二、票的有效期算法
--------------------------------------------------------------------------------
Manager/TicketManager.cs:564
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
真机验证(source/2026-10-05.log:123 的 userChargeList):
chargeId=5 purchaseDate="2026-10-04 18:22:10"
-> 减 4 小时 = 10-04 14:22,加 90 天 = 2027-01-02
-> validDate="2027-01-02 04:00:00" ✅ 完全吻合
--------------------------------------------------------------------------------
三、ticket.creditNum 从哪来
--------------------------------------------------------------------------------
游戏本地主数据:
<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml
已抄进 TICKET_CREDIT_NUM(付费票 2/3/4/5/6 -> creditNum 1/2/3/4/5)。
--------------------------------------------------------------------------------
四、安全闸门
--------------------------------------------------------------------------------
本接口会**真实写入一条购买记录**,所以默认不发包:
UpsertUserChargelog(...) -> 抛 ChargelogConfirmError
UpsertUserChargelog(..., dry_run=True) -> 只构造报文
UpsertUserChargelog(..., confirm=True) -> 真实发送
全局开关:config.UPSERT_CHARGELOG_CONFIRM(默认 False)
--------------------------------------------------------------------------------
五、调用示例
--------------------------------------------------------------------------------
from api import setup_logging, ParseQRCode, UserLogin, UserLogout,
LogoutType, UpsertUserChargelog
setup_logging("买票")
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
UserLogout(user_id, token, login.loginDateTime, logout_type=LogoutType.TestIn)
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Union
import config
from config import (
CLIENT_ID,
PLACE_ID,
REGION_ID,
TIMEOUT_MS,
UPSERT_CHARGELOG_CONFIRM,
)
# =============================================================================
# 功能票(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):
"""有副作用的接口被安全闸门拦下(此时**未发包**)。"""
class ChargelogGuardError(RuntimeError):
"""
安全检查未通过(此时**未发包**)。
★ 目前唯一的触发条件:**账号当前已经持有功能票**。
真机行为:`UseTicketPrepare` 只在「本局要用这张票」时才走购买分支,
而一张票买到手后会立刻被本局用掉(`RequestSuccess` 里 stock--)。
所以正常状态下 `userChargeList` 里的 stock 全是 0。
如果账号**已经持有**一张功能票还去发购买请求,
服务端看到的是「重复购买 / 状态矛盾」,有**封号风险**。
所以本模块默认硬拦。
"""
# =============================================================================
# 一、VO(字段顺序照 source/Assembly-CSharp/Net.VO.Mai2/*.cs 声明)
# =============================================================================
@dataclass
class UserChargeVO:
"""GetUserCharge —— 一张票的持有状态(GetUserCharge.cs,4 字段)。"""
chargeId: int = 0
stock: int = 0
purchaseDate: str = ""
validDate: str = ""
@dataclass
class UserChargelogVO:
"""UserChargelog —— 一条购买记录(UserChargelog.cs,8 字段)。"""
chargeId: int = 0
price: int = 0
purchaseDate: str = ""
playCount: int = 0
playerRating: int = 0
placeId: int = 0
regionId: int = 0
clientId: str = ""
@dataclass
class UserChargelogRequestVO:
"""请求 VO(UserChargelogRequestVO.cs,4 字段,顺序照声明)。"""
userId: int
userChargelog: UserChargelogVO
userCharge: UserChargeVO
loginDateTime: int = 0
# =============================================================================
# 二、工具
# =============================================================================
def ticket_name(charge_id: int) -> str:
"""取票名(只用于日志)。"""
return TICKET_NAMES.get(int(charge_id), "未知票(" + str(charge_id) + ")")
def ticket_price(charge_id: int, override: Optional[int] = None) -> int:
"""
取 userChargelog.price —— 即 ticket.creditNum。
依据 VOExtensions.cs:410 result.price = ticket.creditNum;
数据来源 TICKET_CREDIT_NUM(抄自游戏 Ticket.xml)。
"""
if override is not None:
return int(override)
if int(charge_id) not in TICKET_CREDIT_NUM:
log_warning("TICKET_CREDIT_NUM 里没有 chargeId=" + str(charge_id)
+ " —— 按 0 处理,建议补进配置")
return 0
return int(TICKET_CREDIT_NUM[int(charge_id)])
#: 北京时间(CST = UTC+8)
#: 依据 Manager/TimeManager.cs:9 CSToffset = new TimeSpan(8, 0, 0)
CST = timezone(timedelta(hours=8))
def now_cst(now: Optional[datetime] = None) -> datetime:
"""
取「北京时间现在」;传了 now 就用它(视作已是北京时间)。
★ 为什么要显式 +8 而不是 datetime.now():
TimeManager.GetNowTime() 返回 **DateTime.UtcNow**,
GetDateString() 再 Add(CSToffset) —— 即游戏里的日期串**恒是北京时间**,
与本机时区无关。脚本跑在非东八区机器上时 datetime.now() 会错。
"""
if now is not None:
return now
return datetime.now(timezone.utc).astimezone(CST)
def _now_string(now: Optional[datetime] = None) -> str:
"""
TimeManager.GetNowDateString() 的等价物。
依据 Manager/TimeManager.cs:7,62-70:
DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"
GetDateString(unix) = GetDateTime(unix).ToString("yyyy-MM-dd HH:mm:ss.f")
★★ 注意末尾那个 **.f**(一位小数)—— 真机报文长这样:
"purchaseDate":"2026-10-06 13:20:10.0"
少写这个 ".0" 服务端会直接 returnCode=0(实测踩过)。
★ 而 validDate 用的是另一套格式(见 calc_valid_date),**不带**小数。
"""
d = now_cst(now)
return d.strftime("%Y-%m-%d %H:%M:%S") + "." + str(d.microsecond // 100000)
def calc_valid_date(
now: Optional[datetime] = None,
*,
hour_offset: int = TICKET_VALID_HOUR_OFFSET,
days: int = TICKET_VALID_DAYS,
) -> str:
"""
算票的有效期字符串。
依据 TicketManager.cs:562-565:
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
★ 注意这里用的是**显式格式串**(没有 .f),所以结果形如
"2027-01-04 04:00:00" —— 与 purchaseDate 的 "…10.0" 不同。
"""
base = now_cst(now)
d = (base + timedelta(hours=hour_offset) + timedelta(days=days)).date()
return d.strftime("%Y-%m-%d") + " 04:00:00"
def parse_wall_time(text: Any) -> Optional[datetime]:
"""
解析墙上时间串,兼容两种格式:
"2026-10-06 13:20:10.0" ← TimeManager.DateTimeFormat(一位小数,真机报文用这个)
"2026-10-06 13:20:10" ← 无小数(服务端回给 GetUserChargeApi 的是这个)
解析不出来返回 None。
"""
s = str(text or "").strip()
if not s:
return None
for fmt in ("%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M:%S"):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
return None
def _find_charge(charge_list: Any, charge_id: int) -> Optional[Dict[str, Any]]:
"""在 GetUserChargeApi 的 userChargeList 里找指定 chargeId。"""
if not isinstance(charge_list, list):
return None
for c in charge_list:
if isinstance(c, dict) and int(c.get("chargeId", -1) or -1) == int(charge_id):
return c
return None
# =============================================================================
# 三、主函数
# =============================================================================
def UpsertUserChargelog(
user_id: int,
charge_id: int,
login_date_time: int,
*,
charge_list: Optional[List[Dict[str, Any]]] = None,
game_charge_list: Optional[List[Dict[str, Any]]] = None,
user_data: Optional[Dict[str, Any]] = None,
stock: Optional[int] = None,
price: Optional[int] = None,
purchase_date: Optional[str] = None,
valid_date: Optional[str] = None,
play_count: Optional[int] = None,
player_rating: Optional[int] = None,
place_id: int = PLACE_ID,
region_id: int = REGION_ID,
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,
client: Optional[MaimaiNetClient] = None,
) -> Union[UserChargelogRequestVO, UpsertResponseVO]:
"""
购买一张功能票(UpsertUserChargelogApi,★ 有副作用)。
参数:
user_id: 用户 ID(必填)
charge_id: 要买的票 ID(必填,= GetGameChargeApi 里的 chargeId)
★ 付费票是 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;不传 = TICKET_CREDIT_NUM[charge_id]
purchase_date: 购买时刻字符串;不传 = 现在
valid_date: 有效期字符串;不传 = 现在-4h+90d(见 calc_valid_date)
play_count: 覆盖 playCount(不传则取 userData.playCount)
player_rating: 覆盖 playerRating(不传则取 userData.playerRating)
guard_purchasable: ★ 默认 True = **只允许买 GetGameChargeApi 列出来的票**
(不在清单里就抛 ChargelogGuardError,未发包)。
依据 TicketManager.cs:137-150 —— 真机也是先在
GetGameChargeDataList() 里找到该 chargeId 才发购买请求,
找不到直接 return false。服务端对未上架的票会 returnCode=0。
实测:本机台只上架 chargeId=2/3,买 5 会失败。
game_charge_list: 可购买清单(可选)。不传则自动调 GetGameChargeApi。
guard_no_stock: ★ 默认 True = **账号当前若已持有任何功能票就直接拒绝购买**
(抛 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 = 只构造并返回报文,绝不发包
返回:
dry_run=True -> UserChargelogRequestVO
dry_run=False -> UpsertResponseVO
"""
if not login_date_time:
raise ValueError("缺少 login_date_time:必须传 UserLoginApi 的 loginDateTime"
"(PacketUploadUserChargelog.cs:23)")
name = ticket_name(charge_id)
log_info("购买功能票:user_id=" + str(user_id) + " chargeId=" + str(charge_id)
+ "(" + name + ")dry_run=" + str(dry_run))
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("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("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 {}
# ---------- 1.4 ★ 安全检查:这张票当前**上架了吗** ----------
# 依据 TicketManager.cs:137-150:真机先在 GetGameChargeDataList() 里找该
# chargeId,找到才 RequestUserCharge,找不到直接 return false(不买)。
# 服务端对未上架的票会 returnCode=0 —— 实测买 chargeId=5 就是这种情况。
if guard_purchasable:
if game_charge_list is None:
log_info("未传 game_charge_list,自动抓取可购买清单 ...")
_gc = resolve_api_func("GetGameCharge")(
client_id=client_id, timeout_ms=timeout_ms, client=client)
game_charge_list = (_gc.get("gameChargeList")
if isinstance(_gc, dict) else None) or []
avail = [int(g.get("chargeId")) for g in (game_charge_list or [])
if isinstance(g, dict) and g.get("chargeId") is not None]
log_info("当前可购买 chargeId:" + str(avail))
if int(charge_id) not in avail:
msg = ("安全检查未通过:chargeId=" + str(charge_id) + "(" + name
+ ")**不在当前可购买清单里**。\n"
" 可购买:" + (str(avail) if avail else "(空)") + "\n"
" 依据 TicketManager.cs:137-150 —— 真机也只买 GetGameChargeApi\n"
" 列出来的票,找不到就不发购买请求。\n"
" 服务端对未上架的票会 returnCode=0(实测过)。\n"
" 确需强行购买请传 guard_purchasable=False。")
log_error(msg)
raise ChargelogGuardError(msg)
log_info("安全检查通过:chargeId=" + str(charge_id) + " 在可购买清单里")
# ---------- 1.5 ★ 安全检查:账号当前是否已持有功能票 ----------
held = [c for c in (charge_list or [])
if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
if held:
desc = "、".join(
"chargeId=" + str(c.get("chargeId")) + "(" + ticket_name(c.get("chargeId"))
+ ") stock=" + str(c.get("stock")) + " validDate=" + str(c.get("validDate"))
for c in held)
log_warning("★ 账号当前已持有 " + str(len(held)) + " 张功能票:" + desc)
if guard_no_stock:
msg = ("安全检查未通过:账号当前**已持有功能票**(" + desc + "),拒绝再次购买。\n"
" 原因:真机只在「本局要用这张票」时才购买,且买到手会立刻用掉"
"(stock--),正常状态 stock 恒为 0。\n"
" 已经持票还发购买请求,服务端看到的是重复购买,有**封号风险**。\n"
" 确需强行购买请传 guard_no_stock=False。")
log_error(msg)
raise ChargelogGuardError(msg)
log_warning("guard_no_stock=False —— 已跳过该检查,继续购买")
else:
log_info("安全检查通过:账号当前没有持有任何功能票")
existing = _find_charge(charge_list, charge_id)
old_stock = int(existing.get("stock", 0) or 0) if existing else 0
if stock is None:
stock = old_stock + 1
stock = int(stock)
if price is None:
price = ticket_price(charge_id)
price = int(price)
if play_count is None:
play_count = int(user_data.get("playCount", 0) or 0)
if player_rating is None:
player_rating = int(user_data.get("playerRating", 0) or 0)
now = now_cst()
if not purchase_date:
purchase_date = _now_string(now)
if not valid_date:
# ★ 真机里 purchaseDate 与 validDate 都来自同一个 DateTime.Now
# (TicketManager.cs:216-219 与 :564),所以调用方若显式给了 purchaseDate,
# 有效期就该从**那个时刻**推,而不是从现在推。
base = parse_wall_time(purchase_date)
if base is None:
log_warning("purchase_date 解析不了(应为 'YYYY-MM-DD HH:MM:SS[.f]'),"
"validDate 改按「现在」计算:" + str(purchase_date))
base = now
valid_date = calc_valid_date(base)
log_info(" 票名 : " + name)
log_info(" 库存 : " + str(old_stock) + " -> " + str(stock))
log_info(" price : " + str(price) + "(= ticket.creditNum)")
log_info(" purchaseDate: " + purchase_date)
log_info(" validDate : " + valid_date)
# ---------- 2. 构造请求 ----------
req = UserChargelogRequestVO(
userId=int(user_id),
userChargelog=UserChargelogVO(
chargeId=int(charge_id),
price=price,
purchaseDate=purchase_date,
playCount=int(play_count),
playerRating=int(player_rating),
placeId=int(place_id),
regionId=int(region_id),
clientId=client_id,
),
userCharge=UserChargeVO(
chargeId=int(charge_id),
stock=stock,
purchaseDate=purchase_date,
validDate=valid_date,
),
loginDateTime=int(login_date_time),
)
# ---------- 3. dry-run ----------
if dry_run:
log_info("[dry-run] 已构造购买报文,未发包")
log_debug("dry-run 报文:" + __import__("json").dumps(
req, ensure_ascii=False, default=str))
return req
# ---------- 4. 副作用闸门 ----------
effective = UPSERT_CHARGELOG_CONFIRM if confirm is None else bool(confirm)
if not effective:
msg = ("UpsertUserChargelogApi 会**真实写入一条购买记录**(不可撤销)。"
"本脚本默认不放行。确认要发请传 confirm=True,"
"或改 config.UPSERT_CHARGELOG_CONFIRM = True。"
"只想看报文请传 dry_run=True。")
log_error(msg)
raise ChargelogConfirmError(msg)
# ---------- 5. 发包 ----------
log_info("闸门已放行,开始上传购买记录 ...")
raw = request_dict(
"UpsertUserChargelogApi", req,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
resp = UpsertResponseVO(
returnCode=int(raw.get("returnCode", 0) or 0),
apiName=str(raw.get("apiName", "") or ""),
)
if resp.returnCode != 1:
msg = ("购买失败:returnCode=" + str(resp.returnCode)
+ " apiName=" + resp.apiName)
log_error(msg)
raise RuntimeError(msg)
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
# =============================================================================
# 四、单独运行(调试)
# =============================================================================
if __name__ == "__main__":
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("chargelog_test")
try:
log_info("构造一张 2 倍券的购买报文(不发包)")
req = UpsertUserChargelog(
12345678, 2, 1791159576,
charge_list=[], user_data={"playCount": 1260, "playerRating": 15000},
dry_run=True,
)
log_info("userChargelog = " + str(req.userChargelog))
log_info("userCharge = " + str(req.userCharge))
log_info("validDate 算法自检:")
log_info(" 2026-10-04 18:22:10 买 5 倍券 -> "
+ calc_valid_date(datetime(2026, 10, 4, 18, 22, 10))
+ "(真机是 2027-01-02 04:00:00)")
return 0
finally:
teardown_logging()
raise SystemExit(_main())
-245
View File
@@ -1,245 +0,0 @@
# -*- coding: utf-8 -*-
"""
UserGetterCommon.py —— 各取数脚本的公共底座
================================================================================
只提供三样东西,取数逻辑都在各自的脚本里:
1. 各接口的请求 VO(严格对应 source/.../Net.VO.Mai2/*RequestVO.cs)
2. _get() 发一次请求并返回**原始响应 dict**
3. get_all_pages() 自动翻页
--------------------------------------------------------------------------------
★ 为什么单独抽出来:各接口的请求体并不统一
--------------------------------------------------------------------------------
权威依据 source/Assembly-CSharp/Net.VO.Mai2/ 下各自的 XxxRequestVO.cs:
GameKaleidxScopeRequestVO : 空类,一个字段都没有
UserDetailRequestVO : 只有 userId
UserMapRequestVO : userId + nextIndex + maxCount = 1000
UserLoginBonusRequestVO : userId + nextIndex + maxCount = 20
UserItemRequestVO : userId + nextIndex + maxCount = 100
UserMusicRequestVO : userId + nextIndex + maxCount = 50
UserFavoriteRequestVO : userId + itemKind
UserFavoriteItemRequestVO : userId + kind + nextIndex + maxCount = 100 + isAllFavoriteItem
UserShopStockRequestVO : userId + shopItemIdList
UserRivalMusicRequestVO : userId + rivalId + nextIndex + userRivalMusicLevelList
发错字段名或漏字段,服务端会返回异常或空数据。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
from config import CLIENT_ID, TIMEOUT_MS
from .LogSetup import log_debug, log_warning
from .MaimaiNet import MaimaiNetClient, get_default_client
# =============================================================================
# 请求 VO(逐字段对应 C# 的 XxxRequestVO)
# =============================================================================
@dataclass
class EmptyRequestVO:
"""空请求体(对应 GameKaleidxScopeRequestVO —— 它确实没有任何字段)。"""
@dataclass
class UserIdRequestVO:
"""只有 userId(对应 UserDetailRequestVO / UserCharacterRequestVO / UserOptionRequestVO 等)。"""
userId: int
@dataclass
class UserIdPageRequestVO:
"""通用分页(对应 UserMapRequestVO,maxCount 默认 1000)。"""
userId: int
nextIndex: int = 0
maxCount: int = 1000
@dataclass
class UserLoginBonusRequestVO:
"""对应 UserLoginBonusRequestVO(maxCount 默认 20)。"""
userId: int
nextIndex: int = 0
maxCount: int = 20
@dataclass
class UserItemRequestVO:
"""对应 UserItemRequestVO(maxCount 默认 100)。"""
userId: int
nextIndex: int = 0
maxCount: int = 100
@dataclass
class UserMusicRequestVO:
"""对应 UserMusicRequestVO(maxCount 默认 50)。"""
userId: int
nextIndex: int = 0
maxCount: int = 50
@dataclass
class UserCourseRequestVO:
"""对应 UserCourseRequestVO(只有 userId + nextIndex,**没有** maxCount)。"""
userId: int
nextIndex: int = 0
@dataclass
class UserFavoriteRequestVO:
"""对应 UserFavoriteRequestVO(按 itemKind 分次请求)。"""
userId: int
itemKind: int = 0
@dataclass
class UserFavoriteItemRequestVO:
"""对应 UserFavoriteItemRequestVO(注意字段名是 kind,不是 itemKind)。"""
userId: int
kind: int = 1
nextIndex: int = 0
maxCount: int = 100
isAllFavoriteItem: bool = False
@dataclass
class UserShopStockRequestVO:
"""对应 UserShopStockRequestVO。"""
userId: int
shopItemIdList: List[int] = field(default_factory=list)
@dataclass
class UserRivalRequestVO:
"""对应 UserRivalRequestVO。"""
userId: int
rivalId: int = 0
@dataclass
class UserRivalMusicRequestVO:
"""对应 UserRivalMusicRequestVO(level 列表固定 6 档)。"""
userId: int
rivalId: int = 0
nextIndex: int = 0
userRivalMusicLevelList: List[Dict[str, int]] = field(
default_factory=lambda: [{"level": lv} for lv in (0, 1, 2, 3, 4, 10)]
)
# =============================================================================
# 常量
# =============================================================================
#: GetUserItemApi 的 itemKind 取值(对应 Net.VO.Mai2/ItemKind.cs)
#: 客户端在 StateUserDownload.cs 里依次下载这些 kind
ITEM_KINDS: tuple = (1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14)
#: ★ GetUserItemApi 首页 nextIndex 的哨兵系数。
#: 依据 source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserItem.cs:17-24
#: Create(GetNetQuery(userId, itemId * 10000000000L));
#: 传 0 会被服务端拒绝。真机日志 source/2026-10-05.log:32 也是 10000000000。
ITEM_NEXT_INDEX_FACTOR: int = 10000000000
#: GetUserFavoriteApi 的 itemKind(真机顺序固定 3/1/2/10/11,见 source/2026-10-05.log:59-68)
FAVORITE_ITEM_KINDS: tuple = (3, 1, 2, 10, 11)
#: GetUserRivalMusicApi 的 level 档位(真机 source/2026-10-05.log:101)
RIVAL_MUSIC_LEVELS: tuple = (0, 1, 2, 3, 4, 10)
# =============================================================================
# 内部工具
# =============================================================================
def _get(
api_name: str,
body: Any,
user_id: int,
*,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> Dict[str, Any]:
"""
发一次请求,返回**原始响应 dict**(不做 VO 映射,保留服务端全部字段)。
★ 取数脚本一律走这里:upsertUserAll 需要响应原文,
映射成 dataclass 会丢掉服务端多给的字段。
"""
net = client or get_default_client()
raw = net.request(
api_name, body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS,
)
if isinstance(raw, str):
return json.loads(raw) if raw else {}
return raw or {}
def get_all_pages(
api_name: str,
user_id: int,
list_key: str,
*,
body_factory: Optional[Callable[[Optional[int]], Any]] = None,
client_id: str = CLIENT_ID,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
max_pages: int = 200,
) -> List[Any]:
"""
自动翻页抓取某个列表接口的全部条目。
分页约定(各 Packet 的 Proc 里都是这个逻辑):
nextIndex == 0 -> 没有更多数据
nextIndex != 0 -> 下一页的起始索引
参数:
api_name: 逻辑接口名
list_key: 响应里承载列表的键名(如 "userMapList")
body_factory: 可选;接收 next_index 返回请求 VO 的工厂。
★ 首页会以 next_index=None 调用它 —— 这是为了支持
GetUserItemApi 这类「首发要带哨兵值」的接口
(首包 nextIndex = itemKind * 1e10,不能是 0)。
max_pages: 翻页上限(防御性)
返回:
合并后的全部条目
"""
all_items: List[Any] = []
next_index: Optional[int] = None
for page in range(max_pages):
if body_factory is not None:
body = body_factory(next_index)
else:
body = UserIdPageRequestVO(userId=user_id, nextIndex=next_index or 0)
resp = _get(api_name, body, user_id,
client_id=client_id, timeout_ms=timeout_ms, client=client)
items = resp.get(list_key) or []
if isinstance(items, dict):
items = [items]
all_items.extend(items)
next_index = int(resp.get("nextIndex") or 0)
log_debug(
api_name + " 第 " + str(page + 1) + " 页:本页 " + str(len(items))
+ " 条,累计 " + str(len(all_items)) + " 条,nextIndex=" + str(next_index)
)
if not next_index:
break
else:
log_warning(api_name + " 超过 " + str(max_pages) + " 页仍未结束,已中止翻页")
return all_items
-222
View File
@@ -1,222 +0,0 @@
# -*- coding: utf-8 -*-
"""
UserLogin.py —— 用户正式登录(UserLoginApi)
================================================================================
功能:创建登录会话,返回 loginId / loginDateTime / 登录天数统计。
loginId 与 loginDateTime 是后续结算上报与登出的**必需输入**。
对应接口:UserLoginApi
请求体 :10 个字段,见 source/Assembly-CSharp/Net.VO.Mai2/UserLoginRequestVO.cs
source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:21-31
响应 :UserLoginResponseVO(returnCode=1 表示成功)
--------------------------------------------------------------------------------
★ 关于 loginDateTime(与后续接口强相关,务必理解)
--------------------------------------------------------------------------------
真机取的是「本地墙上时间被当作 UTC 时刻」的秒数,即
loginDateTime = 标准 Unix 时间戳 + 本地时区偏移
东八区即 now + 28800。
成因:MAI2.DateTime.cs 里 DateTime.UtcNow + LocalOffset 得到 Kind=Unspecified
的值,PacketUserLogin.cs:29 再用 new DateTimeOffset(该值) 又加一次本地偏移。
影响:PacketUserLogin.cs:43 会把请求值回填响应;
PacketUpsertUserAll.cs:31 / PacketUserLogout.cs:29 都复用该值,
与登录时不一致会被服务端拒绝。
本模块默认按真机算法生成;需要手动指定时传 login_date_time。
★ 关于 dateTime 与 loginDateTime 的区别
dateTime = ALL.Net 认证时刻(Auth.AuthTime,标准 Unix 秒)
loginDateTime= 本次登录时刻(上面的特殊算法)
两者都出现在 UserLoginRequestVO 里,但只有 loginDateTime 会被后续接口复用。
调用示例:
from api import UserLogin
login = UserLogin(12345678, "TESTTOKEN0000000000", region_id=1, place_id=1000)
print(login.loginId, login.loginDateTime)
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_LONG_MS
from .LogSetup import log_debug, log_error, log_info
from .MaimaiNet import (
MaimaiNetClient,
local_utc_offset_seconds,
login_date_time_now,
request_dict,
)
@dataclass
class UserLoginRequestVO:
"""请求 VO(对应 UserLoginRequestVO,字段顺序照 C# 声明)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
dateTime: int = 0
loginDateTime: int = 0
isContinue: bool = False
genericFlag: int = 0
token: str = ""
@dataclass
class UserLoginResponseVO:
"""
响应 VO(对应 UserLoginResponseVO)。
★ 后两个字段是**客户端回填**的,服务端不返回:
loginDateTime —— PacketUserLogin.cs:43 用请求值回填,
UpsertUserAll / UserLogout 都复用它
dateTime —— 本次请求发出的 ALL.Net 认证时刻,
UpsertUserAll 的 userData.dateTime 要用它
(真机 source/2026-10-05.log:123 两处都是 1791134329)
"""
returnCode: int = 0
lastLoginDate: str = ""
loginCount: int = 0
consecutiveLoginCount: int = 0
loginId: int = 0
loginDateTime: int = 0
token: str = ""
dateTime: int = 0
def UserLogin(
user_id: int,
token: str,
*,
access_code: str = "",
region_id: int = REGION_ID,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
is_continue: bool = False,
generic_flag: int = 0,
date_time: Optional[int] = None,
login_date_time: Optional[int] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserLoginResponseVO:
"""
用户正式登录(创建登录会话)。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填,来自 ParseQRCode)
access_code: 访问码(可选,通常为空)
region_id: 区域 ID(可选,默认 config.REGION_ID)
place_id: 店铺 ID(可选,默认 config.PLACE_ID)
client_id: 机台短值(可选)
is_continue: 是否续玩(可选)
generic_flag: 通用标志位(可选)
date_time: ALL.Net 认证时刻(可选,默认当前 Unix 秒)
login_date_time: 本次登录时刻(可选,默认按真机算法生成)
timeout_ms: 超时毫秒(可选,默认 60 秒)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserLoginResponseVO(loginDateTime 已按真机行为回填)
异常:
RuntimeError: returnCode != 1
"""
log_info(
"用户正式登录:user_id=" + str(user_id)
+ " region_id=" + str(region_id) + " place_id=" + str(place_id)
)
now = int(time.time())
if date_time is None:
date_time = now
if login_date_time is None:
login_date_time = login_date_time_now()
log_debug(
"时间戳:dateTime=" + str(date_time) + " loginDateTime=" + str(login_date_time)
+ "(本地时区偏移 " + str(local_utc_offset_seconds()) + " 秒)"
)
raw = request_dict(
"UserLoginApi",
UserLoginRequestVO(
userId=user_id,
accessCode=access_code,
regionId=region_id,
placeId=place_id,
clientId=client_id,
dateTime=date_time,
loginDateTime=login_date_time,
isContinue=is_continue,
genericFlag=generic_flag,
token=token,
),
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_LONG_MS, client=client,
)
known = {f for f in UserLoginResponseVO.__dataclass_fields__}
resp = UserLoginResponseVO(**{k: v for k, v in raw.items() if k in known})
if resp.returnCode != 1:
msg = "登录失败:returnCode=" + str(resp.returnCode)
log_error(msg)
raise RuntimeError(msg)
# ★ 回填 loginDateTime(对应 PacketUserLogin.cs:43)
# 服务端不返回该字段,客户端用请求值回填;后续 UpsertUserAll / UserLogout 复用。
if not resp.loginDateTime:
resp.loginDateTime = login_date_time
log_debug("服务端未返回 loginDateTime,按真机行为回填为 " + str(login_date_time))
# ★ 回填 dateTime:本次请求实际发出的 ALL.Net 认证时刻。
# UpsertUserAll 的 userData.dateTime 要用它(真机两处一致)。
resp.dateTime = date_time
log_info(
"用户登录成功:loginId=" + str(resp.loginId)
+ " loginDateTime=" + str(resp.loginDateTime)
+ " dateTime=" + str(resp.dateTime)
+ " lastLoginDate=" + resp.lastLoginDate
+ " loginCount=" + str(resp.loginCount)
+ " consecutiveLoginCount=" + str(resp.consecutiveLoginCount)
)
return resp
if __name__ == "__main__":
import sys
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogin_test")
try:
if len(sys.argv) < 3:
print("用法: python -m api.userlogin <user_id> <token> [region_id] [place_id]")
return 1
rid = int(sys.argv[3]) if len(sys.argv) > 3 else REGION_ID
pid = int(sys.argv[4]) if len(sys.argv) > 4 else PLACE_ID
lg = UserLogin(int(sys.argv[1]), sys.argv[2], region_id=rid, place_id=pid)
print("")
for name in UserLoginResponseVO.__dataclass_fields__:
print(" " + name.ljust(24) + "= " + str(getattr(lg, name)))
return 0
except Exception as exc:
log_error("用户登录失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-254
View File
@@ -1,254 +0,0 @@
# -*- coding: utf-8 -*-
"""
UserLogout.py —— 用户登出(UserLogoutApi)
================================================================================
功能:结束登录会话,可选上报本次会话的网络统计(delayLog)。
对应接口:UserLogoutApi
请求体 :两种形态,见 source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogout.cs:22-63
type == 1 (Logout) -> UserLogoutRequest2VO(含 delayLog)
其他 type -> UserLogoutRequestVO (不含 delayLog)
--------------------------------------------------------------------------------
登出类型(source/Assembly-CSharp/Net.VO.Mai2/LogoutType.cs)
--------------------------------------------------------------------------------
None = 0
Logout = 1 正常登出(带 delayLog 统计)
Cancel = 2 取消 / 强制登出指定座席(NetPacketUtil.cs:47)
Error = 3 错误强制登出(GameMain.cs:91)
TestIn = 4 测试登入 / 顶号(GameMain.cs:104)
Quit = 5 退出时强制登出,可释放残留会话(GameMainObject.cs:200)
★ 真机会话结束时用的是 type=4(TestIn),见 source/2026-10-05.log:125。
★ delayLog 结构(source/Assembly-CSharp/Net.VO.Mai2/DelayLog.cs)
request 是定长 26 的数组(DelayLog.cs:17 new RequestAPI[26]),
每个元素 {count, size, msec, retry}。
调用示例:
from api import UserLogout, LogoutType
UserLogout(12345678, token, login.loginDateTime,
logout_type=LogoutType.TestIn, region_id=1, place_id=1000)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from config import CLIENT_ID, PLACE_ID, REGION_ID, TIMEOUT_MS
from .LogSetup import log_debug, log_error, log_info
from .MaimaiNet import MaimaiNetClient, request_dict
#: DelayLog.request 的定长(DelayLog.cs:17)
DELAY_LOG_SLOTS = 26
class LogoutType:
"""登出类型(对应 Net.VO.Mai2.LogoutType)。"""
None_ = 0
Logout = 1 # 正常登出(带 delayLog)
Cancel = 2 # 取消 / 强制登出
Error = 3 # 错误强制登出
TestIn = 4 # 测试登入 / 顶号(真机会话结束用这个)
Quit = 5 # 退出强制登出
@dataclass
class RequestAPI:
"""单个接口的统计(对应 Net.VO.Mai2/RequestAPI.cs)。"""
count: int = 0
size: int = 0
msec: int = 0
retry: int = 0
@dataclass
class DelayLog:
"""会话网络统计(对应 Net.VO.Mai2/DelayLog.cs)。"""
dlRequests: int = 0
dlSize: int = 0
dlRetry: int = 0
loginMsec: int = 0
saveMsec: int = 0
reductionMusic: int = 0
reductionItem: int = 0
request: List[RequestAPI] = field(
default_factory=lambda: [RequestAPI() for _ in range(DELAY_LOG_SLOTS)]
)
@dataclass
class UserLogoutRequestVO:
"""简单登出请求 VO(对应 UserLogoutRequestVO)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
loginDateTime: int = 0
type: int = 0
@dataclass
class UserLogoutRequest2VO:
"""完整登出请求 VO(对应 UserLogoutRequest2VO,含 delayLog)。"""
userId: int
accessCode: str = ""
regionId: int = 0
placeId: int = 0
clientId: str = ""
loginDateTime: int = 0
type: int = 0
delayLog: Optional[DelayLog] = None
@dataclass
class UserLogoutResponseVO:
"""响应 VO(对应 UserLogoutResponseVO)。"""
returnCode: int = 0
def create_empty_delay_log() -> DelayLog:
"""创建全零的 DelayLog(26 个槽位)。"""
return DelayLog()
def create_delay_log_from_stats(
requests_stats: Optional[List[Dict[str, Any]]] = None,
*,
dl_requests: int = 0,
dl_size: int = 0,
dl_retry: int = 0,
login_msec: int = 0,
save_msec: int = 0,
reduction_music: int = 0,
reduction_item: int = 0,
) -> DelayLog:
"""
用统计数据构造 DelayLog。
参数:
requests_stats: 每个接口的统计列表,元素形如
{"count":1,"size":2090,"msec":73,"retry":0}
按 DelayLog.request 的槽位顺序填入(最多 26 个)
"""
dl = DelayLog(
dlRequests=dl_requests, dlSize=dl_size, dlRetry=dl_retry,
loginMsec=login_msec, saveMsec=save_msec,
reductionMusic=reduction_music, reductionItem=reduction_item,
)
for i, stat in enumerate(requests_stats or []):
if i >= DELAY_LOG_SLOTS:
break
dl.request[i] = RequestAPI(
count=int(stat.get("count", 0)),
size=int(stat.get("size", 0)),
msec=int(stat.get("msec", 0)),
retry=int(stat.get("retry", 0)),
)
return dl
def UserLogout(
user_id: int,
token: str,
login_date_time: int,
*,
logout_type: int = LogoutType.TestIn,
access_code: str = "",
region_id: int = REGION_ID,
place_id: int = PLACE_ID,
client_id: str = CLIENT_ID,
delay_log: Optional[DelayLog] = None,
timeout_ms: Optional[int] = None,
client: Optional[MaimaiNetClient] = None,
) -> UserLogoutResponseVO:
"""
用户登出。
参数:
user_id: 用户 ID(必填)
token: 会话令牌(必填)
login_date_time: 登录时刻(必填,取自 UserLogin 的 loginDateTime)
logout_type: 登出类型(可选,默认 4=TestIn,与真机一致)
access_code: 访问码(可选)
region_id: 区域 ID(可选)
place_id: 店铺 ID(可选)
client_id: 机台短值(可选)
delay_log: 会话统计(可选;仅 logout_type=1 时随报文发出)
timeout_ms: 超时毫秒(可选)
client: 可复用的 MaimaiNetClient(可选)
返回:
UserLogoutResponseVO
异常:
RuntimeError: returnCode != 1
"""
log_info(
"用户登出:user_id=" + str(user_id) + " logout_type=" + str(logout_type)
+ " loginDateTime=" + str(login_date_time)
)
if logout_type == LogoutType.Logout:
body: Any = UserLogoutRequest2VO(
userId=user_id, accessCode=access_code,
regionId=region_id, placeId=place_id, clientId=client_id,
loginDateTime=login_date_time, type=logout_type,
delayLog=delay_log if delay_log is not None else create_empty_delay_log(),
)
log_debug("使用完整登出报文(含 delayLog)")
else:
body = UserLogoutRequestVO(
userId=user_id, accessCode=access_code,
regionId=region_id, placeId=place_id, clientId=client_id,
loginDateTime=login_date_time, type=logout_type,
)
log_debug("使用简单登出报文(不含 delayLog)")
raw = request_dict(
"UserLogoutApi", body,
user_id=user_id, client_id=client_id,
timeout_ms=timeout_ms or TIMEOUT_MS, client=client,
)
resp = UserLogoutResponseVO(returnCode=int(raw.get("returnCode", 0) or 0))
if resp.returnCode != 1:
msg = "登出失败:returnCode=" + str(resp.returnCode)
log_error(msg)
raise RuntimeError(msg)
log_info("用户登出成功:returnCode=" + str(resp.returnCode))
return resp
if __name__ == "__main__":
import sys
from .LogSetup import setup_logging, teardown_logging
def _main() -> int:
setup_logging("userlogout_test")
try:
if len(sys.argv) < 4:
print("用法: python -m api.userlogout <user_id> <token> <loginDateTime> "
"[logout_type] [region_id] [place_id]")
return 1
lt = int(sys.argv[4]) if len(sys.argv) > 4 else LogoutType.TestIn
rid = int(sys.argv[5]) if len(sys.argv) > 5 else REGION_ID
pid = int(sys.argv[6]) if len(sys.argv) > 6 else PLACE_ID
resp = UserLogout(int(sys.argv[1]), sys.argv[2], int(sys.argv[3]),
logout_type=lt, region_id=rid, place_id=pid)
print("")
print("returnCode =", resp.returnCode)
return 0
except Exception as exc:
log_error("用户登出失败:" + str(exc))
return 1
finally:
teardown_logging()
raise SystemExit(_main())
-563
View File
@@ -1,563 +0,0 @@
# -*- coding: utf-8 -*-
"""
api 包 —— 统一导出
================================================================================
主脚本只需要 from api import ... 就能拿到全部功能。
★ 本文件在导入时就给下面 _API_FUNCS 里列出的每个函数套上「日志外壳」,
所以主脚本拿到的已经是带日志的版本 —— 调用时会自动产出:
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
主脚本**不需要**写装饰器,也不需要自己埋点。
--------------------------------------------------------------------------------
目录结构(一个 api 一个脚本)
--------------------------------------------------------------------------------
基础设施
LogSetup.py 统一日志(INFO / DEBUG 双模式)
MaimaiNet.py 网络核心(AES / deflate / URL 混淆 / Cookie)
UserGetterCommon.py 取数脚本公共底座(请求 VO / _get / 翻页)
ChargeGuard.py ★ 买票前置闸门:买票前必须先跑过 GetUserCharge
会话链路
Chime.py 扫码登录 -> userId + token
GetGameSettings.py GetGameSettingApi(游戏设置)
Ping.py 心跳探活
GetUserPreview.py GetUserPreviewApi(用户预览)
UserLogin.py UserLoginApi(正式登录 -> loginId + loginDateTime)
UserLogout.py UserLogoutApi(登出)
上传 / 结算(有副作用的接口)
UpsertUserAll.py ★ UpsertUserAllApi(上传成绩 + 解锁歌曲/解禁难度)
UpsertUserChargelog.py UpsertUserChargelogApi(购买功能票)
GetNeedUserData.py ★ 结算快照编排(统一调用下面的取数脚本),
并把结果落盘到 <工程根>/data/<userId>.json
★ 命名约定:服务端 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 .LogSetup import (
# 会话
setup_logging,
teardown_logging,
# 自动跟踪
autowrap,
get_called_apis,
# 模式
get_level,
set_level,
is_debug,
LEVEL_INFO,
LEVEL_DEBUG,
# 装饰器 / 兼容
api_log,
get_logger,
# 流程日志(INFO 可见)
log_info,
log_step,
log_result,
log_warning,
log_error,
log_section,
log_field,
log_debug,
# 收发数据日志(DEBUG 专属)
log_request,
log_response,
log_payload,
)
# ---------------------------------------------------------------------------
# 1) 网络核心
# ---------------------------------------------------------------------------
from .MaimaiNet import (
# 协议常量(原在 config.py,已下沉到 MaimaiNet.py)
BASE_URL,
AES_KEY,
AES_IV,
OBFUSCATE_PARAM,
MaimaiNetClient,
MaimaiNetError,
get_default_client,
close_default_client,
request,
request_dict,
request_vo,
obfuscate_api_name,
build_api_url,
build_user_agent,
adler32,
aes_encrypt,
aes_decrypt,
deflate_compress,
deflate_decompress,
vo_to_json,
vo_from_json,
local_utc_offset_seconds,
login_date_time_now,
EmptyRequestVO,
UserIdRequestVO,
ReturnCodeVO,
)
# ---------------------------------------------------------------------------
# 2) 会话链路
# ---------------------------------------------------------------------------
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 .GetGameSettings import (
GetGameSettings,
GameSetting,
GameSettingRequestVO,
GameSettingResponseVO,
is_maintenance,
is_ping_disabled,
)
from .Ping import Ping
from .GetUserPreview import (
GetUserPreview,
UserPreviewRequestVO,
UserPreviewResponseVO,
)
from .UserLogin import (
UserLogin,
UserLoginRequestVO,
UserLoginResponseVO,
)
from .UserLogout import (
UserLogout,
LogoutType,
DelayLog,
RequestAPI,
UserLogoutRequestVO,
UserLogoutRequest2VO,
UserLogoutResponseVO,
create_empty_delay_log,
create_delay_log_from_stats,
DELAY_LOG_SLOTS,
)
# ---------------------------------------------------------------------------
# 3) 上传成绩
# ---------------------------------------------------------------------------
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,
ConfirmRequiredError,
PatchError,
# 节点结构
NODE_TYPES,
NODE_ORDER,
BITS_TO_ARRAY,
ARRAY_TO_BITS,
DIFF_PRIMARY_KEYS,
MAY_BE_EMPTY_NODES,
DEFAULT_EMPTY_NODES,
# 组包辅助
empty_node,
empty_user_all,
coerce_node_value,
sync_bits_to_arrays,
build_delta,
build_favorite_delta,
build_user_all,
build_user_playlog,
build_user_game_playlog,
build_post_play_user_data,
validate_user_playlog,
validate_before_send,
apply_play_effects,
# ★ 额外 JSON 合并 / 落库回查
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,
normalize_snapshot,
wait_send_cooldown,
# 字段表
PLAYLOG_FIELDS,
PLAYLOG_BOOL_FIELDS,
PLAYLOG_STRING_FIELDS,
JUDGE_TYPES,
JUDGE_BOXES,
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,
)
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,
)
# ---------------------------------------------------------------------------
# ★ 买票前置闸门: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,
UserChargeVO,
UserChargelogVO,
UserChargelogRequestVO,
ticket_name,
ticket_price,
calc_valid_date,
parse_wall_time,
now_cst,
)
# ---------------------------------------------------------------------------
# 4) 取数公共底座
# ---------------------------------------------------------------------------
from .UserGetterCommon import (
EmptyRequestVO as GetterEmptyRequestVO,
UserIdRequestVO as GetterUserIdRequestVO,
UserIdPageRequestVO,
UserLoginBonusRequestVO,
UserItemRequestVO,
UserMusicRequestVO,
UserCourseRequestVO,
UserFavoriteRequestVO,
UserFavoriteItemRequestVO,
UserShopStockRequestVO,
UserRivalRequestVO,
UserRivalMusicRequestVO,
get_all_pages,
ITEM_KINDS,
ITEM_NEXT_INDEX_FACTOR,
FAVORITE_ITEM_KINDS,
RIVAL_MUSIC_LEVELS,
)
# ---------------------------------------------------------------------------
# 5) 取数脚本(一个接口一个脚本)
# ---------------------------------------------------------------------------
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 .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 .GetGameCharge import GetGameCharge, GameChargeRequestVO
# ---------------------------------------------------------------------------
# 6) 需要日志跟踪的 api 函数(新增 api 时把函数名加到这里)
# ---------------------------------------------------------------------------
_API_FUNCS = (
# --- 会话链路 ---
"ParseQRCode",
"GetGameSettings",
"Ping",
"GetUserPreview",
"UserLogin",
"UserLogout",
# --- 上传成绩 ---
"UpsertUserAll",
"build_play_from_config",
"derive_play_characters",
"merge_extra_data",
"verify_upsert_landing",
"build_user_all",
"build_unlock_items",
# --- 购买功能票 ---
"UpsertUserChargelog",
"ticket_name",
"ticket_price",
"calc_valid_date",
"build_user_playlog",
"build_user_game_playlog",
"validate_user_playlog",
"validate_before_send",
"apply_play_effects",
"build_post_play_user_data",
"build_delta",
"build_favorite_delta",
"normalize_snapshot",
"GetNeedUserData",
"summarize_snapshot",
"GetUserNewItemList",
# --- 取数:单接口 ---
"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
# --- 取数:游戏侧 ---
"GetGameCharge", # GetGameChargeApi
)
# ---------------------------------------------------------------------------
# 7) 导入即包装:保证 from api import X 拿到的是带日志的版本
# ---------------------------------------------------------------------------
autowrap(globals(), _API_FUNCS)
# ---------------------------------------------------------------------------
# 8) 统一导出
# ---------------------------------------------------------------------------
__all__ = [
# ---- 日志:会话 ----
"setup_logging", "teardown_logging",
# ---- 日志:自动跟踪 ----
"autowrap", "get_called_apis",
# ---- 日志:模式 ----
"get_level", "set_level", "is_debug", "LEVEL_INFO", "LEVEL_DEBUG",
# ---- 日志:装饰器 / 兼容 ----
"api_log", "get_logger",
# ---- 日志:流程 ----
"log_info", "log_step", "log_result", "log_warning", "log_error",
"log_section", "log_field",
# ---- 日志:收发数据 ----
"log_debug", "log_request", "log_response", "log_payload",
# ---- 网络核心 ----
"MaimaiNetClient", "MaimaiNetError", "get_default_client", "close_default_client",
"request", "request_dict", "request_vo",
"obfuscate_api_name", "build_api_url", "build_user_agent", "adler32",
"aes_encrypt", "aes_decrypt", "deflate_compress", "deflate_decompress",
"vo_to_json", "vo_from_json",
"local_utc_offset_seconds", "login_date_time_now",
"EmptyRequestVO", "UserIdRequestVO", "ReturnCodeVO",
# ---- 会话链路 ----
"ParseQRCode",
"GetGameSettings", "GameSetting", "GameSettingRequestVO", "GameSettingResponseVO",
"is_maintenance", "is_ping_disabled",
"Ping",
"GetUserPreview", "UserPreviewRequestVO", "UserPreviewResponseVO",
"UserLogin", "UserLoginRequestVO", "UserLoginResponseVO",
"UserLogout", "LogoutType", "DelayLog", "RequestAPI",
"UserLogoutRequestVO", "UserLogoutRequest2VO", "UserLogoutResponseVO",
"create_empty_delay_log", "create_delay_log_from_stats", "DELAY_LOG_SLOTS",
# ---- 上传成绩 ----
"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",
"UpsertUserAllError", "ConfirmRequiredError", "PatchError",
"NODE_TYPES", "NODE_ORDER", "BITS_TO_ARRAY", "ARRAY_TO_BITS",
"DIFF_PRIMARY_KEYS", "MAY_BE_EMPTY_NODES", "DEFAULT_EMPTY_NODES",
"empty_node", "empty_user_all", "coerce_node_value", "sync_bits_to_arrays",
"build_delta", "build_favorite_delta", "build_user_all",
"build_user_playlog", "build_user_game_playlog", "build_post_play_user_data",
"validate_user_playlog", "validate_before_send", "apply_play_effects",
"default_user_weekly_data", "default_user_2p_playlog", "calc_play_special",
"normalize_snapshot", "wait_send_cooldown",
"PLAYLOG_FIELDS", "PLAYLOG_BOOL_FIELDS", "PLAYLOG_STRING_FIELDS",
"JUDGE_TYPES", "JUDGE_BOXES",
"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",
# ---- 快照编排 ----
"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",
"get_all_pages",
"ITEM_KINDS", "ITEM_NEXT_INDEX_FACTOR", "FAVORITE_ITEM_KINDS", "RIVAL_MUSIC_LEVELS",
# ---- 取数脚本 ----
"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",
"GetUserRivalData", "GetUserRivalMusic", "GetUserRivalMusicAll",
"GetUserFriendBonus", "extract_gift_points",
"GetUserKaleidxScope", "GetGameKaleidxScope",
"GetUserRegion",
"GetUserRecommendRateMusic", "GetUserRecommendSelectMusic",
"GetUserNewItemList", "UserNewItemListRequestVO",
"GetGameCharge", "GameChargeRequestVO",
# --- 协议常量(原在 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",
]
-563
View File
@@ -1,563 +0,0 @@
#!/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())
-247
View File
@@ -1,247 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
buy_ticket.py —— 主脚本范例:扫码登录 → 购买功能票 → 登出
================================================================================
一条完整的「买票」链路:
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. 查 TICKET_CREDIT_NUM(已下沉到 api/UpsertUserChargelog.py)得到
userChargelog.price(= ticket.creditNum)
5. 闸门 → 发包
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python buy_ticket.py <二维码> # 预检:只看报文,不发包
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 # 跳过入场数据下载
付费票(api.PAID_TICKET_IDS):
2 = 2倍チケット creditNum 1
3 = 3倍チケット creditNum 2
4 = 4倍チケット creditNum 3
5 = 5倍チケット creditNum 4
6 = 6倍チケット creditNum 5
★ 真实购买会写入一条购买记录(不可撤销)。不传 --send 时只构造报文。
★ 二维码有**时效性**(**有效期 10 分钟**),过期后 aimedb 返回 errorID=2
—— **不是按次数计量的**,重新生成即可。
★ 购买前会先查一次功能票;**账号已持有功能票时直接拒绝购买**(重复购买有封号风险)。
"""
from __future__ import annotations
import sys
import time
from api import (
setup_logging,
teardown_logging,
log_error,
log_info,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
UpsertUserChargelog,
ChargelogConfirmError,
ChargelogGuardError,
GetUserCharge,
ticket_name,
GetNeedUserData,
summarize_snapshot,
Ping,
)
from config import PLACE_ID, REGION_ID
def show_charges(user_id, label):
"""
★ 调 GetUserChargeApi 打印当前票券状态,并返回 stock > 0 的那些。
★★ 为什么跑前跑后都要查:
真机只在「本局要用这张票」时才购买,买到手会立刻被用掉(stock--),
所以正常状态 stock 恒为 0。
**已经持票还去买,服务端看到的是重复购买,有封号风险。**
所以买之前必须确认没有持票;买之后要确认状态符合预期。
"""
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 held
def parse_args(argv):
opts = {"qr": "", "charge_id": 5, "send": False, "list": False,
"debug": False, "skip_entry": False, "wait_before": 15.0}
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 == "--wait-before" and i + 1 < len(argv):
opts["wait_before"] = float(argv[i + 1])
i += 1
elif a == "--charge-id" and i + 1 < len(argv):
opts["charge_id"] = 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("buy_ticket")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
# ---- ★ 心跳:决定后续所有请求的 number 头 ----
# 真机序列里 Ping 在 [11],紧接着才是 GetUserPreviewApi [12]。
# 不跑的话 number 恒为 "0"(真机是 75)。
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)
# ---- ★ 复刻真机的「入场」序列:登录后先把用户数据全量拉一遍 ----
# 真机 13:19:45 登录 -> 13:19:46~13:19:51 拉 47 个节点
# -> 13:20:10 才发 UpsertUserChargelogApi
# 本脚本原来 login 后直接买,跳过了这一段。
if not opts["skip_entry"]:
snap = GetNeedUserData(user_id, token=token,
place_id=PLACE_ID, region_id=REGION_ID)
_sum = summarize_snapshot(snap)
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
+ str(len(snap.get("_failed") or [])) + " 个")
# ---- ★★ 跑前:必须确认账号当前**没有**持有功能票 ----
held_before = show_charges(user_id, "购买前")
if opts["list"]:
log_info("[--list] 只查询票券状态,不购买。")
return 0
aborted = False
if held_before:
log_error("账号当前已持有功能票,**拒绝购买**(重复购买有封号风险):")
for c in held_before:
log_error(" chargeId=" + str(c.get("chargeId"))
+ " " + ticket_name(c.get("chargeId"))
+ " stock=" + str(c.get("stock"))
+ " validDate=" + str(c.get("validDate")))
log_error("请先在机台把票用掉,或等它过期后再来。")
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"])
log_info("等待结束,开始购买")
# ---- 购买 ----
# ★ 一个函数搞定:取当前票券/档案 -> 安全检查 -> 算 validDate/price -> 闸门 -> 发包
UpsertUserChargelog(
user_id, opts["charge_id"], login.loginDateTime,
dry_run=not opts["send"],
confirm=opts["send"],
guard_no_stock=True,
guard_purchasable=False, # ★ 不再按 GetGameCharge 上架清单拦截
place_id=PLACE_ID, region_id=REGION_ID,
)
# ---- ★★ 跑后:无论有没有真发包,都再查一次 ----
# (中止时也查,确认状态确实没被动过)
held_after = show_charges(user_id, "购买后")
if aborted:
log_info("已按安全检查中止,**未发包**;上面是中止后的复查结果")
if len(held_after) == len(held_before):
log_info("复查确认:票券状态未发生变化 ✅")
else:
log_warning("复查发现票券数量变了 —— 请人工确认")
return 3
if not opts["send"]:
log_info("[dry-run] 未发包。想真实购买请加 --send(★ 会写入购买记录)")
return 0
if held_after:
log_info("购买后持有 " + str(len(held_after)) + " 张票(购买成功)")
else:
log_warning("购买后没有查到持有中的票 —— 服务端可能没落库,"
"或票被立刻消耗了")
return 0
except ChargelogGuardError:
return 3
except SystemExit:
raise
except ChargelogConfirmError:
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())
-405
View File
@@ -1,405 +0,0 @@
# -*- coding: utf-8 -*-
"""
config.py —— 舞萌DX 纯网络脚本工程 · 运行时可配置项
================================================================================
★★ 这是**模板文件**。第一次使用请先复制一份:
cp config.py.example config.py # Linux / macOS
copy config.py.example config.py # Windows
然后**必须**填好第二节「机台与店铺标识」的 6 个值,否则脚本发出去的请求
带的是别人的机台身份,服务端会拒绝。
★ config.py 已在 .gitignore 里 —— 它属于你的私有配置,不要提交。
================================================================================
本文件集中存放**用户可能需要修改**的内容,其余协议常量写死在各自脚本内。
分六块:
1. 网络与加密常量(AES 密钥、协议版本、服务器地址)
2. 机台与店铺标识(Keychip、区域、店铺)
3. 扫码登录常量(aimedb)
4. ★ UpsertUserAll 即将上传的歌曲数据(默认取 source/2026-10-05.log 真机样本)
5. UpsertUserAll 安全闸门
6. 日志级别
权威来源全部标注在注释里,改之前请先看依据。
"""
# =============================================================================
# 一、网络与加密常量
# =============================================================================
# ★ 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)
MAI_ENCODING = "1.55"
# --- 超时(毫秒)---
# 依据:source/Assembly-CSharp/Net/NetConfig.cs:7-11
TIMEOUT_MS = 10000 # 普通接口
TIMEOUT_LONG_MS = 60000 # 登录 / 结算上传
# =============================================================================
# 二、机台与店铺标识
# =============================================================================
# 客户端 ID(Keychip 短值)
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketGetGameSetting.cs:23
# clientId = AMDaemon.System.KeychipId.ShortValue
# 实测:真机日志里 clientId 恒等于 Keychip 短值
# ★ 形态 = 15 位 Keychip 去掉连字符后的**前 11 位**(大写十六进制)。
CLIENT_ID = "" # ★★ 必填:形如 "A63E01C3047"
# 区域 ID / 店铺 ID
# 依据:source/Assembly-CSharp/Net.Packet.Mai2/PacketUserLogin.cs:24-25
# regionId = Auth.RegionCode ; placeId = Auth.LocationId
# 实测:真机日志里的 regionId / placeId 与机台所在地一致
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
# - GetUserRegionApi 只回 regionId/playCount/created
# 店铺名来自机台本地 AMDaemon:
# source/AMDaemon.NET/AMDaemon/Allnet/Auth.cs:33 LocationName <- allnet_Auth_getLocationName()
# 是开机时 ALL.Net 认证下发的,客户端本地就有,不会向游戏服务器索取。
#
# ★★ 现在不用手工抄了: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)
# =============================================================================
#
# 依据:source/ChimeLib.NET/ChimeLib/NET/COMM/CCommGetUserData.cs
# + docs/舞萌DX_扫码登录协议技术规范.md
# ★ aimedb / QR 常量已下沉到 api/Chime.py
# =============================================================================
# 三之二、★ 功能票(Ticket)主数据
# =============================================================================
#
# 依据:游戏本地主数据
# D:\SDGB2026\Package\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 含义(Ticket.xml 里的枚举):
# 1=Paid(付费,可在机台买) 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara
# ★ TICKET_CREDIT_NUM / TICKET_NAMES / PAID_TICKET_IDS /
# TICKET_VALID_HOUR_OFFSET / TICKET_VALID_DAYS
# 已下沉到 api/UpsertUserChargelog.py
#: 买票闸门(和 UpsertUserAll 一样,默认不放行)
UPSERT_CHARGELOG_CONFIRM = False
# =============================================================================
# 四、★ UpsertUserAll 即将上传的歌曲数据
# =============================================================================
#
# ★ 为什么放在 config:
# 一局游玩的成绩是**唯一无法由脚本自行产生**的输入 ——
# 纯网络脚本复刻不出游玩过程,服务端要的就是这份原始成绩。
# 换歌 / 换成绩只改这里,不用动任何 api 脚本。
#
# ★ 默认值 = source/2026-10-05.log:123 的**真机跑通报文**(UpsertUserAllApi 请求)
# 即 userPlaylogList[0] 逐字段照抄。
#
# ★ 两个"歌曲数据"的区别(别混):
# userPlaylogList 本局**原始判定输入** —— 服务端据此算分、写战绩表
# userMusicDetailList 按 (musicId, level) 的**历史归档** —— 本局那条 playCount +1
# 下面 PLAY_* 同时喂给这两处,保证一致。
# --- 曲目与难度 ---
PLAY_MUSIC_ID = 143 # 曲目 ID
PLAY_MUSIC_LEVEL = 4 # 0=Basic 1=Advanced 2=Expert 3=Master 4=Re:Master
PLAY_TRACK_NO = 1 # 本局第几首
# --- 版本号(同一版本两种写法,两处都要)---
PLAY_VERSION_INT = 1056000 # 整数形式,用于 userPlaylogList[].version(1.56.00)
PLAY_VERSION_STR = "1.56.00" # 字符串形式,用于 userGamePlaylogList[].version
# --- 成绩 ---
PLAY_ACHIEVEMENT = 0 # 达成率 ×10000(0 = 本局失败)
PLAY_DELUXSCORE = 0 # DX 分数
PLAY_SCORE_RANK = 0 # 评级档(0 = 无)
PLAY_IS_CLEAR = False # 是否 Clear
PLAY_MAX_COMBO = 0
PLAY_TOTAL_COMBO = 655 # 该谱面 note 总数(= 各类型判定数之和)
PLAY_MAX_SYNC = 0
PLAY_TOTAL_SYNC = 0
PLAY_COMBO_STATUS = 0 # 0=无 1=全连
PLAY_SYNC_STATUS = 0
PLAY_FAST_COUNT = 0
PLAY_LATE_COUNT = 0
# --- 判定明细 {类型: {判定位: 次数}} ---
# 真机样本是一局**失败**的成绩(全 Miss)
PLAY_JUDGE_COUNTS = {
"tap": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 441},
"hold": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 19},
"slide": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 192},
"touch": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 0},
"break": {"CriticalPerfect": 0, "Perfect": 0, "Great": 0, "Good": 0, "Miss": 3},
}
# --- 评级(本局前后)---
# ★ PLAY_BEFORE/AFTER_RATING 等已改为运行时从服务器快照取(derive_play_rating)
# --- 曲种索引 ---
# ★ 必须与 userExtend.categoryIndex 一致(真机两处都是 2022)
PLAY_CATEGORY_INDEX = 2022
PLAY_MUSIC_INDEX = 10 # userExtend.musicIndex
PLAY_EXTRA_FLAG = 1721 # userExtend.extraFlag
# --- 本局使用的角色快照(characterId1..5 / Level / Awakening)---
#
# ★ 语义:**进入游玩时**的快照值,不要加经验 —— 这一点极易搞错。
# (结算后的等级在 upsertUserAll.userCharacterList 里,那处才 +经验)
# 真机实证(source/2026-10-05.log:123 同一份报文):
# userPlaylogList[0].characterLevel1..5 = 3800/4455/134/134/134 ← 快照(游玩前)
# upsertUserAll.userCharacterList = 3801/4456/135/135/135 ← 结算后
#
# ★★ 通常**不用改这里**:main.py 会调用 derive_play_characters(snapshot),
# 直接从服务器刚下发的 userData.charaSlot + userCharacterList 推出游玩前等级,
# 并覆盖本配置。只有拿不到快照(离线组包)时才回退到这里。
#
# 默认值 = 真机 charaSlot [395, 101, 400402, 400401, 200401] 的游玩前等级
# --- 本局消耗 ---
# ★ playCredit 语义(VOExtensions.cs:838-845):
# playCredit = Credit.GameCostEnoughPlay()(投币成本) + TicketManager.GetTicketCredit(useTicketId)
# 即"这一局总共折合多少 credit",不是余额、不是曲目数。真机样本 = 1。
PLAY_CREDIT = 1
# ★ -1 = 不使用票。依据 TicketManager.cs:47 初始化 _useTicketId[i] = -1;
# GetTicketCredit(-1) 里 GetTicket(-1) 返回 null -> 返回 0,即不用票不折算 credit。
PLAY_USE_TICKET_ID = -1
PLAY_MODE = 0 # 0=通常
PLAY_IS_TUTORIAL = False
PLAY_IS_EVENT_MODE = False
PLAY_IS_NEW_FREE = False
PLAY_IS_FREEDOM_MODE = False
# --- 机台本地计数器(★ 无法从服务器推导,只能手填)---
#
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:881
# result.playCount = Backup.localParameter.playCount
# 这是机台**本地**的游玩计数器,任何 Get 接口都不会返回它,
# 所以既推不出来也不会被服务端纠正 —— 填错只是个统计数字不对。
# 取值 = 该机台当天/本轮的累计游玩局数(每打一局 +1)。
# 真机实测:19:21 那一局 = 9,22:31 那一局 = 10。
# ★ PLAY_LOCAL_PLAY_COUNT 恒为 2(已下沉到 api/UpsertUserAll.py)
# --- ★ playSpecial 里的 "CalcSpecialNum"(机台硬件标志位)---
#
# 依据:source/Assembly-CSharp/Manager/GameManager.cs:332-378
# source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:846-855
#
# playSpecial = 位反转32( Random.Range(1,1037933) * 2069 + CalcSpecialNum() )
#
# CalcSpecialNum() 是**按位或**出来的机台特征掩码,逐位含义(源码原文):
# 0x001 SpecialAP (测试/特殊 AP)
# 0x002 IsAutoPlay()
# 0x004 Win32.getRawInputDeviceCount 的 keyboard > 0
# 0x008 同上 mouse > 0
# 0x010 config.IsAutoPlay / IsDebugDisp / IsAllOpen
# 0x020 AMDaemon.System.IsDevelop
# 0x040 !SystemInfo.deviceName.Contains("ACA") ← 真机是 ACA,故该位为 0
# 0x080 !graphicsDeviceName.Contains("GeForce GTX") ← 真机是 GTX,故该位为 0
# 0x100 !processorType.Contains("Core(TM) i3-8100") ← 真机是 i3-8100,故该位为 0
# 0x400 无条件置位(源码 GameManager.cs:375) ← ★ 恒为 1
#
# ★ 真机实测(gamehook 抓到 CalcSpecialNum 调用):
# [CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
# 1024 = 0x400,即除"无条件置位"那一位外全部为 0 —— 正好是一台标准 ACA 机台。
#
# ★ 反推验证:真机 playSpecial = 892014272
# 位反转32(892014272) = 57726124 = 27900 * 2069 + 1024
# 余数恰好 1024,与抓到的 CalcSpecialNum 完全吻合。
#
# 改成 0 不会报错,但 playSpecial 的位模式就不是真机那种了。
# ★ PLAY_SPECIAL_NUM = 1024 已下沉到 api/UpsertUserAll.py
# --- 用户总游玩次数增量 ---
#
# ★ 默认 0 = **userData.playCount / currentPlayCount 都不变**(本局不计入总次数)。
# 想复刻真机的「+1」就改成 1。
#
# 为什么默认改成 0:
# playCount 是账号的**总游玩次数**,是玩家可见的统计值;
# 本工程做「成绩上传」时不需要改动它,保持与上传前一致更安全。
# ★ 改成 0 之后的副作用:「落库回查」不能再靠 playCount 是否 +1 判断,
# 会自动改用 lastPlayDate 是否更新来判断(见 verify_upsert_landing)。
PLAY_USER_PLAY_COUNT_GAIN = 0
# --- 曲目历史游玩次数增量 ---
#
# ★ 默认 0 = 本局**不改动**该曲目的历史游玩次数。
# ★★ 注意语义:是「条目照传、次数不变」,**不是**「不回传这个节点」。
# 实测(同一份代码、同一账号,唯一差别就是这个节点):
# userMusicDetailList 回传 1 条 -> returnCode=1 且 playCount 真的 +1(落库)
# userMusicDetailList 留空 -> returnCode=1 但 playCount 纹丝不动(**没落库**)
# 所以「不想让乐曲 playCount 增加」必须靠把**增量**设成 0,
# 而不是把整个节点清空 —— 后者会让整包被服务端静默丢弃。
#
# 想复刻真机的「该谱面 playCount +1」就改成 1。
PLAY_MUSIC_PLAY_COUNT_GAIN = 0
# --- 地图进度增量 ---
#
# ★ 默认 0 = **本局不推进地图**,userMapList 回传空数组。
# 想复刻真机的"地图 +11000"就改成 11000(真机样本:mapId=550003 的
# distance 1424000 -> 1435000)。
#
# 依据:source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs 的地图导出逻辑
# —— 没变化的条目不会进差分结果。
PLAY_MAP_DISTANCE_GAIN = 0
# --- 组队角色等级增量 ---
#
# ★ 默认 0 = **本局不给角色加经验**,userCharacterList 回传空数组。
# 想复刻真机的"组队 5 只各 +1 级"就改成 1(真机样本 4455->4456 等)。
#
# ★ 注意:设成 0 时 userCharacterList 差集为空 -> 该节点不回传,
# 这是**预期的**,不是错误;validate_before_send 也只在你把增量设成非 0
# 却又没产出条目时才告警。
PLAY_CHARACTER_EXP_GAIN = 0
# --- ★ 关于 userGetPointList / userLoginBonusList(留空即可)---
#
# 这两个节点是**登录奖励发放**才产生的,普通游玩结算不需要回传:
#
# userGetPointList(里程/点数明细)
# 真机 39 次 UpsertUserAll 样本里,**25 次为空(64%)**;
# 只有发生登录奖励/任务奖励时才非空(如 getKind=1 point=200)。
# 留空 => 服务端不累加 point,账户点数保持不变。
#
# userLoginBonusList(签到奖励)
# 同理,只有本次登录触发新签到才回传那一条(isCurrent=true)。
# 留空 => isNewLoginBonusList 也是空串。
#
# ★ 连带影响(正常现象):留空后 userData.point 保持基线值不变。
#
# 源码参考(如需还原发放行为):
# Manager/UserData.cs:57-62 enum MaiMileGetKind { Mission=1, FriendBonus=2, Present=3 }
# Manager/UserData.cs:352-367 AddMissionPointLog / AddFriendBonusPointLog
# --- 店铺库存查询清单(不填则跳过该节点)---
PLAY_SHOP_ITEM_IDS = []
# =============================================================================
# 五、UpsertUserAll 安全闸门
# =============================================================================
#
# ★ 这是**唯一有副作用**的接口:会把本局成绩真实写进服务器,不可撤销。
# 保持 False(默认),调用时必须显式传 confirm=True 才真正发包。
# 改成 True 会永久放行(不建议)。
UPSERT_CONFIRM = False
# 真实发包前的冷却等待(秒)
#
# ★ 语义:**每次**真实发送 UpsertUserAll 之前,都固定等待这么多秒(含首次)。
# 目的:让上报节奏贴近真机 —— 真机从登录到结算上报约 80 秒,
# 不会登录后立刻上报(source/2026-10-05.log:25 登录 → :123 上报,间隔 81 秒)。
#
# ★ 只作用于**真实发送**(confirm=True);dry_run 不等待。
# ★ 冷却发生在「发包前自检」之后,所以自检有问题时会先看到告警,不必白等。
# ★ 调试时想跳过等待:把这里改成 0,或调用时传 cooldown_sec=0。
UPSERT_SEND_COOLDOWN_SEC = 60
# =============================================================================
# 六、日志级别
# =============================================================================
#
# "info" 只输出 api 脚本的运行情况与结果:
# === XxxApi 开始运行 ===
# === XxxApi 运行结束 ===
#
# "debug" 在上面基础上,额外输出向 api 发送的数据与 api 返回的数据:
# → 请求 XxxApi URL / 请求头 / 请求体 JSON 全文
# ← 响应 XxxApi 状态码 / 耗时 / 响应头 / 响应体 JSON 全文
#
LOG_LEVEL = "info"
# 单条日志里内嵌长文本的截断长度(0 = 不截断)
LOG_VALUE_MAX = 0
# =============================================================================
# 导出
# =============================================================================
__all__ = [
# 网络与加密
"BASE_URL", "AES_KEY", "AES_IV", "OBFUSCATE_PARAM", "MAI_ENCODING",
"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",
# 扫码登录
"AIMEDB_HOST", "AIMEDB_PORT", "AIMEDB_PATH", "QR_SIGN_KEY",
"QR_OPEN_GAME_ID", "QR_TITLE_KEY", "QR_USER_AGENT",
# 歌曲数据
"PLAY_MUSIC_ID", "PLAY_MUSIC_LEVEL", "PLAY_TRACK_NO",
"PLAY_VERSION_INT", "PLAY_VERSION_STR",
"PLAY_ACHIEVEMENT", "PLAY_DELUXSCORE", "PLAY_SCORE_RANK", "PLAY_IS_CLEAR",
"PLAY_MAX_COMBO", "PLAY_TOTAL_COMBO", "PLAY_MAX_SYNC", "PLAY_TOTAL_SYNC",
"PLAY_COMBO_STATUS", "PLAY_SYNC_STATUS", "PLAY_FAST_COUNT", "PLAY_LATE_COUNT",
"PLAY_JUDGE_COUNTS",
"PLAY_BEFORE_RATING", "PLAY_AFTER_RATING",
"PLAY_BEFORE_GRADE", "PLAY_AFTER_GRADE", "PLAY_AFTER_GRADE_RANK",
"PLAY_BEFORE_DELUX_RATING", "PLAY_AFTER_DELUX_RATING",
"PLAY_CATEGORY_INDEX", "PLAY_MUSIC_INDEX", "PLAY_EXTRA_FLAG",
"PLAY_CHARACTERS", "PLAY_CREDIT", "PLAY_USE_TICKET_ID", "PLAY_MODE",
"PLAY_IS_TUTORIAL", "PLAY_IS_EVENT_MODE", "PLAY_IS_NEW_FREE", "PLAY_IS_FREEDOM_MODE",
"PLAY_LOCAL_PLAY_COUNT", "PLAY_SPECIAL_NUM",
"PLAY_USER_PLAY_COUNT_GAIN",
"PLAY_MUSIC_PLAY_COUNT_GAIN", "PLAY_MAP_DISTANCE_GAIN", "PLAY_CHARACTER_EXP_GAIN",
"PLAY_SHOP_ITEM_IDS",
# 闸门
"UPSERT_CONFIRM", "UPSERT_SEND_COOLDOWN_SEC",
# 日志
"LOG_LEVEL", "LOG_VALUE_MAX",
]
+487
View File
@@ -0,0 +1,487 @@
# UpsertUserAll 实跑数据 vs 真机日志 逐条对比
> **本文档数据来源**
> - **实跑侧**:用你提供的二维码真实跑通了「扫码 → 预览 → 登录 → 抓快照 → 构造 UpsertUserAll → 登出」,
> 报文由 `api/upsert_user_all.py` 生成(**dry-run,未发包**)
> - **真机侧**:`source/2026-10-05.log:123` 的 UpsertUserAllApi 请求报文
>
> **结论速览**
>
> | 维度 | 结果 |
> |---|---|
> | 顶层字段 | 7/7 结构一致 |
> | 36 个节点 | **形态 36/36 全部一致** |
> | 11 条位串 | **11/11 全部一致**(含 `isNewFavoriteList="22212"`) |
> | userData 字段 | 76/82 命中(6 处差异全部可解释,见 §5) |
> | userPlaylog 字段 | 106/110 命中(4 处差异全部可解释,见 §6) |
---
## 一、本次实跑记录
| 项目 | 值 |
|---|---|
| 二维码时间戳 | `261005175231`(2026-10-05 17:52:31) |
| user_id | 12345678 |
| 昵称 / Rating | ☆Astral☆ / 15000 |
| loginId | **7693120247791879616** |
| loginDateTime | **1791222938** |
| dateTime(Auth.AuthTime) | **1791194138** |
| 快照节点 | 19 个全部成功(`_failed: []`) |
| 是否发包 | **否**(dry_run=True) |
### 快照抓到的条数(对比真机日志的 Get 侧)
| 节点 | 本次实跑 | 真机 2026-10-05 | 说明 |
|---|---:|---:|---|
| userCharacterList | 312 | 312 | 一致 |
| userMapList | 104 | 104 | 一致 |
| userLoginBonusList | 38 | 38 | 一致 |
| userItemList | 1070 | 1070 | 一致 |
| userMusicDetailList | **1037** | 739(组数) | 实跑已摊平成单条,真机是 739 组嵌套 |
| userCourseList | 16 | 16 | 一致 |
| userChargeList | 3 | 3 | 一致 |
| userFavoriteList | 5 | 5 | 一致 |
| userMissionDataList | 6 | 6 | 一致 |
| userIntimateList | 2 | 2 | 一致 |
| userGhost | 15 | 15 | 一致 |
> ★ `userMusicDetailList` 1037 是**摊平后**的 (musicId, level) 条目数;真机日志的 739 是**按曲分组**的组数。
> 两者不是同一口径,摊平后 739 组共 1037 条。
---
## 二、顶层结构对比
```jsonc
// 实跑产出(api/upsert_user_all.py 生成)
{
"userId": 12345678,
"playlogId": 7693120247791879616, // = UserLoginApi 的 loginId
"isEventMode": false,
"isFreePlay": false,
"loginDateTime": 1791222938, // = UserLoginApi 的 loginDateTime
"userPlaylogList": [ { ...110 字段... } ],
"upsertUserAll": { ...36 节点... }
}
```
| 字段 | 真机 | 本次实跑 | 判定 |
|---|---|---|---|
| `userId` | 12345678 | 12345678 | 一致 |
| `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同**(不同次登录,正常) |
| `isEventMode` | false | false | 一致 |
| `isFreePlay` | false | false | 一致 |
| `loginDateTime` | 1791159576 | 1791222938 | **不同**(不同次登录,正常) |
| `userPlaylogList` | 1 条 | 1 条 | 一致 |
| `upsertUserAll` | 36 节点 | 36 节点 | 一致 |
> `playlogId` / `loginDateTime` 两次必然不同 —— 它们是**本次登录会话**产生的,
> 不是固定值。关键是它们与**本次** `UserLoginApi` 的返回值一致(已核对)。
---
## 三、36 个节点逐节点对比
| # | 节点 | 真机 | 实跑 | 形态 |
|---|---|---|---:|---|
| 1 | `userData` | 1 条 | 1 条 | 一致 |
| 2 | `userExtend` | 1 条 | 1 条 | 一致 |
| 3 | `userOption` | 1 条 | 1 条 | 一致 |
| 4 | `userCharacterList` | 5 条 | 5 条 | 一致 |
| 5 | `userGhost` | 0 条 | 0 条 | 一致 |
| 6 | `userMapList` | 1 条 | 1 条 | 一致 |
| 7 | `userLoginBonusList` | 0 条 | 0 条 | 一致 |
| 8 | `userRatingList` | 1 条 | 1 条 | 一致 |
| 9 | `userItemList` | 0 条 | 0 条 | 一致 |
| 10 | `userMusicDetailList` | 1 条 | 1 条 | 一致 |
| 11 | `userCourseList` | 0 条 | 0 条 | 一致 |
| 12 | `userFriendSeasonRankingList` | 0 条 | 0 条 | 一致 |
| 13 | `userChargeList` | **3 条** | **3 条** | 一致(整表回传) |
| 14 | `userFavoriteList` | **5 条** | **5 条** | 一致(恒 5 条) |
| 15 | `userActivityList` | 1 条 | 1 条 | 一致 |
| 16 | `userMissionDataList` | 6 条 | 6 条 | 一致 |
| 17 | `userWeeklyData` | 对象 | 对象 | 一致 |
| 18 | `userGamePlaylogList` | 1 条 | 1 条 | 一致 |
| 19 | `user2pPlaylog` | 对象 | 对象 | 一致 |
| 20 | `userIntimateList` | 0 条 | 0 条 | 一致 |
| 21 | `userShopItemStockList` | 0 条 | 0 条 | 一致 |
| 22 | `userGetPointList` | 0 条 | 0 条 | 一致 |
| 23 | `userTradeItemList` | 0 条 | 0 条 | 一致 |
| 24 | `userFavoritemusicList` | 0 条 | 0 条 | 一致 |
| 25 | `userKaleidxScopeList` | 0 条 | 0 条 | 一致 |
| 26-36 | 11 条 `isNew*` 位串 | — | — | 见下节 |
**形态命中 36/36。**
---
## 四、11 条位串对比(全部一致)
| 位串 | 真机 | 实跑 | 一致 |
|---|---|---|---|
| `isNewCharacterList` | `"00000"` | `"00000"` | ✅ |
| `isNewMapList` | `"0"` | `"0"` | ✅ |
| `isNewLoginBonusList` | `""` | `""` | ✅ |
| `isNewItemList` | `""` | `""` | ✅ |
| `isNewMusicDetailList` | `"0"` | `"0"` | ✅ |
| `isNewCourseList` | `""` | `""` | ✅ |
| **`isNewFavoriteList`** | **`"22212"`** | **`"22212"`** | ✅ |
| `isNewFriendSeasonRankingList` | `""` | `""` | ✅ |
| `isNewUserIntimateList` | `""` | `""` | ✅ |
| `isNewFavoritemusicList` | `""` | `""` | ✅ |
| `isNewKaleidxScopeList` | `""` | `""` | ✅ |
> **`isNewFavoriteList = "22212"` 是本次最值得注意的一处**:
> 第 4 位是 `"1"`(而不是 `"2"`),因为服务器对 `itemKind=10`(搭档)的 Get 响应
> 回的是 `{"itemKind":null, "itemIdList":[]}` —— 服务端怪癖。
> 实现上用**未归一化的原始响应**作差分基线,才能复现出这个 `"1"`。
---
## 五、userData[0] 逐字段对比
真机 82 字段,实跑 82 字段,**命中 76/82**。
### 5.1 一致的关键字段(节选)
| 字段 | 真机 | 实跑 | 说明 |
|---|---|---|---|
| `userName` | ☆Astral☆ | ☆Astral☆ | 一致 |
| `playerRating` | 15000 | 15000 | 一致 |
| `point` / `totalPoint` | 4110 / 52620 | 4110 / 52620 | 一致 |
| `iconId` / `plateId` / `titleId` | 559501 / 458001 / 55502 | 同 | 一致 |
| `charaSlot` | [395,101,400402,400401,200401] | 同 | 一致 |
| `lastGameId` | "SDGB" | "SDGB" | 一致(兜底填充) |
| `accessCode` | "" | "" | 一致(兜底填充) |
| `lastPlaceId` / `lastPlaceName` | 1000 / 测试店铺… | 同 | 一致 |
| `lastRegionId` / `lastRegionName` | 5 / 测试区域 | 同 | 一致 |
| `lastClientId` | "TESTCLIENT1" | "TESTCLIENT1" | 一致 |
| `lastPlayMode` | 0 | 0 | 一致(归 0) |
| `banState` | 0 | 0 | 一致(已搬进 userData 内部) |
| `lastRomVersion` | "1.56.00" | "1.56.00" | 一致 |
### 5.2 六处差异(全部可解释)
| 字段 | 真机 | 实跑 | 原因 |
|---|---|---|---|
| `playCount` | 1258 | **1259** | 基线不同:真机基线 1257,实跑基线 1258(真机那局已写入)。**增量都是 +1** ✅ |
| `currentPlayCount` | 186 | **187** | 同上,增量都是 +1 ✅ |
| `dateTime` | 1791134329 | **1791194138** | 本次登录的 Auth.AuthTime(不同次登录) ✅ |
| `lastLoginDate` | 2026-10-05 00:19:37.0 | **2026-10-05 17:55:38.0** | 本次登录时刻 ✅ |
| `lastPlayDate` | 2026-10-05 00:20:57.0 | **2026-10-05 17:55:38.0** | 本次游玩时刻 ✅ |
| `eventWatchedDate` | 2026-10-05 00:19:37.0 | **2026-10-05 17:55:38.0** | 本次登录时刻 ✅ |
> **验证方式**:把实跑的 `playCount` 减 1 得 1258,正好等于本次快照基线;
> 真机的 1258 减 1 得 1257,等于真机快照基线。**两边的增量逻辑完全一致。**
### 5.3 实跑 userData 全文(节选关键段)
```json
{
"accessCode": "",
"userName": "☆Astral☆",
"playCount": 1259,
"currentPlayCount": 187,
"eventWatchedDate": "2026-10-05 17:55:38.0",
"lastGameId": "SDGB",
"lastLoginDate": "2026-10-05 17:55:38.0",
"lastPlayDate": "2026-10-05 17:55:38.0",
"lastPlayCredit": 1,
"lastPlayMode": 0,
"lastPlaceId": 1000,
"lastPlaceName": "测试店铺",
"lastRegionId": 5,
"lastRegionName": "测试区域",
"lastClientId": "TESTCLIENT1",
"lastCountryCode": "CHN",
"dateTime": 1791194138,
"banState": 0
}
```
---
## 六、userPlaylogList[0] 逐字段对比(110 字段)
**命中 106/110。**
### 6.1 四字段差异(全部可解释)
| 字段 | 真机 | 实跑 | 原因 |
|---|---|---|---|
| `playlogId` | 7692848101484135861 | 7693120247791879616 | 本次登录的 loginId ✅ |
| `loginDate` | 1791130777 | 1791194138 | 标准 Unix 秒 = loginDateTime − 28800 ✅ |
| `playDate` | "2026-10-04" | "2026-10-05" | **日志日**(7 点前算前一天)——真机游玩在 00:20 所以回退到 10-04;实跑在 17:55 不回退 ✅ |
| `userPlayDate` | "2026-10-05 00:20:30.0" | "2026-10-05 17:55:38.0" | 游玩时刻 ✅ |
> **`playDate` 的差异恰好验证了算法正确性**:
> 真机在 00:20 游玩 → `playDate` 回退为前一天 `2026-10-04`;
> 实跑在 17:55 → `playDate` 保持 `2026-10-05`。
> 这正是 `TimeManager.ToLogDateTime`(`TimeManager.cs:77-85`,7 点分界)的行为。
### 6.2 完全一致的 106 个字段(节选关键项)
| 字段 | 值 | 说明 |
|---|---|---|
| `userId` | 0 | 恒为 0 |
| `version` | 1056000 | 1.56.00 |
| `placeId` / `placeName` | 1000 / 测试店铺… | 一致 |
| `musicId` / `level` | 143 / 4 | 一致 |
| `trackNo` | 1 | 一致 |
| `playerNum` | 1 | 一致 |
| `characterId1..5` | 395,101,400402,400401,200401 | 一致 |
| `characterLevel1..5` | 3800,4455,134,134,134 | 一致(**游玩前快照,不加经验**) |
| `achievement` / `deluxscore` | 0 / 0 | 一致 |
| `totalCombo` | 655 | 一致 |
| `tapMiss` / `holdMiss` / `slideMiss` / `breakMiss` | 441 / 19 / 192 / 3 | 一致 |
| `isTap` / `isHold` / `isSlide` / `isTouch` / `isBreak` | true,true,true,false,true | 一致 |
| `isClear` | false | 一致 |
| `trialPlayAchievement` | -1 | 一致 |
| `extNum4` | 2022 | 一致(= categoryIndex) |
| `extBool1/2/3` | false | 一致(**bool 而非 0**) |
| `vsUserName` / `playedUserName1..3` | "" | 一致(**空串而非 0**) |
---
## 七、关键节点逐条示例
### 7.1 `userMusicDetailList`(差分:只回传本局那条)
**真机**
```json
[
{ "musicId": 143, "level": 4, "playCount": 8, "achievement": 0,
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
"scoreRank": 0, "extNum1": 0 }
]
```
**实跑**
```json
[
{ "musicId": 143, "level": 4, "playCount": 9, "achievement": 0,
"comboStatus": 0, "syncStatus": 0, "deluxscoreMax": 0,
"scoreRank": 0, "extNum1": 0 }
]
```
**对比**:结构、字段、键序完全一致;`playCount` 真机 7→8、实跑 8→9,
**都是基线 +1**。Get 侧有 1037 条,这里只回传本局那 1 条 —— 差分生效。
---
### 7.2 `userMapList`(差分:只回传变化那条)
**真机**
```json
[
{ "mapId": 550003, "distance": 1435000, "isLock": false,
"isClear": false, "isComplete": false, "unlockFlag": 0 }
]
```
**实跑**
```json
[
{ "mapId": 550003, "distance": 1446000, "isLock": false,
"isClear": false, "isComplete": false, "unlockFlag": 0 }
]
```
**对比**:`mapId` 相同(都是当前 `selectMapId`);
真机 1424000→1435000(+11000),实跑 1435000→1446000(+11000),
**增量完全一致**。Get 侧有 104 条,只回传 1 条。
---
### 7.3 `userCharacterList`(差分:组队 5 只各 +1 级)
**真机**
```json
[
{ "characterId": 101, "level": 4456, "awakening": 5, "useCount": 0 },
{ "characterId": 395, "level": 3801, "awakening": 5, "useCount": 0 },
{ "characterId": 200401, "level": 135, "awakening": 3, "useCount": 0 },
{ "characterId": 400401, "level": 135, "awakening": 3, "useCount": 0 },
{ "characterId": 400402, "level": 135, "awakening": 3, "useCount": 0 }
]
```
**实跑**
```json
[
{ "characterId": 395, "useCount": 0, "level": 3802, "awakening": 5 },
{ "characterId": 101, "useCount": 0, "level": 4457, "awakening": 5 },
{ "characterId": 400402, "useCount": 0, "level": 136, "awakening": 3 },
{ "characterId": 400401, "useCount": 0, "level": 136, "awakening": 3 },
{ "characterId": 200401, "useCount": 0, "level": 136, "awakening": 3 }
]
```
**对比**:
- 条目数都是 **5**(只回传组队角色,Get 侧有 312 条)
- 每只都是 **基线 +1 级**:真机 4455→4456、3800→3801、134→135;实跑 4456→4457、3801→3802、135→136
- 字段集合一致(`characterId`/`level`/`awakening`/`useCount`)
- 唯一差别是**键序**(真机按 characterId 升序,实跑按 charaSlot 顺序)—— JSON 对象键序不影响语义
---
### 7.4 `userChargeList`(★ 整表回传,不做差分)
**真机**(3 条)与**实跑**(3 条)**完全相同**:
```json
[
{ "chargeId": 2, "stock": 0, "purchaseDate": "2026-07-14 18:33:37", "validDate": "2026-10-12 04:00:00" },
{ "chargeId": 3, "stock": 0, "purchaseDate": "2026-10-02 10:49:23", "validDate": "2026-12-31 04:00:00" },
{ "chargeId": 5, "stock": 0, "purchaseDate": "2026-10-04 18:22:10", "validDate": "2027-01-02 04:00:00" }
]
```
**对比**:Get 侧 3 条 → Upsert 侧也是 3 条。
说明 `userChargeList` 是**整表回传**,**不能**当差分节点处理
(否则会被误收敛成空数组)。实跑已正确处理。
---
### 7.5 `userFavoriteList`(★ 恒 5 条,位串 "22212")
**真机**
```json
[
{ "itemKind": 3, "itemIdList": [559501] },
{ "itemKind": 1, "itemIdList": [] },
{ "itemKind": 2, "itemIdList": [] },
{ "itemKind": 10, "itemIdList": [] },
{ "itemKind": 11, "itemIdList": [] }
]
```
位串:`"22212"`
**实跑**
```json
[
{ "itemKind": 3, "itemIdList": [559501] },
{ "itemKind": 1, "itemIdList": [] },
{ "itemKind": 2, "itemIdList": [] },
{ "itemKind": 10, "itemIdList": [] },
{ "itemKind": 11, "itemIdList": [] }
]
```
位串:`"22212"`
**对比**:**逐字节一致**,包括位串。
第 4 位 `"1"` 的来源:服务器对 `itemKind=10` 回的是 `{"itemKind":null}`,
差分基线查不到该 kind → 位 `"1"`。
---
### 7.6 `userWeeklyData`(★ 对象,非数组)
**真机**
```json
{ "lastLoginWeek": "2026-09-28 04:00:00", "beforeLoginWeek": "2026-09-21 04:00:00", "friendBonusFlag": true }
```
**实跑**
```json
{ "lastLoginWeek": "2026-10-05 04:00:00", "beforeLoginWeek": "2026-09-28 04:00:00", "friendBonusFlag": true }
```
**对比**:结构与键完全一致。周界值差一周是因为**本次登录已跨周**
(实跑时服务器 `GetGameWeeklyDataApi` 下发的 `updateDate` 已是 2026-10-05)。
该值取自服务器下发的 `userWeeklyData`,不是本地推导。
---
### 7.7 `user2pPlaylog`(★ 对象,非数组)
真机与实跑**完全一致**:
```json
{ "userId1": 0, "userId2": 0, "userName1": "", "userName2": "",
"regionId": 0, "placeId": 0, "user2pPlaylogDetailList": [] }
```
---
### 7.8 `userGamePlaylogList`
**真机**
```json
[{ "playlogId": 7692848101484135861, "version": "1.56.00",
"playDate": "2026-10-05 00:20:57.0", "playMode": 0, "useTicketId": -1,
"playCredit": 1, "playTrack": 1, "clientId": "TESTCLIENT1",
"isPlayTutorial": false, "isEventMode": false, "isNewFree": false,
"playCount": 8, "playSpecial": 55951502, "playOtherUserId": 0 }]
```
**实跑**
```json
[{ "playlogId": 7693120247791879616, "version": "1.56.00",
"playDate": "2026-10-05 17:55:38.0", "playMode": 0, "useTicketId": -1,
"playCredit": 1, "playTrack": 1, "clientId": "TESTCLIENT1",
"isPlayTutorial": false, "isEventMode": false, "isNewFree": false,
"playCount": 8, "playSpecial": 16417894, "playOtherUserId": 0 }]
```
**对比**:13 个字段名与类型完全一致。
- `playlogId` / `playDate` 是本次会话值(正常)
- `playSpecial` 两边都是**随机位掩码**(真机 55951502,实跑 16417894),
按 `VOExtensions.cs:846-855` 的算法生成,每次不同 —— 正确
- `useTicketId=-1`(不使用票)、`playCredit=1`、`playCount=8` 全部命中
---
## 八、差异汇总与判定
| 类别 | 差异项 | 判定 |
|---|---|---|
| 顶层 | `playlogId`、`loginDateTime` | ✅ 正常(每次登录不同) |
| userData | `playCount`、`currentPlayCount` | ✅ 正常(基线不同,**增量都是 +1**) |
| userData | `dateTime`、`lastLoginDate`、`lastPlayDate`、`eventWatchedDate` | ✅ 正常(本次会话时刻) |
| playlog | `playlogId`、`loginDate`、`playDate`、`userPlayDate` | ✅ 正常(本次会话时刻 + 日志日算法) |
| 结构 | 无 | ✅ **36 节点形态、11 位串全部一致** |
**没有任何一处是「实现错误」导致的差异。**
---
## 九、本次实跑发现并修复的 3 个 Bug
| # | 问题 | 根因 | 修复 |
|---|---|---|---|
| 1 | `userData.dateTime` 为 `null` | `UserLogin` 没有回填它实际发出的 `dateTime` | `UserLoginResponseVO` 增加 `dateTime` 字段并在登录时回填 |
| 2 | `userFavoriteList` 为空、位串为 `""` | 取数脚本返回的字典用**节点名**作键(`userFavoriteList`),而原始响应用 `userFavorite`;提取逻辑只认后者 | 提取时增加按节点名兜底 |
| 3 | `userWeeklyData` 周界比真机晚一周 | 用了本地推导而非服务器下发值 | 优先从 `GetUserMissionDataApi` 响应的 `userWeeklyData` 取(`UserMissionResponseVO.cs:10`) |
修复后重跑,**结构差异归零**。
---
## 十、怎么复现这次对比
```powershell
# 1) 实跑(dry-run,不发包)—— 把二维码换成你自己的
python upsert.py "<二维码URL>"
# 2) 只看构造出的报文(不联网)
python -c "import json;d=json.load(open('upsert_payload.json',encoding='utf-8'));print(json.dumps(d['upsertUserAll']['userData'],ensure_ascii=False,indent=2))"
# 3) 零网络自检(129 项)
python selftest.py
```
> **注意**:本次实跑**没有真实发送** UpsertUserAll。
> 要真实上传需显式加 `--send`(会把成绩写进服务器,不可撤销)。
---
## 十一、相关文件
| 文件 | 说明 |
|---|---|
| `api/upsert_user_all.py` | 上传成绩实现(36 节点 / 110 字段 / 差分) |
| `api/upsert_snapshot.py` | 结算快照编排 |
| `docs/上传成绩开发文档.md` | 字段级开发文档 |
| `source/2026-10-05.log:123` | 真机报文(本文档对比基准) |
| `docs/UpsertUserAll实跑对比.md` | 本文档 |
+175
View File
@@ -0,0 +1,175 @@
# UpsertUserAll 落库排查报告
> **背景**:`python upsert.py "<二维码>" --send` 修复闸门后能真实发包,
> 服务端返回 `returnCode=1`,但**数据未写入服务器**。
> 本文档记录已完成的全部排查与结论。
---
## 一、结论速览
| 项 | 状态 |
|---|---|
| 闸门放行 | 已修复,`returnCode=1` |
| 报文与真机对齐 | **非预期差异归零** |
| 数据落库 | **未落库**(`point`/`playCount` 回查不变) |
| 根因 | **未确定**,已排除 6 类可能 |
---
## 二、已修复的 5 个 Bug
### Bug 1:`upsert.py` 漏传 `confirm`(你遇到的直接原因)
`upsert.py` 原来只传 `dry_run=not opts["send"]`,但 `UpsertUserAll` 的规则是
`confirm=None` 时取 `config.UPSERT_CONFIRM`(默认 `False`),所以照样被拦。
```python
# 修复前
dry_run=not opts["send"],
# 修复后
dry_run=not opts["send"],
confirm=bool(opts["send"]), # 关键
```
### Bug 2:`userExtend` 缺 2 个字段
| | Get 侧 | Upsert 侧 |
|---|---|---|
| 键数 | 14 | **16** |
| 差 | — | `isPhotoAgree`、`isGotoCodeRead` |
这两个字段**只在 Upsert 侧出现**,必须补上。
### Bug 3:`userRating.udemae` 有 7 个冗余大写字段
服务器 Get 响应里 `npcWinNum` 与 `NpcWinNum` **并存**(23 键):
```json
// Get 侧(source/2026-10-05.log:87)
{ "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, ... } // 23 键
// Upsert 侧(source/2026-10-05.log:123)
{ "npcWinNum": 0, ... } // 16 键
```
源码 `Net.VO.Mai2/UserUdemae.cs` 只声明了**小写的 16 个**,所以大写那批是
服务端冗余输出,回传时必须裁掉。修复前请求体比真机大 119 字节。
### Bug 4:`userCharacterList` 排序错
| | 顺序 |
|---|---|
| `charaSlot` | [395, 101, 400402, 400401, 200401] |
| 真机 `userCharacterList` | **[101, 395, 200401, 400401, 400402]**(按 characterId 升序) |
回传时按 `characterId` 升序,不是按 `charaSlot` 顺序。
### Bug 5:扫码用的 chipID 形式错
| 用途 | 正确值 | 源码依据 |
|---|---|---|
| aimedb 扫码 | `XXXX-XXXXXXXXXXX`(**完整**) | `ChimeReaderManager.cs:250` `KeychipId.Value` |
| 游戏服务器 | `TESTCLIENT1`(短值) | `PacketGetGameSetting.cs:23` `KeychipId.ShortValue` |
我原来扫码也用了短值。已按源码修正为完整形式。
---
## 三、报文对齐最终状态
```
顶层键 : 7/7
36 节点形态 : 36/36
userData : 82/82 键
userPlaylog : 110/110 键
userExtend : 16/16 键
udemae : 16/16 键
非预期差异 : 0
请求体大小 : 17158 vs 17161 字节(差 3 字节,来自数值位数)
```
**深度递归对比(逐字段、逐元素)后,除「不同次登录必然不同」的值外,零差异。**
---
## 四、已排除的可能(6 类)
| # | 假设 | 验证方式 | 结果 |
|---|---|---|---|
| 1 | 时序问题(写入有延迟) | 发送后 T+0 / T+20s / T+45s 三次回查 | 都不变 |
| 2 | 差分节点为空导致「看不出变化」 | 直接改绝对值(`point` 4110→4111) | 仍不写 |
| 3 | 字段缺失/多余 | 深度递归键集合 + 值对比 | 已归零 |
| 4 | `number` 请求头缺失 | 补上 `Packet.cs:170` 要求的头 | 仍不写 |
| 5 | `playSpecial` 值不对 | 查源码 `CalcSpecialNum` = 硬件标志位 | 非根因 |
| 6 | 缺少 `GetUserNewItemList` 前置 | 完整复刻真机 121→123 流程 | 仍不写 |
### 关于第 2 项的探测细节
这是最关键的一次探测 —— 直接 `patch` 把 `userData.point` 从 4110 改成 4111 发上去:
```
baseline point = 4110
上传成绩成功:returnCode=1
T+ 0s point = 4110
T+20s point = 4110
T+45s point = 4110
```
**连最简单的整数字段都不写**,说明不是「数据不对」而是「整包没被接受」。
---
## 五、当前判断
服务端**收下了**请求(返回正确的 `apiName`),但**没有应用**。
可能的方向(未验证):
1. **账号/机台绑定校验** —— token 可能绑定了特定 keychip,而 `XXXX-XXXXXXXXXXX`
只是文档里的示例值,不是**你实际机台**的 keychip。
若服务端校验「token 对应的机台 ≠ 请求里的 chipID」,就会静默拒收。
2. **需要真实游玩产生的会话状态** —— 真机的 Upsert 发生在**真实游玩之后**,
服务端可能记录了「本次 loginId 下有一局真实游玩」才允许结算。
纯网络脚本没有这个前置状态。
3. **服务端侧的风控/审计** —— 真机与脚本的 TLS 指纹、请求节奏等特征不同。
---
## 六、需要你提供的信息
要继续定位,最有价值的是:
| # | 信息 | 为什么关键 |
|---|---|---|
| 1 | **你机台的真实 Keychip ID** | 若 `XXXX-XXXXXXXXXXX` 不是你的机台,方向 1 就成立 |
| 2 | 该机台在 `keychip.csv` 里的对应行 | 可确认 keychip 与店铺/区域的绑定关系 |
| 3 | 真机跑一局后立刻导出 Upsert 报文 | 可拿同账号同机台的最新报文做 1:1 对比 |
> `source/keychip.csv` 有 229KB 全量机台对照表,如果你能告诉我机台所在的
> 店铺 ID,我可以从中查出正确的 keychip。
---
## 七、本次排查的正面成果
即使尚未落库,这次排查也修掉了 **5 个真实 Bug**,其中 3 个(`userExtend` 缺字段、
`udemae` 冗余字段、`userCharacterList` 排序)是**只有做深度值对比才能发现**的:
- 修复前:报文与真机有 11 处非预期差异
- 修复后:**0 处**
自检 129/129 仍全绿。
---
## 八、复现方式
```powershell
# 1) 真实发送 + 落库验证(会写服务器,不可撤销)
python upsert.py "<二维码>" --send
# 2) 零网络自检
python selftest.py
```
+670
View File
@@ -0,0 +1,670 @@
# UpsertUserAll 踩坑清单
> **这份文档只讲「坑」** —— 每一个都是我实际踩过、并且用真机数据或反编译源码验证过的。
> 想看接口全貌请读 [`上传成绩开发文档.md`](上传成绩开发文档.md);
> 想看真实跑通证据请读 [`实跑验证记录.md`](实跑验证记录.md)。
>
> ---
> **判据来源**(全部可查):
>
> | 来源 | 内容 |
> |---|---|
> | `source/2026-10-05.log:123` | 真机 00:20 会话的 UpsertUserAll 报文 |
> | `source/2026-10-05-new.log:123` | 真机 19:21 会话的 UpsertUserAll 报文 |
> | `MaimaiNetSpy/raw/068_*.json` | 真机 22:31 会话的 UpsertUserAll 报文(MelonLoader hook 抓) |
> | `source/Assembly-CSharp/` | 反编译源码(每个坑都标了行号) |
>
> **三个真机会话的横向对照**是本文最有力的工具 —— 很多坑只有横向比才看得出来。
---
## 零、先记住这句话
> ### ★ 服务端返回 `returnCode=1` 只代表「报文格式合法」,**不代表数据被应用**。
「`returnCode=1` 但 `playCount` 纹丝不动」是这条路线上**最主要的失败模式**,
而且它有**至少 4 个完全不同的成因**(坑 1 / 2 / 3 / 4)。所以:
**唯一的验收标准是「发包后重新拉一次服务器数据,看数字有没有动」。**
`upsert.py --send` 已经内置了这个回查(第 7.5 步),会打印 `【落库成功】` 或 `【疑似未落库】`。
不要只看 `returnCode`。
---
## 一、致命坑:会让「回码成功但什么都没发生」的
### 坑 1:`userData` 是**整表覆盖**语义,不是「只写你改的字段」
**现象**:把 `GetUserDataApi` 拿到的 `userData` 原样塞进 Upsert 发出去,`returnCode=1`,
回查 `playCount` / `point` **完全不变**。
**根因**:服务端对 `userData` 这个单元素数组是**整表覆盖**。
你回传旧值 = 覆盖成旧值 = 「看起来什么都没发生」。
**正确做法**:必须先按本局演算出**新值**再整表回传。
| 字段 | Get 侧(登录时) | 必须回传的 Upsert 侧 |
|---|---|---|
| `playCount` | 1257 | **1258**(+1) |
| `currentPlayCount` | 185 | **186**(+1) |
| `lastPlayDate` | 2026-10-04 18:46:31 | **2026-10-05 00:20:57.0**(本局上报时刻) |
| `lastPlayMode` | 2 | **0** |
| `lastLoginDate` | 2026-10-04 18:34:22 | **2026-10-05 00:19:37.0**(本次登录时刻) |
| `lastPlaceId` | 0 | **1000** |
| `lastRegionId` | 11 | **5** |
| `dateTime` | null | **1791134329**(登录请求里的 dateTime) |
| `lastGameId` | null | **"SDGB"** |
| `accessCode` | null | **""** |
对应实现:`apply_play_effects()`(`api/upsert_user_all.py`)。
**自检**:`validate_before_send()` 会检查
`playCount` / `currentPlayCount` 是否为 0、`dateTime` 是否为 0、
`lastPlayDate` / `lastLoginDate` 是否为空。
---
### 坑 2:差分节点全空 = 服务端认为「本局什么都没变」★ 已用 A/B 实验证实
**现象**:`userData` 改对了,但 `userMusicDetailList` / `userMapList` / `userCharacterList`
全是 `[]`,回码成功但战绩表没多一条。
**根因**:这些节点是**差分语义** —— 只回传「本局发生变化的条目」。
全空等于告诉服务端「没有任何条目变化」。
#### ★★ 2026-10-06 的 A/B 对照实验(决定性证据)
同一天、同一账号、同一份代码,**唯一差别是 `userMusicDetailList` 空不空**:
| 时间 | `userMusicDetailList` | 其他差分节点 | `userData.playCount` | 结果 |
|---|---|---|---|---|
| 12:52 | **`[]`** | 都空 | 1261 → 1261 | ❌ `returnCode=1` 但**没落库** |
| 12:57 | **1 条**(次数不变) | 都空 | 1261 → **1262** | ✅ **落库** |
两次的 `returnCode` 都是 1、`apiName` 都是 `com.sega.maimai.api.UpsertUserAllApi`,
**唯一变量就是这个节点**。
> **推论(已实测)**:
> 只要 `userPlaylogList` 里有本局成绩,`userMusicDetailList` 就**必须**回传本局那条。
> 「有 playlog 却没有任何曲目明细」会被服务端当成无效包**静默丢弃**。
>
> 注意这里的关键是**节点非空**,不是**次数必须 +1** ——
> 回传条目但 `playCount` 保持不变(+0)同样能落库(12:57 那次就是)。
> 所以「不想让乐曲 playCount 增加」应该把增量设成 0,
> **而不是**把整个节点清空。见 `config.PLAY_MUSIC_PLAY_COUNT_GAIN`。
**真机对照**(同一账号 Get 侧 vs Upsert 侧):
| 节点 | Get 侧条数 | Upsert 侧条数 | 是差分吗 |
|---|---:|---:|---|
| `userMusicDetailList` | 739 组 | **1** | 是(本局那条 playCount +1) |
| `userMapList` | 104 | **1** | 是(选中地图 distance +11000) |
| `userCharacterList` | 312 | **5** | 是(组队 5 只各 +1 级) |
| `userItemList` | 1070 | **0** | 是(无变化 → 空) |
| `userCourseList` | 16 | **0** | 是(无变化 → 空) |
| `userChargeList` | 3 | **3** | ★ **否!整表回传** |
**正确做法**:`build_user_all()` 用 `DIFF_PRIMARY_KEYS` 对每个差分节点算 (新 − 基线)。
**★ 反例警告 1**:`userChargeList` **不是差分节点**,真机原样发了全部 3 条。
如果把它当差分处理(且本局没买票),会被误收敛成 `[]` —— 这是我踩过的具体坑。
**★ 反例警告 2(本次新增)**:差分收敛会把「和基线完全相同」的条目剔掉。
`PLAY_MUSIC_PLAY_COUNT_GAIN = 0` 时本局那条和基线一样,会被剔光 → 节点变空 → 整包失效。
所以 `build_user_all()` 里对这个节点做了**兜底**:
`apply_play_effects` 会把本局那条暂存到 `_played_music_entry`,
差分收敛后如果节点空了就再塞回去,并把 `isNewMusicDetailList` 置 `"0"`(存量更新)。
---
### 坑 3:`playlogId` / `loginDateTime` 必须**逐字节等于** `UserLoginApi` 的返回值
**现象**:`playlogId` 自己造一个、或用了上一局的 `loginId` → 服务端静默拒收。
**根因**(`PacketUpsertUserAll.cs:31`):
这两个值**不是本接口产生的**,而是登录会话的标识。服务端拿它们找会话,
找不到就不应用。
**正确做法**:
```python
login = UserLogin(user_id, token, ...) # 先登录
UpsertUserAll(user_id, login.loginId, # ★ 必须是它
login_date_time=login.loginDateTime, # ★ 必须是它
auth_date_time=login.dateTime, # userData.dateTime 用这个
...)
```
**★ 容易混的三个时间**:
| 变量 | 是什么 | 用在哪 |
|---|---|---|
| `login.loginDateTime` | 「本地墙上时间当 UTC」(= 标准 Unix 秒 + 28800) | 顶层 `loginDateTime` 字段 |
| `login.dateTime` | 登录**请求**里发的 `Auth.AuthTime` | `userData.dateTime` |
| `loginDate`(playlog 里) | **标准 Unix 秒** | `userPlaylogList[].loginDate` |
**中途重新登录必须重取这两个值** —— 换了会话还发旧 `loginId` 一定失败。
**自检**:`validate_before_send()` 会检查 `playlogId` / `loginDateTime` 是否为 0。
---
### 坑 4:`confirm` 参数漏传 —— 闸门根本没开,但你以为开了
**现象**:`upsert.py --send` 跑完显示「上传成功」,其实**一个包都没发**。
**根因**:`UpsertUserAll` 的闸门规则是
`confirm=None` 时取 `config.UPSERT_CONFIRM`(默认 `False`)。
所以只写 `dry_run=False` **不够**,必须同时显式给 `confirm=True`。
```python
# ✗ 错的:dry_run=False 但 confirm 没给 → 走 config 的 False → 被拦
UpsertUserAll(..., dry_run=False)
# ✓ 对的
UpsertUserAll(..., dry_run=False, confirm=True)
```
**这是「排查了半天以为服务端不收,其实是自己没发」的经典案例。**
---
## 二、结构坑:报文对不上
### 坑 5:`userWeeklyData` / `user2pPlaylog` 是**对象**,不是数组
36 个节点里有 **2 个** JSON 形态是 `{}` 而不是 `[]`:
```jsonc
"userWeeklyData": { "lastLoginWeek": "...", "beforeLoginWeek": "...", "friendBonusFlag": true }
"user2pPlaylog" : { "userId1": 0, ..., "user2pPlaylogDetailList": [] }
```
传成数组会被 `coerce_node_value()` 直接抛 `PatchError`(刻意的防呆)。
**四种形态总览**:
| 类别 | 数量 | JSON 形态 |
|---|---:|---|
| 单元素数组(userData/userExtend/userOption/userRatingList) | 4 | `[{...}]` |
| **对象** | 2 | `{...}` ★ |
| 普通数组 | 19 | `[...]` |
| **位串** | 11 | `"0101..."` |
4 + 2 + 19 + 11 = 36。
---
### 坑 6:11 条位串的长度**必须**等于对应数组的条数
`isNew*` 与同名数组一一对应。长度不符 → 服务端误判「哪些是新增」。
`build_user_all()` 末尾用 `sync_bits_to_arrays()` 自动补齐,但如果你手工 `patch` 了数组,
位串也要跟着对。
**收藏夹是特例**(见坑 12):位串取值不是 `0/1` 而是 `1/2`。
---
### 坑 7:`userExtend` 在 Upsert 侧**多出 2 个字段**
| | Get 侧 | Upsert 侧 |
|---|---:|---:|
| 键数 | 14 | **16** |
| 多出 | — | `isPhotoAgree`、`isGotoCodeRead` |
这两个字段**只在 Upsert 侧出现**(Get 响应里没有),必须手工补上,
否则报文比真机少 2 个键。
对应实现:`USER_EXTEND_EXTRA_KEYS`。
---
### 坑 8:`userRating.udemae` 有一批**首字母大写的重复字段**,必须裁掉
**现象**:`GetUserRatingApi` 的响应里 `npcWinNum` 和 `NpcWinNum` **同时存在**(23 键)。
```jsonc
// Get 侧(source/2026-10-05.log:87)—— 23 键
{ "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, "MaxLoseNum": 0, ... }
// Upsert 侧(source/2026-10-05.log:123)—— 只有小写 16 键
{ "npcWinNum": 0, ... }
```
**根因**:源码 `Net.VO.Mai2/UserUdemae.cs` 只声明了小写的 16 个字段,
大写那批是**服务端的冗余输出**。
**影响**:不裁掉的话 ① 报文里出现源码不存在的字段 ② 请求体比真机大 **119 字节**。
对应实现:`USER_RATING_UDEMAE_DROP_KEYS`。
> ★ 这个坑**只有做深度递归值对比才能发现** —— 光看键数或报文长度是看不出来的。
---
### 坑 9:`userCharacterList` 要按 `characterId` **升序**,不是 `charaSlot` 顺序
| | 顺序 |
|---|---|
| `userData.charaSlot` | `[395, 101, 400402, 400401, 200401]` |
| 真机 `userCharacterList` | `[101, 395, 200401, 400401, 400402]` |
**两者顺序不同** —— 回传时按 `characterId` 升序排。
对应实现:`grown.sort(key=lambda c: int(c.get("characterId", 0)))`。
---
### 坑 10:字段裁剪 —— Get 侧有的,Upsert 侧不一定有
| 节点 | 要丢掉的字段 |
|---|---|
| `userData` | `friendCode`、`nameplateId`、`trophyId`、`cmLastEmoneyCredit`、`cmLastEmoneyBrand` |
| `userOption` | `tempoVolume` |
| `userCharacterList` | 只保留 `characterId`/`level`/`awakening`/`useCount` |
| `userMusicDetailList` | `extNum2` |
| `userChargeList` | `extNum1` |
| `userMissionDataList` / `userWeeklyData` | `userId` |
### 坑 10.1:`banState` 的位置会「漂移」
- **Get 侧**:`banState` 是 `userData` 的**同级顶层**字段
- **Upsert 侧**:它被**移进了 `userData` 内部**
`_extract_from_get_responses()` 会自动搬运,但如果你自己手工拼快照,别忘了。
---
## 三、字段值坑:结构对了但值错了
### 坑 11:`characterLevelN` 是**游玩前**等级,不要加经验
**源码**(`VOExtensions.cs:1361`):
```csharp
result.characterLevel1 = (int)gameScore.CharaLevel[0];
```
`gameScore.CharaLevel` 是**本次开始游玩时**灌进去的快照,整局不变。
**三个真机会话的三方对照**(这是唯一能定论的方式):
| 会话 | Get 侧 `userCharacterList` | `userPlaylogList[].characterLevel` | Upsert 侧 `userCharacterList` |
|---|---|---|---|
| 00:20 | 3800/4455/134 | **3800/4455/134** | 3801/4456/135 |
| 19:21 | 3801/4456/135 | **3801/4456/135** | 3802/4457/136 |
| 22:31 | 3802/4457/136 | **3802/4457/136** | 3803/4458/137 |
**结论:`playlog.characterLevel` 恒等于 Get 侧(游玩前),
`upsertUserAll.userCharacterList` 恒为 +1(游玩后)。**
> ⚠ 我一度写成 `level + character_exp_gain`,因为只看了单个会话、
> 把「Get 侧」和「Upsert 侧」的数值记混了。**横向比三个会话立刻暴露。**
**推荐做法**:别手填 `config.PLAY_CHARACTERS`,直接用
`derive_play_characters(snapshot)` 从服务器快照推(`upsert.py` 已接上)。
账号一升级手填值就过期,会出现「playlog 与 userCharacterList 对不上」的诡异现象。
---
### 坑 12:收藏夹**不做剔除**,且位串取值是 `1/2` 不是 `0/1`
`build_favorite_delta()` 复刻 `VOExtensions.cs:345-367`:
- **恒返回完整 5 条**(不做差分剔除)
- 位串按真机 `"22212"` 反推:`"2"` = 服务器侧有该 itemKind,`"1"` = 查不到
**★ 必须用未归一化的原始 Get 响应作基线**:
真机里 `itemKind=10`(搭档)的 Get 响应回的是 `{"itemKind": null}`,
正因为查不到才产生那个 `"1"`。
但 `user_favorite.py` 会「补 itemKind」,补完之后就看不出原始 null 了。
**两个真机会话都是 `"22212"`** —— 第 4 位(itemKind=10)**稳定为 "1"**,
说明服务端对该 kind 恒回 null。
**实现上的处理**:
- 有原始响应 → 用它建索引,自然得到 `"22212"`
- 只有归一化基线 → 传 `assume_missing=FAVORITE_MISSING_KINDS`(= `(10,)`)兜底
---
### 坑 13:`loginDate` 是**标准 Unix 秒**,不是 `loginDateTime`
```
loginDateTime = 1791239482 (「墙上时间当 UTC」= 标准 Unix 秒 + 28800)
loginDate = 1791210683 (标准 Unix 秒)
```
**三个真机会话的实际差值**:
| 会话 | `loginDateTime` | `loginDate` | 差 |
|---|---|---|---|
| 00:20 | 1791159576 | 1791130777 | **28799** |
| 19:21 | 1791228113 | 1791199315 | **28798** |
| 22:31 | 1791239482 | 1791210683 | **28799** |
**注意不是整 28800!** 原因:`loginDate` 来自
`TimeManager.GetUnixTime(src.Detail.LastLoginDate)`(`VOExtensions.cs:1290`),
而 `lastLoginDate` 是客户端在**登录响应处理完那一刻**写的,比 `loginDateTime` 晚 1~2 秒。
本工程用 `loginDateTime − 28800`,**稳定差 1~2 秒**。
服务端两次都接受了 → 它**不对这几个时刻做等式校验**。知道这一点可以省掉一轮无谓排查。
---
### 坑 14:`playDate` 是**日志日**(7 点分界),不是自然日
源码 `TimeManager.cs:77-85`:
```csharp
public static DateTime ToLogDateTime(DateTime dateTime, int hour = 0)
{
DateTime result = new(dateTime.Year, dateTime.Month, dateTime.Day, hour, 0, 0);
if (7 > dateTime.Hour) return result.AddDays(-1.0); // ★ 7 点前回退一天
return result;
}
```
真机验证:00:20 那一局的 `userPlaylogList[0].playDate = "2026-10-04"`
(自然日是 10-05,因为 00:20 < 07:00 所以回退)。19:21 那局则是 `"2026-10-05"`。
**`playDate` 是日期串(`YYYY-MM-DD`),`userPlayDate` 才是完整时间串**,别搞混。
---
### 坑 15:`lastLoginDate` 与 `lastPlayDate` 是**两个不同的时刻**
**现象**:所有时间字段都填成 `loginDateTime` → 数值上「能用」,但和真机对不上。
**真机规律**(三个会话全部成立):
| 字段 | 语义 | 00:20 会话 | 19:21 会话 | 22:31 会话 |
|---|---|---|---|---|
| `userData.lastLoginDate` | **登录**时刻 | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 |
| `userData.eventWatchedDate` | **与 lastLoginDate 同值** | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 |
| `userData.lastPlayDate` | **上报**时刻 | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 |
| `userGamePlaylogList[0].playDate` | **与 lastPlayDate 同值** | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 |
| `userPlaylogList[0].userPlayDate` | **游玩**时刻(更早) | 00:20:30.0 | 19:22:36.0 | 22:31:59.0 |
即:登录 → 游玩 → 上报是三个时刻,真机分别是
「loginDateTime +1~2s」「userPlayDate」「上报那一刻」。
本工程把「游玩/上报时刻」抽成参数 `play_date_time`(不传则退回 `login_date_time`),
`upsert.py` 传 `login_date_time_now()`。
---
### 坑 16:`userGetPointList` / `userLoginBonusList` 是**登录奖励**才产生的
**真机证据**:
| 会话 | `userData.point` | `userGetPointList` | `userLoginBonusList` |
|---|---|---|---|
| 00:20 | 4110 | `[]` | `[]` |
| 19:21 | 4460 | `[{"getKind":1,"point":200},{"getKind":2,"point":150}]` | `[{"bonusId":61,"point":1,"isCurrent":true,...}]` |
| 22:31 | 4460 | `[]` | `[]` |
**规律**:这两个节点非空 ⟺ 本次登录触发了奖励发放。
留空则 `point` 保持基线值不变 —— 这是「不发放点数」的**必然结果,不是 bug**。
**★ 排查陷阱**:如果你把 `point` 手工 +200 但 `userGetPointList` 留空,
会出现「数字对不上」的矛盾。要么两个一起改,要么两个都不动。
对应实现:`DEFAULT_EMPTY_NODES`。
---
### 坑 17:`userGamePlaylogList[].playCount` 是**机台本地计数器**,服务器不返回
真机值:00:20 会话 = **8**、19:21 = **9**、22:31 = **10**(每打一局 +1)。
源码 `VOExtensions.cs:881`:`result.playCount = Backup.localParameter.playCount`。
**任何 Get 接口都不返回它** —— 既推不出来,服务端也不会纠正。
填错只是统计数字不对,不影响落库。放在 `config.PLAY_LOCAL_PLAY_COUNT`。
---
### 坑 18:`playSpecial` 不是 0,是一枚**随机位掩码**
源码 `VOExtensions.cs:846-855`:
```csharp
int max = 1037933;
int num2 = UnityEngine.Random.Range(1, max) * 2069;
num2 += GameManager.CalcSpecialNum();
int num3 = 0;
for (int i = 0; i < 32; i++) { num3 <<= 1; num3 += num2 % 2; num2 >>= 1; }
result.playSpecial = num3;
```
**`CalcSpecialNum()` 是机台硬件标志位掩码**(`GameManager.cs:332-378`),
逐位含义:`SpecialAP`、`IsAutoPlay()`、键鼠数量、`IsDevelop`、
以及三条**否定条件**(不含 "ACA" / 不含 "GeForce GTX" / 不含 "Core(TM) i3-8100"),
最后 `num |= 0x400` **无条件置位**。
**真机实测**:`[CalcSpecialNum] = 1024`(= 0x400,标准 ACA 机台其余位恰好全 0)。
**反推验证**(这一步很有说服力):
```
真机 playSpecial = 892014272
位反转32(892014272) = 57726124 = 27900 × 2069 + 1024
└───┬───┘ └─┬─┘
随机系数 余数 = CalcSpecialNum ✅
```
配置项:`config.PLAY_SPECIAL_NUM = 1024`。
> 注意位反转后可能超过 int32 变成负数 —— 真机 19:21 那局就是 `-603697438`,这是正常的。
---
### 坑 19:`extBool1/2/3` 是 bool 但**名字不带 `is` 前缀**
用 `name.startswith("is")` 判断布尔字段会**漏掉这三个**,
导致序列化成 `0` 而不是 `false`。
同理字符串字段(`vsUserName`、`playedUserName1..3`、`placeName`、`playDate`、`userPlayDate`)
默认值必须是 `""` 而不是 `0`。
对应实现:显式集合 `PLAYLOG_BOOL_FIELDS` / `PLAYLOG_STRING_FIELDS`。
---
### 坑 20:110 个字段**一个都不能省**
把真机报文的 `userPlaylogList[0]` 与 `Net.VO.Mai2/UserPlaylog.cs` 做双向集合比对:
| 方向 | 差异 |
|---|---|
| C# 声明但报文未出现 | **0** |
| 报文有但 C# 未声明 | **0** |
**能精简的是「填写的内容要真实」,而不是删字段。**
---
## 四、网络层坑
### 坑 21:`number` 头只由 **Ping 心跳**刷新,不是每个请求都更新
**真机 70 个请求里它只变过一次**:
```
#1 ~ #10 number = 0 ← 还没跑过心跳
#11 ~ #17 Ping 包(按 Packet.cs:187 **不带** number 头)
#18 ~ #70 number = 63 ← 心跳往返 63ms,此后 53 个业务请求恒定
```
**源码链路**:
```
PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
PacketPing.cs:34 _onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒
PingChecker.cs:227 GameManager.PingMsec = pingMsec
Packet.cs:189 Client.AddHeader("number", GameManager.PingMsec.ToString())
```
**我原来的错法**:每个请求结束后把「本请求耗时」写进 `ping_msec`,
于是 number 随每个包抖动。已改成只有 Ping 包更新。
**调用方注意**:`upsert.py` 在取完服务器设置后加了一步 `Ping()`,否则 number 恒为 0。
---
### 坑 22:Cookie —— 登录响应**会**下发 `JSESSIONID`,而且必须带着
**这个坑我踩了两次(先误判、后自我推翻)**:
1. hook 日志里显示 `Cookie : userId=12345678` —— 这是我 hook **自己拼的假串**
(`gamehook/Main.cs:196-199` 的 `_cookie = "userId=" + userId`),不是真实 Cookie 头。
2. 于是我用 `Ping` 探针去验证,看到响应头里**没有 Set-Cookie**,
就得出「服务器从不下发 Cookie,无需处理」的结论 —— **这是错的**。
3. 后来跑完整登录链路,`UserLoginApi` 的响应头里明确有:
```
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
```
**源码**(`Packet.cs:228-234`):**只在 `UserLogin` 响应上**把 Set-Cookie 收进
`CookieContainer`;之后 `Packet.cs:190-193` 每个带 userId 的请求都挂回去。
**结论**:这是**真实的会话粘滞**。
`requests.Session` 会自动处理,**但前提是全程复用同一个 `MaimaiNetClient`**
(本工程各 api 函数在 `client=None` 时都走 `get_default_client()` 单例)。
**如果谁手动 new 一个 client 去发结算包,就会丢掉 JSESSIONID。**
> **教训**:用「只读探针」验证一件事时,要确认探针覆盖了完整链路 ——
> 我只探了 Ping,就下了关于整个服务器的结论。
---
### 坑 23:`Accept-Encoding` 必须**显式置空**
`requests` 默认发 `Accept-Encoding: gzip, deflate`。
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码会返回**空 body**。
实测:不发该头 → HTTP 200 / 0 字节;显式置空 → 正常返回。
C# 端不需要是因为 `HttpWebRequest` 默认 `AutomaticDecompression = None`。
---
### 坑 24:扫码 chipID 用**完整形式**,游戏服务器用**短值**
| 用途 | 正确值 | 源码依据 |
|---|---|---|
| aimedb 扫码 | `XXXX-XXXXXXXXXXX`(**带连字符**) | `ChimeReaderManager.cs:250` `KeychipId.Value` |
| 游戏服务器 `clientId` | `TESTCLIENT1`(短值) | `PacketGetGameSetting.cs:23` `KeychipId.ShortValue` |
我原来扫码也用了短值,导致 aimedb 返回错误。
---
## 五、工程/流程坑
### 坑 25:不做**落库回查**,你永远不知道有没有写进去
`returnCode=1` 完全不可信(见坑 1~4)。**必须**发包后重新拉一次数据比对。
`upsert.py --send` 的第 7.5 步已经内置:
```
7.5 落库回查(重新拉服务器数据)
playCount 1260 -> 1261
currentPlayCount 188 -> 189
lastPlayDate 2026-10-05 22:32:21 -> 2026-10-06 01:28:04
musicId=143 level=4 playCount -> 11
【落库成功】userData.playCount 已 +1
```
**为什么在登出前回查**:同一个登录会话内 `GetUserDataApi` 立刻能读到新值,
不用再开一次会话(也就不会消耗第二个二维码)。
---
### 坑 26:报文体长度差异要**逐节点**看,不要只看总长
总长差几字节可能来自随机数位数(`playSpecial`),也可能来自真实的结构错误。
**逐节点比对字节数**才能定位:
```
节点 真机068 本次 差
userData 1985 1985 0
...
userGamePlaylogList 294 295 1 <<< 只有它差(playSpecial 随机数)
(36 节点合计) 14255 14256 1
```
**35 个节点 0 差异**才是「结构完全对齐」的证据。
---
### 坑 27:`--debug` 日志会很大
DEBUG 模式会完整打印每个请求的头 + 体 + 响应体,
一次结算流程(含 19 节点快照 + 739 组曲目分页)能到 **18 MB**。
只想看流程就别加 `--debug`;想减小体积调 `config.LOG_VALUE_MAX`(0 = 不截断)。
---
## 六、发包前 Checklist
按顺序过一遍,能挡掉上面绝大多数坑:
- [ ] **闸门**:`confirm=True`(或 `config.UPSERT_CONFIRM=True`)
- [ ] **会话**:`playlogId == login.loginId`,`loginDateTime == login.loginDateTime`
- [ ] **同会话**:中途没重新登录过(重登必须重取上面两个值)
- [ ] **快照**:`fetch_upsert_snapshot()` 抓了 19 个节点,失败 0 个
- [ ] **userData 演算**:`playCount`/`currentPlayCount` +1,`lastPlayDate` 是上报时刻,
`lastLoginDate`/`eventWatchedDate` 是登录时刻,`dateTime` 非 0
- [ ] **差分节点**:`userMusicDetailList` 至少 1 条(本局曲目战绩);
`userMapList` / `userCharacterList` 按 config 增量决定 ——
`PLAY_MAP_DISTANCE_GAIN` / `PLAY_CHARACTER_EXP_GAIN` **默认都是 0**,
此时它们为空是**预期行为**;只有把增量设成非 0 时才必须有条目
- [ ] **`userChargeList`** 是整表回传,没被当差分收敛成空
- [ ] **位串**:11 条长度等于对应数组条数;`isNewFavoriteList` 应为 `"22212"`
- [ ] **playlog**:110 个字段齐全;`characterLevelN` 是**游玩前**等级
- [ ] **playSpecial**:非 0,且余数 = `config.PLAY_SPECIAL_NUM`
- [ ] **网络**:跑过 `Ping()`(number 头),同一个 client(Cookie)
- [ ] **自检**:`validate_before_send()` 返回 0 个问题
- [ ] **回查**:发包后确认 `playCount` 真的 +1
---
## 六点五、关于 `userMapList` / `userCharacterList` 的默认空值
本工程默认:
```python
PLAY_MAP_DISTANCE_GAIN = 0 # 本局不推进地图 -> userMapList 回传 []
PLAY_CHARACTER_EXP_GAIN = 0 # 本局不给角色加经验 -> userCharacterList 回传 []
```
**这不是坑,是刻意的默认值** —— 因为「地图走多远」「角色升几级」取决于真实游玩,
纯网络脚本推不出来。想要真机那种 +11000 / +1 级,把这两个配置改回去即可。
★ 唯一要注意的连带影响:`validate_before_send()` 的「差分节点为空」告警
只在**增量非 0 却没产出条目**时才触发,所以默认值下不会刷无意义的告警。
---
## 七、这个清单是怎么攒出来的
| 阶段 | 手段 | 抓到的坑 |
|---|---|---|
| 初版实现 | 读源码 + 单个真机日志 | 坑 5、6、13、14、18、19、20、23、24 |
| 深度值对比 | 逐字段递归 diff 真机报文 vs 本工程 | 坑 7、8、9、10 |
| 落库失败排查 | 直接 `patch` 绝对值探测服务端行为 | 坑 1、2、3、4 |
| **MelonLoader hook 抓真机** | 在游戏里 hook 网络层抓完整会话 | 坑 11、12、15、16、17、21、22 |
| 真实跑通 | `--send` + 落库回查 | 坑 25、26、27 |
**最有效的一招是「横向比多个真机会话」** —— 坑 11(characterLevel 语义)、
坑 13(loginDate 差 28798/28799)、坑 15(两个时刻分离)都是单看一个会话看不出来、
一横向比就立刻暴露的。
第二有效的是 **MelonLoader hook** —— 它能看到 HTTP 头(`number`)、
能抓到 `CalcSpecialNum()` 的真实返回值,这些在纯日志里根本看不到。
+103
View File
@@ -0,0 +1,103 @@
# netdelivery.py 说明(ALL.Net DLI)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里
> netdelivery 部分的**详细版**(含运行原理)。
---
## 一、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=` 里:
```ini
[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/initialize` | `@0x788170` |
| `http://at.sys-all.cn/net/delivery/instruction` | `@0x788198` |
| `title_id=%s&title_ver=%s&client_id=%s` | `@0x7882b8` |
| `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` |
| `alAbEx_lite Ver 4.00.01` | `@0x784a31` |
协议是 ALL.Net **alAbEx v4**:AES-128-CBC 信封,明文是 `key=value&...`。
```
请求 = AES-CBC( 16 字节全 0 前缀 || 明文, IV = 全 0 )
响应 = 16 字节 IV || 密文
```
---
## 三、用法
```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
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
```
交互选择支持:逗号/空格分隔、区间(`1-3`)、`a`=全选、`n`=全不选、`q`=退出。
下载带**断点续传**(`.part` 临时文件 + `Range`,下完改名)与进度/速率显示。
---
## 四、实测结论(2026-10-06)
| 结论 | 证据 |
|---|---|
| `title_ver` 决定拿哪份指示書 | `1.55` → patch 1.56 + opt A005;`1.56` → 仅 opt A031;`≤1.54` → `uri=null` |
| `client_id` **必须**是 11 位短值 | 发 15 位完整 Keychip → `result=-3`;发小写 → `result=-2` |
| 包可以断点续传 | 返回 `206` + `Content-Range` |
| 已下架的历史包会回 **504** | 这是华立 CDN 的状态,**不是脚本的问题** |
实测清单(`--ver 1.56`):
```
序号 类型 大小 来源段/键 文件名
[ 1] opt 7.25 MB COMMON/INSTALL1 SDGB_A031_20260909101347_0.opt
[ 2] opt 3.00 MB OPTIONAL/INSTALL1 SDGB_A005_20260610180958_0.opt
```
下载并校验字节数完全一致:`SDGB_A031...opt` = 7602176、`SDGB_A005...opt` = 3145728。
---
## 五、注意
- 默认下载到 `download/`(已加入 `.gitignore`,`.app` 动辄上 GB)。
- 需要同目录下的 `authv1.py` 提供 ALL.Net 协议层。
- 这是**只读**操作,不写任何账号数据。
@@ -0,0 +1,46 @@
{
"user_id": 12345678,
"login": {
"loginId": 7693120247791879616,
"loginDateTime": 1791222938,
"lastLoginDate": "2026-10-05 17:53:49",
"loginCount": 184,
"consecutiveLoginCount": 3,
"token": "HcXOwSdDwBSEPzngjdfp"
},
"auth_date_time": 1791194138,
"snapshot_summary": {
"userData": 1,
"userExtend": 1,
"userOption": 1,
"userCharacterList": 312,
"userMapList": 104,
"userLoginBonusList": 38,
"userRatingList": 1,
"userItemList": 1070,
"userMusicDetailList": 1037,
"userCourseList": 16,
"userChargeList": 3,
"userFavoriteList": 5,
"userActivityList": 1,
"userMissionDataList": 6,
"userIntimateList": 2,
"userFavoritemusicList": 27,
"userKaleidxScopeList": 7,
"userGhost": 15
},
"snapshot_failed": [],
"preview": {
"userName": "☆Astral☆",
"playerRating": 15000,
"isLogin": false,
"banState": 0,
"errorId": 0,
"lastLoginDate": "1970-01-01 00:00:00",
"lastPlayDate": "1970-01-01 00:00:00",
"totalAwake": 184,
"iconId": 559501,
"frameId": 0
},
"qr_run_at": "2026-10-05 17:53"
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+626
View File
@@ -0,0 +1,626 @@
## 顶层字段对比
| 字段 | 真机 | 本次实跑 | 一致 |
|---|---|---|---|
| `userId` | 12345678 | 12345678 | 一致 |
| `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同** |
| `isEventMode` | False | False | 一致 |
| `isFreePlay` | False | False | 一致 |
| `loginDateTime` | 1791159576 | 1791222938 | **不同** |
| `userPlaylogList` | 1 条 | 1 条 | 一致 |
| `upsertUserAll` | 36 节点 | 36 节点 | 一致 |
## 36 个节点逐节点对比
| # | 节点 | 真机 | 实跑 | 形态一致 |
|---|---|---|---|---|
| 1 | `userData` | 1 条 | 1 条 | 一致 |
| 2 | `userExtend` | 1 条 | 1 条 | 一致 |
| 3 | `userOption` | 1 条 | 1 条 | 一致 |
| 4 | `userCharacterList` | 5 条 | 5 条 | 一致 |
| 5 | `userGhost` | 0 条 | 0 条 | 一致 |
| 6 | `userMapList` | 1 条 | 1 条 | 一致 |
| 7 | `userLoginBonusList` | 0 条 | 0 条 | 一致 |
| 8 | `userRatingList` | 1 条 | 1 条 | 一致 |
| 9 | `userItemList` | 0 条 | 0 条 | 一致 |
| 10 | `userMusicDetailList` | 1 条 | 1 条 | 一致 |
| 11 | `userCourseList` | 0 条 | 0 条 | 一致 |
| 12 | `userFriendSeasonRankingList` | 0 条 | 0 条 | 一致 |
| 13 | `userChargeList` | 3 条 | 3 条 | 一致 |
| 14 | `userFavoriteList` | 5 条 | 5 条 | 一致 |
| 15 | `userActivityList` | 1 条 | 1 条 | 一致 |
| 16 | `userMissionDataList` | 6 条 | 6 条 | 一致 |
| 17 | `userWeeklyData` | 对象(3 键) | 对象(3 键) | 一致 |
| 18 | `userGamePlaylogList` | 1 条 | 1 条 | 一致 |
| 19 | `user2pPlaylog` | 对象(7 键) | 对象(7 键) | 一致 |
| 20 | `userIntimateList` | 0 条 | 0 条 | 一致 |
| 21 | `userShopItemStockList` | 0 条 | 0 条 | 一致 |
| 22 | `userGetPointList` | 0 条 | 0 条 | 一致 |
| 23 | `userTradeItemList` | 0 条 | 0 条 | 一致 |
| 24 | `userFavoritemusicList` | 0 条 | 0 条 | 一致 |
| 25 | `userKaleidxScopeList` | 0 条 | 0 条 | 一致 |
| 26 | `isNewCharacterList` | 位串 '00000' | 位串 '00000' | 一致 |
| 27 | `isNewMapList` | 位串 '0' | 位串 '0' | 一致 |
| 28 | `isNewLoginBonusList` | 位串 '' | 位串 '' | 一致 |
| 29 | `isNewItemList` | 位串 '' | 位串 '' | 一致 |
| 30 | `isNewMusicDetailList` | 位串 '0' | 位串 '0' | 一致 |
| 31 | `isNewCourseList` | 位串 '' | 位串 '' | 一致 |
| 32 | `isNewFavoriteList` | 位串 '22212' | 位串 '22212' | 一致 |
| 33 | `isNewFriendSeasonRankingList` | 位串 '' | 位串 '' | 一致 |
| 34 | `isNewUserIntimateList` | 位串 '' | 位串 '' | 一致 |
| 35 | `isNewFavoritemusicList` | 位串 '' | 位串 '' | 一致 |
| 36 | `isNewKaleidxScopeList` | 位串 '' | 位串 '' | 一致 |
## 11 条位串对比
| 位串 | 真机 | 实跑 | 一致 |
|---|---|---|---|
| `isNewCharacterList` | `00000` | `00000` | 一致 |
| `isNewMapList` | `0` | `0` | 一致 |
| `isNewLoginBonusList` | `` | `` | 一致 |
| `isNewItemList` | `` | `` | 一致 |
| `isNewMusicDetailList` | `0` | `0` | 一致 |
| `isNewCourseList` | `` | `` | 一致 |
| `isNewFavoriteList` | `22212` | `22212` | 一致 |
| `isNewFriendSeasonRankingList` | `` | `` | 一致 |
| `isNewUserIntimateList` | `` | `` | 一致 |
| `isNewFavoritemusicList` | `` | `` | 一致 |
| `isNewKaleidxScopeList` | `` | `` | 一致 |
## userData[0] 逐字段对比
| 字段 | 真机 | 实跑 | 一致 |
|---|---|---|---|
| `accessCode` | "" | "" | 一致 |
| `userName` | "☆Astral☆" | "☆Astral☆" | 一致 |
| `isNetMember` | 1 | 1 | 一致 |
| `point` | 4110 | 4110 | 一致 |
| `totalPoint` | 52620 | 52620 | 一致 |
| `iconId` | 559501 | 559501 | 一致 |
| `plateId` | 458001 | 458001 | 一致 |
| `titleId` | 55502 | 55502 | 一致 |
| `partnerId` | 1 | 1 | 一致 |
| `frameId` | 209504 | 209504 | 一致 |
| `selectMapId` | 550003 | 550003 | 一致 |
| `totalAwake` | 184 | 184 | 一致 |
| `gradeRating` | 0 | 0 | 一致 |
| `musicRating` | 15000 | 15000 | 一致 |
| `playerRating` | 15000 | 15000 | 一致 |
| `highestRating` | 15000 | 15000 | 一致 |
| `gradeRank` | 0 | 0 | 一致 |
| `classRank` | 1 | 1 | 一致 |
| `courseRank` | 10 | 10 | 一致 |
| `charaSlot` | [395, 101, 400402, 400401, 200401] | [395, 101, 400402, 400401, 200401] | 一致 |
| `charaLockSlot` | [395, 101, 0, 0, 0] | [395, 101, 0, 0, 0] | 一致 |
| `contentBit` | 2145488608 | 2145488608 | 一致 |
| `playCount` | 1258 | 1259 | **不同** |
| `currentPlayCount` | 186 | 187 | **不同** |
| `renameCredit` | 0 | 0 | 一致 |
| `mapStock` | 0 | 0 | 一致 |
| `eventWatchedDate` | "2026-10-05 00:19:37.0" | "2026-10-05 17:55:38.0" | **不同** |
| `lastGameId` | "SDGB" | "SDGB" | 一致 |
| `lastRomVersion` | "1.56.00" | "1.56.00" | 一致 |
| `lastDataVersion` | "1.55.05" | "1.55.05" | 一致 |
| `lastLoginDate` | "2026-10-05 00:19:37.0" | "2026-10-05 17:55:38.0" | **不同** |
| `lastPlayDate` | "2026-10-05 00:20:57.0" | "2026-10-05 17:55:38.0" | **不同** |
| `lastPlayCredit` | 1 | 1 | 一致 |
| `lastPlayMode` | 0 | 0 | 一致 |
| `lastPlaceId` | 1000 | 1000 | 一致 |
| `lastPlaceName` | "测试店铺" | "测试店铺" | 一致 |
| `lastAllNetId` | 0 | 0 | 一致 |
| `lastRegionId` | 5 | 5 | 一致 |
| `lastRegionName` | "测试区域" | "测试区域" | 一致 |
| `lastClientId` | "TESTCLIENT1" | "TESTCLIENT1" | 一致 |
| `lastCountryCode` | "CHN" | "CHN" | 一致 |
| `lastSelectEMoney` | 0 | 0 | 一致 |
| `lastSelectTicket` | 0 | 0 | 一致 |
| `lastSelectCourse` | 552304 | 552304 | 一致 |
| `lastCountCourse` | 0 | 0 | 一致 |
| `firstGameId` | "SDGB" | "SDGB" | 一致 |
| `firstRomVersion` | "1.41.00" | "1.41.00" | 一致 |
| `firstDataVersion` | "1.40.08" | "1.40.08" | 一致 |
| `firstPlayDate` | "2024-12-31 21:25:58" | "2024-12-31 21:25:58" | 一致 |
| `compatibleCmVersion` | "1.55.00" | "1.55.00" | 一致 |
| `dailyBonusDate` | "1970-01-01 08:00:00" | "1970-01-01 08:00:00" | 一致 |
| `dailyCourseBonusDate` | "2026-07-13 00:00:00" | "2026-07-13 00:00:00" | 一致 |
| `lastPairLoginDate` | "2026-10-04 18:05:18" | "2026-10-04 18:05:18" | 一致 |
| `lastTrialPlayDate` | "2026-10-03 21:07:50" | "2026-10-03 21:07:50" | 一致 |
| `playVsCount` | 0 | 0 | 一致 |
| `playSyncCount` | 0 | 0 | 一致 |
| `winCount` | 0 | 0 | 一致 |
| `helpCount` | 0 | 0 | 一致 |
| `comboCount` | 0 | 0 | 一致 |
| `totalDeluxscore` | 1869771 | 1869771 | 一致 |
| `totalBasicDeluxscore` | 1274 | 1274 | 一致 |
| `totalAdvancedDeluxscore` | 6566 | 6566 | 一致 |
| `totalExpertDeluxscore` | 470976 | 470976 | 一致 |
| `totalMasterDeluxscore` | 1236693 | 1236693 | 一致 |
| `totalReMasterDeluxscore` | 154262 | 154262 | 一致 |
| `totalSync` | 1112 | 1112 | 一致 |
| `totalBasicSync` | 5 | 5 | 一致 |
| `totalAdvancedSync` | 5 | 5 | 一致 |
| `totalExpertSync` | 425 | 425 | 一致 |
| `totalMasterSync` | 604 | 604 | 一致 |
| `totalReMasterSync` | 73 | 73 | 一致 |
| `totalAchievement` | 975613200 | 975613200 | 一致 |
| `totalBasicAchievement` | 2984753 | 2984753 | 一致 |
| `totalAdvancedAchievement` | 7755815 | 7755815 | 一致 |
| `totalExpertAchievement` | 315483810 | 315483810 | 一致 |
| `totalMasterAchievement` | 578226016 | 578226016 | 一致 |
| `totalReMasterAchievement` | 71162806 | 71162806 | 一致 |
| `playerOldRating` | 10927 | 10927 | 一致 |
| `playerNewRating` | 4606 | 4606 | 一致 |
| `banState` | 0 | 0 | 一致 |
| `friendRegistSkip` | 0 | 0 | 一致 |
| `dateTime` | 1791134329 | 1791194138 | **不同** |
真机 82 字段,实跑 82 字段,命中 76/82
## 差分节点明细
### `userMusicDetailList`
真机:
```json
[
{
"musicId": 143,
"level": 4,
"playCount": 8,
"achievement": 0,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": 0,
"scoreRank": 0,
"extNum1": 0
}
]
```
实跑:
```json
[
{
"musicId": 143,
"level": 4,
"playCount": 9,
"achievement": 0,
"comboStatus": 0,
"syncStatus": 0,
"deluxscoreMax": 0,
"scoreRank": 0,
"extNum1": 0
}
]
```
### `userMapList`
真机:
```json
[
{
"mapId": 550003,
"distance": 1435000,
"isLock": false,
"isClear": false,
"isComplete": false,
"unlockFlag": 0
}
]
```
实跑:
```json
[
{
"mapId": 550003,
"distance": 1446000,
"isLock": false,
"isClear": false,
"isComplete": false,
"unlockFlag": 0
}
]
```
### `userCharacterList`
真机:
```json
[
{
"characterId": 101,
"level": 4456,
"awakening": 5,
"useCount": 0
},
{
"characterId": 395,
"level": 3801,
"awakening": 5,
"useCount": 0
},
{
"characterId": 200401,
"level": 135,
"awakening": 3,
"useCount": 0
},
{
"characterId": 400401,
"level": 135,
"awakening": 3,
"useCount": 0
},
{
"characterId": 400402,
"level": 135,
"awakening": 3,
"useCount": 0
}
]
```
实跑:
```json
[
{
"characterId": 395,
"useCount": 0,
"level": 3802,
"awakening": 5
},
{
"characterId": 101,
"useCount": 0,
"level": 4457,
"awakening": 5
},
{
"characterId": 400402,
"useCount": 0,
"level": 136,
"awakening": 3
},
{
"characterId": 400401,
"useCount": 0,
"level": 136,
"awakening": 3
},
{
"characterId": 200401,
"useCount": 0,
"level": 136,
"awakening": 3
}
]
```
### `userChargeList`
真机:
```json
[
{
"chargeId": 2,
"stock": 0,
"purchaseDate": "2026-07-14 18:33:37",
"validDate": "2026-10-12 04:00:00"
},
{
"chargeId": 3,
"stock": 0,
"purchaseDate": "2026-10-02 10:49:23",
"validDate": "2026-12-31 04:00:00"
},
{
"chargeId": 5,
"stock": 0,
"purchaseDate": "2026-10-04 18:22:10",
"validDate": "2027-01-02 04:00:00"
}
]
```
实跑:
```json
[
{
"chargeId": 2,
"stock": 0,
"purchaseDate": "2026-07-14 18:33:37",
"validDate": "2026-10-12 04:00:00"
},
{
"chargeId": 3,
"stock": 0,
"purchaseDate": "2026-10-02 10:49:23",
"validDate": "2026-12-31 04:00:00"
},
{
"chargeId": 5,
"stock": 0,
"purchaseDate": "2026-10-04 18:22:10",
"validDate": "2027-01-02 04:00:00"
}
]
```
### `userFavoriteList`
真机:
```json
[
{
"itemKind": 3,
"itemIdList": [
559501
]
},
{
"itemKind": 1,
"itemIdList": []
},
{
"itemKind": 2,
"itemIdList": []
},
{
"itemKind": 10,
"itemIdList": []
},
{
"itemKind": 11,
"itemIdList": []
}
]
```
实跑:
```json
[
{
"itemKind": 3,
"itemIdList": [
559501
]
},
{
"itemKind": 1,
"itemIdList": []
},
{
"itemKind": 2,
"itemIdList": []
},
{
"itemKind": 10,
"itemIdList": []
},
{
"itemKind": 11,
"itemIdList": []
}
]
```
## 两个对象节点
### `userWeeklyData`
真机:
```json
{
"lastLoginWeek": "2026-09-28 04:00:00",
"beforeLoginWeek": "2026-09-21 04:00:00",
"friendBonusFlag": true
}
```
实跑:
```json
{
"lastLoginWeek": "2026-09-28 04:00:00",
"beforeLoginWeek": "2026-09-21 04:00:00",
"friendBonusFlag": true
}
```
### `user2pPlaylog`
真机:
```json
{
"userId1": 0,
"userId2": 0,
"userName1": "",
"userName2": "",
"regionId": 0,
"placeId": 0,
"user2pPlaylogDetailList": []
}
```
实跑:
```json
{
"userId1": 0,
"userId2": 0,
"userName1": "",
"userName2": "",
"regionId": 0,
"placeId": 0,
"user2pPlaylogDetailList": []
}
```
## userGamePlaylogList
真机:
```json
[
{
"playlogId": 7692848101484135861,
"version": "1.56.00",
"playDate": "2026-10-05 00:20:57.0",
"playMode": 0,
"useTicketId": -1,
"playCredit": 1,
"playTrack": 1,
"clientId": "TESTCLIENT1",
"isPlayTutorial": false,
"isEventMode": false,
"isNewFree": false,
"playCount": 8,
"playSpecial": 55951502,
"playOtherUserId": 0
}
]
```
实跑:
```json
[
{
"playlogId": 7693120247791879616,
"version": "1.56.00",
"playDate": "2026-10-05 17:55:38.0",
"playMode": 0,
"useTicketId": -1,
"playCredit": 1,
"playTrack": 1,
"clientId": "TESTCLIENT1",
"isPlayTutorial": false,
"isEventMode": false,
"isNewFree": false,
"playCount": 8,
"playSpecial": 16417894,
"playOtherUserId": 0
}
]
```
## userPlaylogList[0] 逐字段对比(110 字段)
| # | 字段 | 真机 | 实跑 | 一致 |
|---|---|---|---|---|
| 1 | `userId` | 0 | 0 | 一致 |
| 2 | `orderId` | 0 | 0 | 一致 |
| 3 | `playlogId` | 7692848101484135861 | 7693120247791879616 | **不同** |
| 4 | `version` | 1056000 | 1056000 | 一致 |
| 5 | `placeId` | 1000 | 1000 | 一致 |
| 6 | `placeName` | "测试店铺" | "测试店铺" | 一致 |
| 7 | `loginDate` | 1791130777 | 1791194138 | **不同** |
| 8 | `playDate` | "2026-10-04" | "2026-10-05" | **不同** |
| 9 | `userPlayDate` | "2026-10-05 00:20:30.0" | "2026-10-05 17:55:38.0" | **不同** |
| 10 | `type` | 0 | 0 | 一致 |
| 11 | `musicId` | 143 | 143 | 一致 |
| 12 | `level` | 4 | 4 | 一致 |
| 13 | `trackNo` | 1 | 1 | 一致 |
| 14 | `vsMode` | 0 | 0 | 一致 |
| 15 | `vsUserName` | "" | "" | 一致 |
| 16 | `vsStatus` | 0 | 0 | 一致 |
| 17 | `vsUserRating` | 0 | 0 | 一致 |
| 18 | `vsUserAchievement` | 0 | 0 | 一致 |
| 19 | `vsUserGradeRank` | 0 | 0 | 一致 |
| 20 | `vsRank` | 0 | 0 | 一致 |
| 21 | `playerNum` | 1 | 1 | 一致 |
| 22 | `playedUserId1` | 0 | 0 | 一致 |
| 23 | `playedUserName1` | "" | "" | 一致 |
| 24 | `playedMusicLevel1` | 0 | 0 | 一致 |
| 25 | `playedUserId2` | 0 | 0 | 一致 |
| 26 | `playedUserName2` | "" | "" | 一致 |
| 27 | `playedMusicLevel2` | 0 | 0 | 一致 |
| 28 | `playedUserId3` | 0 | 0 | 一致 |
| 29 | `playedUserName3` | "" | "" | 一致 |
| 30 | `playedMusicLevel3` | 0 | 0 | 一致 |
| 31 | `characterId1` | 395 | 395 | 一致 |
| 32 | `characterLevel1` | 3800 | 3800 | 一致 |
| 33 | `characterAwakening1` | 5 | 5 | 一致 |
| 34 | `characterId2` | 101 | 101 | 一致 |
| 35 | `characterLevel2` | 4455 | 4455 | 一致 |
| 36 | `characterAwakening2` | 5 | 5 | 一致 |
| 37 | `characterId3` | 400402 | 400402 | 一致 |
| 38 | `characterLevel3` | 134 | 134 | 一致 |
| 39 | `characterAwakening3` | 3 | 3 | 一致 |
| 40 | `characterId4` | 400401 | 400401 | 一致 |
| 41 | `characterLevel4` | 134 | 134 | 一致 |
| 42 | `characterAwakening4` | 3 | 3 | 一致 |
| 43 | `characterId5` | 200401 | 200401 | 一致 |
| 44 | `characterLevel5` | 134 | 134 | 一致 |
| 45 | `characterAwakening5` | 3 | 3 | 一致 |
| 46 | `achievement` | 0 | 0 | 一致 |
| 47 | `deluxscore` | 0 | 0 | 一致 |
| 48 | `scoreRank` | 0 | 0 | 一致 |
| 49 | `maxCombo` | 0 | 0 | 一致 |
| 50 | `totalCombo` | 655 | 655 | 一致 |
| 51 | `maxSync` | 0 | 0 | 一致 |
| 52 | `totalSync` | 0 | 0 | 一致 |
| 53 | `tapCriticalPerfect` | 0 | 0 | 一致 |
| 54 | `tapPerfect` | 0 | 0 | 一致 |
| 55 | `tapGreat` | 0 | 0 | 一致 |
| 56 | `tapGood` | 0 | 0 | 一致 |
| 57 | `tapMiss` | 441 | 441 | 一致 |
| 58 | `holdCriticalPerfect` | 0 | 0 | 一致 |
| 59 | `holdPerfect` | 0 | 0 | 一致 |
| 60 | `holdGreat` | 0 | 0 | 一致 |
| 61 | `holdGood` | 0 | 0 | 一致 |
| 62 | `holdMiss` | 19 | 19 | 一致 |
| 63 | `slideCriticalPerfect` | 0 | 0 | 一致 |
| 64 | `slidePerfect` | 0 | 0 | 一致 |
| 65 | `slideGreat` | 0 | 0 | 一致 |
| 66 | `slideGood` | 0 | 0 | 一致 |
| 67 | `slideMiss` | 192 | 192 | 一致 |
| 68 | `touchCriticalPerfect` | 0 | 0 | 一致 |
| 69 | `touchPerfect` | 0 | 0 | 一致 |
| 70 | `touchGreat` | 0 | 0 | 一致 |
| 71 | `touchGood` | 0 | 0 | 一致 |
| 72 | `touchMiss` | 0 | 0 | 一致 |
| 73 | `breakCriticalPerfect` | 0 | 0 | 一致 |
| 74 | `breakPerfect` | 0 | 0 | 一致 |
| 75 | `breakGreat` | 0 | 0 | 一致 |
| 76 | `breakGood` | 0 | 0 | 一致 |
| 77 | `breakMiss` | 3 | 3 | 一致 |
| 78 | `isTap` | true | true | 一致 |
| 79 | `isHold` | true | true | 一致 |
| 80 | `isSlide` | true | true | 一致 |
| 81 | `isTouch` | false | false | 一致 |
| 82 | `isBreak` | true | true | 一致 |
| 83 | `isCriticalDisp` | true | true | 一致 |
| 84 | `isFastLateDisp` | true | true | 一致 |
| 85 | `fastCount` | 0 | 0 | 一致 |
| 86 | `lateCount` | 0 | 0 | 一致 |
| 87 | `isAchieveNewRecord` | false | false | 一致 |
| 88 | `isDeluxscoreNewRecord` | false | false | 一致 |
| 89 | `comboStatus` | 0 | 0 | 一致 |
| 90 | `syncStatus` | 0 | 0 | 一致 |
| 91 | `isClear` | false | false | 一致 |
| 92 | `beforeRating` | 15000 | 15000 | 一致 |
| 93 | `afterRating` | 15000 | 15000 | 一致 |
| 94 | `beforeGrade` | 0 | 0 | 一致 |
| 95 | `afterGrade` | 0 | 0 | 一致 |
| 96 | `afterGradeRank` | 1 | 1 | 一致 |
| 97 | `beforeDeluxRating` | 15000 | 15000 | 一致 |
| 98 | `afterDeluxRating` | 15000 | 15000 | 一致 |
| 99 | `isPlayTutorial` | false | false | 一致 |
| 100 | `isEventMode` | false | false | 一致 |
| 101 | `isFreedomMode` | false | false | 一致 |
| 102 | `playMode` | 0 | 0 | 一致 |
| 103 | `isNewFree` | false | false | 一致 |
| 104 | `trialPlayAchievement` | -1 | -1 | 一致 |
| 105 | `extNum1` | 0 | 0 | 一致 |
| 106 | `extNum2` | 0 | 0 | 一致 |
| 107 | `extNum4` | 2022 | 2022 | 一致 |
| 108 | `extBool1` | false | false | 一致 |
| 109 | `extBool2` | false | false | 一致 |
| 110 | `extBool3` | false | false | 一致 |
命中 106/110
## 汇总
- userData 字段命中:76/82
- playlog 字段命中:106/110
- 节点形态命中:36/36
+507
View File
@@ -0,0 +1,507 @@
# 上传成绩(UpsertUserAll)开发文档
> 对应 `api/upsert_user_all.py` 与 `api/upsert_snapshot.py`。
> **判据 = `source/2026-10-05.log:123` 的真机跑通报文 + `source/Assembly-CSharp/` 反编译源码。**
---
## 一、这是什么
`UpsertUserAllApi` 是**唯一一个有副作用的写接口**:把一局游玩的完整结算数据
(成绩明细 + 全量用户数据快照)写进服务器。发出去收不回来。
| 项目 | 值 |
|---|---|
| 逻辑 API 名 | `UpsertUserAllApi` |
| 实际 URL 尾段 | `md5("UpsertUserAllApiMaimaiChn" + "8bF76dE9")` |
| 超时 | 60 秒(`NetConfig.TimeOutInMSecLong`) |
| 副作用 | ★ 有,不可撤销 |
---
## 二、顶层请求结构(7 字段)
依据 `PacketUpsertUserAll.cs:22-35`:
```python
UserAllRequestVO(
userId = user_id,
playlogId = login.loginId, # ★ 必须 == UserLoginApi 的 loginId
isEventMode = False,
isFreePlay = False,
loginDateTime = login.loginDateTime, # ★ 必须 == UserLoginApi 的 loginDateTime
userPlaylogList = [...本局成绩...],
upsertUserAll = {...36 个节点...},
)
```
响应:`{"returnCode": 1, "apiName": "com.sega.maimai.api.UpsertUserAllApi"}`。
> ⚠ `playlogId` 与 `loginDateTime` **不是本接口产生的**,它们来自 `UserLoginApi`。
> 发错登录 ID 服务端会直接拒。中途换了登录会话必须重新登录。
---
## 三、upsertUserAll 的 36 个节点
依据 `Net.VO.Mai2/UserAll.cs`,并用真机报文逐节点核对。**四种形态**:
| 类别 | 数量 | C# 类型 | JSON 形态 |
|---|---:|---|---|
| 单元素数组 | 4 | UserDetail[] / UserExtend[] / UserOption[] / UserRating[] | 数组,恒 1 元素 |
| **对象(非数组)** | 2 | UserWeeklyData / User2pPlaylog | `{}` ★ |
| 普通数组 | 19 | Xxx[] | `[]` |
| **位串** | 11 | string | 定长字符串 |
合计 4 + 2 + 19 + 11 = **36**。
### 3.1 ★ 两个对象节点
`userWeeklyData` 与 `user2pPlaylog` **不是数组**。传数组会被 `coerce_node_value()`
直接拦下并抛 `PatchError`(这是刻意设计的防呆)。
```jsonc
"userWeeklyData": { "lastLoginWeek": "...", "beforeLoginWeek": "...", "friendBonusFlag": true }
"user2pPlaylog" : { "userId1": 0, ..., "user2pPlaylogDetailList": [] }
```
### 3.2 ★ 位串长度 == 对应数组条数
11 个 `isNew*` 与同名数组一一对应。**长度不符服务端会误判「哪些是新增」**。
`build_user_all()` 末尾会自动补齐(`sync_bits_to_arrays()`)。
| 位串 | 对应数组 |
|---|---|
| `isNewCharacterList` | userCharacterList |
| `isNewMapList` | userMapList |
| `isNewLoginBonusList` | userLoginBonusList |
| `isNewItemList` | userItemList |
| `isNewMusicDetailList` | userMusicDetailList |
| `isNewCourseList` | userCourseList |
| `isNewFavoriteList` | userFavoriteList |
| `isNewFriendSeasonRankingList` | userFriendSeasonRankingList |
| `isNewUserIntimateList` | userIntimateList |
| `isNewFavoritemusicList` | userFavoritemusicList |
| `isNewKaleidxScopeList` | userKaleidxScopeList |
---
## 四、★★ 核心:快照基线 + 本局演算 + 差分
### 4.1 真机对照(同一账号的 Get 侧 vs Upsert 侧)
| 节点 | Get 侧(登录时) | Upsert 侧(结算时) | 结论 |
|---|---|---|---|
| `userData.playCount` | 1257 | **1258** | +1 |
| `userData.currentPlayCount` | 185 | **186** | +1 |
| `userData.lastPlayDate` | 2026-10-04 18:46:31 | **2026-10-05 00:20:57** | 更新 |
| `userData.lastPlayMode` | 2 | **0** | 归 0 |
| `userData.lastLoginDate` | 2026-10-04 18:34:22 | **2026-10-05 00:19:37** | 本次登录 |
| `userData.dateTime` | null | **1791134329** | 登录请求的 dateTime |
| `userData.lastPlaceId` | 0 | **1000** | 当前店铺 |
| `userData.lastRegionId` | 11 | **5** | 当前区域 |
| `userMusicDetailList` | 739 组 | **1 条** | 只回传本局那条,playCount 7→8 |
| `userMapList` | 104 条 | **1 条** | 只回传变化那条,distance +11000 |
| `userCharacterList` | 312 条 | **5 条** | 组队 5 只,各 +1 级 |
| `userItemList` | 1070 条 | **0 条** | 无变化 |
| `userCourseList` | 16 条 | **0 条** | 无变化 |
| `userChargeList` | 3 条 | **3 条** | ★ **整表回传,不做差分** |
| `userFavoriteList` | 5 类 | **5 类** | ★ 不做剔除,恒返回全部 |
### 4.2 ★★ 为什么必须做差分(这是「returnCode=1 但没落库」的根因)
**服务器对 `userData` 是整表覆盖语义** —— 把 Get 下来的旧值原样发回去,
写入后「看起来什么都没发生」。
**差分节点若为空,服务端也看不出本局有任何变化**,整包等于没生效。
所以本模块的流程是:
```
1. 从 snapshot(登录后抓的 Get 响应)建立基线
2. 按本局数据演算出新状态,并对差分节点算 (新 - 基线)
3. 用 patch 做精确覆盖
4. 生成 11 条位串(长度自动对齐对应数组)
```
### 4.3 差分算法(复刻 `VOExtensions.BuildListData`)
三个要点(源码 `VOExtensions.cs:309-324`):
1. `nsrc.Except(osrc)` 退化成**全字段逐字段比较** → 任一字段变了整条就回传
2. `compare` 委托**只用于算位串**,不参与剔除
3. 位串 `"0"` = 存量已有同主键(更新)、`"1"` = 全新(新增)
### 4.4 收藏夹是特例
`build_favorite_delta()` 复刻 `VOExtensions.cs:345-367`:**不做剔除,恒返回完整 5 条**,
位串按真机 `"22212"` 反推:
- `"2"` = 服务器侧有对应 itemKind
- `"1"` = 服务器侧查不到(真机 `itemKind=10` 的 Get 响应回的是 `{"itemKind":null}`)
> 因此**必须用未归一化的原始 Get 响应作基线**。`user_favorite.py` 会把原始副本
> 放在返回值的 `_raw` 里,`upsert_snapshot.py` 把它存成 `_raw_favorite`。
---
## 五、userPlaylogList(110 字段成绩明细)
依据 `Net.VO.Mai2/UserPlaylog.cs`,实际声明 **110 个** `public` 字段。
### 5.1 ★ 字段必须全部发送
把真机报文的 `userPlaylogList[0]` 与 C# 声明做集合比对:
| 比对方向 | 结果 |
|---|---|
| C# 声明但报文未出现 | **0 个** |
| 报文有但 C# 未声明 | **0 个** |
**能精简的是「填写的内容要真实」,而不是删字段。**
### 5.2 值之间的硬性关系(`validate_user_playlog()` 会查)
| 关系 | 说明 |
|---|---|
| 各类型判定数之和 == `totalCombo` | totalCombo 是**该谱面 note 总数** |
| `maxCombo` <= 判定总数 | 全连时二者相等 |
| `achievement > 0` 时不应全是 Miss | 否则数值矛盾 |
| `isClear=True` 时 `achievement` 不能为 0 | 否则矛盾 |
| `comboStatus=1`(全连)时 `maxCombo == 判定总数` | |
| `extNum4` == `userExtend.categoryIndex` | 曲种索引冗余两处 |
### 5.3 ★ 日期字段的真实语义(`TimeManager.cs`)
真机 `TimeManager` 的日期处理有三个容易搞错的点:
```csharp
// Manager/TimeManager.cs:9
public static readonly TimeSpan CSToffset = new TimeSpan(8, 0, 0);
// :33-37 把 Unix 秒转成"北京时间墙上时间"
public static DateTime GetDateTime(long unixTime)
=> DateTimeOffset.FromUnixTimeSeconds(unixTime).DateTime.Add(CSToffset);
// :77-85 ★ 游玩日志日不是自然日:7 点前算前一天
public static DateTime ToLogDateTime(DateTime dateTime, int hour = 0)
{
DateTime result = new(dateTime.Year, dateTime.Month, dateTime.Day, hour, 0, 0);
if (7 > dateTime.Hour) return result.AddDays(-1.0);
return result;
}
```
对应到 `VOExtensions.cs:1290-1292`:
| 字段 | 算法 | 真机值 |
|---|---|---|
| `loginDate` | `GetUnixTime(LastLoginDate)` → **标准 Unix 秒** | 1791130777 |
| `playDate` | `GetLogDateString(playTime)` → **日志日**(7 点前回退一天) | "2026-10-04" |
| `userPlayDate` | `GetDateString(playTime)` → 北京时间墙上串 | "2026-10-05 00:20:30.0" |
★ 注意 `loginDate` 是**标准 Unix 秒**,不是 `loginDateTime`:
```
loginDateTime = 1791159576 (「墙上时间当 UTC」)
loginDate = 1791130777 (= loginDateTime - 28800,标准 Unix 秒)
```
### 5.4 bool 字段陷阱
`extBool1/2/3` 在 C# 里是 `bool`,但**名字不带 `is` 前缀**。
用 `name.startswith("is")` 判断会漏掉它们,导致序列化成 `0` 而不是 `false`。
本模块用显式的 `PLAYLOG_BOOL_FIELDS` 集合判定。
同理,字符串字段(`vsUserName` / `playedUserNameN` 等)默认值必须是 `""` 而非 `0`,
见 `PLAYLOG_STRING_FIELDS`。
---
## 六、userGamePlaylogList
真机样本(13 字段):
```json
{"playlogId":7692848101484135861,"version":"1.56.00","playDate":"2026-10-05 00:20:57.0",
"playMode":0,"useTicketId":-1,"playCredit":1,"playTrack":1,"clientId":"TESTCLIENT1",
"isPlayTutorial":false,"isEventMode":false,"isNewFree":false,"playCount":8,
"playSpecial":55951502,"playOtherUserId":0}
```
### 6.1 `playSpecial` 是随机位掩码
源码 `VOExtensions.cs:846-855`:
```csharp
int max = 1037933;
int num2 = Random.Range(1, max) * 2069;
num2 += GameManager.CalcSpecialNum();
int num3 = 0;
for (int i = 0; i < 32; i++) { num3 <<= 1; num3 += num2 % 2; num2 >>= 1; }
```
恒传 0 不像真机生成的值,`calc_play_special()` 复刻了该算法。
### 6.2 `playCredit` 与 `useTicketId`
源码 `VOExtensions.cs:838-845`:
```csharp
int num = Credit.GameCostEnoughPlay(); // 投币成本
int useTicketId = TicketManager.GetUseTicketId(playerId);
int ticketCredit = TicketManager.GetTicketCredit(useTicketId);
int playCredit = num + ticketCredit; // 本局折合多少 credit
```
`useTicketId = -1` 表示**不使用票**(`TicketManager.cs:47` 初始化即 -1)。
---
## 七、字段裁剪(Get 侧 → Upsert 侧要丢掉的)
| 节点 | 丢弃字段 |
|---|---|
| `userData` | `friendCode`、`nameplateId`、`trophyId`、`cmLastEmoneyCredit`、`cmLastEmoneyBrand` |
| `userOption` | `tempoVolume` |
| `userCharacterList` | 只保留 `characterId`/`level`/`awakening`/`useCount` |
| `userMusicDetailList` | `extNum2` |
| `userChargeList` | `extNum1` |
| `userMissionDataList` / `userWeeklyData` | `userId` |
### 7.1 `banState` 位置漂移
- **Get 侧**:`banState` 是 `userData` 的**同级顶层**字段(`UserDetailResponseVO.cs:15`)
- **Upsert 侧**:它被**移进了 `userData` 内部**
`_extract_from_get_responses()` 会自动搬运。
---
## 八、安全闸门
| 调用方式 | 行为 |
|---|---|
| `UpsertUserAll(...)` | 抛 `ConfirmRequiredError`,**未发包** |
| `UpsertUserAll(..., dry_run=True)` | 只构造并返回报文 |
| `UpsertUserAll(..., confirm=True)` | 真实发送 |
全局开关在 `config.py`:`UPSERT_CONFIRM = False`(改成 True 会永久放行,不建议)。
### 8.1 发包前冷却(默认 60 秒)
真实发包前会固定等待 `config.UPSERT_SEND_COOLDOWN_SEC` 秒(**默认 60**),
让上报节奏贴近真机 —— 真机从登录到结算上报间隔约 81 秒
(`source/2026-10-05.log:25` 登录 → `:123` 上报)。
| 调用方式 | 是否等待 |
|---|---|
| `dry_run=True` | 不等待(只构造报文) |
| 未开闸门(`confirm` 未置位) | 不等待(直接抛 `ConfirmRequiredError`) |
| `confirm=True` | **等待 60 秒**后再发包 |
实现要点:
- 冷却位于**「发包前自检」之后** —— 自检有问题时会先看到告警,不必白等 60 秒
- 每 10 秒打印一次进度,避免刷屏又能看出没卡死
- 调试时跳过:`cooldown_sec=0`
```
上传成绩冷却:等待 60.0 秒后再发送(模拟真机节奏)...
冷却中 ... 已等待 10s / 剩 50s
冷却中 ... 已等待 20s / 剩 40s
冷却中 ... 已等待 30s / 剩 30s
冷却中 ... 已等待 40s / 剩 20s
冷却中 ... 已等待 50s / 剩 10s
冷却结束(共等待 60.0 秒)
闸门已放行,开始上传成绩 ...
```
```python
UpsertUserAll(..., confirm=True) # 默认按 config 的 60 秒
UpsertUserAll(..., confirm=True, cooldown_sec=0) # 调试时跳过
wait_send_cooldown(60, label="上传成绩") # 也可单独调用
```
---
## 九、完整用法
### 9.1 ★ 推荐写法:一个 `UpsertUserAll` 就够了
```python
from api import (setup_logging, teardown_logging,
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType,
UpsertUserAll)
from config import REGION_ID, PLACE_ID
setup_logging("结算") # ← 只需要这一句日志;api 自己会记
try:
user_id, token = ParseQRCode(qr)
UserPreview(user_id, token)
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
# ★ 一个函数跑完:抓快照 -> 组包 -> 前置接口 -> 自检 -> 冷却 -> 发包 -> 落库回查
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token, # 让内部自动抓基线快照
dry_run=True, # 先只看报文;真实发送改成 confirm=True
)
UserLogout(user_id, token, login.loginDateTime,
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
finally:
teardown_logging()
```
**注意 `snapshot` 都不用自己抓** —— 传了 `token` 且 `auto_snapshot=True`(默认),
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot`。
### 9.2 想手工控制每一步(进阶)
```python
snapshot = fetch_upsert_snapshot(user_id, token=token) # 自己抓
play = build_play_from_config() # 自己改本局数据
play.characters = derive_play_characters(snapshot, play) # 自己推角色
UpsertUserAll(user_id, login.loginId,
login_date_time=login.loginDateTime,
snapshot=snapshot, play=play,
pre_new_item_list=False, # 不调前置接口
verify_landing=False, # 不做落库回查
dry_run=True)
```
### 9.3 想额外塞节点:`extra_data`
```python
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
extra_data={
"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",
},
confirm=True,
)
```
`extra_data` 会自动匹配节点名、按形态强制转换、覆盖快照演算结果:
| 输入 | 行为 |
|---|---|
| 键不在 36 节点里 | 抛 `PatchError`,报错信息里列出全部可用节点名 |
| 值形态不对(如对象节点传数组) | 抛 `PatchError` |
| 给了数组但没给对应位串 | 按数组长度补 `"0"`,并在日志里提示「新增条目请显式传 "1"」 |
| 也接受 JSON 字符串 / JSON 文件路径 | 内部用 `load_extra_data` 规整 |
> 位串 `"0"` = 存量已有(更新),`"1"` = 全新(新增)。
> 传新增条目时**一定要显式给 `"1"`**,否则服务端会当成更新。
---
## 十、歌曲数据在哪改
全部在 `config.py` 的 `PLAY_*` 系列(默认值取自真机 `source/2026-10-05.log:123`):
| 配置项 | 含义 | 真机默认 |
|---|---|---|
| `PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 曲目与难度 | 143 / 4 |
| `PLAY_ACHIEVEMENT` | 达成率 ×10000 | 0 |
| `PLAY_JUDGE_COUNTS` | 判定明细 | 全 Miss |
| `PLAY_TOTAL_COMBO` | 谱面 note 总数 | 655 |
| `PLAY_CATEGORY_INDEX` | 曲种索引(== userExtend.categoryIndex) | 2022 |
| `PLAY_CHARACTERS` | 游玩时的角色快照(**不加经验**)★ 通常不用管,见 10.2 | 5 只 |
| `PLAY_MAP_DISTANCE_GAIN` | 地图进度增量 | **0**(不推进地图) |
| `PLAY_CHARACTER_EXP_GAIN` | 组队角色等级增量 | **0**(不给角色加经验) |
| `PLAY_LOCAL_PLAY_COUNT` | 机台本地计数器 | 8 |
| `PLAY_SPECIAL_NUM` | playSpecial 里的 CalcSpecialNum | 1024 |
**换歌 / 换成绩只改这里,不用动任何 api 脚本。**
### 10.1 ★ 两个默认 0 的含义
`PLAY_MAP_DISTANCE_GAIN` 与 `PLAY_CHARACTER_EXP_GAIN` **默认都是 0**,
即「本局不推进地图、不给角色加经验」,对应节点回传空数组:
```json
"userMapList": [], "isNewMapList": ""
"userCharacterList": [], "isNewCharacterList": ""
```
这是**预期行为,不是错误**。`validate_before_send()` 也只在
「你把增量设成非 0、却没产出条目」时才告警。
想复刻真机的「地图 +11000 / 组队 5 只各 +1 级」,把这两个值改回
`11000` / `1` 即可(真机样本:mapId=550003 的 distance 1424000→1435000)。
### 10.2 ★ `PLAY_CHARACTERS` 通常不用管
`UpsertUserAll` 默认会调 `derive_play_characters(snapshot)`,
直接从服务器刚下发的 `userData.charaSlot` + `userCharacterList`
推出**游玩前**的 5 只角色,并覆盖这份配置。
只有拿不到快照(纯离线组包)时才会回退到这里。
---
## 十一、接口速查
| 函数 | 作用 |
|---|---|
| `UpsertUserAll(...)` | ★ 主函数:**一个函数跑完整条结算链路**(抓快照 → 组包 → 前置接口 → 自检 → 冷却 → 发包 → 落库回查) |
| `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) |
| `build_play_from_config()` | 按 config 构造本局数据 |
| `derive_play_characters(snapshot, play)` | 从快照推游玩前 5 只角色 |
| `load_extra_data(extra)` | 把 dict / JSON 字符串 / 文件路径 规整成 dict |
| `merge_extra_data(user_all, extra)` | 把外部 JSON 合并进 36 节点(自动匹配 + 补位串 + 校验) |
| `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉数据,确认真的落库 |
| `build_user_all(snapshot, play, patch, extra_data, ...)` | 组装 36 节点 |
| `build_user_playlog(playlog_id, login_date, ...)` | 构造 110 字段成绩明细 |
| `build_user_game_playlog(...)` | 构造 userGamePlaylogList 一条 |
| `validate_user_playlog(playlog)` | 成绩自检,返回问题列表 |
| `validate_before_send(req)` | 发包前自检,返回问题列表 |
| `build_delta(local, server, keys)` | 通用差分 |
| `build_favorite_delta(local, server)` | 收藏夹专用差分 |
| `sync_bits_to_arrays(user_all)` | 位串按数组长度补齐 |
| `coerce_node_value(node, value)` | 单节点类型强制(传错即抛) |
| `empty_user_all()` | 全空骨架 |
---
## 十二、必知的坑
**完整清单(28 条,含现象/根因/源码依据/正确做法/自检项)见
[`UpsertUserAll踩坑清单.md`](UpsertUserAll踩坑清单.md)。**
先记住最要命的五条:
1. **服务端返回 `returnCode=1` 不代表数据被应用** —— 唯一验收标准是发包后回查数值有没有变
2. **`userData` 是整表覆盖语义** —— 原样回传旧值 = 什么都没发生(这是「回码成功但没落库」的头号成因)
3. **差分节点必须反映本局变化** —— `userMusicDetailList`/`userMapList`/`userCharacterList` 全空 = 服务端认为本局无变化
4. **`playlogId` / `loginDateTime` 必须逐字节等于 `UserLoginApi` 的返回值**
5. **`userWeeklyData` / `user2pPlaylog` 是对象不是数组**;**`userChargeList` 是整表回传不是差分**
---
## 十三、验证方式
```powershell
python selftest.py
```
覆盖 **196 项**零网络检查,含:URL 混淆(对真机 md5 值)、加解密往返、
36 节点结构与形态、110 字段完整性、差分算法、收藏夹三值位串、日期语义、
副作用闸门、位串自动补齐、快照归一化、配置常量、
`derive_play_characters` 角色推导、`extra_data` 四种输入形态与自动补位串、
number 头 / Cookie 复用等。
+302
View File
@@ -0,0 +1,302 @@
# 主脚本编写指南
> 面向「想用这个工程写自己业务流程」的人。
> 目标:**你只写业务,日志和协议细节都交给 `api/`**。
---
## 一、三条铁律
### 1. 不要为每个 api 调用写日志
`api/` 里每个函数在导入时都被自动套了日志外壳(`log_setup.autowrap`),
调用时会自动打印:
```
=== UserLogin 开始运行 ===
[接口说明] 用户正式登录(开启本次登录会话)。
[位置参数] 12345678, 'TpzUKJM6x1d2iKUP5yLI'
[关键字参数] region_id=1, place_id=1000
[运行结果] 成功 UserLoginResponseVO(8 字段)(耗时 77 ms)
=== UserLogin 运行结束 ===
```
所以你**不需要**在调用前后写 `log_section` / `log_info`。
主脚本里唯一必需的日志语句只有开头结尾那两句:
```python
setup_logging("我的脚本") # 开头
...
teardown_logging() # 结尾(写在 finally 里)
```
### 2. 一定要保证登出
账号若停留在 `isLogin=True`,下次登录会返回 `returnCode=100`。
**用 `try/finally` 兜底**:
```python
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
...
login = UserLogin(user_id, token, ...)
session.update(login_dt=login.loginDateTime, logged_in=True)
...
UserLogout(...)
session["logged_in"] = False
finally:
if session["logged_in"]:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn)
```
### 3. 有副作用的接口要显式开闸门
`UpsertUserAll` / `UpsertUserChargelog` 这类写接口默认**不发包**,
必须显式传 `confirm=True`(或 `dry_run=True` 只看报文):
```python
UpsertUserAll(..., dry_run=True) # 只构造,不发
UpsertUserAll(..., dry_run=False, confirm=True) # 真发
```
> ⚠ 只写 `dry_run=False` **不够** —— `confirm=None` 时会去读
> `config.UPSERT_CONFIRM`(默认 `False`),照样被拦。
---
## 二、最小骨架
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from api import (setup_logging, teardown_logging,
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType)
from config import REGION_ID, PLACE_ID
def main() -> int:
setup_logging("我的脚本")
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
user_id, token = ParseQRCode(sys.argv[1])
session.update(user_id=user_id, token=token)
UserPreview(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)
# ---- 你的业务写在这里 ----
UserLogout(user_id, token, login.loginDateTime,
logout_type=LogoutType.TestIn,
region_id=REGION_ID, place_id=PLACE_ID)
session["logged_in"] = False
return 0
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:
pass
teardown_logging()
if __name__ == "__main__":
sys.exit(main())
```
---
## 三、日志
### 3.1 两种模式(改 config.py 一行)
| `LOG_LEVEL` | 输出 |
|---|---|
| `"info"`(默认) | 只展示 api 脚本的**运行情况和结果** |
| `"debug"` | 额外展示**向 api 发送的数据**和 **api 返回的数据**(含完整报文) |
临时切换(不改 config):
```bash
python upsert.py <二维码> --debug
```
> ★ DEBUG 会完整打印收发报文,一次结算流程能到 **十几 MB**。
> 只想看流程就别开;想瘦身调 `config.LOG_VALUE_MAX`。
### 3.2 两个日志文件(同时写)
| 文件 | 模式 | 内容 |
|---|---|---|
| `logs/latest.log` | 覆盖(`w`) | 只含**当次**主脚本运行 |
| `logs/YYYY-MM-DD.log` | 追加(`a`) | 当日**全部**日志 |
### 3.3 想记自己的话(可选)
```python
from api import log_info, log_warning, log_error, log_section, log_field
log_section("阶段标题") # 带分隔线
log_info("普通信息")
log_warning("要留意的情况")
log_error("出错了")
log_field("预览数据", preview) # INFO 一行概要;DEBUG 展开完整 JSON
```
### 3.4 嵌套调用也会自动记
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot` / `UserNewItemList` / `UserData`,
这些**内部调用同样打印自己的开始/结束行**,日志层次完整嵌套:
```
=== UpsertUserAll 开始运行 ===
=== fetch_upsert_snapshot 开始运行 ===
...
=== fetch_upsert_snapshot 运行结束 ===
=== UserNewItemList 开始运行 ===
...
=== UserNewItemList 运行结束 ===
...
=== UpsertUserAll 运行结束 ===
```
---
## 四、参数约定
| 类型 | 位置 | 例子 |
|---|---|---|
| **业务数据**(必需) | 位置参数 | `user_id` / `token` / `二维码` / `playlog_id` / `charge_id` |
| **配置项**(可省) | 关键字参数 | `client_id` / `region_id` / `place_id` / `timeout_ms` / `client` |
**`client` 参数**:所有 api 都接受一个可选的 `MaimaiNetClient`。
不传时走全局单例 `get_default_client()`,**连接和 Cookie(JSESSIONID)自动复用**。
> ### ★ 不要自己 `MaimaiNetClient()` 新建去发任何带 userId 的包
>
> 源码 `Packet.cs:187-194`:**所有非 Ping 且 `userId != 0` 的请求都会
> `Client.AddCookie(Query.UserId)`** —— **登出也不例外**。
>
> ```csharp
> if (!IsPingPacket())
> {
> Client.AddHeader("number", GameManager.PingMsec.ToString());
> if (Query.UserId != 0L) { Client.AddCookie(Query.UserId); } // ← 登出也走这里
> }
> ```
>
> 那个 CookieContainer 是 **`UserLoginApi` 响应里的 `Set-Cookie: JSESSIONID=...`**
> 存下来的(`Packet.cs:228-234`)。
>
> **为什么对登出尤其要命**:登出请求体里**没有 token**,只有
> `userId` / `accessCode` / `regionId` / `placeId` / `clientId` /
> `loginDateTime` / `type` / `delayLog`。
> 服务端能用来定位「哪个会话」的,除了 `userId + loginDateTime`,就剩这个 JSESSIONID。
> **丢掉它有可能登出失败 → 账号卡在 `isLogin=True`(下次登录 returnCode=100)**。
>
> 结论:**全程复用同一个 client**(不传 `client=` 即默认单例,自动满足)。
---
## 五、四个典型流程模板
### 5.1 只登录(探活 / 查状态)
```python
user_id, token = ParseQRCode(qr)
UserPreview(user_id, token)
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
# ... 用 UserData / UserMusicDetailAll 等取数 ...
```
### 5.2 上传一局成绩(最常用)
```python
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
# ★ 一个函数跑完整条链路:抓快照 → 推角色 → 造成绩明细 → 前置接口
# → 组 36 节点 → 合并 extra_data → 自检 → 冷却 → 发包 → 落库回查
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token, # 让内部自动抓基线快照
dry_run=False, confirm=True,
)
```
**换歌 / 换成绩只改 `config.py` 的 `PLAY_*`**,不用动主脚本。
**想额外塞节点**用 `extra_data`:
```python
UpsertUserAll(..., extra_data={
"userKaleidxScopeList": [{"gateId": 1, "isGateFound": True, ...}],
"isNewKaleidxScopeList": "1",
})
```
### 5.3 买一张功能票
见 `buy_ticket.py` 与 `docs/购买功能票流程.md`。核心就一句:
```python
UpsertUserChargelog(user_id, charge_id=2,
login_date_time=login.loginDateTime,
dry_run=False, confirm=True)
```
### 5.4 只取数(不发任何写接口)
```python
from api import UserData, UserMusicDetailAll, UserRating
ud = UserData(user_id)
print(ud["userData"]["playCount"])
for m in (UserMusicDetailAll(user_id).get("userMusicList") or []):
if m.get("musicId") == 143:
print(m)
```
> 取数类 api 不需要 `confirm`,也没有副作用,可以随便调。
---
## 六、常见错误
| 现象 | 原因 | 解决 |
|---|---|---|
| 登录 `returnCode=100` | 账号卡在 `isLogin=True` | 上次没登出;等超时或用 `finally` 兜底 |
| `UpsertUserAll` 抛 `ConfirmRequiredError` | 只传了 `dry_run=False`,没传 `confirm=True` | 补 `confirm=True` |
| 扫码 `errorID=2` | 二维码**过期**了。它有**时效性**(**有效期 10 分钟**),**不是按次数计量的** | 重新生成一个 |
| `returnCode=1` 但数据没变 | 差分节点全空(见踩坑清单坑 2) | 检查 `PLAY_*` 增量配置 |
| 日志文件特别大 | 开了 DEBUG | 关掉 `--debug` 或调小 `LOG_VALUE_MAX` |
| `RuntimeError: 接口 xxx 返回 0 字节` | `Accept-Encoding` 被改过 | 不要动 `maimai_net` 的该头 |
---
## 七、自己写一个新 api
1. 在 `api/` 下新建脚本,定义一个能传参的函数
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
3. 用 `from .maimai_net import request_dict` 发包
4. 用 `from .log_setup import log_info` 记关键结果
5. **在 `api/__init__.py` 里导入并加进 `_API_FUNCS`** ← 漏了这步就没有开始/结束提示
---
## 八、相关文档
| 文件 | 内容 |
|---|---|
| `docs/项目总览.md` | 工程结构与 api 清单 |
| `docs/日志与主脚本指南.md` | 日志模块完整参考 |
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 |
| `docs/UpsertUserAll踩坑清单.md` | 28 条坑 + 发包前 Checklist |
| `docs/购买功能票流程.md` | 登录 → 买票 → 登出 |
| `upsert.py` | 主脚本范例(极简版) |
+173
View File
@@ -0,0 +1,173 @@
# 功能票使用说明(购买 / 消耗)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里功能票部分的**详细版**。
> 购买接口的完整排查过程见 [购买功能票流程.md](购买功能票流程.md) 与
> [购买功能票_存档与交接.md](购买功能票_存档与交接.md)。
---
## 一、票的种类
机台**可购买**的是付费票(`ticketKind=1`,`api.PAID_TICKET_IDS = (2,3,4,5,6)`):
| chargeId | 票 | 上报的 `price`(= 票的 `creditNum`) |
|---|---|---|
| 2 | 2倍チケット | 1 |
| 3 | 3倍チケット | 2 |
| 5 | 5倍チケット | 4 |
> 完整表(含 4/6 倍与活动票)见 `api/upsert_user_chargelog.py` 的 `TICKET_CREDIT_NUM`。
>
> ⚠️ **不同店铺上架的票不一样。** 买之前先 `--list` 看一眼 ——
> 服务端对未上架的票会直接 `returnCode=0`(实测过)。
---
## 二、购买:`buy_ticket.py`
```bash
python buy_ticket.py "<二维码>" --list # 只列当前票券和可买的票
python buy_ticket.py "<二维码>" --charge-id 2 # 预检:只看报文
python buy_ticket.py "<二维码>" --charge-id 2 --send # ★ 真实购买
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--charge-id N` | **5** | 买哪张票 |
| `--list` | — | 只列票券与可购买清单,不做任何写操作 |
| `--send` | 关 | ★ 真实发包 |
| `--debug` | 关 | 日志里带完整收发报文 |
| `--skip-entry` | 关 | 跳过登录后的「入场全量下载」 |
| `--wait-before N` | **30** | 买票前等待秒数(真机 login→买票是 26 秒) |
### 三道安全检查
1. **买之前**先查 `GetGameChargeApi` —— 目标票**不在当前可购买清单里就直接退出**,
连登录都不做(不浪费二维码)。
依据 `source/Assembly-CSharp/Manager/TicketManager.cs:137-150`。
2. **跑前查一次** `GetUserChargeApi`:**账号已经持有功能票时直接拒绝购买**
(重复购买有封号风险)。想清掉手里的票用 `use_ticket.py`。
3. **跑后再查一次**,确认票券状态符合预期。
### 报文形态
```json
{
"userId": <uid>,
"userChargelog": {
"chargeId": 2, "price": 1,
"purchaseDate": "2026-10-06 22:23:00.0",
"playCount": 1268, "playerRating": 15534,
"placeId": 1007, "regionId": 8, "clientId": "A63E01C3047"
},
"userCharge": {
"chargeId": 2, "stock": 1,
"purchaseDate": "2026-10-06 22:23:00.0",
"validDate": "2027-01-04 04:00:00"
},
"loginDateTime": 1791325354
}
```
| 字段 | 依据 |
|---|---|
| `price` | = `ticket.creditNum`(**不是** `GetGameChargeApi` 里的 money price)。`VOExtensions.cs:410` |
| `stock` | **购买后**的库存(原值 + 1) |
| `purchaseDate` | `TimeManager.DateTimeFormat` = `yyyy-MM-dd HH:mm:ss.f`,**带一位小数** |
| `validDate` | `TicketManager.cs:562-565`:`Now.AddHours(-4).AddDays(90).ToString("yyyy-MM-dd 04:00:00")`,**不带小数** |
> ⚠️ 这两个日期格式不一样 —— `purchaseDate` 带 `.f` 而 `validDate` 不带。
> 少写 `.0` 服务端直接 `returnCode=0`(踩过)。
---
## 三、消耗:`use_ticket.py`
```bash
python use_ticket.py "<二维码>" --list # 只列当前持有的票
python use_ticket.py "<二维码>" --ticket-id 2 # 预检:只看报文
python use_ticket.py "<二维码>" --ticket-id 2 --send # ★ 真实消耗
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--ticket-id N` | 第一张持有的 | 消耗哪一张 |
| `--list` | — | 只列票券,不做任何写操作 |
| `--send` | 关 | ★ 真实发包 |
| `--music-id N` / `--level N` | `config.PLAY_MUSIC_ID` / `PLAY_MUSIC_LEVEL` | 「这一局」打的歌 |
| `--skip-entry` | 关 | 跳过入场全量下载 |
| `--debug` | 关 | 日志里带完整收发报文 |
### 原理
`GetUserChargeApi` 与 `UpsertUserAllApi` 里的 `userChargeList`
**格式完全相同**(`UserCharge.cs`:`chargeId` / `stock` / `purchaseDate` / `validDate`),
所以只要把该节点里目标票的 `stock` 减 1,再让 playlog 带上
`useTicketId` / `playCredit`,服务端就认为这张票被这一局用掉了。
**真机实证**(2026-10-06 22:23 那局正好用了 2 倍券):
```
userChargeList: chargeId=2 stock 1 -> 0 ← 票被消耗
userGamePlaylogList[0].useTicketId = 2 ← 用了哪张票
userGamePlaylogList[0].playCredit = 2
userPlaylogList[0].playCredit = 2
userData.lastPlayCredit = 2
(报文里**没有** isNewChargeList 这个节点)
```
`playCredit` 的算法(`VOExtensions.cs:838-845`):
```csharp
int num = Credit.GameCostEnoughPlay(); // 投币数,普通局 = 1
int useTicketId = TicketManager.GetUseTicketId(playerId);
int ticketCredit= TicketManager.GetTicketCredit(useTicketId); // = 票的 creditNum
int playCredit = num + ticketCredit;
```
2 倍券的 `creditNum = 1` → `playCredit = 1 + 1 = 2`,与抓包吻合。
### 实测记录(2026-10-07 00:24)
```
[消耗前] chargeId=2 2倍チケット stock=1 ← 持有中
选定要消耗的票:chargeId=2 creditNum=1 -> playCredit=1+1=2
userChargeList 里 chargeId=2 的 stock:1 -> 0
★ 上传成绩成功:returnCode=1
落库回查:
playCount 1269 -> 1269 (PLAY_USER_PLAY_COUNT_GAIN=0,不再 +1)
currentPlayCount 197 -> 197
lastPlayDate 2026-10-06 22:23:56 -> 2026-10-07 00:24:07 ← 落库成功
musicId=143 level=4 playCount -> 11 (曲目次数不变)
★ 票券已消耗:chargeId=2 不再持有 ✅
```
---
## 四、典型用法:票挂在手里,想再买一张
`buy_ticket.py` 检测到**已持票**会拒绝购买。想再买一张:
1. 用 `use_ticket.py --send` 把手里那张消耗掉,或
2. 直接在机台上打一局并选用它
然后再跑 `buy_ticket.py`。
---
## 五、购买接口曾经的坑(摘要)
`UpsertUserChargelogApi` 曾经一直返回 `returnCode=0`,报文与真机**逐字节一致**也照样失败。
**根因:请求头多发了 `Accept` 与 `Connection`。**
真机(Unity Mono 的 `HttpWebRequest`)实际只发这几个头:
```
number / Content-Type / User-Agent / charset / Mai-Encoding / Content-Encoding / Cookie
```
删掉这两个头之后一次通过(`stock` 0 → 1,落库确认)。
> 完整排查过程(12 项已排除假设、账号对照实验、真机抓包对比)见
> [购买功能票流程.md](购买功能票流程.md) 与 [购买功能票_存档与交接.md](购买功能票_存档与交接.md)。
+133
View File
@@ -0,0 +1,133 @@
# 实跑验证记录(真实发包 + 落库确认)
> 本文记录本工程**第一次真实跑通**「扫码登录 → 取数 → 上传成绩 → 落库回查 → 登出」的完整证据。
> 执行方式:`python upsert.py <二维码> --send`(等价于下面的驱动方式)。
---
## 一、结论
| 环节 | 结果 |
|---|---|
| 扫码登录 | ✅ user_id=12345678,token 获取成功 |
| 用户预览 | ✅ errorId=0、banState=0、isLogin=False |
| 正式登录 | ✅ returnCode=1,loginId=7693236821794227665 |
| 抓基线快照 | ✅ 19 个节点全部成功,失败 0 个 |
| 组包 + 发包前自检 | ✅ 0 问题 |
| 冷却 | ✅ 等待 60.0 秒(模拟真机节奏) |
| **上传成绩** | ✅ **returnCode=1** |
| **落库回查** | ✅ **userData.playCount 1260 → 1261** |
| 登出 | ✅ returnCode=1 |
**这是关键突破**:之前的失败模式是「returnCode=1 但没落库」。
本次是**回码成功 + 重新拉服务器数据确认数值真的变了**,两者同时成立。
---
## 二、时间线(2026-10-06 01:28)
```
01:28:01 扫码登录成功 user_id=12345678
01:28:01 用户登录成功 loginId=7693236821794227665
loginDateTime=1791250081 dateTime=1791221281
01:28:03 结算快照抓取完成 成功 19 个节点,失败 0 个
01:28:04 角色快照取自服务器 395:3803 / 101:4458 / 400402:137 / 400401:137 / 200401:137
01:28:04 发包前自检通过
01:28:04 上传成绩冷却 等待 60.0 秒后再发送(模拟真机节奏)
01:29:04 冷却结束 共等待 60.0 秒
01:29:04 上传成功 returnCode=1
01:29:04 落库回查:
playCount 1260 -> 1261
currentPlayCount 188 -> 189
lastPlayDate 2026-10-05 22:32:21 -> 2026-10-06 01:28:04
musicId=143 level=4 playCount -> 11
01:29:05 【落库成功】userData.playCount 已 +1
01:29:05 用户登出成功 returnCode=1
```
---
## 三、落库证据(上传后重新拉服务器数据)
上传成功后**不登出、立刻**调 `GetUserDataApi` + `GetUserMusicApi` 回读,服务器返回:
```json
{
"userData": {
"playCount": 1261,
"currentPlayCount": 189,
"lastPlayDate": "2026-10-06 01:28:04",
"lastLoginDate": "2026-10-06 01:28:01",
"eventWatchedDate": "2026-10-06 01:28:01",
"lastPlaceId": 0,
"point": 4460,
"charaSlot": [395, 101, 400402, 400401, 200401]
}
}
```
```
musicId=143 level=4 -> playCount = 11
```
对照**上传前**的基线:`playCount=1260`、`currentPlayCount=188`、`(143,4).playCount=10`。
三项全部 +1,**证明服务端真的把这份报文写进去了**。
同时确认:
- `point` 保持 4460 不变 —— 与「`userGetPointList` / `userLoginBonusList` 留空」的预期一致
- `lastLoginDate` = 登录时刻(01:28:01),`lastPlayDate` = 上报时刻(01:28:04),两者分离正确
- `charaSlot` 未被破坏
---
## 四、这次跑通依赖的关键实现点
1. **`number` 请求头**:先跑 `Ping()` 取得往返毫秒,之后所有请求带上(本次 47 ms)
2. **Cookie 复用**:全程同一个 `MaimaiNetClient`,`UserLoginApi` 的 `Set-Cookie: JSESSIONID` 自动带入后续请求
3. **userData 整表覆盖**:先抓 19 节点快照作基线,再按本局演算 +1 后整表回传
4. **差分节点只回传变化项**:`userMusicDetailList` 只回传 (143,4) 那条、`userMapList` 只回传 550003、
`userCharacterList` 只回传 5 只组队角色
5. **11 条位串与数组长度严格对齐**:`isNewFavoriteList="22212"` 等全部复刻真机
6. **`playSpecial` 用 `CalcSpecialNum=1024`**:与真机位模式一致
7. **`characterLevelN` 用游玩前等级**:playlog 3803/4458/137,userCharacterList 才是 3804/4459/138
---
## 五、复现方式
```bash
# 1) 预检(不发包,零副作用)
python upsert.py "<二维码>" --debug
# 2) 真实上传(★ 会把成绩写进服务器,不可撤销)
python upsert.py "<二维码>" --send
# 想跳过 60 秒冷却
python upsert.py "<二维码>" --send --cooldown 0
# 想跳过上传后的落库回查
python upsert.py "<二维码>" --send --no-verify
```
`--send` 会自动做落库回查:重新拉 `GetUserDataApi` + `GetUserMusicApi`,
对比 `playCount` 有没有 +1,并打印 `【落库成功】` 或 `【疑似未落库】`。
> ★ 建议先用 `--debug` 跑一次预检:DEBUG 日志会完整打印请求头、请求体、响应体,
> 便于逐字段核对。代价是日志文件会很大(本次 DEBUG 日志约 200 万行)。
---
## 六、账号状态变化(本次真实写入的内容)
| 项 | 上传前 | 上传后 |
|---|---|---|
| `userData.playCount` | 1260 | **1261** |
| `userData.currentPlayCount` | 188 | **189** |
| `userData.lastPlayDate` | 2026-10-05 22:32:21 | **2026-10-06 01:28:04** |
| `userMusicDetailList` (143, Re:Master) playCount | 10 | **11** |
| `userMapList` 550003 distance | 1457000 | **1468000**(+11000) |
| `userCharacterList` 5 只组队角色 | 3803/4458/137/137/137 | **3804/4459/138/138/138**(+1) |
| `userData.point` | 4460 | 4460(不变) |
写入的 `userPlaylogList[0]` 是 `musicId=143`(Re:Master)全 Miss 的失败局,
判定数 441/19/192/0/3,与 `config.PLAY_*` 一致。
+114
View File
@@ -0,0 +1,114 @@
# 常见坑与注意事项
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里被精简掉的
> 「常见坑与注意事项」章节的**详细版**。
---
## 一、二维码
| 项 | 说明 |
|---|---|
| 有效期 | **10 分钟**,过期后 aimedb 返回 `errorID=2` |
| 计量方式 | ★ **不是按次数计量的**,只是有时效;重新生成即可 |
| 来源 | 机台的「扫码登录」界面 |
| 每个脚本各要一次 | `reincarnate.py` 需要目标号的二维码;源号可以用 `--src-json` 免掉 |
---
## 二、账号状态
- **一定要登出。** 账号卡在 `isLogin=True` 时,下次登录会 `returnCode=100`。
本工程所有主脚本都在 `finally` 里兜底登出,异常也不会漏。
- 想确认状态可以看 `GetUserPreviewApi` 的 `isLogin` 字段。
- 登出类型:真机用 **type=1(Logout)**,见 [账号转生说明.md](账号转生说明.md) 第五节。
---
## 三、功能票
- 账号**已经持有**功能票时,`buy_ticket.py` **会拒绝购买**(重复购买有封号风险)。
- 想再买一张,先用 `use_ticket.py` 把手里那张用掉,或直接在机台上打一局选用它。
- 不同店铺上架的票不一样,买之前先 `--list`。
- 详见 [功能票使用说明.md](功能票使用说明.md)。
---
## 四、副作用
| 命令 | 后果 |
|---|---|
| 不带 `--send` | 只构造报文,**不发包**(安全,随便跑) |
| 带 `--send` | **真实写入服务器,不可撤销** |
涉及真实写入的脚本:`upsert.py` / `buy_ticket.py` / `use_ticket.py` / `reincarnate.py`。
---
## 五、日志
```
logs/latest.log 只含**当次**运行(下次覆盖)
logs/YYYY-MM-DD.log 当日全部日志(追加)
```
改 `config.py` 的 `LOG_LEVEL`:
| 值 | 效果 |
|---|---|
| `"info"` | 只展示 api 脚本的运行情况和结果 |
| `"debug"` | 额外展示向 api 发送的数据与 api 返回的数据 |
> 主脚本也可以加 `--debug` 临时开启,不用改 `config.py`。
> DEBUG 日志很大(一局能到十几 MB),`config.LOG_VALUE_MAX` 可以限制单值长度。
---
## 六、网络
- 服务器偶发超时 / 连接重置,**重试即可**(买票那次就是这么过的)。
- 所有请求默认 `timeout` 10 秒,登录与结算用 60 秒。
- `GetGameChargeApi` 偶尔会读超时,重跑一次通常就好。
---
## 七、不要提交的东西
```
config.py 你的机台身份,.gitignore 已忽略
data/ 用户数据快照
download/ .app / .opt 更新包
source/ 反编译源码(版权)
logs/ 运行日志
```
### ⚠️ `.gitignore` 的行尾注释不是注释
写:
```gitignore
data/ # 每账号一份用户数据快照
```
**是错的** —— `#` 只在**行首**才是注释,整行会被当成一个含空格和 `#` 的 pattern,
**匹配不到任何东西**。结果就是规则失效。
正确写法:
```gitignore
# 每账号一份用户数据快照
data/
```
> 这条坑踩过一次:`data/12570152.json`(604 KB 用户数据)被误提交进 git。
> 修复方式是 `git rm --cached` + `commit --amend` + `reflog expire` + `gc --prune=now`。
---
## 八、其它
- **换歌 / 换成绩只改 `config.py` 的 `PLAY_*`**,不用动任何代码。
- `config.py` 不入库,仓库里只有 `config.py.example` 模板;
第一次用要先 `copy config.py.example config.py` 并填好「机台与店铺标识」。
- `api/` 里每个脚本 = 一个接口,函数名与文件名都带 `Get`(如果 API 名里有 Get)。
- 想离线复现某次请求,用 `--debug` 抓报文 + `selftest.py` 里的对拍用例。
+380
View File
@@ -0,0 +1,380 @@
# 日志与主脚本指南
> 模块:`api/log_setup.py` | 模式:INFO / DEBUG
---
## 一、一句话理解
**api 调用会被自动识别并记录,主脚本不用写任何日志代码。**
主脚本要写的只有两件:
```python
setup_logging("脚本名") # 开头一句
... # 业务代码,照常调 api
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
```
---
## 二、自动日志长什么样
调用了 `ParseQRCode` 与 `UserPreview`,日志自动产出:
```
==================================================================
日志会话开始 —— main
==================================================================
主脚本 : main
开始时间 : 2026-10-05 17:37:42
日志模式 : INFO(仅运行情况与结果)
Python : 3.12.6 (CPython AMD64)
工作目录 : D:\SDGBSource\杂乱的api\wahappy-v0-goal
已挂载 api : 60 个(自动跟踪,主脚本无需写日志代码)
当次日志 : ...\logs\latest.log
日期日志 : ...\logs\2026-10-05.log
==================================================================
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] SGWCMAID2610022219463A297AF4CAB863406AA91ED2D29FF3BE20BF1113AC03095D97BD1573EDD691B6
解析二维码登录:chip_id=TESTCLIENT1
发送登录请求:timestamp=261005173742 host=ai.sys-allnet.cn/wc_aime/api/get_data
扫码登录成功:user_id=12345678 token=t3WikM5n...(长度 20)
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
=== UserPreview 开始运行 ===
[接口说明] 获取用户预览信息。
[位置参数] 12345678, 'TESTTOKEN0000000000'
[关键字参数] client_id='TESTCLIENT1'
获取用户预览:user_id=12345678 client_id=TESTCLIENT1
用户预览获取完成:userName=☆Astral☆ playerRating=15000 isLogin=False isNetMember=1
[运行结果] 成功 UserPreviewResponseVO(22 字段)(耗时 388 ms)
=== UserPreview 运行结束 ===
==================================================================
本次运行调用的 api
==================================================================
1. ParseQRCode
2. UserPreview
==================================================================
日志会话结束 —— main
==================================================================
```
自动带出来的东西:
| 自动内容 | 来源 |
|---|---|
| `===函数名 开始运行===` / `===函数名 运行结束===` | 导入时自动包装 |
| `[接口说明]` | 函数 docstring 首行 |
| `[位置参数]` / `[关键字参数]` | 本次调用传入的值 |
| `[运行结果]` | 成功摘要或异常类型,附耗时 |
| 会话结束的 **api 汇总清单** | 运行时记录调用顺序 |
---
## 三、两种模式
| 模式 | 你能看到什么 |
|---|---|
| **INFO**(默认) | 开始 → 接口说明 → 参数 → 结果 → 结束 |
| **DEBUG** | 上面全部 **+** 请求 URL、请求头、**请求体 JSON 全文**、状态码、耗时、响应头、**响应体 JSON 全文** |
切换方式:改 `config.py` 一行。
```python
LOG_LEVEL = "info" # 日常运行
LOG_LEVEL = "debug" # 排查协议问题
```
DEBUG 下额外多出:
```
-> 请求 GetGameSettingApi
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5d5a20b062c5c2ad817460b8f7f76
请求头 : {"Content-Type":"application/json","charset":"UTF-8","Mai-Encoding":"1.55","User-Agent":"83a5...#TESTCLIENT1","Accept-Encoding":"","Content-Encoding":"deflate"}
请求体 :
{
"placeId": 1000,
"clientId": "TESTCLIENT1"
}
<- 响应 GetGameSettingApi
状态码 : 200
耗时 : 312 ms
响应头 : {"Date":"...","Content-Type":"application/json"}
响应体 :
{
"isAouAccession": false,
"gameSetting": {
"isMaintenance": false,
"requestInterval": 1200,
...
}
}
```
> DEBUG 模式下**完整输出,不折叠、不截断** —— 调试时就是要能逐条核对
> 1000+ 条曲目、104 条地图。如需限制,改 `config.py` 的 `LOG_VALUE_MAX`。
---
## 四、主脚本怎么写
### 4.1 ★ 核心原则:**不要为每个 api 调用写日志**
日志全部写在 api 脚本内部。主脚本**只需要**:
1. 开头 `setup_logging("脚本名")`
2. 照常调 api 函数
3. 结尾 `teardown_logging()`
**不要**在每次调用前写 `log_section`、调用后写 `log_info` —— 那是多余的,
api 函数自己会打印 `=== 函数名 开始运行 ===` / `[运行结果]` / `=== 函数名 运行结束 ===`。
> 完整范例见工程根目录的 `upsert.py`:整条「扫码 → 预览 → 登录 → 结算上传 → 登出」
> 只有四行业务代码,**一行日志都没写**。
### 4.2 最小可用版本
```python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
from api import (setup_logging, teardown_logging,
ParseQRCode, UserPreview, UserLogin, UserLogout, LogoutType)
def main() -> int:
setup_logging("我的脚本") # ← 只需要这一句
try:
user_id, token = ParseQRCode(sys.argv[1])
UserPreview(user_id, token)
login = UserLogin(user_id, token)
UserLogout(user_id, token, login.loginDateTime,
logout_type=LogoutType.TestIn)
return 0
finally:
teardown_logging()
```
**就这些。** 没有装饰器,没有埋点,没有 log_section。
### 4.3 嵌套调用也会自动记日志
`UpsertUserAll` 内部会调 `fetch_upsert_snapshot` / `UserNewItemList` / `UserData`,
这些**内部调用同样会打印自己的开始/结束行**(见 `upsert_user_all._resolve_api_func`),
所以结算链路的日志层次是完整嵌套的:
```
=== UpsertUserAll 开始运行 ===
[接口说明] 上传一局成绩(UpsertUserAllApi)—— 一个函数跑完整条结算链路。
=== fetch_upsert_snapshot 开始运行 ===
...
=== fetch_upsert_snapshot 运行结束 ===
=== UserNewItemList 开始运行 ===
...
=== UserNewItemList 运行结束 ===
...
=== UpsertUserAll 运行结束 ===
```
### 4.4 想额外记点自己的话(可选)
不写也完全不影响:
```python
from api import log_info, log_warning, log_error, log_section, log_field
log_section("阶段标题") # 带分隔线的分节
log_info("普通信息") # 就当成「会进日志文件的 print」
log_warning("需要留意的情况")
log_error("出错了")
log_field("预览数据", preview) # INFO 一行概要;DEBUG 展开完整 JSON
```
### 4.5 ★ 保证登出
账号若停留在 `isLogin=True`,下次登录会 `returnCode=100`。用这个模板:
```python
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
try:
...
login = UserLogin(user_id, token)
session.update(login_dt=login.loginDateTime, logged_in=True)
...
UserLogout(...)
session["logged_in"] = False
finally:
if session["logged_in"]:
log_warning("[收尾] 异常中断,强制登出 ...")
try:
UserLogout(session["user_id"], session["token"], session["login_dt"],
logout_type=LogoutType.TestIn)
except Exception as e:
log_error(f"[收尾] 强制登出失败:{e}")
```
---
## 五、api 脚本怎么写
**不用加装饰器**,直接定义函数就行:
```python
# api/userpreview.py
from .log_setup import log_info, log_debug, log_request, log_response
from .maimai_net import request_dict
def UserPreview(user_id: int, token: str, *, client_id: str = CLIENT_ID):
"""获取用户预览信息。""" # ← 首行成为 [接口说明]
log_info(f"获取用户预览:user_id={user_id}") # 可选
raw = request_dict("GetUserPreviewApi", req, user_id=user_id, client_id=client_id)
# ↑ maimai_net 内部已经调了 log_request / log_response,
# DEBUG 模式下会自动输出收发数据,不用在这里重复写
return parse(raw)
```
### 新增 api 必须登记
写完新函数后,在 `api/__init__.py` 做两步:
```python
# 1) 导入
from .xxx import MyNewApi
# 2) 加进跟踪名单
_API_FUNCS = (
"ParseQRCode",
"UserPreview",
...
"MyNewApi", # ← 加这里
)
```
`autowrap(globals(), _API_FUNCS)` 会在导入时自动给它套上日志外壳。
**漏了这步,该函数就不会输出 `===开始运行===`。**
---
## 六、完整 API 参考
### 6.1 会话(主脚本用)
```python
setup_logging(script_name, level=None) -> str
# 初始化会话:确定模式、打开两个日志文件、打印横幅
# level=None 时读 config.LOG_LEVEL
# 同一进程重复调用安全(只初始化一次)
teardown_logging()
# 收尾:打印「本次运行调用的 api」清单 + 结束横幅 + 关文件
# 不调用也会在退出时自动执行
```
### 6.2 自动跟踪
```python
autowrap(namespace, names) -> list[str] # api/__init__.py 调用,一般不用手写
get_called_apis() -> list[str] # 本次会话调用过的 api,按首次调用顺序
```
### 6.3 模式
```python
get_level() -> "info" | "debug"
set_level(v) -> "info" | "debug"
is_debug() -> bool
LEVEL_INFO = "info"
LEVEL_DEBUG = "debug"
```
### 6.4 流程日志(INFO 可见)
```python
log_info("普通信息")
log_step("扫码登录") # → [步骤] 扫码登录
log_step("扫码登录", index=1, total=5) # → [步骤 1/5] 扫码登录
log_result("user_id=123") # → [结果] user_id=123
log_warning("账号已登录") # → [警告] ...
log_error("网络不可达") # → [错误] ...
log_section("阶段标题") # 带分隔线的分节
log_field("预览数据", preview) # INFO 一行;DEBUG 多行展开
log_field("原始响应", text, debug_only=True) # 只在 DEBUG 出现
```
### 6.5 收发数据日志(DEBUG 专属)
```python
log_debug("只在 DEBUG 出现")
log_request(url="https://...", body=req, headers=hdr, api_name="XxxApi")
log_response(status=200, body=raw, headers=hdr, api_name="XxxApi", elapsed_ms=412)
log_payload("解密后明文", text)
```
**INFO 模式下这些函数第一行就 `return`**,不做序列化、不拼字符串,可以放心留在热路径里。
### 6.6 兼容写法
```python
from api import api_log, get_logger
@api_log # 仍可用(会自动跳过重复包装)
def MyFunc(): ...
logger = get_logger()
logger.info("..."); logger.warning("..."); logger.debug("...")
```
---
## 七、日志文件
| 文件 | 打开模式 | 说明 |
|---|---|---|
| `logs/latest.log` | `w`(覆盖) | 每次运行重写,**只保留最近一次** |
| `logs/YYYY-MM-DD.log` | `a`(追加) | **当日所有运行累积**,按天分文件 |
两者**同时写入**。每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
DEBUG 的收发数据同样写进文件,可以跑一次 DEBUG 再翻 `latest.log`。
---
## 八、常见问题
**Q: 为什么 api 调用没有 `===开始运行===`?**
A: 该函数没登记在 `api/__init__.py` 的 `_API_FUNCS` 里。补上即可。
**Q: 为什么 `log_debug` 什么都没输出?**
A: 当前是 INFO 模式。改 `config.py` 的 `LOG_LEVEL = "debug"`。
**Q: 改了 config.py 没生效?**
A: 模式在 `setup_logging()` 时读取。之前已初始化过就需重启脚本,
或显式 `setup_logging("名字", level="debug")`。
**Q: 主脚本没调用 `setup_logging` 会怎样?**
A: 仍会打印到控制台(自动跟踪在导入时就生效),但不写文件、没有横幅与汇总。
**Q: 写测试 / 单独跑 api 脚本时不想要日志?**
A: 不调用 `setup_logging` 就不会开文件;控制台输出仍在。
**Q: 能完全关掉吗?**
A: 可以 `set_level("info")` 抑制 DEBUG 部分。要连控制台也静音需改 `_emit` 的 `console` 参数。
---
## 九、相关文件
| 文件 | 作用 |
|---|---|
| `api/log_setup.py` | 日志模块本体(含自动包装引擎) |
| `api/__init__.py` | 统一导出 + `_API_FUNCS` 登记表 |
| `config.py` | `LOG_LEVEL` / `LOG_VALUE_MAX` 开关 |
| `upsert.py` | 主脚本范例(含完整的登出兜底) |
| `api/maimai_net.py` | 网络层,已接入 DEBUG 收发数据日志 |
+215
View File
@@ -0,0 +1,215 @@
# 真机 Hook 对照报告(MaimaiNetSpy 实测)
> 本文记录用 MelonLoader Mod **MaimaiNetSpy** 在真机上抓取一整局(登录 → 游玩 → 结算上报 → 登出)
> 的完整网络行为,并与本工程的实现逐项对照后做的修正。
>
> 抓包样本:
> - `D:\SDGB2026\Package\MaimaiNetSpy\2026-10-05_222919.log`(70 个请求的人类可读时间线)
> - `D:\SDGB2026\Package\MaimaiNetSpy\raw\*`(每个接口的请求/响应原文 JSON)
> - 关键样本:`raw/068_UpsertUserAllApiMaimaiChn_request.json`(17164 字节明文)
>
> Hook 挂载点(`gamehook/Main.cs`,8 个,全部 `[ok]`):
> `NetHttpClient.Create / AddHeader / AddCookie / Request / GetResponse`、
> `NetPacketUtil.GetUserAgent`、`GameManager.CalcSpecialNum`、`NetHttpClient.SetError`
---
## 一、结论速览
| 项 | 结论 |
|---|---|
| UpsertUserAll 报文内容 | **与真机逐字段一致**(顶层 7 键、36 节点、110 字段 playlog、11 条位串全部对齐) |
| 报文长度 | 真机 17164 B / 本工程 17164 B(同一基线重建) |
| 剩余差异 | 只有「随机数」和「时刻字符串」,见第五节 |
| HTTP 头 | `number` 已修正;`Cookie` 经实测为**无需处理**;其余一致 |
| `playSpecial` | 已按 `CalcSpecialNum = 1024` 修正 |
| `characterLevel1..5` | **发现并修掉一处回归**:应为游玩前等级,不加经验 |
---
## 二、`number` 请求头:真机不是恒 0,也不是每包都变
抓到的 70 个请求里 `number` 只有两次取值变化:
```
#1 ~ #10 number = 0 ← 还没跑过心跳
#11 ~ #17 这是 Ping 心跳包(按 Packet.cs:187 不带 number 头)
#18 ~ #70 number = 63 ← 心跳往返 63 ms,此后 53 个业务请求全部恒定
```
**源码链路**(逐条可查):
```
Main/PingChecker.cs:166 PacketHelper.StartPacket(new PacketPing(PingSuccess, PingError))
Net.Packet.Mai2/PacketPing.cs:34
_onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒
Main/PingChecker.cs:227 GameManager.PingMsec = pingMsec
Net.Packet/Packet.cs:187-189 if (!IsPingPacket()) Client.AddHeader("number", GameManager.PingMsec.ToString());
```
即:`number` = `GameManager.PingMsec`,**只由 Ping 心跳刷新**,普通业务请求不改动它。
### 本工程的修正
`api/maimai_net.py`:
- 字段从 `last_ping_msec`(每个请求都更新)改成 **`ping_msec`(只有 Ping 包更新)**,
并保留 `last_ping_msec` 只读属性兼容旧代码。
- `request()` 里新增:`if api_name.startswith("Ping"): self.ping_msec = elapsed_ms`。
- `_build_headers()` 的 `ping_msec` 参数默认 `None`(= 取 `self.ping_msec`)。
- `upsert.py` 在取完服务器设置后加了一步 `Ping()`(真机也是在第一屏之后跑心跳)。
> 实测:本机到该服务器 Ping 往返约 **266 ms**,所以本脚本发出去的 `number` 会是那个值,
> 而不是真机的 63 —— 这是**网络环境差异,不是实现差异**。
---
## 三、`Cookie` 头:hook 显示的串是假的,但 Cookie 本身是真的
抓包日志里从 #18 起显示 `Cookie : userId=12345678`,一度被误认为「要手工带一个 userId Cookie」。
**核实过程(两步,第二步推翻了我自己的第一步结论)**:
1. `gamehook/Main.cs:196-199` 的 `PreAddCookie(ulong userId)` 里写的是
`_cookie = "userId=" + userId;` —— 这行是 **hook 自己拼的展示串**,
**不是真实的 HTTP Cookie 头**。
它出现的位置正好对应 `Packet.cs:190-193` 的
`if (Query.UserId != 0L) Client.AddCookie(Query.UserId);`,
只是「本次请求带了 userId」的意思。
2. **但 Cookie 机制本身是真实存在的**,而且很关键。
用 `Ping` 探针看不到 Set-Cookie(Ping 响应头只有
`Date / Content-Type / Content-Length / Connection / Pragma / Cache-Control`),
于是我先得出了「服务器从不下发 Cookie」的结论 —— **这是错的**。
后来跑完整登录链路时,`UserLoginApi` 的响应头里明确有:
```
Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
```
```
Packet.cs:228-234 if (Query.Api.StartsWith("UserLogin")) { cookieContainer.Add(Client.GetCookie());
SetCookie(Query.UserId, cookieContainer); }
Packet.cs:190-193 之后每个 userId != 0 的请求都 AddCookie(Query.UserId) 把容器挂回去
```
即:**登录时拿到的 JSESSIONID 会被后续所有请求带着** —— 这是真实的会话粘滞,
不是可有可无的装饰。
**结论**:`requests.Session` 会自动收下 `Set-Cookie` 并在后续请求自动带上,
行为与 C# 的 `CookieContainer` 完全一致,**因此仍然不需要写任何代码** ——
但前提是**必须复用同一个 `MaimaiNetClient`/`Session`**。
本工程的 `api/` 各函数在 `client=None` 时都走 `get_default_client()`(全局单例),
所以自动满足。如果谁手动 new 一个 `MaimaiNetClient` 去发结算包,就会丢掉 JSESSIONID。
---
## 四、`playSpecial` 与 `CalcSpecialNum`
Hook 抓到 `Manager.GameManager.CalcSpecialNum()` 的返回值:
```
[CalcSpecialNum] = 1024 (SpecialAP=False IsFreedomMode=False)
```
**源码**(`Manager/GameManager.cs:332-378`)中 `1024 = 0x400` 是**无条件置位**的那一位,
其余各位的条件在这台机台上恰好都不成立(ACA 机型、GTX 显卡、i3-8100 CPU 三条否定条件都为假)。
**反推验证**(`Net.VO.Mai2/VOExtensions.cs:846-855`):
```
真机 playSpecial = 892014272
位反转32(892014272) = 57726124 = 27900 × 2069 + 1024
└────┬────┘ └─┬─┘
随机系数 余数 = CalcSpecialNum ✅
```
### 本工程的修正
- `config.py` 新增 `PLAY_SPECIAL_NUM = 1024`,注释里逐位列出 10 个标志位及源码行号。
- `calc_play_special(calc_special_num=None)` 用它替换原来的写死 `0`。
- `selftest.py` 新增反推断言:`位反转32(真机 playSpecial) % 2069 == 1024`。
---
## 五、逐字段比对的剩余差异(全部已解释)
用真机 `068` 报文**反向重建基线**后再跑本工程组包,逐路径 diff:
| 路径 | 真机 | 本工程 | 原因 |
|---|---|---|---|
| `upsertUserAll.userGamePlaylogList[0].playSpecial` | 892014272 | 随机 | **本来就是随机数**(`Random.Range(1,1037933)*2069+1024` 后位反转) |
| `upsertUserAll.userGamePlaylogList[0].playCount` | 10 | 8 | 机台**本地**计数器,服务器不返回,只能手填 `config.PLAY_LOCAL_PLAY_COUNT` |
| `userData[0].lastLoginDate` / `eventWatchedDate` | 22:31:23.0 | 22:31:22.0 | 差 1 秒:真机在「登录响应处理完那一刻」才写这个值,而 `loginDateTime` 是请求时刻 |
| `userPlaylogList[0].loginDate` | 1791210683 | 1791210682 | 同上,1 秒 |
| `userPlaylogList[0].userPlayDate` | 22:31:59.0 | 22:32:21.0 | 真机是「本局打完那一刻」,脚本没有真实游玩过程,用上报时刻代替 |
除上表外,**顶层 7 键、36 个节点名、各节点条数、userData 全部键名、11 条位串,两个方向都是 0 差异。**
> 1 秒级差异已由真机自身证明无害:同一账号两个会话里
> `userPlaylog.loginDate - loginDateTime` 分别是 **-28798**(19:21 会话)和 **-28799**(22:31 会话),
> 服务端两次都返回 `{"returnCode":1}` 并正常落库 —— 它不对这几个时刻做等式校验。
---
## 六、本轮修正清单
| 文件 | 改动 |
|---|---|
| `api/maimai_net.py` | `number` 头改为只由 Ping 刷新;补 `last_ping_msec` 兼容属性;docstring 写清 number/Cookie 语义 |
| `api/upsert_user_all.py` | ① `calc_play_special` 支持 `CalcSpecialNum`(默认 1024)<br>② **修掉 `characterLevelN` 加经验的回归**,改为游玩前快照<br>③ 新增 `derive_play_characters(snapshot)`:从服务器快照推游玩前角色<br>④ 新增 `play_date_time` 参数,把「登录时刻」与「游玩/上报时刻」拆开<br>⑤ `build_favorite_delta` 新增 `assume_missing`,区分「原始基线」与「归一化基线」 |
| `config.py` | 新增 `PLAY_SPECIAL_NUM`;补 `PLAY_CHARACTERS` / `PLAY_LOCAL_PLAY_COUNT` 的用法与实测值 |
| `api/ping.py` | docstring 写清「Ping 是 number 头的唯一来源」 |
| `upsert.py` | 加 `Ping()` 步骤;用 `derive_play_characters` 覆盖手填角色;传 `play_date_time=login_date_time_now()` |
| `api/__init__.py` | 导出 `derive_play_characters` |
| `selftest.py` | 148 → 155 项;新增 CalcSpecialNum 反推、角色快照、登录/游玩时刻分离等断言 |
---
## 七、关于 `characterLevelN` 的回归(重点)
`Net.VO.Mai2/VOExtensions.cs:1361`:
```csharp
result.characterLevel1 = (int)gameScore.CharaLevel[0];
```
`gameScore.CharaLevel` 是**本次开始游玩时**灌进去的快照,整局不变 ——
所以 `userPlaylog.characterLevelN` 是**游玩前**等级,游玩后的等级只出现在
`upsertUserAll.userCharacterList` 里。
**两个真机会话的三方对照**(Get 侧 / playlog / upsertUserCharacterList):
| 会话 | GetUserCharacterApi | userPlaylogList[0] | userCharacterList |
|---|---|---|---|
| `source/2026-10-05-new.log`(19:21) | 395=**3801** 101=**4456** 135 135 135 | **3801 4456 135 135 135** | 395=**3802** 101=**4457** 136 136 136 |
| MaimaiNetSpy `2026-10-05_222919`(22:31) | 395=**3802** 101=**4457** 136 136 136 | **3802 4457 136 136 136** | 395=**3803** 101=**4458** 137 137 137 |
**playlog 恒等于 Get 侧(游玩前),userCharacterList 恒为 +1(游玩后)。**
代码里曾经是 `level + character_exp_gain`,现已改回 `level`。
为了避免手填的 `config.PLAY_CHARACTERS` 随账号升级而过期,
新增 `derive_play_characters(snapshot)`:直接读快照里的
`userData.charaSlot`(5 个槽位,顺序即 characterId1..5)与
`userCharacterList`(每只角色的 level / awakening),`upsert.py` 已接上。
---
## 八、真实发包验证(✅ 已完成,2026-10-06 01:28)
`upsert.py <二维码> --send` 用上面这套实现真实上报了一局成绩,
**服务端返回 returnCode=1,且重新拉数据确认 playCount 1260→1261 —— 真正落库了**。
完整证据见 [`docs/实跑验证记录.md`](实跑验证记录.md)。
流程与真机一致:
```
GetGameSetting -> Ping -> ParseQRCode -> GetUserPreview -> UserLogin
-> fetch_upsert_snapshot(19 个 Get 节点)
-> GetUserNewItemList(游玩后档案)
-> UpsertUserAll(★ 有副作用)
-> UserLogout
```
安全闸门保持默认关闭:不传 `--send` 时只构造报文(`dry_run`),
`UpsertUserAll(confirm=None)` 会读取 `config.UPSERT_CONFIRM = False` 并抛 `ConfirmRequiredError`。
@@ -0,0 +1,586 @@
# 舞萌DX(maimai DX)扫码登录协议技术规范
> **来源文档**:
> - `docs-old/舞萌DX_aimedb调用链分析.md` —— 完整调用链、二维码格式、ChimeLib/aimedb 关系
> - `docs-old/chimelib协议逆向.md` —— 纯网络实现细节、签名算法、抓包验证、关键坑点
>
> **适用场景**:纯网络(纯 HTTP)复刻舞萌DX国服微信扫码登录,获取 userId 与 token
> **协议路径**:路径 A —— 微信扫码(HTTPS + JSON,`chimelib_dll.dll` + WinHTTP)
> **非适用**:路径 B —— 传统 Aime 物理卡(TCP 二进制,`amdaemon_api.dll` + IPC)
---
## 一、架构总览
### 1.1 两条 aimedb 通信路径
| 路径 | 名称 | 协议 | 核心组件 | 你实际用的 |
|------|------|------|----------|------------|
| **A** | 微信扫码 | HTTPS + JSON | `chimelib_dll.dll` (WinHTTP) | ✅ **是** |
| B | 传统 Aime 物理卡 | TCP 二进制 | `amdaemon_api.dll` (IPC) | ❌ 否 |
### 1.2 调用链(路径 A)
```
游戏客户端 Assembly-CSharp
│ ChimeReaderManager (驱动扫码、取结果)
▼
ChimeLib.NET.dll (托管封装)
│ Chime / ChimeUnit / ChimeResult / CCommGetUserData / CCommServAliveCheck
▼
chimelib_dll.dll (x64 原生,62KB,静态链接 WinHTTP)
│ CCommGetUserData_Create/Execute/IsEnd/GetUserID/GetToken
│ CCommServAliveCheck_Create/StartCheck/Execute/GetIndex/IsAlive
▼
aimedb 服务器集群
│ https://ai.sys-all.cn/ (主)
│ https://ai.sys-allnet.cn/ (备)
▼
POST /wc_aime/api/get_data → 返回 userID + token
POST /wc_aime/api/alive_check → 返回 "alive"
```
### 1.3 关键数据流向
```
二维码串(SGWCMAID...)
→ ChimeLib.NET.CCommQRCode.IsValid() 校验
→ EraseHashIdentifier() 取 [20:] 64位载荷
→ CCommGetUserData_Create(strGameID="MAID", strChipID, strCommonKey="", strQRData=载荷, strTitleKey="SDGB", serverURLIndex)
→ WinHTTP HTTPS POST /wc_aime/api/get_data
→ 响应 {errorID, userID, token, key, timestamp}
→ ChimeResult.GetChimeId() → userId (uint)
→ ChimeResult.GetToken() → token (string)
→ OperationManager.SetToken(userId, token) 缓存
→ 后续游戏服务器 API 携带 token
```
---
## 二、二维码格式规范
### 2.1 完整二维码字符串(84 字符)
```
结构:SGWC(4) + MAID(4) + timestamp(12) + payload(64 hex) = 84 字符
偏移 长度 字段 说明 示例
0–3 4 微信标识 常量 "SGWC" SGWC
4–7 4 GameID "MAID"=舞萌DX MAID
8–19 12 时间戳 yymmddHHMMss (UTC+9) 260613133715
20–83 64 载荷/哈希 64位十六进制(疑似SHA-256) 77503FD2...427759BCA5
```
### 2.2 校验规则(`CCommQRCode.IsValid()`)
```python
def is_valid(qr: str) -> bool:
return len(qr) == 84 and qr[0:4] == "SGWC" and qr[4:8] == "MAID"
```
### 2.3 传给原生层的数据(`EraseHashIdentifier()`)
```python
def erase_hash_identifier(qr: str) -> str:
"""去掉前 20 字符,返回 64 位载荷"""
return qr[20:] # 即 payload 部分
```
### 2.4 二维码 URL ⇄ 字符串互转
**URL 格式**:
```
https://wq.wahlap.net/qrcode/req/{GameID+timestamp+payload}.html?l=...&t=...&d=...
└──────────── 80 字符 ────────────┘
```
**提取规则**:
```python
from urllib.parse import urlparse
import re
def url_to_qr_string(url: str) -> str:
"""URL → 完整 84 字符二维码串"""
path = urlparse(url).path
match = re.search(r"/qrcode/req/([^/]+)\.html", path)
if not match:
raise ValueError("URL 路径格式不符")
return "SGWC" + match.group(1) # 前加 SGWC 前缀
def qr_string_to_url_body(qr: str) -> str:
"""完整二维码串 → URL 路径段(80字符,无 SGWC)"""
return qr[4:] # 去掉 SGWC
```
**查询参数**(`l`/`t`/`d`):**不参与**机台协议,仅为微信侧生成二维码时的元数据。
---
## 三、服务器端点与网络协议
### 3.1 服务器地址
| 环境 | 域名 | IP | 端口 | TLS |
|------|------|-----|------|-----|
| 主 | `ai.sys-all.cn` | 81.71.193.236 | 443 | TLSv1.2 |
| 备 | `ai.sys-allnet.cn` | (同解析) | 443 | TLSv1.2 |
### 3.2 API 端点
| 端点 | Method | Content-Type | User-Agent | 用途 |
|------|--------|--------------|------------|------|
| `/wc_aime/api/alive_check` | POST | `application/json` (body `{}`) | 无 | 探活,返回 `200 alive` |
| `/qrcode/api/alive_check` | POST | 无(空body) | `WC_AIME_LIB` | 探活变体,返回 `200 alive` |
| `/wc_aime/api/get_data` | POST | **无** | `WC_AIME_LIB` | **核心登录接口** |
### 3.3 请求头(关键:无 Content-Type)
```http
POST /wc_aime/api/get_data HTTP/1.1
Host: ai.sys-allnet.cn
User-Agent: WC_AIME_LIB
Connection: Keep-Alive
Content-Length: 243
{"chipID":"...","openGameID":"MAID","key":"...","qrCode":"...","timestamp":"...","titlekey":"SDGB"}
```
> ⚠️ **严禁添加 `Content-Type: application/json`**,否则服务端返回 `errorID=57`(HTTP 200 + 合法 JSON,极易误判为签名错误)。
### 3.4 请求体 JSON 字段
| 字段 | 类型 | 来源 | 说明 |
|------|------|------|------|
| `chipID` | string | Keychip ID | 机台唯一标识,如 `XXXX-XXXXXXXXXXX` |
| `openGameID` | string | 固定 | `"MAID"` |
| `key` | string | 签名计算 | SHA-256 大写 hex,**核心签名字段** |
| `qrCode` | string | 二维码[20:] | **64 位载荷,非完整 84 字符串** |
| `timestamp` | string | 本机本地时间 | yymmddHHMMss 格式 |
| `titlekey` | string | 固定 | `"SDGB"` |
### 3.5 响应体 JSON 字段
| 字段 | 类型 | 说明 |
|------|------|------|
| `errorID` | int | 0=成功,非0=失败 |
| `userID` | int | 成功时为正整数(如 12345678),失败时 -1 |
| `token` | string | 成功时 20 位临时凭据,失败时空串 |
| `key` | string | 服务端返回的签名(可忽略) |
| `timestamp` | string | 服务端时间戳 |
---
## 四、签名算法(核心)
### 4.1 算法规范
```python
import hashlib
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" # 32字符,含前导 X
def calculate_key(chip_id: str, timestamp: str) -> str:
"""
key = SHA-256(chipID + timestamp + SIGN_KEY).hexdigest().upper()
参数:
chip_id: 如 "XXXX-XXXXXXXXXXX"
timestamp: 如 "261001004115" (yymmddHHMMss)
返回:64字符大写十六进制
"""
sign_input = chip_id + timestamp + SIGN_KEY
return hashlib.sha256(sign_input.encode()).hexdigest().upper()
```
### 4.2 技术细节
| 项目 | 值 | 验证来源 |
|------|-----|----------|
| 哈希算法 | SHA-256 | Hook `CryptCreateHash` ALGID=0x800C (CALG_SHA_256) |
| 密钥串 | `XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW` | .rdata UTF-16 @0x00B040,**含前导 X** |
| 拼接顺序 | `chipID + timestamp + KEY` | **不含 qrCode、不含 titlekey** |
| 输出编码 | 大写十六进制 | hex 表 `0123456789ABCDEF` @0x00A440 |
### 4.3 抓包验证向量
```
输入:
chipID = "XXXX-XXXXXXXXXXX"
timestamp = "261001004115"
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
SHA256("XXXX-XXXXXXXXXXX" + "261001004115" + "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW")
= 67015F28CFC3C7FA5E629BECE54893B581A6DBD153426AE5E6BA1681C6D8E327
```
与 mitmproxy 抓包完全一致 ✓
---
## 五、完整请求流程(纯 Python 实现)
### 5.1 最小可用代码
```python
import hashlib, http.client, json, ssl
from datetime import datetime
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW"
CHIP_ID = "XXXX-XXXXXXXXXXX" # 可配置
HOST = "ai.sys-allnet.cn"
PATH = "/wc_aime/api/get_data"
def parse_qr_login(qr_input: str, chip_id: str = CHIP_ID) -> tuple[int, str]:
"""
输入:二维码串(84字符)或 URL
返回:(user_id: int, token: str)
"""
# 1. 统一输入 → 标准 84 字符串
qr_string = normalize_qr_input(qr_input)
# 2. 校验格式
validate_qr_string(qr_string)
# 3. 构造请求体
ts = datetime.now().strftime("%y%m%d%H%M%S")
key = calculate_key(chip_id, ts)
qr_code = qr_string[20:] # 仅 64 位载荷
body_dict = {
"chipID": chip_id,
"openGameID": "MAID",
"key": key,
"qrCode": qr_code,
"timestamp": ts,
"titlekey": "SDGB",
}
body_bytes = json.dumps(body_dict, separators=(",", ":")).encode()
# 4. 发送请求(关键:无 Content-Type)
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(HOST, 443, context=ctx, timeout=20)
try:
conn.request("POST", PATH, body=body_bytes, headers={
"Connection": "Keep-Alive",
"User-Agent": "WC_AIME_LIB",
"Content-Length": str(len(body_bytes)),
# ★ 绝不加 Content-Type
})
resp = conn.getresponse()
data = json.loads(resp.read().decode())
finally:
conn.close()
# 5. 解析响应
if data.get("errorID") != 0 or data.get("userID") == -1:
raise RuntimeError(f"登录失败: errorID={data.get('errorID')}, userID={data.get('userID')}")
return data["userID"], data["token"]
def normalize_qr_input(qr_input: str) -> str:
"""URL 或字符串 → 标准 84 字符二维码串"""
qr = qr_input.strip()
if qr.startswith("http://") or qr.startswith("https://"):
qr = url_to_qr_string(qr)
return qr
def validate_qr_string(qr: str) -> None:
"""校验二维码格式,不通过抛 ValueError"""
if len(qr) != 84:
raise ValueError(f"长度应为84,实际{len(qr)}")
if not qr.startswith("SGWC"):
raise ValueError("必须以 SGWC 开头")
if qr[4:8] != "MAID":
raise ValueError(f"GameID 必须为 MAID,实际 {qr[4:8]}")
# 可选:校验 payload 为 64 位 hex
if not all(c in "0123456789ABCDEF" for c in qr[20:]):
raise ValueError("载荷必须为大写 hex")
```
### 5.2 关键实现要点清单
| 要点 | 正确做法 | 错误做法后果 |
|------|----------|--------------|
| qrCode 字段 | `qr_string[20:]` (64位) | 传完整 84 字符 → 签名不匹配 |
| 签名拼接 | `chipID + timestamp + KEY` | 加入 qrCode/titlekey → 签名错误 |
| 密钥串 | 含前导 `X` (32字符) | 漏掉 X → 签名完全不同 |
| 请求头 | 无 `Content-Type` | 加上 → `errorID=57` |
| timestamp | 本机本地时间即可 | 纠结时区 → 其实服务端不强校验 |
| SSL 验证 | 关闭验证 | 开启 → 证书链可能不通过 |
---
## 六、错误码对照
| errorID | 含义 | 处理建议 |
|---------|------|----------|
| 0 | 成功 | 正常返回 userID + token |
| 1-8 | 原生 ChimeErrorId | 读卡器/服务器/二维码无效等通用错误 |
| **57** | **二维码已失效/已被使用/请求头不符** | 检查:二维码是否过期(~10分钟)、是否重复提交、**请求头是否含 Content-Type** |
| `userID:-1` | 失败标志 | 配合 errorID 判断具体原因 |
> ### ★ 实测补充(2026-10-06)
>
> 本工程用同一套参数实测到的失败响应是 **`errorID=2`**(不是 57):
>
> ```json
> {"errorID": 2, "key": "B744ADD5...", "timestamp": "261006125706",
> "userID": -1, "token": ""}
> ```
>
> **触发条件是「二维码过期」,不是「用过一次」。**
>
> | 二维码生成时刻(timestamp 内嵌) | 使用时刻 | 结果 |
> |---|---|---|
> | `261006012047`(01:20:47) | 01:21:55(+68s) | ✅ 成功 |
> | `261006124637`(12:46:37) | 12:52:22(+5m45s) | ✅ 成功 |
> | `261006130621`(13:06:21) | 13:10:42(+4m21s) | ✅ 成功 |
> | 上面那个 12:46:37 的二维码 | 13:0x(+约 15min) | ❌ `errorID=2` |
>
> 即:**二维码是「有时效」的,不是「按次数计量」的**。
>
> **有效期 = 10 分钟**(实测至少 5 分 45 秒内均可用,超时后返回 `errorID=2`)。
> 所以报错时**不要怀疑「用过了」**,重新生成一个即可。
---
## 七、时间戳处理细节
| 时间戳位置 | 时区 | 格式 | 说明 |
|------------|------|------|------|
| 二维码内 (偏移8-19) | **UTC+9 (日本时间)** | yymmddHHMMss | 微信侧生成,约 10 分钟有效期 |
| 请求体 `timestamp` | **本机本地时间** | yymmddHHMMss | 实测 UTC+8 与 DLL 行为一致,服务端不强校验时区 |
| 响应 `timestamp` | 服务端时间 | yymmddHHMMss | 仅作参考 |
> **注意**:本机时区非 UTC+8 时,请求体 timestamp 仍用本地时间,但需确保系统时间准确(NTP 同步)。
---
## 八、探活接口(可选,纯 Python 可独立实现)
### 8.1 探活请求
```python
def check_alive(host: str = "ai.sys-allnet.cn") -> bool:
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
conn = http.client.HTTPSConnection(host, 443, context=ctx, timeout=10)
try:
conn.request("POST", "/wc_aime/api/alive_check", body=b"{}", headers={
"Content-Type": "application/json", # 探活需加 Content-Type
"Connection": "Keep-Alive",
})
resp = conn.getresponse()
return resp.status == 200 and resp.read().decode().strip() == "alive"
finally:
conn.close()
```
### 8.2 行为差异
| 端点 | Content-Type | User-Agent | 响应 |
|------|--------------|------------|------|
| `/wc_aime/api/alive_check` | `application/json` | 无 | `200 alive` |
| `/qrcode/api/alive_check` | 无 | `WC_AIME_LIB` | `200 alive` |
| `/wc_aime/api/get_data` | **无** | `WC_AIME_LIB` | 业务响应 |
> DLL 实测:`CCommServAliveCheck` 探活阶段**不发真实网络请求**(WinHTTP hook 不触发),纯 Python 可直接调用 alive_check 验证服务器存活。
---
## 九、原生 DLL 接口签名(供 P/Invoke 参考)
### 9.1 CCommServAliveCheck 系列
```c
// 创建探活会话
HANDLE CCommServAliveCheck_Create();
// 设置连接权限
void CCommServAliveCheck_setConnectPermission(HANDLE h, BOOL permit);
// 开始探活
void CCommServAliveCheck_startCheck(HANDLE h);
// 执行轮询
void CCommServAliveCheck_execute(HANDLE h);
// 是否忙碌
BOOL CCommServAliveCheck_isBusy(HANDLE h);
// 获取存活服务器索引
int CCommServAliveCheck_getIndex(HANDLE h);
// 是否存活
BOOL CCommServAliveCheck_isAlive(HANDLE h);
// 销毁
void CCommServAliveCheck_Destroy(HANDLE h);
```
### 9.2 CCommGetUserData 系列
```c
// 创建登录会话
// strGameID="MAID", strChipID=KeychipID, strCommonKey="", strQRData=二维码[20:], strTitleKey="SDGB", serverURLIndex=探活返回索引
HANDLE CCommGetUserData_Create(
LPCSTR strGameID,
LPCSTR strChipID,
LPCSTR strCommonKey,
LPCSTR strQRData,
LPCSTR strTitleKey,
int serverURLIndex
);
// 执行请求
void CCommGetUserData_execute(HANDLE h);
// 是否结束
BOOL CCommGetUserData_isEnd(HANDLE h);
// 获取错误码 (0=成功)
int CCommGetUserData_getErrorID(HANDLE h);
// 获取 userID (uint)
unsigned int CCommGetUserData_getUserID(HANDLE h);
// 获取 token (ANSI 字符串)
LPCSTR CCommGetUserData_getToken(HANDLE h);
// 销毁
void CCommGetUserData_Destroy(HANDLE h);
```
### 9.3 托管层调用序列(ChimeUnit.Execute 状态机)
```csharp
// 1. 探活
var alive = CCommServAliveCheck_Create();
CCommServAliveCheck_setConnectPermission(alive, true);
CCommServAliveCheck_startCheck(alive);
while (CCommServAliveCheck_isBusy(alive)) CCommServAliveCheck_execute(alive);
int urlIndex = CCommServAliveCheck_getIndex(alive);
bool isAlive = CCommServAliveCheck_isAlive(alive);
CCommServAliveCheck_Destroy(alive);
// 2. 登录
var login = CCommGetUserData_Create(
"MAID", // gameID
keychipId, // chipID (如 XXXX-XXXXXXXXXXX)
"", // commonKey (托管层传空,原生层内部处理)
qrString.Substring(20), // qrData (64位载荷)
"SDGB", // titleKey
urlIndex // 探活返回的服务器索引
);
while (!CCommGetUserData_isEnd(login)) CCommGetUserData_execute(login);
if (CCommGetUserData_getErrorID(login) == 0) {
uint userId = CCommGetUserData_getUserID(login);
string token = CCommGetUserData_getToken(login); // PtrToStringAnsi
}
CCommGetUserData_Destroy(login);
```
---
## 十、常量汇总表
```python
# ===== 签名与密钥 =====
SIGN_KEY = "XcW5FW4cPArBXEk4vzKz3CIrMuA5EVVW" # 含前导 X,32字符
# ===== 默认机台标识 =====
DEFAULT_CHIP_ID = "XXXX-XXXXXXXXXXX"
# ===== 服务器 =====
PRIMARY_HOST = "ai.sys-all.cn"
BACKUP_HOST = "ai.sys-allnet.cn"
PORT = 443
LOGIN_PATH = "/wc_aime/api/get_data"
ALIVE_PATH = "/wc_aime/api/alive_check"
# ===== 固定请求字段 =====
OPEN_GAME_ID = "MAID"
TITLE_KEY = "SDGB"
USER_AGENT = "WC_AIME_LIB"
# ===== 二维码常量 =====
QR_PREFIX = "SGWC"
QR_GAME_ID = "MAID"
QR_PREFIX_LEN = 20 # SGWC(4) + MAID(4) + timestamp(12)
QR_PAYLOAD_LEN = 64
QR_TOTAL_LEN = 84
# ===== HTTP 头(登录接口) =====
LOGIN_HEADERS = {
"Connection": "Keep-Alive",
"User-Agent": USER_AGENT,
# "Content-Type": "application/json", # ★ 绝不加这一行
}
```
---
## 十一、抓包调试方法论
### 11.1 强制走代理
```csharp
// Hook WinHttpOpen
// dwAccessType = 3 (WINHTTP_ACCESS_TYPE_NAMED_PROXY)
// pwszProxy = "127.0.0.1:8890"
```
### 11.2 mitmproxy CA 证书安装
```powershell
# 必须用 X509Store 静默导入,Import-Certificate 会因 UI 限制失败
$store = New-Object System.Security.Cryptography.X509Certificates.X509Store("Root", "CurrentUser")
$store.Open("ReadWrite")
$cert = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2("mitmproxy-ca-cert.pem")
$store.Add($cert)
$store.Close()
```
### 11.3 落盘与读取
```bash
mitmdump -w flow.mitm
# Python 读取
from mitmproxy.io import FlowReader
with open("flow.mitm", "rb") as f:
for flow in FlowReader(f).stream():
print(flow.request.pretty_url)
print(flow.request.content)
print(flow.response.content)
```
### 11.4 捕获签名输入
```csharp
// Hook CryptHashData 导出入口
// 捕获 pbData 指针指向的字节数组 → 即签名原文
// 对比:chipID + timestamp + KEY
```
### 11.5 常见 Hook 坑点
| 坑点 | 原因 | 解决 |
|------|------|------|
| IAT hook 全失效 | pefile `imp.address` 已是完整 VA,再加 ImageBase 导致地址错误 | 直接用 `imp.address` + ASLR_delta |
| IAT 写入后还原 | CrackProof 驱动写保护页面 | 改用全局 inline hook:改写 advapi32 导出函数入口 + capstone 精确 trampoline |
| x64 trampoline 崩 | 指令切碎不完整 | capstone 逐条解析,累计 >=14 字节再跳转 |
| 误判加壳 | Hook 全失效以为加壳 | 实测:.text 熵 6.2、465 个 FF 15、入口点可正常反汇编 → **未加壳** |
---
## 十二、实测验证记录
| # | 二维码时间戳 | userID | token (20位) | 备注 |
|---|--------------|--------|--------------|------|
| 1 | 260925205225 | 12345678 | TESTTOKEN0000000000 | DLL 验证成功 |
| 2 | 260930235204 | 12345678 | e1Tsql1aP1SHLUthRdYI | |
| 3 | 261001001304 | 12345678 | rI48n34Ttne1qR9FDFnY | 同一时间戳多次请求 token 各异 |
| 4 | 261001001304 | 12345678 | znoWwYW1bMpwlNUzcTpX | |
| 5 | 261001001304 | 12345678 | 8Cp6IztEGvLlYt5SRjxw | |
**结论**:
- userID 固定(同一账号)
- token 每次请求生成新的 20 位临时凭据(一次性)
- 探活+登录总耗时 ~800ms(网络延迟主导)
---
## 十三、最小复刻检查清单
实现纯网络扫码登录,必须覆盖:
- [ ] 二维码输入归一化(URL → 84字符串)
- [ ] 格式校验(长度84、前缀SGWC、GameID=MAID、载荷64位hex)
- [ ] 载荷提取(`qr_string[20:]`)
- [ ] 时间戳生成(本地时间 `%y%m%d%H%M%S`)
- [ ] 签名计算(SHA256(chipID+timestamp+KEY) 大写hex)
- [ ] 请求体构造(紧凑JSON,字段顺序固定)
- [ ] HTTPS POST(无Content-Type,UA=WC_AIME_LIB,Keep-Alive)
- [ ] SSL 不验证证书
- [ ] 响应解析(errorID=0、userID>0、token非空)
- [ ] 错误处理(errorID=57→请求头问题,errorID=2→二维码过期)
---
*本文档仅包含技术协议实现细节,不含项目结构、代码规范、文件路径等工程化内容。*
+186
View File
@@ -0,0 +1,186 @@
# 账号转生说明(`reincarnate.py`)
> 本文是 [`README.md`](https://git.fragrance.moe/Txiaochen/wahappy) 里转生部分的**详细版**。
---
## 一、它干了什么
把一个账号(源 **A**)的数据克隆到另一个账号(目标 **B**):
```
源账号 A:二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData
或:直接读 data/<A>.json(离线复用)
↓ 节点数据(19 个节点)
目标账号 B:二维码 -> ParseQRCode -> UserPreview -> UserLogin
循环:
等待 -> UpsertUserAll(一包曲目)
-> UserLogout(type=1)
-> UserLogin(isContinue=true) 续关,拿新 loginId
收尾:最后一包登出后结束(不再续关)
```
---
## 二、用法
```bash
# 源数据来自二维码(会联网抓 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
```
| 参数 | 默认 | 说明 |
|---|---|---|
| `--dst-qr` | 必填 | 目标账号 B 的二维码 |
| `--src-qr` / `--src-json` | 二选一 | 源数据来源 |
| `--plan` | — | 只列分批计划,不发包 |
| `--send` | 关 | ★ 真实发包 |
| `--limit N` | 0(全部) | 只迁前 N 首(试跑用) |
| `--per-packet N` | 4 | 每包曲目数,**只能是 1 / 3 / 4** |
| `--wait N` | 5.0 | 每次上传前的等待秒数(0 = 不等) |
| `--clone-identity` | 关 | 连 `userName` / `accessCode` 一起克隆(默认**保留 B 自己的**) |
| `--no-continue` | 关 | 不续关,只发一包(调试用) |
| `--debug` | 关 | 日志里带完整收发报文 |
---
## 三、★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
真机一局(一个 credit)最多打 4 首,所以一条 `UpsertUserAll` 报文里的
`userPlaylogList` / `userGamePlaylogList` **最多 4 条**。
但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
所以本脚本的分批器会**主动避开 2 首包**:
| 总量 | `--per-packet` | 原始切分 | 修正后 |
|---|---|---|---|
| 10 首 | 4 | `[4,4,2]` | `[4,3,3]` |
| 6 首 | 4 | `[4,2]` | `[3,3]` |
| 2 首 | 4 | `[2]` | `[1,1]` |
| 5 首 | 3 | `[3,2]` | `[3,1,1]` |
| 8 首 | 3 | `[3,3,2]` | `[3,3,1,1]` |
| 5 首 | 4 | `[4,1]` | 本来就合法,不动 |
修正规则(`reincarnate.split_batches`):
1. 前一批 **>= 4** → 从它借 1 首过来(前一批变 >=3,本批变 3)
2. 借不到(前一批只有 3,或自己就是第一批)→ 把这个 2 **拆成 1 + 1**
> ★ 只借位是不够的:前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
自检里有 `1..299 首 × per∈{1,3,4}` 的全量扫描,保证永不产生 2 首包。
---
## 四、★ 为什么要「续关登录」
`UpsertUserAll` 的 `playlogId` 必须等于**本次登录**的 `loginId`
(`PacketUpsertUserAll.cs:31` / `UserAllRequestVO`)。
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 —— 真机走的就是「续关」:
```csharp
// source/Assembly-CSharp/Process/ContinueProcess.cs:403
new PacketUserLogin(userID, accessCode, isContinue: true, GameManager.IsEventMode ? 1 : 0, ...)
```
对应本工程:`UserLogin(user_id, token, is_continue=True)`
—— 和普通登录的唯一区别就是 `UserLoginRequestVO.isContinue = true`,
会返回**新的 `loginId` / `loginDateTime`**。
---
## 五、★ 登出必须用 type=1(Logout),不要用 4(TestIn)
`Net.VO.Mai2/LogoutType.cs`:
```csharp
public enum LogoutType { None=0, Logout=1, Cancel=2, Error=3, TestIn=4, Quit=5 }
```
**真机抓包实证** `UserLogoutApi` 用的是 **type=1**:
```json
{"userId":12570152,"accessCode":"","regionId":8,"placeId":1007,
"clientId":"A63E01C3047","loginDateTime":1791325354,"type":1,"delayLog":{...}}
```
源码侧的正常会话结束也是 type=1:
```csharp
// source/Assembly-CSharp/Process.UserDataNet.State.UserDataULState/StateULUserLogout.cs:24
PacketHelper.StartPacket(new PacketUserLogout(_index, _userId, LogoutType.Logout, ...));
```
**续关场景下用 4(TestIn)会出问题** —— 那是「测试登入 / 顶号」的语义
(`GameMain.cs:104` 退出游戏时才用)。
`UserLogout` 对 type=1 会自动带上完整 `delayLog`
(`PacketUserLogout.cs:21`:`logoutType == LogoutType.Logout` 时才收集会话统计)。
---
## 六、playlog 从哪来
`GetNeedUserData` 抓不到 `userPlaylogList`(那是逐局记录,没有对应的 Get 接口),
所以本脚本**从曲目记录反推合成**:
| `UserMusicDetail`(9 字段) | → | `UserPlaylog`(110 字段) |
|---|---|---|
| `musicId` / `level` | | `musicId` / `level` |
| `achievement` | | `achievement` |
| `deluxscoreMax` | | `deluxscore` |
| `scoreRank` | | `scoreRank` / `isClear` |
| `comboStatus` / `syncStatus` | | 同名 |
| 其余 | | `config.PLAY_*` 默认值 |
`UserMusicDetail` 的全部字段(`Net.VO.Mai2/UserMusicDetail.cs`):
`musicId` / `level` / `playCount` / `achievement` / `comboStatus` /
`syncStatus` / `deluxscoreMax` / `scoreRank` / `extNum1`。
---
## 七、迁移范围
默认迁**全部节点**(整账号克隆):
- **第 1 包**带全部非曲目节点(`userData` / `userExtend` / `userOption` /
`userCharacterList` / `userMapList` / …)
- **之后每包**只带这一批曲目 + 合成的 `userGamePlaylogList`
- 位串 `isNew*` 按 **B 的基线**逐条算(B 没有 → `"1"`,已有 → `"0"`),
主键表见 `api/upsert_user_all.py` 的 `DIFF_PRIMARY_KEYS`
**不迁的节点**(`SKIP_NODES`):
| 节点 | 原因 |
|---|---|
| `userMusicDetailList` | 单独分批 |
| `userGamePlaylogList` | 本脚本按曲目合成 |
| `user2pPlaylog` | 2P 记录,迁移无意义 |
| `userGetPointList` | 里程/奖励,迁过去会凭空发点 |
| `userTradeItemList` | 无 Get 来源 |
| `userFriendSeasonRankingList` | 无 Get 来源 |
**默认不克隆 `userName` / `accessCode`**(避免把 B 改名);要连身份一起搬就加
`--clone-identity`。
---
## 八、⚠️ 注意事项
1. **未实跑过。** 建议先用 `--plan` 看计划,再 `--limit 8` 小批量试,确认无误再全量。
2. **会真实写入账号 B(不可撤销)。** 不传 `--send` 时只构造报文。
3. **耗时**:曲目多的话包数很多(每包最多 4 首),加上 `--wait` 的等待,
几百首会跑很久。想快可以调小 `--wait`。
4. **二维码有效期 10 分钟**,但转生过程中只用一次(登录时)——
续关用的是 token,不需要新二维码。
5. 基线快照只在**第一次登录后抓一次**,后续包沿用同一份做差分。
曲目节点由 `extra_data` 整体覆盖,位串也单独算,不受影响。
+498
View File
@@ -0,0 +1,498 @@
# 购买功能票 —— 存档与交接文档
> **状态:暂停(2026-10-06)**
> 暂停原因:多次失败购买后主账号疑似被服务端拉黑;实现仍未跑通。
> 本文把「从开始做买票 → 到暂停」之间的**全部有用信息**归档,
> 目的是**将来恢复这项工作时能 5 分钟上手**,不用重新踩一遍。
---
## 〇、30 秒速览
1. **协议已完全搞清楚** —— 源码逐行 + 真机抓包 328 字节原文都有
2. **报文已做到与真机逐字节一致**,但服务端仍回 `returnCode=0`
3. **同一账号对照实验证明:机台能买、脚本不能买** → 差异在**客户端**,不是账号/服务端
4. 差异最可能在 **HTTP 层**(请求头 / Cookie / 连接特征),**不在报文体**
5. 暂停前最后动作:给 MelonLoader Mod 加了「完整请求头 + 响应头 dump」,**等一次真机抓包**
6. ⚠️ **恢复工作前先确认账号没被拉黑**(见第八节)
---
## 一、结论速览
| 项 | 状态 |
|---|---|
| 接口名 / URL 混淆 | ✅ 已确认 |
| 请求体结构(4 顶层 + 8 + 4 字段) | ✅ 与真机逐字节一致 |
| `purchaseDate` 的 `.f` 小数位 | ✅ 已确认并修正 |
| `validDate` 算法 | ✅ 已确认并双向验证 |
| `price` 语义(= `ticket.creditNum`) | ✅ 已确认 |
| `stock` 语义(购买后的值) | ✅ 已确认 |
| 键集合 / 字段顺序 | ✅ 与真机一致 |
| 可购买清单校验 | ✅ 已实现(真机也有这一步) |
| 已持票守卫 | ✅ 已实现(**自加的安全措施**,真机没有) |
| **服务端接受** | ❌ **`returnCode=0`** |
| **根因** | ❓ **未定位**,怀疑 HTTP 层 |
---
## 二、协议事实(全部有源码依据)
### 2.1 接口
| 项目 | 值 | 依据 |
|---|---|---|
| 逻辑 API 名 | `UpsertUserChargelogApi` | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
| URL 尾段 | `md5("UpsertUserChargelogApiMaimaiChn" + "8bF76dE9")` = `4411a4c8f7f5c0e95a3ac17f1746aad3` | `Packet.cs:485` + `NetQuery.cs:14` |
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs` | — |
| 响应 | `{returnCode, apiName}` | — |
| 副作用 | ★ 有,写入购买记录 | — |
### 2.2 请求体(字段顺序照 C# 声明)
```jsonc
{
"userId": <ulong>,
"userChargelog": { // UserChargelog.cs,8 字段
"chargeId": 2,
"price": 1, // ★ = ticket.creditNum,不是投币数
"purchaseDate": "2026-10-06 13:20:10.0", // ★ 带 .f 一位小数
"playCount": 1262,
"playerRating": 15000,
"placeId": 1000,
"regionId": 5,
"clientId": "TESTCLIENT1"
},
"userCharge": { // UserCharge.cs,4 字段
"chargeId": 2,
"stock": 1, // ★ 购买后的库存(= 原值 + 1)
"purchaseDate": "2026-10-06 13:20:10.0",
"validDate": "2027-01-04 04:00:00" // ★ 不带小数
},
"loginDateTime": 1791159576 // ★ long,来自 UserLoginApi
}
```
### 2.3 两个容易踩的格式
| 字段 | 格式 | 例子 |
|---|---|---|
| `purchaseDate` | `TimeManager.DateTimeFormat` = `yyyy-MM-dd HH:mm:ss.f` | `2026-10-06 13:20:10.0` |
| `validDate` | 显式串 `yyyy-MM-dd 04:00:00` | `2027-01-04 04:00:00` |
> **一个带小数一个不带** —— 少写 `.0` 服务端直接 `returnCode=0`(踩过)。
### 2.4 `validDate` 算法
`Manager/TicketManager.cs:562-565`:
```csharp
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
```
真机双向验证:
| chargeId | 真机 purchaseDate | 减 4h + 90d | 真机 validDate | 吻合 |
|---|---|---|---|---|
| 5 | 2026-10-04 18:22:10 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
| 3 | 2026-10-02 10:49:23 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
### 2.5 `price` = `ticket.creditNum`
`Net.VO.Mai2/VOExtensions.cs:410`:`result.price = ticket.creditNum;`
数据来自游戏本地主数据 `Sinmai_Data/StreamingAssets/A000/ticket/ticketXXXXXX/Ticket.xml`
(已抄进 `config.TICKET_CREDIT_NUM`):
| chargeId | 票名 | `creditNum`(上报的 price) | 机台投币数 |
|---|---|---|---|
| 2 | 2倍チケット | **1** | 1 |
| 3 | 3倍チケット | **2** | 2 |
| 4 | 4倍チケット | 3 | — |
| 5 | 5倍チケット | 4 | — |
| 6 | 6倍チケット | 5 | — |
### 2.6 真机的完整流程
```
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
├─ ticketKind.id == 1(付费票)
├─ :137-150 在 GetGameChargeDataList() 里找该 chargeId
│ ★ 找不到直接 return false,不发请求
├─ :207 SetBuyPaidTicketData -> stock++、更新 purchaseDate / validDate
└─ :234 RequestUserCharge
├─ VOExtensions.cs:436 ExportEnableUserCharge -> 购买后的 UserCharge
├─ VOExtensions.cs:403 ExportUserChargelog -> price = ticket.creditNum
└─ 发 UpsertUserChargelogApi
```
成功后 `TicketManager.cs:255 RequestSuccess` 会**本地** `stock--`(票被本局用掉),
但这个减一**不会发给服务器** —— 所以服务端会一直保留 `stock=1`,
直到下一次结算的 `UpsertUserAll` 把新的 `userChargeList` 写回去。
---
## 三、真机抓包原文(★ 成功样本)
来源:`MaimaiNetSpy/raw/061_UpsertUserChargelogApiMaimaiChn_request.json`(328 字节)
```json
{"userId":12345678,"userChargelog":{"chargeId":2,"price":1,"purchaseDate":"2026-10-06 13:20:10.0","playCount":1262,"playerRating":15000,"placeId":1000,"regionId":5,"clientId":"TESTCLIENT1"},"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0","validDate":"2027-01-04 04:00:00"},"loginDateTime":1791159576}
```
响应:
```json
{"returnCode":1,"apiName":"com.sega.maimai.api.UpsertUserChargelogApi"}
```
请求前后的时序(同一会话):
```
[13] 13:19:45 UserLoginApi
[14]~[60] 入场全量数据下载(47 个请求)
[61] 13:20:10 UpsertUserChargelogApi ← 成功
[62] 13:20:46 GetUserNewItemListApi
[63] 13:20:53 UserLogoutApi
```
---
## 四、我的实现现状
| 文件 | 说明 |
|---|---|
| `api/upsert_user_chargelog.py` | 买票 API。报文构造 + 三道守卫 + 闸门 |
| `buy_ticket.py` | 主脚本:扫码 → 登录 → 入场下载 → 查票 → 等待 → 买票 → 查票 → 登出 |
### 4.1 `UpsertUserChargelog` 参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
| `charge_list` | 自动抓 | 当前票券(`GetUserChargeApi`) |
| `game_charge_list` | 自动抓 | 可购买清单(`GetGameChargeApi`) |
| `user_data` | 自动抓 | 取 `playCount` / `playerRating` |
| `stock` | 现有 + 1 | 购买后库存 |
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `creditNum` |
| `purchase_date` / `valid_date` | 现在 / 由前者推 | |
| `guard_purchasable` | `True` | 不在可购买清单里就拒绝 |
| `guard_no_stock` | `True` | ★ **已持票就拒绝**(自加,真机没有) |
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM` | 不开不发包 |
### 4.2 已验证「正确」的东西
| 项 | 证据 |
|---|---|
| URL / 混淆名 | `4411a4c8f7f5c0e95a3ac17f1746aad3` = 真机 |
| User-Agent | 逐字符相同 |
| 键集合 / 顺序 / 类型 | 与真机逐字节同构(328 B vs 328 B) |
| `.f` 格式 | `purchaseDate` 已带一位小数 |
| Cookie | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
| `number` 头 | 已调 `Ping()`(真机 75,我这边 47~61) |
| 入场数据下载 | 已补(18~19 节点) |
| 节奏 | 已加 30 秒等待(真机是 25 秒) |
### 4.3 我发出的实际请求头(离线检视)
```
User-Agent = '4411a4c8f7f5c0e95a3ac17f1746aad3#<userId>'
Accept-Encoding = '' ← 空串(.NET 不发这个头)
Accept = '*/*' ← requests 自带
Connection = 'keep-alive'
Content-Type = 'application/json'
charset = 'UTF-8'
Mai-Encoding = '1.55'
Content-Encoding = 'deflate'
number = '61'
Cookie = 'JSESSIONID=...'
Content-Length = '32'
```
---
## 五、完整实验时间线(全部失败)
| # | 时间 | 账号 | chargeId | 关键改动 | 结果 |
|---|---|---|---|---|---|
| 1 | 13:10 | A | 2 | `purchaseDate` **少 `.0`** | `returnCode=0` |
| 2 | 13:41 | A | 5 | 格式已修 | `returnCode=0`(★ 5 号票**未上架**) |
| 3 | 13:50 | A | 3 | 换了上架的票 | `returnCode=0` |
| 4 | 13:53 | A | 3 | + 入场全量下载 | `returnCode=0` |
| 5 | 14:03 | A | 2 | + 5 秒等待 | `returnCode=0` |
| 6 | 15:51 | A | 3 | + `Ping()`、30 秒等待 | `returnCode=0` |
| 7 | 16:33 | **B** | 2 | 换了个**没被黑的号** | 🛡️ 被守卫拦下(B 已持票) |
| 8 | **16:39** | **B** | 2 | B 的票已用掉,守卫放行 | ❌ **`returnCode=0`** |
### ★ 第 8 次是决定性实验
| | 机台 | 我的脚本 |
|---|---|---|
| 账号 A | ✅ 13:20、14:06 成功 | ❌ 6 次失败 |
| **账号 B** | ✅ **16:31:51 成功** | ❌ **16:39:37 失败** |
**同一个账号 B:机台能买、脚本不能买 → 差异在客户端,不在账号/服务端。**
> 此前一直以为是「账号被拉黑」,第 8 次推翻了它。
---
## 六、已排除的假设
| # | 假设 | 排除依据 |
|---|---|---|
| 1 | URL / 混淆名错 | 服务端回正确 `apiName` |
| 2 | User-Agent 错 | 与真机逐字符相同 |
| 3 | Cookie 缺失 | 登录响应确有 `Set-Cookie: JSESSIONID` |
| 4 | 报文字段 / 顺序 / 类型错 | 与真机逐字节同构 |
| 5 | `purchaseDate` 格式错 | 已补 `.f`(第 1 次之后) |
| 6 | 票未上架 | 第 3 次起换成清单里的票 |
| 7 | 已持有功能票 | 第 8 次确认持有 0 张 |
| 8 | `number` 头为 0 | 第 6 次起已 `Ping()` |
| 9 | 缺少入场数据下载 | 第 4 次起已补 |
| 10 | 节奏太快 | 第 6 次起 30 秒等待 |
| 11 | `chargeId` 本身有问题 | 换用真机验证过的 2 号票仍失败 |
| 12 | 账号被拉黑 | ★ **第 8 次用没被黑的号 B 仍然失败** |
### 剩余假设
| # | 假设 | 怎么验证 |
|---|---|---|
| **A** | ★ **HTTP 请求头 / 连接特征有差异** | 抓真机完整请求头逐字节对比(mod 已就绪) |
| B | 服务端对金钱接口做了 TLS 指纹校验 | 需要 MITM 代理 |
| C | 需要某个本工程不知道的前置状态 | 同上 |
---
## 七、★ 别人的可用实现(截图转录)
> 图片主人说这套代码**能成功买票**,流程为「登录 → userdata → ticket → logout」。
### 7.1 报文构造
```python
import json
import pytz
from .sdgb import sdgb_api
from datetime import datetime, timedelta
def get_ticket(userId: int, cookie, chargeId, price, regionId, placeId,
clientId, playCount: int, playerRating: int):
now = (datetime.now(pytz.timezone('Asia/Shanghai'))).strftime("%Y-%m-%d %H:%M:%S.0")
validDate = (datetime.now(pytz.timezone('Asia/Shanghai')) + timedelta(days=90)) \
.replace(hour=4, minute=0, second=0).strftime("%Y-%m-%d %H:%M:%S")
data = json.dumps({
"userId": userId,
"userCharge": {
"chargeId": chargeId,
"stock": 1,
"purchaseDate": now,
"validDate": validDate
},
"userChargelog": {
"chargeId": chargeId,
"price": price,
"purchaseDate": now,
"placeId": placeId,
"regionId": regionId,
"clientId": clientId,
"playCount": playCount,
"playerRating": playerRating,
},
"loginDate": now, # ★★ 注意:是 loginDate(字符串),不是 loginDateTime
})
ticket_result = sdgb_api(data, "UpsertUserChargelogApi", cookie)
return ticket_result
```
### 7.2 调用方流程
```python
if ticket_id in ticket_list:
login_time = login_datetime_now()
login_result = user_login_checked(user_id, token, region_id, place_id, client_id, login_time)
if login_result['Result']['returnCode'] == 1:
login_cookie = login_result['Cookie'] # ★ 显式取出 cookie
logger.info(f'{user_id} get_ticket: 获取到 Cookie = {login_cookie}')
match ticket_id: # ★ price 表
case 2: ticket_price = 1
case 3: ticket_price = 2
case 4: ticket_price = 3
case 5: ticket_price = 4
case 6: ticket_price = 5
case _: ticket_price = 0
userdata_result = user_datas(user_id, login_cookie) # ★ cookie 显式传
play_count = userdata_result["userData"]["playCount"]
player_rating = userdata_result["userData"]["playerRating"]
# ★★ 获取 userdata 后等待 60 秒再请求 ticket
logger.info(f'{user_id} get_ticket: 等待 60 秒后请求 ticket')
time.sleep(60)
ticket_result = api_get_ticket(
user_id, login_cookie, ticket_id, ticket_price,
region_id, place_id, client_id, play_count, player_rating
)
logger.debug(f'ticket_result 完整内容 = {json.dumps(ticket_result, ensure_ascii=False, indent=2)}')
if ticket_result and ticket_result.get('returnCode') == 1:
logout_result = user_logout(user_id, login_cookie, region_id, place_id, client_id, 0, 4)
...
```
### 7.3 ★ 与我实现的差异对照
| # | 他们 | 我 | 重要性 |
|---|---|---|---|
| **1** | **`"loginDate": "2026-10-06 16:39:37.0"`**(字符串) | `"loginDateTime": 1791304745`(数字) | ★★★ **最大疑点** |
| 2 | `stock` 硬编码 `1` | 现有 + 1 | 低(结果一样) |
| 3 | 等待 **60 秒** | 30 秒 | 中 |
| 4 | `validDate` = `(now+90d).replace(hour=4)` | `(now-4h+90d).date() + " 04:00:00"` | 低(源码支持我的) |
| 5 | `purchaseDate` 的 `.0` 写死 | 取真实十分位 | 低 |
| 6 | **Cookie 显式传递** | `requests.Session` 自动 | ★★ 中 |
| 7 | 无 `GetGameChargeApi` 前置 | 有 | 低 |
| 8 | 无「已持票」守卫 | 有(自加) | 低(安全相关) |
| 9 | 流程:登录→userdata→ticket→logout | 登录→入场下载→查票→等待→ticket→查票→logout | ★ 中 |
| 10 | `userCharge` 在 `userChargelog` 之前 | 反之 | 低(JSON 无序) |
| 11 | `userChargelog` 内部顺序也不同 | 照 C# 声明 | 低 |
| 12 | `sdgb_api(data, "UpsertUserChargelogApi", cookie)` | `request_dict(...)` | ★★ **黑盒,未知** |
### 7.4 ★ 我的疑惑(恢复工作时优先解答)
1. **`loginDate` vs `loginDateTime`(最重要)**
- 源码 `UserChargelogRequestVO.cs` 声明的是 `public long loginDateTime;`
- 真机抓包发的是 `"loginDateTime":1791159576`(数字)
- **他们发的是 `"loginDate"`(字符串)**,而且说能成功
- 三种可能:
a. 服务端**不校验**这个字段 → 那我的 `loginDateTime` 也不是问题
b. 服务端认的是 `loginDate`(旧字段名)
c. 他们的 `sdgb_api` 内部做了改名(把 `loginDate` → `loginDateTime`)
- **必须先搞清楚这一条**,否则会一直在错的方向排查
2. **`sdgb_api(data, apiName, cookie)` 到底做了什么?**(最大的黑盒)
- 是否补 `MaimaiChn` 后缀?是否做 md5 混淆?
- 请求头怎么设的?(`Accept-Encoding`?`number`?`Mai-Encoding`?`Content-Type`?)
- **cookie 是怎么放进请求的**(header 直写?cookie jar?)
- 加解密实现是否一致
- ★ **他们的成功很可能就藏在这里**
3. **Cookie 显式传递的含义**
- 他们从登录结果里取出 cookie,手工传给每个请求
- 这个 cookie 是 `Set-Cookie` 原样,还是提取出的 `JSESSIONID=...`?
- 和 `requests.Session` 自动管理的**有没有不同**?
(比如:他们可能把**整个 Set-Cookie 串**塞进 `Cookie:` 头)
4. **60 秒等待是必需的吗?**
- 真机是 25 秒,他们用 60 秒 —— 是协议要求还是保险起见?
5. **`stock` 硬编码 1**
- 如果账号已有该票(`stock=1`),他们还是发 1
- 那「已持票不能买」的规则到底存不存在?(我加守卫是基于用户的警告)
6. **`validDate` 在凌晨会差一天**
- 他们的算法在 00:00~04:00 之间会比我多算一天
- 谁对?(源码支持我的,但他们的能跑通)
---
## 八、⚠️ 账号状态与风险
| 账号 | 状态 | 备注 |
|---|---|---|
| **A**(主号) | ⚠️ **疑似被拉黑** | 6 次失败购买后,机台与脚本都买不了 |
| **B**(测试号) | 暂未见异常 | 机台成功过;脚本失败 1 次 |
**风险提示:**
1. **不要反复重试买票** —— 每次都是一次真实的服务端交互,**可能正是失败次数导致拉黑**
2. 恢复工作前**先用机台确认账号还能不能买**
3. 优先用**非主力账号**做实验
4. 已有的三道守卫(可购买清单 / 已持票 / 副作用闸门)**不要为了「试通」而关掉**
---
## 九、恢复工作时怎么做
### 9.1 先读这些
| 文件 | 看什么 |
|---|---|
| 本文 | 全部背景 |
| `api/upsert_user_chargelog.py` | 实现与守卫 |
| `buy_ticket.py` | 主脚本流程 |
| `source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs` | 接口定义 |
| `source/Assembly-CSharp/Manager/TicketManager.cs:130-270` | 真机购买流程 |
| `source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:403-451` | 三个 Export 函数 |
### 9.2 第一件事:抓真机完整 HTTP 头
**MelonLoader Mod 已经改好了**(`gamehook/Main.cs`),现在会 dump:
- `★ 真实 HTTP 请求头(HttpWebRequest,含框架自带)` —— Method / ContentType / UserAgent /
ContentLength / KeepAlive / Expect / AutomaticDecompression / **Headers.AllKeys** / **Cookie 头**
- `★ 真实 HTTP 响应头` —— StatusCode / 全部响应头(含 **`Set-Cookie: JSESSIONID`**)
构建与部署:
```bash
cd gamehook && dotnet build -c Release
copy bin\Release\MaimaiNetSpy.dll "D:\SDGB2026\Package\Mods\MaimaiNetSpy.dll"
```
然后**在机台上用没被黑的账号买一张票**,把日志发回来:
```
D:\SDGB2026\Package\MaimaiNetSpy\YYYY-MM-DD_HHMMSS.log
```
### 9.3 拿到日志后怎么比
1. 找 `UpsertUserChargelogApi` 那一段
2. 对比**请求头集合**:多了什么?少了什么?
- 重点:`Expect`、`Accept-Encoding`、`Cookie`、`Connection`、以及**头的顺序**
3. 对比 `Cookie` 头:真机发的是 `JSESSIONID=...` 还是别的?
4. 对比登录响应的 `Set-Cookie`:和我 Session 里存的是不是同一个
5. 如果请求头完全一致 → 转向 TLS 指纹 / MITM 抓完整 TCP 层
### 9.4 验证买票是否成功的命令
```bash
# 预检(不发包)
python buy_ticket.py "<二维码>" --charge-id 2
# 真实购买(★ 有副作用)
python buy_ticket.py "<二维码>" --charge-id 2 --send
# 可选参数
--wait-before 60 # 默认 30 秒
--skip-entry # 跳过入场全量下载
--debug # 含完整收发报文
```
### 9.5 一个可能的快速验证
如果第 7.4 节的疑惑 1(`loginDate` vs `loginDateTime`)成立,
那么最快的验证是:**在 `extra_data` 里把 `loginDateTime` 换掉**——
但目前 `extra_data` 只作用于 `upsertUserAll` 的 36 个节点,
**顶层字段(`userId` / `userChargelog` / `userCharge` / `loginDateTime`)改不了**,
需要临时改 `upsert_user_chargelog.py` 的 `UserChargelogRequestVO` 定义。
---
## 十、代码位置索引
| 位置 | 内容 |
|---|---|
| `api/upsert_user_chargelog.py` | `UpsertUserChargelog` / `ChargelogConfirmError` / `ChargelogGuardError` / `calc_valid_date` / `parse_wall_time` / `now_cst` |
| `buy_ticket.py` | 主脚本 + `show_charges()`(跑前跑后查票) |
| `config.py` | `TICKET_CREDIT_NUM` / `TICKET_NAMES` / `PAID_TICKET_IDS` / `UPSERT_CHARGELOG_CONFIRM` / `TICKET_VALID_DAYS` / `TICKET_VALID_HOUR_OFFSET` |
| `selftest.py` | 13.6 节:买票相关的 20+ 项自检(含**与真机报文逐字节回归**) |
| `gamehook/Main.cs` | `PostRequestDumpHeaders` / `PostGetResponse`(响应头) |
---
*本文写于 2026-10-06,买票功能暂停时。*
+477
View File
@@ -0,0 +1,477 @@
# 购买功能票流程(登录 → 买票 → 登出)
> 对应 `api/upsert_user_chargelog.py` 与主脚本范例 `buy_ticket.py`。
> **判据 = `source/Assembly-CSharp/` 反编译源码 + 游戏本地主数据 Ticket.xml。**
>
> ⚠ 本文的实现**全部来自源码推导**,还**没有真机 NetworkLog 对照**。
> 文末列出「需要一次抓包确认」的点。
---
## 一、一句话
在机台买一张功能票 = 往服务器发一个 `UpsertUserChargelogApi`,
把「买了哪张票、多少钱、什么时候买的、有效期到什么时候、买完还剩几张」写进去。
```
ParseQRCode -> UserPreview -> UserLogin -> UpsertUserChargelog -> UserLogout
```
---
## 二、接口
| 项目 | 值 |
|---|---|
| 逻辑 API 名 | `UpsertUserChargelogApi` |
| 源码 | `Net.Packet.Mai2/PacketUploadUserChargelog.cs:19` |
| 请求 VO | `Net.VO.Mai2/UserChargelogRequestVO.cs`(4 字段) |
| 响应 | `{returnCode, apiName}` |
| 副作用 | ★ 有,会写入一条购买记录 |
### 2.1 请求体(字段顺序照 C# 声明)
```jsonc
{
"userId": 12345678,
"userChargelog": { // ← UserChargelog.cs,8 字段
"chargeId": 2, // 买的哪张票
"price": 1, // ★ = ticket.creditNum,不是 money price
"purchaseDate": "2026-10-06 13:01:22",
"playCount": 1260, // 玩家总游玩次数
"playerRating": 15000, // 玩家 Rating
"placeId": 1000,
"regionId": 1,
"clientId": "TESTCLIENT1"
},
"userCharge": { // ← UserCharge.cs,4 字段
"chargeId": 2,
"stock": 1, // ★ 购买后的库存
"purchaseDate": "2026-10-06 13:01:22",
"validDate": "2027-01-04 04:00:00"
},
"loginDateTime": 1791291143
}
```
---
## 三、源码流程(逐行可查)
```
Manager/TicketManager.cs:130 UseTicketPrepare(playerId, ticketId)
│
├─ ticket.ticketKind.id == 1 ? ← 只有 Paid 票才走购买
│ (ticketKind: 1=Paid 2=Event 3=Free 4=Link 5=ShopWeekly 6=ShopChara)
│
├─ 在 OperationManager.GetGameChargeDataList() 里找该 chargeId 的 price
│ (= GetGameChargeApi 的响应,含 orderId/chargeId/price/startDate/endDate)
│
├─ TicketManager.cs:207 SetBuyPaidTicketData(playerId, ticketId)
│ ├─ ChargeList 里已有该 chargeId -> stock++、更新 purchaseDate / validDate
│ └─ 没有 -> 新增一条 stock=1
│
└─ TicketManager.cs:234 RequestUserCharge(playerId)
├─ VOExtensions.cs:436 ExportEnableUserCharge(ticketId)
│ -> 取**购买后**(stock > 0)的那条 UserCharge
│
├─ VOExtensions.cs:403 ExportUserChargelog(ticketId)
│ chargeId = ticketId
│ price = DataManager.GetTicket(ticketId).creditNum ★
│ purchaseDate = TimeManager.GetNowDateString()
│ playCount = src.Detail.PlayCount
│ playerRating = (int)src.Detail.Rating
│ placeId = Auth.LocationId
│ regionId = Auth.RegionCode
│ clientId = AMDaemon.System.KeychipId.ShortValue
│
└─ 发 UpsertUserChargelogApi
returnCode == 1 -> RequestSuccess(本地 stock 再 --,因为票马上被用掉)
否则 -> RequestFail
```
### 3.1 ★ `price` 不是「花的钱」
`VOExtensions.cs:410`:
```csharp
result.price = ticket.creditNum;
```
`creditNum` 是**票的信用点倍数**,来自游戏本地主数据,**不是** `GetGameChargeApi` 的 money price。
| chargeId | 票名 | `creditNum`(= 上报的 price) | `GetGameChargeApi.price`(投币数) |
|---|---|---|---|
| 2 | 2倍チケット | **1** | 1 |
| 3 | 3倍チケット | **2** | 2 |
| 4 | 4倍チケット | **3** | 未上架 |
| 5 | 5倍チケット | **4** | 未上架 |
| 6 | 6倍チケット | **5** | 未上架 |
> 2/3 号票两者数值恰好相同,别被误导 —— 源码取的是 `creditNum`。
> 数据已抄进 `config.TICKET_CREDIT_NUM`,来源:
> `<游戏目录>\Sinmai_Data\StreamingAssets\A000\ticket\ticketXXXXXX\Ticket.xml`
### 3.2 ★ `validDate` 算法
`Manager/TicketManager.cs:564`:
```csharp
DateTime.Now.AddHours(-4.0).AddDays(90.0).ToString("yyyy-MM-dd 04:00:00")
```
**真机双向验证**(`source/2026-10-05.log:123` 的 `userChargeList`):
| chargeId | purchaseDate | 减 4h | 加 90d | validDate(真机) | 吻合 |
|---|---|---|---|---|---|
| 5 | 2026-10-04 18:22:10 | 10-04 14:22 | 2027-01-02 | `2027-01-02 04:00:00` | ✅ |
| 3 | 2026-10-02 10:49:23 | 10-02 06:49 | 2026-12-31 | `2026-12-31 04:00:00` | ✅ |
---
## 四、怎么用
### 4.1 主脚本(`buy_ticket.py`)
```bash
# 预检:只看报文,零副作用
python buy_ticket.py "<二维码>" --charge-id 2
# 先看看现在有什么票、能买什么
python buy_ticket.py "<二维码>" --list
# ★ 真实购买
python buy_ticket.py "<二维码>" --charge-id 2 --send
```
### 4.2 在代码里调
```python
from api import (setup_logging, teardown_logging,
ParseQRCode, UserLogin, UserLogout, LogoutType,
UpsertUserChargelog)
setup_logging("买票")
try:
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
# ★ 一个函数搞定:取当前票券 -> 取档案 -> 算 validDate -> 算 price -> 闸门 -> 发包
UpsertUserChargelog(user_id, 2, login.loginDateTime,
dry_run=False, confirm=True)
UserLogout(user_id, token, login.loginDateTime,
logout_type=LogoutType.TestIn)
finally:
teardown_logging()
```
### 4.3 参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `user_id` / `charge_id` / `login_date_time` | 必填 | 后两个必须与 `UserLoginApi` 一致 |
| `charge_list` | 自动抓 | 当前票券列表(`GetUserChargeApi`),用来算购买后库存 |
| `user_data` | 自动抓 | 当前档案(`GetUserDataApi`),用来填 `playCount`/`playerRating` |
| `stock` | 现有 + 1 | 购买后该票的库存 |
| `price` | `TICKET_CREDIT_NUM[charge_id]` | = `ticket.creditNum` |
| `purchase_date` | 现在 | `YYYY-MM-DD HH:MM:SS` |
| `valid_date` | 由 `purchase_date` 推 | 现在 -4h +90d,取日期补 `04:00:00` |
| `play_count` / `player_rating` | 取自 `user_data` | |
| `confirm` | `config.UPSERT_CHARGELOG_CONFIRM`(False) | ★ 不开不发包 |
| `dry_run` | `False` | `True` = 只构造报文 |
---
## 五、买完之后票会怎样?
按源码,**买票接口自己就把 `userCharge`(含新库存)写进去了**,
不需要额外再发 `UpsertUserAll`。
真机的完整顺序是「买票 → 立刻用掉这一张」:
```
SetBuyPaidTicketData: stock++ (本地 +1)
RequestUserCharge: 发 stock(购买后的值)
RequestSuccess: stock-- (票被本局用掉了)
```
所以 `userChargeList` 里的 `stock` 平时大多是 **0** ——
真机 22:31 那次的 `userChargeList` 三条全是 `stock: 0`,就是这个原因。
> **本模块只负责「买」**,默认上报的是**购买后**的库存(现有 + 1)。
> 如果你要复刻「买完立刻用掉」的完整语义,买完再发一次
> `UpsertUserAll(..., extra_data={"userChargeList": [...]})` 把 `stock` 减回去。
---
## 六、★ 真机抓包对照(已修正,报文逐字节一致)
### 6.0 结论:唯一差异是 `purchaseDate` 少了一个 `.0`
真机抓包(`MaimaiNetSpy` `raw/061_UpsertUserChargelogApiMaimaiChn_request.json`,328 字节):
```json
{"userId":12345678,
"userChargelog":{"chargeId":2,"price":1,"purchaseDate":"2026-10-06 13:20:10.0",
"playCount":1262,"playerRating":15000,"placeId":1000,
"regionId":1,"clientId":"TESTCLIENT1"},
"userCharge":{"chargeId":2,"stock":1,"purchaseDate":"2026-10-06 13:20:10.0",
"validDate":"2027-01-04 04:00:00"},
"loginDateTime":1791292785}
```
**14 个字段逐项对照:**
| 字段 | 真机 | 修正前 | 结论 |
|---|---|---|---|
| `userChargelog.chargeId` | 2 | 2 | ✅ |
| `userChargelog.price` | **1** | 1(= `creditNum`) | ✅ **源码推导正确** |
| `userChargelog.purchaseDate` | **`2026-10-06 13:20:10.0`** | `2026-10-06 13:10:42` | ❌ **少 `.0`** |
| `userChargelog.playCount` | 1262 | 1262 | ✅ |
| `userChargelog.playerRating` | 15000 | 15000 | ✅ |
| `userChargelog.placeId` / `regionId` / `clientId` | 1000 / 5 / `TESTCLIENT1` | 同 | ✅ |
| `userCharge.chargeId` | 2 | 2 | ✅ |
| `userCharge.stock` | **1** | 1(**加 1 后**) | ✅ **源码推导正确** |
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | 少 `.0` | ❌ |
| `userCharge.validDate` | **`2027-01-04 04:00:00`** | 同 | ✅ **算法完全正确** |
| `loginDateTime` | 1791292785 | 同 | ✅ |
| 字段顺序 | — | 同 | ✅ |
**修正后逐字节比对:真机 328 字节 / 本工程 328 字节,完全相同。**
### 6.0.1 根因:`TimeManager.DateTimeFormat` 带一位小数
`Manager/TimeManager.cs:7,62-70`:
```csharp
public const string DateTimeFormat = "yyyy-MM-dd HH:mm:ss.f"; // ← .f = 一位小数
public static string GetNowDateString()
=> GetDateString(GetNowUnixTime());
public static string GetDateString(long unixTime)
=> GetDateTime(unixTime).ToString("yyyy-MM-dd HH:mm:ss.f");
```
我原来用了 `%Y-%m-%d %H:%M:%S`(无小数),所以服务端 `returnCode=0`。
> ★ 两个格式别搞混:
> | 字段 | 格式 | 例子 |
> |---|---|---|
> | `purchaseDate` | `yyyy-MM-dd HH:mm:ss.f` | `2026-10-06 13:20:10.0` |
> | `validDate` | `yyyy-MM-dd 04:00:00`(显式串) | `2027-01-04 04:00:00` |
>
> **一个带小数一个不带**,这是最容易踩的地方。
> ★ 另一个细节:`GetNowTime()` 返回的是 **`DateTime.UtcNow`**,
> `GetDateString` 再 `Add(CSToffset)` —— 所以游戏里的日期串**恒是北京时间**,
> 与本机时区无关。脚本跑在非东八区机器上时 `datetime.now()` 会错,
> 所以本模块用 `now_cst()`(UTC+8)显式计算。
### 6.1 修正前的实跑记录(2026-10-06 13:10,已废弃)
```
[购买前] 当前票券 3 条:
chargeId=2 stock=0 validDate=2026-10-12 04:00:00
chargeId=3 stock=0 validDate=2026-12-31 04:00:00
chargeId=5 stock=0 validDate=2027-01-02 04:00:00
[购买前] 持有中的功能票:0 张 ← 安全检查通过,可以买
安全检查通过:账号当前没有持有任何功能票
票名 : 2倍チケット
库存 : 0 -> 1
price : 1(= ticket.creditNum)
purchaseDate: 2026-10-06 13:10:42
validDate : 2027-01-04 04:00:00
闸门已放行,开始上传购买记录 ...
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
```
**账号无恙**(已只读复查):`userChargeList` 仍是原来 3 条、
`playCount=1262`、无封禁。
### 6.2 这次失败排除了什么
| 结论 | 依据 |
|---|---|
| **URL / 混淆名正确** | 服务端回了 `apiName=com.sega.maimai.api.UpsertUserChargelogApi` |
| **报文格式被接受** | 不是 0 字节响应、不是解密解压失败,是**业务层拒绝**(`returnCode=0`) |
| **不是闸门问题** | 闸门已放行,确实发了包 |
| **不是「已持票」问题** | 购买前确认 3 条票 stock 全是 0 |
| **就是 `purchaseDate` 格式** | 抓包对照后确认(见 6.0) |
### 6.3 剩下的未验证点
| # | 待验证 | 现状 |
|---|---|---|
| 1 | 买票后是否**还要**发 `UpsertUserAll` 才持久化库存 | 源码流程里没有;抓包只抓到这一条请求,**推测不需要** |
| 2 | `validDate` 是否会被服务端按本地时区重算 | 真机发的是 `2027-01-04 04:00:00`,与本工程算法一致 |
| 3 | 服务端是否对「有效期未过的同一 chargeId」有额外限制 | 真机在 `chargeId=2`(validDate 2026-10-12 未过)的情况下照样买了,**说明没有这个限制** |
### 6.4 抓包时确认过的时序
抓到的这一次只发了 **`UpsertUserChargelogApi` 一条写请求**,
请求体里同时带了 `userCharge`(新库存)和 `userChargelog`(购买记录)——
印证了「买票接口自己就把库存写进去了」。
---
## 六点五、★ 第二次实跑:`chargeId=5` 又被 `returnCode=0`(2026-10-06 13:41)
修完 `.f` 格式后再跑一次(这次买 **5 倍券**),**还是 `returnCode=0`**:
```
[购买前] 持有中的功能票:0 张 ← 安全检查通过
安全检查通过:账号当前没有持有任何功能票
票名 : 5倍チケット
库存 : 0 -> 1
price : 4(= ticket.creditNum)
purchaseDate: 2026-10-06 13:41:43.3 ← .f 格式已正确
validDate : 2027-01-04 04:00:00
闸门已放行,开始上传购买记录 ...
★ 购买失败:returnCode=0 apiName=com.sega.maimai.api.UpsertUserChargelogApi
```
### 6.5.1 根因:`chargeId=5` 根本没上架
只读查 `GetGameChargeApi`:
```json
{"length":2,"gameChargeList":[
{"orderId":1,"chargeId":2,"price":1,...}, ← 只有 2 和 3
{"orderId":2,"chargeId":3,"price":2,...}]}
```
**chargeId=5 不在清单里 → 服务端直接拒。**
### 6.5.2 源码里本来就有这道检查(我漏了)
`Manager/TicketManager.cs:137-150`:
```csharp
if (ticket.ticketKind.id == 1)
{
GameCharge[] gameChargeDataList = Singleton<OperationManager>.Instance.GetGameChargeDataList();
for (int i = 0; i < gameChargeDataList.Length; i++)
{
GameCharge gameCharge = gameChargeDataList[i];
if (gameCharge.chargeId == ticketId) // ★ 必须在清单里找到
{
...
RequestUserCharge(playerId); // 才发购买请求
return true;
}
}
}
return false; // ★ 找不到 -> 不买
```
**真机也是「先查清单,找到才买」** —— 我第一版实现直接跳到发请求,漏了这一步。
### 6.5.3 已补的守卫
`UpsertUserChargelog` 新增 `guard_purchasable=True`(默认开):
- 不传 `game_charge_list` 就自动调 `GetGameChargeApi`
- `charge_id` 不在清单里 → 抛 `ChargelogGuardError`,**未发包**
`buy_ticket.py` 更把这道检查放到了**扫码之前**(`GetGameChargeApi` 不需要登录),
不满足就直接退出 —— **不浪费二维码、不产生登录会话**:
```
当前可购买 chargeId:[2, 3]
chargeId=2 2倍チケット money price=1 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
chargeId=3 3倍チケット money price=2 有效期 2021-03-18 07:00:00 ~ 2029-12-31 00:00:00
[错误] chargeId=5(5倍チケット)**不在当前可购买清单里** —— 连登录都不做,直接退出。
```
### 6.5.4 本机台到底能买什么
| chargeId | 票名 | 上架 | `creditNum`(上报的 price) | money price |
|---|---|---|---|---|
| 2 | 2倍チケット | ✅ | 1 | 1 |
| 3 | 3倍チケット | ✅ | 2 | 2 |
| 4 | 4倍チケット | ❌ | 3 | — |
| 5 | 5倍チケット | ❌ | 4 | — |
| 6 | 6倍チケット | ❌ | 5 | — |
> ★ 上架清单是**机台/店铺配置**,不同店铺可能不一样。
> 每次买之前都该先看 `GetGameChargeApi`。
### 6.5.5 账号状态
两次失败都**没有写入任何东西**:`userChargeList` 三条 `stock` 全是 0。
服务端的 `returnCode=0` 是「干净拒绝」。
---
## 六点六、★★ 第三、四次实跑:`chargeId=2`(真机验证过的配置)**也失败**
### 6.6.1 做了什么
第 3 次(13:50,chargeId=3)和第 4 次(14:03,**chargeId=2**)分别补齐了:
| 补齐项 | 说明 |
|---|---|
| `number` 头 | 加了 `Ping()`,从 `"0"` 变成 `"61"`(真机 75) |
| 入场全量下载 | 登录后先拉 18~19 个节点,复刻真机 [14]~[60] |
| 买票前等待 | 加 5 秒,贴近真机 login→买票 的 25 秒节奏 |
**结果仍然是 `returnCode=0`。**
### 6.6.2 与真机报文的最终并排对比
第 4 次的报文 vs 真机抓包:
| 路径 | 真机 | 本工程 |
|---|---|---|
| 键集合 | — | **完全相同** |
| `userChargelog` 键序 | — | **完全相同** |
| `userCharge` 键序 | — | **完全相同** |
| `userChargelog.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
| `userChargelog.playCount` | 1262 | 1263 |
| `userCharge.purchaseDate` | `2026-10-06 13:20:10.0` | `2026-10-06 14:03:14.6` |
| `loginDateTime` | 1791292785 | 1791295385 |
**唯一不同的就是这 3 个「当下时刻」类的值。** 其余 11 个字段逐字节相同。
### 6.6.3 已逐项排除的可能
| # | 假设 | 排除依据 |
|---|---|---|
| 1 | URL / 混淆名错 | `4411a4c8f7f5c0e95a3ac17f1746aad3` 与真机一致;服务端回正确 `apiName` |
| 2 | User-Agent 错 | 与真机逐字符相同 |
| 3 | Cookie 缺失 | 登录响应确实下发 `Set-Cookie: JSESSIONID=...`,Session 自动带 |
| 4 | 报文字段/顺序/类型错 | 与真机逐字节同构 |
| 5 | `purchaseDate` 格式错 | 已补 `.f`(`14:03:14.6`) |
| 6 | 票未上架 | `chargeId=2` 在 `GetGameChargeApi` 清单里 |
| 7 | 已持有功能票 | 购买前确认 3 条 `stock` 全 0 |
| 8 | `number` 头为 0 | 已加 `Ping()` → `"61"` |
| 9 | 缺少入场数据下载 | 已补(18~19 节点,0 失败) |
| 10 | 节奏太快 | 已加 5 秒等待 |
| 11 | `chargeId` 本身有问题 | **改用真机验证过的 `chargeId=2` 仍然失败** |
### 6.6.4 剩余假设
请求体已经被证明「结构上完全正确」,所以问题只可能在**请求之外**:
| # | 假设 | 怎么验证 |
|---|---|---|
| **A** | ★ **服务端有「购买频率/每日上限」规则** —— 账号在 **13:20 刚买过同一张票**,43 分钟后再买被拒 | **在真机上再买一次 2 倍券**:若真机也失败 → 规则成立,本工程实现正确;若真机成功 → 本工程有 bug |
| B | 服务端对**金钱相关接口**做了 TLS/HTTP 指纹校验(非游戏客户端特征) | 需要本地 MITM 代理对比完整 HTTP 请求头 |
| C | 服务端要求某个本工程不知道的前置状态 | 同上 |
**★ 假设 A 是最可能、也最容易验证的**:`returnCode=0` 只用了 **47 ms** 就返回,
像是命中了一条规则检查,而不是超时或异常。
---
## 七、安全闸门
| 调用方式 | 行为 |
|---|---|
| `UpsertUserChargelog(...)` | 抛 `ChargelogConfirmError`,**未发包** |
| `UpsertUserChargelog(..., dry_run=True)` | 只构造并返回报文 |
| `UpsertUserChargelog(..., confirm=True)` | 真实发送 |
全局开关:`config.UPSERT_CHARGELOG_CONFIRM = False`(改成 `True` 会永久放行,不建议)。
+255
View File
@@ -0,0 +1,255 @@
# 舞萌DX 纯网络脚本工程 · 项目总览
> 项目名 **WaHappy** —— 舞萌DX 的游戏助手。
> 面向使用者的说明见根目录 [`README.md`](../README.md);
> 本文件是**开发向的主文档**,开工前先读它;遇到「该放哪、该怎么写」的问题以本文件为准。
---
## 一、这个工程是什么
用 **Python 脚本**,通过**纯网络(纯 HTTP)** 形式直接与舞萌DX(SDGB 国服)的**游戏服务器**交互,
复刻游戏客户端的协议行为。
| 对象 | 是什么 | 对应反编译源码 |
|---|---|---|
| **游戏服务器** | 玩家数据读写:扫码登录、取数、上传成绩、登出 | `source/Assembly-CSharp/` |
| **纯网络形式** | 脚本**直接收发 HTTP 报文**(AES 加密 JSON),不依赖运行中的游戏客户端 | `source/Assembly-CSharp/Net/` |
| **扫码登录** | 微信二维码换 userId + token,走 aimedb | `source/ChimeLib.NET/` |
---
## 二、目录结构
```
wahappy-v0-goal/
├── config.py ★ 运行时可配置项(密钥/设备/区域/歌曲数据/日志级别)
├── upsert.py ★ 主脚本范例:扫码登录 → 上传成绩 → 登出
├── buy_ticket.py ★ 主脚本范例:扫码登录 → 购买功能票 → 登出
├── selftest.py 零网络自检(220 项,判据=源码+真机日志+真机抓包)
│
├── api/ ★ 所有与服务器交互的脚本(一个 api 一个脚本)
│ ├── __init__.py 统一导出 + 日志跟踪登记
│ ├── log_setup.py 统一日志(INFO / DEBUG 双模式)
│ ├── maimai_net.py 网络核心(AES / deflate / URL 混淆 / Cookie)
│ ├── user_getter_common.py 取数脚本公共底座
│ ├── chime.py 扫码登录
│ ├── upsert_user_all.py ★ 上传成绩
│ └── ... 其余见第六节
│
├── source/ ★ 反编译源码 + 网络日志,只读查证用
├── docs/ ★ 文档
└── logs/ ★ 日志输出(latest.log + 日期.log)
```
---
## 三、api/ 脚本契约
`api/` 里的每个脚本是一个**功能模块**,对外暴露**函数**;函数**能接收参数**,
内部完成「组包 → 发包 → 解析 → 返回结果」的全过程。
```python
def 某Api函数(必填参数, *, 可选参数...) -> 结果:
"""一句话说明(会出现在日志的 [接口说明] 里)"""
...
```
参数形态规律:
- **业务数据**(user_id / token / 二维码 / playlog_id)→ **位置参数**
- **配置项**(client_id / region_id / timeout_ms)→ **关键字参数**,可省略走默认值
---
## 四、日志
### 4.1 两种模式
| 模式 | 输出内容 |
|---|---|
| **INFO**(默认) | 只展示 api 脚本的**运行情况和结果** |
| **DEBUG** | 在 INFO 基础上,额外展示**向 api 发送的数据**和**api 返回的数据** |
切换只改 `config.py` 一行:
```python
LOG_LEVEL = "info" # 只看流程
LOG_LEVEL = "debug" # 连收发数据一起看
```
### 4.2 运行提示
每个 api 函数工作时会提示开始,结束时提示结束:
```
=== ParseQRCode 开始运行 ===
[接口说明] 解析二维码并换取 userId 与 token。
[位置参数] 'SGWCMAID2610022219463A297AF4CA...'
[运行结果] 成功 tuple(2 项)(耗时 412 ms)
=== ParseQRCode 运行结束 ===
```
DEBUG 模式额外输出:
```
-> 请求 GetGameSettingApi
URL : https://maimai-gm.wahlap.com:42081/Maimai2Servlet/mE2s3Jhd/83a5...
请求头 : {"Content-Type":"application/json","Mai-Encoding":"1.55"}
请求体 :
{
"placeId": 1000,
"clientId": "TESTCLIENT1"
}
<- 响应 GetGameSettingApi
状态码 : 200
耗时 : 312 ms
响应体 :
{
"isAouAccession": false,
"gameSetting": { ... }
}
```
### 4.3 日志文件(两种形式同时存在、同时写入)
| 文件 | 模式 | 说明 |
|---|---|---|
| `logs/latest.log` | **覆盖**(`w`) | 只包含**当次**主脚本运行产出的日志,下次运行被覆盖 |
| `logs/YYYY-MM-DD.log` | **追加**(`a`) | 存放**当日**产生的所有日志 |
每行都带 `[YYYY-MM-DD HH:MM:SS]` 时间戳。
### 4.4 主脚本怎么写
```python
from api import setup_logging, teardown_logging, ParseQRCode, UserLogin
def main():
setup_logging("我的主脚本") # 开头一句;api 调用从此自动记录
try:
user_id, token = ParseQRCode(qr)
login = UserLogin(user_id, token)
finally:
teardown_logging() # 结尾一句(不写也会在退出时自动收尾)
```
**不需要**写装饰器,**不需要**自己埋点 —— `api/__init__.py` 在导入时就统一套好了日志外壳。
---
## 五、协议实现要点(全部有源码依据)
| 环节 | 依据 | 要点 |
|---|---|---|
| **URL 生成** | `Packet.cs:485` + `NetQuery.cs:14` | BASE_URL + md5(接口名 + "MaimaiChn" + "8bF76dE9") |
| **请求封装** | `NetHttpClient.cs:262-300` | JSON → 原始 deflate → 0x78 0x9C + Adler32(大端) → AES-256-CBC |
| **响应解封装** | `NetHttpClient.cs:355-380` | AES 解密 → 去 2 字节头 / 4 字节尾 → deflate → JSON |
| **AES 参数** | `CipherAES.cs:11-12` | Key 32B / IV 16B / CBC / PKCS7 |
| **请求头** | `NetHttpClient.cs:174-190` | Mai-Encoding: 1.55、Content-Encoding: deflate、UA = 混淆名#userId |
| **number 头** | `Packet.cs:187-189` + `PingChecker.cs:227` | = Ping 心跳的往返毫秒(`GameManager.PingMsec`),**只由 Ping 刷新**;除 Ping 外每个接口都带 |
| **Cookie** | `Packet.cs:190-193,228-234` | ★ **UserLoginApi 响应会下发 `Set-Cookie: JSESSIONID=...`**,之后每个带 userId 的请求都会带上;`requests.Session` 自动处理,但**必须复用同一个 client**(`get_default_client()`) |
| **playSpecial** | `VOExtensions.cs:846-855` + `GameManager.cs:332-378` | `位反转32(Random(1,1037933)*2069 + CalcSpecialNum)`;标准 ACA 机台 `CalcSpecialNum = 1024 = 0x400` |
| **扫码签名** | 技术规范 §4 | SHA256(chipID + timestamp + KEY) 大写;**不带 Content-Type** |
| **loginDateTime** | `PacketUserLogin.cs:29` | 「本地墙上时间当 UTC」= 标准 Unix 秒 + 时区偏移 |
### ★ 一个必须注意的坑:Accept-Encoding 必须为空
`requests` 默认发 `Accept-Encoding: gzip, deflate`。本协议响应体是 AES 密文(二进制),
服务端若据此做内容编码会返回**空 body**。C# 端不需要是因为 `HttpWebRequest`
默认 `AutomaticDecompression = None`。所以 `maimai_net.py` 固定置空该头。
---
## 六、API 清单
### 6.1 会话链路(6 个)
| 脚本 | 函数 | 对应接口 |
|---|---|---|
| `chime.py` | `ParseQRCode(qr, *, chip_id)` | aimedb /wc_aime/api/get_data |
| `gamesettings.py` | `GetGameSettings(*, place_id, client_id)` | GetGameSettingApi |
| `ping.py` | `Ping(*, client_id)` | Ping |
| `userpreview.py` | `UserPreview(user_id, token, *, ...)` | GetUserPreviewApi |
| `userlogin.py` | `UserLogin(user_id, token, *, region_id, place_id)` | UserLoginApi |
| `userlogout.py` | `UserLogout(user_id, token, login_dt, *, logout_type)` | UserLogoutApi |
### 6.2 上传成绩(2 个)
| 脚本 | 函数 | 说明 |
|---|---|---|
| `upsert_user_all.py` | `UpsertUserAll(user_id, playlog_id, *, login_date_time, auth_date_time, token, snapshot, extra_data, play, ...)` | ★ **一个函数跑完整条结算链路**:抓快照 → 组包 → 前置接口 → 自检 → 60 秒冷却 → 发包 → 落库回查。唯一有副作用的接口 |
| `upsert_user_all.py` | `derive_play_characters(snapshot)` | 从服务器快照推「本局 5 只角色 + 游玩前等级」,免得手填的 `config.PLAY_CHARACTERS` 过期 |
| `upsert_user_all.py` | `load_extra_data(extra)` / `merge_extra_data(user_all, extra)` | 把外部 JSON(dict / JSON 串 / 文件路径)自动匹配插入 36 节点 |
| `upsert_user_all.py` | `verify_upsert_landing(user_id, snapshot, play)` | 发包后重新拉服务器数据,确认真的落库 |
| `upsert_snapshot.py` | `fetch_upsert_snapshot(user_id, ...)` | 一键抓结算基线快照(`UpsertUserAll` 内部会按需自动调) |
| `upsert_user_chargelog.py` | `UpsertUserChargelog(user_id, charge_id, login_date_time, ...)` | ★ **购买功能票**(UpsertUserChargelogApi):自动取当前票券/档案、算 validDate 与 price、闸门、发包 |
| `upsert_user_chargelog.py` | `calc_valid_date(now)` / `ticket_price(id)` / `ticket_name(id)` | 有效期算法(-4h+90d)、票的 creditNum、票名 |
### 6.3 取数(一个接口一个脚本)
| 脚本 | 函数 | 对应接口 |
|---|---|---|
| `userdata.py` | UserData | GetUserDataApi |
| `user_extend.py` | UserExtend | GetUserExtendApi |
| `user_option.py` | UserOption | GetUserOptionApi |
| `user_character.py` | UserCharacter | GetUserCharacterApi |
| `user_ghost.py` | UserGhost | GetUserGhostApi |
| `user_map.py` | UserMap / UserMapAll | GetUserMapApi |
| `user_login_bonus.py` | UserLoginBonus / UserLoginBonusAll | GetUserLoginBonusApi |
| `user_rating.py` | UserRating | GetUserRatingApi |
| `user_item.py` | UserItem / UserItemAll | GetUserItemApi |
| `user_music.py` | UserMusicDetail / UserMusicDetailAll | GetUserMusicApi |
| `user_course.py` | UserCourse / UserCourseAll | GetUserCourseApi |
| `user_charge.py` | UserCharge | GetUserChargeApi |
| `user_favorite.py` | UserFavorite / UserFavoriteAll | GetUserFavoriteApi |
| `user_activity.py` | UserActivity | GetUserActivityApi |
| `user_mission.py` | UserMissionData | GetUserMissionDataApi |
| `user_intimate.py` | UserIntimate | GetUserIntimateApi |
| `user_shop_stock.py` | UserShopStock | GetUserShopStockApi |
| `user_favorite_item.py` | UserFavoriteItem / UserFavoriteItemAll | GetUserFavoriteItemApi |
| `user_rival.py` | UserRivalData / UserRivalMusic(All) | GetUserRivalDataApi / GetUserRivalMusicApi |
| `user_friend_bonus.py` | UserFriendBonus | GetUserFriendBonusApi |
| `user_kaleidx_scope.py` | UserKaleidxScope / GameKaleidxScope | GetUserKaleidxScopeApi / GetGameKaleidxScopeApi |
| `user_region.py` | UserRegion | GetUserRegionApi |
| `user_recommend_music.py` | UserRecommendRateMusic / UserRecommendSelectMusic | GetUserRecommendRateMusicApi / GetUserRecommendSelectMusicApi |
| `user_new_item.py` | UserNewItemList | GetUserNewItemListApi |
| `game_ranking.py` | GetGameRanking | GetGameRankingApi |
| `game_event.py` | GetGameEvent | GetGameEventApi |
| `game_charge.py` | GetGameCharge | GetGameChargeApi |
| `game_weekly_data.py` | GetGameWeeklyData | GetGameWeeklyDataApi |
| `game_tournament_info.py` | GetGameTournamentInfo | GetGameTournamentInfoApi |
| `game_ng_music_id.py` | GetGameNgMusicId | GetGameNgMusicIdApi |
---
## 七、新增一个 api 要做什么
1. 在 `api/` 下新建脚本,定义一个能传参的函数
2. 函数首行写 docstring(会变成日志的 `[接口说明]`)
3. 用 `from .log_setup import log_info, log_request, log_response` 记日志
4. 用 `from .maimai_net import request_dict` 发包
5. 在 `api/__init__.py` 里**导入**并**加进 `_API_FUNCS`** ← 漏了这步就不会有运行提示
---
## 八、相关文档
| 文件 | 内容 |
|---|---|
| `docs/上传成绩开发文档.md` | UpsertUserAll 的 36 节点 / 110 字段 / 差分语义 / 真机对照 |
| `docs/主脚本编写指南.md` | ★ **写自己的主脚本**:三条铁律 / 最小骨架 / 四个流程模板 / 常见错误 |
| `docs/购买功能票流程.md` | ★ 登录 → 买票 → 登出:接口结构 / 源码流程 / validDate 算法 / 待抓包确认项 |
| `docs/UpsertUserAll踩坑清单.md` | ★ **28 条坑的逐条记录**:现象 / 根因 / 源码依据 / 正确做法 / 自检项 + 发包前 Checklist |
| `docs/实跑验证记录.md` | ★ **真实跑通记录**:returnCode=1 + 回读确认 playCount 1260→1261,含完整时间线与落库证据 |
| `docs/真机Hook对照报告.md` | ★ MelonLoader Mod 真机抓包(70 请求)与本实现的逐项对照:number / Cookie / CalcSpecialNum / 剩余差异说明 |
| `docs/UpsertUserAll实跑对比.md` | 逐字段对比明细(110 字段 × 真机 vs 本工程) |
| `docs/UpsertUserAll落库排查报告.md` | 「returnCode=1 但没落库」的根因分析 |
| `docs/日志与主脚本指南.md` | 日志模块完整参考 + 主脚本写法 |
| `docs/舞萌DX_扫码登录协议技术规范.md` | 扫码登录协议(上一代整理) |
| `source/2026-10-05.log` | 真机网络日志(本工程最重要的判据) |
---
*新增 api 后请同步更新第六节清单。*
-63
View File
@@ -1,63 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
MaimaiNetSpy —— MelonLoader Mod:hook 舞萌DX 游戏网络层做实时拦截
==========================================================================
目标框架必须与游戏/MelonLoader 一致:net472(MonoBleedingEdge + MelonLoader 0.6.4)
构建:dotnet build -c Release
产物:bin/Release/net472/MaimaiNetSpy.dll -> 复制到 D:\SDGB2026\Package\Mods\
-->
<PropertyGroup>
<TargetFramework>net472</TargetFramework>
<AssemblyName>MaimaiNetSpy</AssemblyName>
<RootNamespace>MaimaiNetSpy</RootNamespace>
<LangVersion>latest</LangVersion>
<Nullable>disable</Nullable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<GenerateAssemblyInfo>true</GenerateAssemblyInfo>
<Version>1.0.0</Version>
<!-- 不生成 apphost / 不需要运行时配置 -->
<UseAppHost>false</UseAppHost>
<GenerateRuntimeConfigurationFiles>false</GenerateRuntimeConfigurationFiles>
<!-- 允许引用 net472 参考程序集 -->
<AutomaticallyUseReferenceAssemblyPackages>true</AutomaticallyUseReferenceAssemblyPackages>
</PropertyGroup>
<!-- 游戏程序集与 MelonLoader 的引用(全部 Private=false,不复制到输出) -->
<ItemGroup>
<Reference Include="MelonLoader">
<HintPath>D:\SDGB2026\Package\MelonLoader\net35\MelonLoader.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="0Harmony">
<HintPath>D:\SDGB2026\Package\MelonLoader\net35\0Harmony.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="Assembly-CSharp-firstpass">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\Assembly-CSharp-firstpass.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="UnityEngine.CoreModule">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="AMDaemon.NET">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\AMDaemon.NET.dll</HintPath>
<Private>false</Private>
</Reference>
<Reference Include="ChimeLib.NET">
<HintPath>D:\SDGB2026\Package\Sinmai_Data\Managed\ChimeLib.NET.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup>
</Project>
-448
View File
@@ -1,448 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using HarmonyLib;
using MelonLoader;
// ★ MelonLoader 靠这个特性识别 mod 并调用 OnInitializeMelon。
// 缺了它只会 "Melon Assembly loaded" 但不会实例化(表现为 "N Mods loaded" 不计入)。
[assembly: MelonInfo(typeof(MaimaiNetSpy.Main), "MaimaiNetSpy", "1.0.0", "wahappy")]
[assembly: MelonGame(null, null)]
[assembly: MelonColor(255, 120, 200, 255)]
namespace MaimaiNetSpy
{
/// <summary>
/// MaimaiNetSpy —— hook 舞萌DX 游戏网络层,做实时拦截与落盘。
///
/// 拦截点(全部依据 Assembly-CSharp 的实际签名):
/// Net.NetHttpClient.AddHeader(name, value) 所有请求头
/// Net.NetHttpClient.AddCookie(userId) Cookie
/// Net.NetHttpClient.Request(bytes, ua, method) ★ 加密前的明文 JSON
/// Net.NetHttpClient.GetResponse() 解密后的响应
/// Net.NetPacketUtil.GetUserAgent(query) 逻辑 API 名
/// Manager.GameManager.CalcSpecialNum() playSpecial 的硬件标志位
///
/// 输出:
/// &lt;游戏根目录&gt;/MaimaiNetSpy/YYYY-MM-DD_HHmmss.log 人类可读时间线
/// &lt;游戏根目录&gt;/MaimaiNetSpy/raw/*.json 每个接口的请求/响应原文
/// </summary>
public class Main : MelonMod
{
internal static MelonLogger.Instance Log;
//: 当前请求的上下文(按线程隔离,游戏是异步回调模型)
private static readonly object _lock = new object();
private static string _outDir;
private static StreamWriter _writer;
//: 本次请求累积的头部(AddHeader 逐条调用,Request 时才真正发出)
[ThreadStatic] private static List<KeyValuePair<string, string>> _headers;
[ThreadStatic] private static string _cookie;
[ThreadStatic] private static string _currentApi;
[ThreadStatic] private static string _currentUrl;
//: 统计
private static int _reqCount;
private static int _respCount;
public override void OnInitializeMelon()
{
Log = LoggerInstance;
string root = MelonLoader.Utils.MelonEnvironment.GameRootDirectory;
_outDir = Path.Combine(root, "MaimaiNetSpy");
Directory.CreateDirectory(_outDir);
Directory.CreateDirectory(Path.Combine(_outDir, "raw"));
string file = Path.Combine(_outDir,
DateTime.Now.ToString("yyyy-MM-dd_HHmmss") + ".log");
_writer = new StreamWriter(file, false, new UTF8Encoding(false)) { AutoFlush = true };
Line("=".PadRight(78, '='));
Line(" MaimaiNetSpy 已加载 —— 舞萌DX 网络层实时拦截");
Line(" 日志: " + file);
Line("=".PadRight(78, '='));
try
{
var h = new HarmonyLib.Harmony("MaimaiNetSpy");
int n = 0;
// ★ Request 有两个重载:Request() 与 Request(byte[], string, string)
// 必须按参数类型精确匹配,否则会命中无参版本导致
// "Parameter bytes not found in method Request()"
n += PatchBySig(h, "Net.NetHttpClient", "Create",
new[] { typeof(string) },
nameof(PreCreate), null);
n += PatchBySig(h, "Net.NetHttpClient", "AddHeader",
new[] { typeof(string), typeof(string) },
nameof(PreAddHeader), null);
n += PatchBySig(h, "Net.NetHttpClient", "AddCookie",
new[] { typeof(ulong) },
nameof(PreAddCookie), null);
n += PatchBySig(h, "Net.NetHttpClient", "Request",
new[] { typeof(byte[]), typeof(string), typeof(string) },
nameof(PreRequest), nameof(PostRequestDumpHeaders));
n += PatchBySig(h, "Net.NetHttpClient", "GetResponse",
Type.EmptyTypes,
null, nameof(PostGetResponse));
n += PatchBySig(h, "Net.NetPacketUtil", "GetUserAgent",
new[] { AccessTools.TypeByName("Net.INetQuery") },
null, nameof(PostGetUserAgent));
n += PatchBySig(h, "Manager.GameManager", "CalcSpecialNum",
Type.EmptyTypes,
null, nameof(PostCalcSpecialNum));
n += PatchBySig(h, "Net.NetHttpClient", "SetError",
new[] { typeof(System.Net.WebExceptionStatus), typeof(string),
typeof(int), typeof(System.Net.HttpWebResponse) },
nameof(PreSetError), null);
Line(" 已挂载 " + n + " 个 hook");
}
catch (Exception e)
{
Line(" !! hook 挂载失败: " + e);
}
Line("");
}
public override void OnDeinitializeMelon()
{
try { _writer?.Flush(); _writer?.Dispose(); } catch { }
}
// ------------------------------------------------------------------
// hook 挂载工具
// ------------------------------------------------------------------
/// <summary>
/// 按**参数类型**精确挂载 hook。
///
/// ★ 为什么必须精确匹配:NetHttpClient.Request 有两个重载
/// Boolean Request() —— 只发请求(无体)
/// Boolean Request(Byte[] bytes, String ua, String m) —— 带请求体
/// 用 AccessTools.Method(t, "Request") 会命中前者,
/// 导致 prefix 里的 bytes 参数对不上而抛
/// "Parameter bytes not found in method Request()"。
/// </summary>
private static int PatchBySig(HarmonyLib.Harmony h, string typeName, string methodName,
Type[] paramTypes, string pre, string post)
{
var t = AccessTools.TypeByName(typeName);
if (t == null) { Line(" [skip] 类型未找到: " + typeName); return 0; }
if (paramTypes == null || paramTypes.Any(x => x == null))
{ Line(" [skip] 参数类型解析失败: " + typeName + "." + methodName); return 0; }
var m = AccessTools.Method(t, methodName, paramTypes);
if (m == null)
{
Line(" [skip] 方法未找到: " + typeName + "." + methodName
+ "(" + string.Join(", ", Array.ConvertAll(paramTypes, x => x.Name)) + ")");
return 0;
}
HarmonyMethod preM = pre == null ? null
: new HarmonyMethod(typeof(Main).GetMethod(pre, BindingFlags.Static | BindingFlags.NonPublic));
HarmonyMethod postM = post == null ? null
: new HarmonyMethod(typeof(Main).GetMethod(post, BindingFlags.Static | BindingFlags.NonPublic));
try
{
h.Patch(m, preM, postM);
Line(" [ok] " + typeName + "." + methodName
+ "(" + string.Join(", ", Array.ConvertAll(paramTypes, x => x.Name)) + ")");
return 1;
}
catch (Exception e)
{
Line(" [fail] " + typeName + "." + methodName + " -> " + e.Message);
return 0;
}
}
// ------------------------------------------------------------------
// Create —— 捕获完整 URL(NetHttpClient.Create(url))
// ------------------------------------------------------------------
private static void PreCreate(string url)
{
_currentUrl = url;
}
// ------------------------------------------------------------------
// AddHeader —— 收集请求头
// ------------------------------------------------------------------
private static void PreAddHeader(string name, string value)
{
if (_headers == null) _headers = new List<KeyValuePair<string, string>>();
_headers.Add(new KeyValuePair<string, string>(name, value));
}
private static void PostAddHeader()
{
// 不在这里输出:AddHeader 会被多次调用,等 Request 时统一打印
}
// ------------------------------------------------------------------
// AddCookie —— 记录 Cookie
// ------------------------------------------------------------------
private static void PreAddCookie(ulong userId)
{
_cookie = "userId=" + userId;
}
// ------------------------------------------------------------------
// Request —— ★ 核心:加密前的明文 JSON 在这里
// ------------------------------------------------------------------
private static void PreRequest(byte[] bytes, string userAgent, string method)
{
_reqCount++;
string plain = null;
try
{
if (bytes != null && bytes.Length > 0)
plain = Encoding.UTF8.GetString(bytes);
}
catch { }
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("+" + new string('-', 76));
sb.AppendLine("| [" + _reqCount + "] >>> 请求 "
+ DateTime.Now.ToString("HH:mm:ss.fff"));
sb.AppendLine("+" + new string('-', 76));
sb.AppendLine("| API : " + (_currentApi ?? "(unknown)"));
if (!string.IsNullOrEmpty(_currentUrl))
sb.AppendLine("| URL : " + _currentUrl);
sb.AppendLine("| User-Agent : " + userAgent);
sb.AppendLine("| Method : " + method);
sb.AppendLine("| Cookie : " + (_cookie ?? "(none)"));
sb.AppendLine("| 明文长度 : " + (bytes == null ? 0 : bytes.Length) + " 字节");
if (_headers != null && _headers.Count > 0)
{
sb.AppendLine("| ---- 请求头(AddHeader 收集)----");
foreach (var kv in _headers)
sb.AppendLine("| " + kv.Key + ": " + kv.Value);
}
else
{
sb.AppendLine("| ---- 请求头: (无 AddHeader 调用) ----");
}
if (plain != null)
{
sb.AppendLine("| ---- 请求体(加密前明文)----");
foreach (var ln in Wrap(plain, 150))
sb.AppendLine("| " + ln);
}
Line(sb.ToString());
// 落盘原始明文
try
{
string safe = Sanitize(_currentApi ?? ("req" + _reqCount));
File.WriteAllText(
Path.Combine(_outDir, "raw", _reqCount.ToString("D3") + "_" + safe + "_request.json"),
plain ?? "", new UTF8Encoding(false));
}
catch { }
// 清空累积状态(本次请求已消费)
_headers = null;
}
private static void PostRequest()
{
// 状态清理交给下一次 PreRequest
}
// ------------------------------------------------------------------
// ★ 真实 HTTP 请求头 dump
// ------------------------------------------------------------------
//
// 为什么必须用 postfix:
// PreRequest 跑在 Request() 方法体**之前**,那时
// Method / ContentType / UserAgent / Content-Encoding 都还没设置。
// 只有 postfix 才能看到最终真正要发出去的头(含框架自带的)。
//
// 目的:把真机的**完整请求头**抓下来,和纯 Python 脚本发的做逐字节对比。
// (排查「报文一致但服务端 returnCode=0」这类问题)
private static void PostRequestDumpHeaders(Net.NetHttpClient __instance)
{
try
{
var fld = AccessTools.Field(typeof(Net.NetHttpClient), "_request");
var wr = (fld == null ? null : fld.GetValue(__instance)) as System.Net.HttpWebRequest;
if (wr == null) { Line("| [头 dump] _request 取不到"); return; }
var sb = new StringBuilder();
sb.AppendLine("| ---- ★ 真实 HTTP 请求头(HttpWebRequest,含框架自带)----");
sb.AppendLine("| [Method] : " + wr.Method);
sb.AppendLine("| [ContentType] : " + wr.ContentType);
sb.AppendLine("| [UserAgent] : " + wr.UserAgent);
sb.AppendLine("| [ContentLength] : " + wr.ContentLength);
sb.AppendLine("| [KeepAlive] : " + wr.KeepAlive);
sb.AppendLine("| [Expect] : " + wr.Expect);
sb.AppendLine("| [AutomaticDecompression] : " + wr.AutomaticDecompression);
sb.AppendLine("| [AllowAutoRedirect] : " + wr.AllowAutoRedirect);
try
{
foreach (string k in wr.Headers.AllKeys)
sb.AppendLine("| " + k + ": " + wr.Headers[k]);
}
catch (Exception e2) { sb.AppendLine("| [Headers 枚举失败] " + e2.Message); }
try
{
sb.AppendLine("| [Cookie 头] : "
+ (wr.CookieContainer == null ? "(null container)"
: "[" + wr.CookieContainer.GetCookieHeader(wr.RequestUri) + "]"));
}
catch (Exception e3) { sb.AppendLine("| [Cookie 取失败] " + e3.Message); }
Line(sb.ToString());
}
catch (Exception e)
{
Line("| [头 dump 失败] " + e);
}
}
// ------------------------------------------------------------------
// GetResponse —— 解密后的响应
// ------------------------------------------------------------------
private static void PostGetResponse(Net.NetHttpClient __instance, ref MemoryStream __result)
{
_respCount++;
// ★ 响应头 dump(尤其看 UserLoginApi 的 Set-Cookie: JSESSIONID)
try
{
var fldR = AccessTools.Field(typeof(Net.NetHttpClient), "_response");
var resp = (fldR == null ? null : fldR.GetValue(__instance)) as System.Net.HttpWebResponse;
if (resp != null)
{
var sh = new StringBuilder();
sh.AppendLine("| ---- ★ 真实 HTTP 响应头 ----");
sh.AppendLine("| [StatusCode] : " + (int)resp.StatusCode);
try
{
foreach (string k in resp.Headers.AllKeys)
sh.AppendLine("| " + k + ": " + resp.Headers[k]);
}
catch (Exception e4) { sh.AppendLine("| [响应头枚举失败] " + e4.Message); }
Line(sh.ToString());
}
}
catch (Exception e5) { Line("| [响应头 dump 失败] " + e5.Message); }
try
{
if (__result == null) return;
long pos = __result.Position;
__result.Position = 0;
byte[] buf = new byte[__result.Length];
int read = __result.Read(buf, 0, buf.Length);
__result.Position = pos;
string txt = Encoding.UTF8.GetString(buf, 0, read);
var sb = new StringBuilder();
sb.AppendLine("| [" + _respCount + "] <<< 响应 "
+ DateTime.Now.ToString("HH:mm:ss.fff")
+ " (" + read + " 字节)");
foreach (var ln in Wrap(txt, 150))
sb.AppendLine("| " + ln);
Line(sb.ToString());
string safe = Sanitize(_currentApi ?? ("resp" + _respCount));
File.WriteAllText(
Path.Combine(_outDir, "raw", _respCount.ToString("D3") + "_" + safe + "_response.json"),
txt, new UTF8Encoding(false));
}
catch (Exception e)
{
Line("| [响应解析失败] " + e.Message);
}
}
// ------------------------------------------------------------------
// GetUserAgent —— 从 UA 反推 API 名;同时记录 URL
// ------------------------------------------------------------------
private static void PostGetUserAgent(Net.INetQuery query, ref string __result)
{
try
{
_currentApi = query?.Api;
// UA 形如 "<md5>#<userId>"
Line(" [API] " + _currentApi + " UA=" + __result);
}
catch { }
}
// ------------------------------------------------------------------
// CalcSpecialNum —— playSpecial 的硬件标志位
// ------------------------------------------------------------------
private static void PostCalcSpecialNum(ref int __result)
{
Line(" [CalcSpecialNum] = " + __result
+ " (SpecialAP=" + SafeBool(() => Manager.GameManager.SpecialAP)
+ " IsFreedomMode=" + SafeBool(() => Manager.GameManager.IsFreedomMode)
+ ")");
}
private static string SafeBool(Func<bool> f)
{
try { return f().ToString(); } catch { return "?"; }
}
// ------------------------------------------------------------------
// SetError —— 记录网络层错误(含 HTTP 状态码)
// ------------------------------------------------------------------
private static void PreSetError(System.Net.WebExceptionStatus status, string error,
int state, System.Net.HttpWebResponse response)
{
Line(" [NetError] status=" + status + " state=" + state
+ " error=" + error
+ (response != null ? (" http=" + (int)response.StatusCode) : ""));
}
// ------------------------------------------------------------------
// 工具
// ------------------------------------------------------------------
private static string Sanitize(string s)
{
var sb = new StringBuilder();
foreach (char c in s)
sb.Append(char.IsLetterOrDigit(c) || c == '_' || c == '-' ? c : '_');
return sb.ToString();
}
private static IEnumerable<string> Wrap(string s, int width)
{
if (s == null) yield break;
for (int i = 0; i < s.Length; i += width)
yield return s.Substring(i, Math.Min(width, s.Length - i));
}
internal static void Line(string s)
{
lock (_lock)
{
try { _writer?.WriteLine(s); } catch { }
}
}
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 514 KiB

-703
View File
@@ -1,703 +0,0 @@
#!/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
@@ -1,512 +0,0 @@
#!/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())
-1365
View File
File diff suppressed because it is too large Load Diff
-30
View File
@@ -1,30 +0,0 @@
# 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
@@ -1,201 +0,0 @@
# 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 个游戏谱面实测一致)。
@@ -1,651 +0,0 @@
#!/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
@@ -1,367 +0,0 @@
# -*- 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
@@ -1,949 +0,0 @@
# -*- 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
@@ -1,138 +0,0 @@
#!/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())
-272
View File
@@ -1,272 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
upsert.py —— 主脚本:扫码登录 → 上传成绩 → 登出
================================================================================
整条流程只有四步,而且**一行日志都不用自己写** ——
每个 api 函数被调用时会自动打印:
=== UserLogin 开始运行 ===
[接口说明] 用户正式登录(开启本次登录会话)。
[位置参数] 12345678, 'iUfgRmjr...'
[关键字参数] region_id=1, place_id=1000
=== UserLogin 运行结束 ===
日志模式改 config.py 一行:
LOG_LEVEL = "info" 只展示 api 脚本的运行情况和结果
LOG_LEVEL = "debug" 额外展示向 api 发送的数据与 api 返回的数据
日志文件(两种同时写):
logs/latest.log 只含**当次**主脚本运行(下次覆盖)
logs/YYYY-MM-DD.log 存放**当日**产生的所有日志(追加)
--------------------------------------------------------------------------------
用法
--------------------------------------------------------------------------------
python upsert.py <二维码> # 预检:登录 + 抓快照 + 组包,不发包
python upsert.py <二维码> --send # 真实上传成绩(★ 有副作用,不可撤销)
python upsert.py <二维码> --send --debug # 同上,日志里带完整收发报文
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"}'
python upsert.py <二维码> --send --extra-data a.json --extra-data b.json
--------------------------------------------------------------------------------
★ UpsertUserAll 一个函数就把整条结算链路跑完了
--------------------------------------------------------------------------------
1. 抓基线快照(没传 snapshot 时自动抓,用 token)
2. 从快照推本局 5 只角色(游玩前等级)
3. 构造本局成绩明细 userPlaylogList
4. 调前置接口 GetUserNewItemListApi
5. 组装 upsertUserAll 的 36 个节点(快照基线 + 本局演算 + 差分)
6. 合并 extra_data 里的外部 JSON
7. 发包前自检
8. 冷却 -> 真实发包
9. 落库回查(重新拉服务器数据,确认真的写进去了)
--------------------------------------------------------------------------------
★ --extra-data:额外塞节点
--------------------------------------------------------------------------------
想手工覆盖 / 补上某几个节点(比如本局拿到的新 KaleidxScope 门)时用:
# ① 从文件读(★ 推荐,不用跟 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 长这样:
{
"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"
}
★ 键必须是 upsertUserAll 的 36 个节点之一(含 11 条 isNew* 位串),
写错会明确报错并列出可用节点名。
★ 没给对应位串时会按数组长度自动补 "0" 并提示 ——
新增条目请显式传 "1","0" 表示存量更新。
★ JSON 在**扫码之前**就校验,写错了直接退出,不会浪费二维码。
--------------------------------------------------------------------------------
★ 这个文件同时是一个可复用的模块
--------------------------------------------------------------------------------
`run(opts, ...)` 就是整套流程(扫码 → 预览 → 登录 → UpsertUserAll → 登出),
`main()` 只做参数解析然后调它。所以别的主脚本可以直接:
import upsert
sys.exit(upsert.run({"qr": qr, "send": True, ...}, log_name="unlockmusic"))
unlockmusic.py 就是这么写的 —— 它只多了一个"要解锁哪些歌"的入参。
── 在代码里直接调 api 的等价写法 ──
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
auth_date_time=login.dateTime,
token=token,
extra_data={"userKaleidxScopeList": [...], "isNewKaleidxScopeList": "1"},
confirm=True,
)
"""
from __future__ import annotations
import sys
from api import (
setup_logging,
teardown_logging,
log_info,
log_warning,
log_error,
ParseQRCode,
GetUserPreview,
UserLogin,
UserLogout,
LogoutType,
UpsertUserAll,
ConfirmRequiredError,
load_extra_data,
PatchError,
NODE_ORDER,
NODE_TYPES,
)
from config import PLACE_ID, REGION_ID
def parse_args(argv):
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
"cooldown": None, "extra_data": []}
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):
# 可重复;值是 JSON 字符串或 JSON 文件路径
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 not a.startswith("--") and not opts["qr"]:
opts["qr"] = a
i += 1
return opts
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.get("debug"):
import config as _cfg
_cfg.LOG_LEVEL = "debug"
# 日志会话:开头一句。之后每次 api 调用都会自动记录,主脚本不用再写日志
setup_logging(log_name)
# ---- ★ 先解析 --extra-data(放在扫码之前:JSON 写错就别浪费二维码了)----
extra = None
if opts["extra_data"]:
merged = {}
try:
for item in opts["extra_data"]:
part = load_extra_data(item)
merged.update(part)
log_info("--extra-data 读入 " + str(len(part)) + " 个节点:" + ", ".join(part))
except PatchError as exc:
log_error("--extra-data 解析失败:" + str(exc))
return 6
# 节点名也在这里查(不然要等到登录后组包时才报错,白跑一趟)
unknown = [k for k in merged if k not in NODE_TYPES]
if unknown:
log_error("--extra-data 里有未知节点名:" + ", ".join(unknown))
log_error(" 可用节点(" + str(len(NODE_ORDER)) + " 个):" + ", ".join(NODE_ORDER))
return 6
if not merged:
log_warning("--extra-data 解析出来是空的,忽略")
else:
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:
# ---- 扫码:二维码 -> userId + token ----
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
# ---- 登录:拿到 playlogId 与 loginDateTime(结算上报要用)----
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
session.update(login_dt=login.loginDateTime, logged_in=True)
# ---- 结算:★ 一个函数搞定构造 + 发包 + 落库回查 ----
UpsertUserAll(
user_id, login.loginId,
login_date_time=login.loginDateTime,
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"],
verify_landing=not opts["no_verify"],
place_id=PLACE_ID, region_id=REGION_ID,
)
return 0
except ConfirmRequiredError:
return 5
except Exception as exc:
log_error(type(exc).__name__ + ": " + str(exc))
return 1
finally:
# 兜底登出:登录过且还没登出就释放会话,否则账号卡在 isLogin=True
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()
def main() -> int:
return run(parse_args(sys.argv[1:]), log_name="upsert")
if __name__ == "__main__":
sys.exit(main())
-287
View File
@@ -1,287 +0,0 @@
#!/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())