Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61bf36661d | ||
|
|
f6e84cf297 | ||
|
|
25ac522391 | ||
|
|
b1d7e33c7b | ||
|
|
3a0f3435f7 | ||
|
|
938341828c | ||
|
|
c537e00865 | ||
|
|
bc145fe853 | ||
|
|
0c995d3b78 | ||
|
|
ebccb9cc9a | ||
|
|
cdbdfb63fa | ||
|
|
4959487a71 | ||
|
|
5971ba4e60 | ||
|
|
652cf95faa | ||
|
|
8e3bd6fcaa | ||
|
|
c297818d8a | ||
|
|
494b40db44 | ||
|
|
05c3f222fa | ||
|
|
52cbae6b67 | ||
|
|
fc475d3167 | ||
|
|
16353c0b72 | ||
|
|
1636a79141 |
@@ -0,0 +1,7 @@
|
|||||||
|
# 统一换行符,避免 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
@@ -0,0 +1,40 @@
|
|||||||
|
# ---- 运行产物 ----
|
||||||
|
# 每账号一份用户数据快照(<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
|
||||||
@@ -0,0 +1,499 @@
|
|||||||
|
<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 启发(暂无)
|
||||||
|
- **香水所有群友** —— 和谐帮助
|
||||||
|
|
||||||
|
*(还有什么以后补充)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 免责声明
|
||||||
|
|
||||||
|
本项目仅供**学习与协议研究**使用。使用本工具产生的任何后果由使用者自行承担。
|
||||||
|
请遵守游戏服务条款,不要用于破坏游戏公平性。
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
ChargeGuard.py —— 买票前置闸门(GetUserCharge 必须已经跑过)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
★ 这个模块解决什么问题:
|
||||||
|
|
||||||
|
````
|
||||||
|
错误写法(危险):
|
||||||
|
UpsertUserChargelog(user_id, 2, login.loginDateTime, confirm=True)
|
||||||
|
# ↑ 没先查 GetUserCharge —— 万一账号已经持有一张功能票,
|
||||||
|
# 服务端看到的就是「重复购买」,有封号风险。
|
||||||
|
```
|
||||||
|
|
||||||
|
正确写法:
|
||||||
|
```
|
||||||
|
held = GetUserCharge(user_id) # ① 先查:有没有持票
|
||||||
|
...
|
||||||
|
UpsertUserChargelog(user_id, 2, ...) # ② 再买
|
||||||
|
```
|
||||||
|
|
||||||
|
本模块在 ① 里记一笔「这个 user_id 刚查过票券」,在 ② 里检查这笔记录,
|
||||||
|
没查过就抛 `ChargelogGuardError`(**未发包**)。
|
||||||
|
|
||||||
|
★ 还顺手挡第二件事:**同一个账号在同一个进程里只允许买一次票**。
|
||||||
|
真机买票是「买完立刻被这一局用掉」(stock--),正常状态 stock 恒为 0;
|
||||||
|
脚本连着买两张是明显的异常行为,这里直接拦掉。
|
||||||
|
|
||||||
|
★ 记忆只存在于**当前进程**(内存字典),脚本退出即清空 —— 不做持久化,
|
||||||
|
免得下次运行时被上一次的记录误拦。
|
||||||
|
|
||||||
|
依据:
|
||||||
|
source/Assembly-CSharp/Manager/TicketManager.cs:130-174 UseTicketPrepare
|
||||||
|
source/Assembly-CSharp/Manager/TicketManager.cs:234-253 RequestUserCharge
|
||||||
|
source/Assembly-CSharp/Net.Packet.Mai2/PacketGetUserCharge.cs:20
|
||||||
|
source/Assembly-CSharp/Net.Packet.Mai2/PacketUploadUserChargelog.cs:19
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
#: GetUserCharge 结果的「新鲜度」窗口(秒)。
|
||||||
|
#: 超过这个时间才买票,认为那次查询已经过期,需要重查。
|
||||||
|
CHARGE_READ_FRESH_SEC = 600.0
|
||||||
|
|
||||||
|
#: user_id -> 最近一次 GetUserCharge 的 monotonic 时刻
|
||||||
|
_LAST_READ: Dict[int, float] = {}
|
||||||
|
|
||||||
|
#: user_id -> 最近一次 GetUserCharge 返回的 userChargeList(原样)
|
||||||
|
_LAST_LIST: Dict[int, List[Dict[str, Any]]] = {}
|
||||||
|
|
||||||
|
#: user_id -> 本进程内已经买过的 chargeId 列表
|
||||||
|
_PURCHASES: Dict[int, List[int]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 一、GetUserCharge 侧:记一笔
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def note_user_charge_read(
|
||||||
|
user_id: int,
|
||||||
|
charge_list: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
记录「刚刚为这个 user_id 查过票券」。
|
||||||
|
|
||||||
|
★ 由 GetUserCharge() 在拿到响应后自动调用,调用方不用管。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
uid = int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
_LAST_READ[uid] = time.monotonic()
|
||||||
|
if isinstance(charge_list, list):
|
||||||
|
_LAST_LIST[uid] = [c for c in charge_list if isinstance(c, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def last_read_age(user_id: int) -> Optional[float]:
|
||||||
|
"""距上次 GetUserCharge 过了多少秒;从没查过返回 None。"""
|
||||||
|
try:
|
||||||
|
uid = int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
ts = _LAST_READ.get(uid)
|
||||||
|
if ts is None:
|
||||||
|
return None
|
||||||
|
return max(0.0, time.monotonic() - ts)
|
||||||
|
|
||||||
|
|
||||||
|
def has_fresh_read(
|
||||||
|
user_id: int,
|
||||||
|
max_age: float = CHARGE_READ_FRESH_SEC,
|
||||||
|
) -> bool:
|
||||||
|
"""这个 user_id 最近(max_age 秒内)查过 GetUserCharge 吗?"""
|
||||||
|
age = last_read_age(user_id)
|
||||||
|
if age is None:
|
||||||
|
return False
|
||||||
|
return age <= float(max_age)
|
||||||
|
|
||||||
|
|
||||||
|
def last_charge_list(user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""最近一次 GetUserCharge 返回的 userChargeList(没有则空表)。"""
|
||||||
|
try:
|
||||||
|
return list(_LAST_LIST.get(int(user_id)) or [])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def held_charges(user_id: int) -> List[Dict[str, Any]]:
|
||||||
|
"""最近一次查到的「持有中」(stock > 0)的票券。"""
|
||||||
|
return [c for c in last_charge_list(user_id) if int(c.get("stock", 0) or 0) > 0]
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 二、UpsertUserChargelog 侧:买之前查
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def note_charge_purchase(user_id: int, charge_id: int) -> None:
|
||||||
|
"""记录「这个 user_id 刚刚买了一张 chargeId 的票」。"""
|
||||||
|
try:
|
||||||
|
uid = int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
_PURCHASES.setdefault(uid, []).append(int(charge_id))
|
||||||
|
|
||||||
|
|
||||||
|
def purchased_charge_ids(user_id: int) -> List[int]:
|
||||||
|
"""本进程内这个 user_id 已经买过的 chargeId。"""
|
||||||
|
try:
|
||||||
|
return list(_PURCHASES.get(int(user_id)) or [])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def reset(user_id: Optional[int] = None) -> None:
|
||||||
|
"""清空记忆(测试用;不传 user_id 就全清)。"""
|
||||||
|
if user_id is None:
|
||||||
|
_LAST_READ.clear()
|
||||||
|
_LAST_LIST.clear()
|
||||||
|
_PURCHASES.clear()
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
uid = int(user_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return
|
||||||
|
_LAST_READ.pop(uid, None)
|
||||||
|
_LAST_LIST.pop(uid, None)
|
||||||
|
_PURCHASES.pop(uid, None)
|
||||||
+311
@@ -0,0 +1,311 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
GetUserCharge.py —— 取用户票券(GetUserChargeApi)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
取玩家的票券持有情况(真机 3 条)。
|
||||||
|
|
||||||
|
对应接口:GetUserChargeApi
|
||||||
|
请求体 :UserIdRequestVO
|
||||||
|
响应 :{userId, length, userChargeList:[{chargeId,stock,purchaseDate,validDate,extNum1}]}
|
||||||
|
依据 :source/Assembly-CSharp/Net.VO.Mai2/UserChargeRequestVO.cs
|
||||||
|
|
||||||
|
★ 回传 Upsert 时要丢掉 extNum1(真机 Upsert 报文里没有这个字段)。
|
||||||
|
|
||||||
|
★ 调用本接口会**登记一次「票券已查询」**(见 api/ChargeGuard.py)。
|
||||||
|
UpsertUserChargelog 发包前会检查这笔登记 —— 没查过票就买票会被直接拒绝。
|
||||||
|
|
||||||
|
调用示例:
|
||||||
|
from api import GetUserCharge
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Dict, Optional
|
||||||
|
|
||||||
|
from config import CLIENT_ID, TIMEOUT_MS
|
||||||
|
|
||||||
|
from .ChargeGuard import note_user_charge_read
|
||||||
|
from .LogSetup import log_info
|
||||||
|
from .MaimaiNet import MaimaiNetClient
|
||||||
|
from .UserGetterCommon import _get, UserIdRequestVO
|
||||||
|
|
||||||
|
|
||||||
|
def GetUserCharge(
|
||||||
|
user_id: int,
|
||||||
|
*,
|
||||||
|
client_id: str = CLIENT_ID,
|
||||||
|
timeout_ms: Optional[int] = None,
|
||||||
|
client: Optional[MaimaiNetClient] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
取用户票券(对应 GetUserChargeApi)。
|
||||||
|
|
||||||
|
返回:
|
||||||
|
原始响应 dict,含 userChargeList
|
||||||
|
"""
|
||||||
|
log_info("取用户票券:user_id=" + str(user_id))
|
||||||
|
raw = _get("GetUserChargeApi", UserIdRequestVO(userId=user_id), user_id,
|
||||||
|
client_id=client_id, timeout_ms=timeout_ms, client=client)
|
||||||
|
|
||||||
|
# ★ 记一笔「这个账号刚查过票券」——
|
||||||
|
# UpsertUserChargelog 发包前会检查这笔记录,没查过就拒绝购买。
|
||||||
|
# 见 api/ChargeGuard.py 的说明。
|
||||||
|
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
||||||
|
note_user_charge_read(user_id, lst)
|
||||||
|
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
||||||
|
if held:
|
||||||
|
log_info("★ 该账号当前持有 " + str(len(held)) + " 张功能票:"
|
||||||
|
+ "、".join("chargeId=" + str(c.get("chargeId"))
|
||||||
|
+ " stock=" + str(c.get("stock")) for c in held))
|
||||||
|
else:
|
||||||
|
log_info("该账号当前没有持有功能票")
|
||||||
|
return raw
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# -*- 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,
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# -*- 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}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# -*- 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
@@ -0,0 +1,704 @@
|
|||||||
|
# -*- 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)
|
||||||
@@ -0,0 +1,820 @@
|
|||||||
|
# -*- 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
@@ -0,0 +1,95 @@
|
|||||||
|
# -*- 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())
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,620 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
# -*- 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
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
# -*- 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())
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
# -*- 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
@@ -0,0 +1,563 @@
|
|||||||
|
# -*- 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",
|
||||||
|
]
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
authv1.py —— ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId / regionId / placeId / placeName / regionName
|
||||||
|
==========================================================================================================
|
||||||
|
|
||||||
|
【解决什么问题】
|
||||||
|
config.py 第二节那六个值
|
||||||
|
KEYCHIP_ID / CLIENT_ID / REGION_ID / PLACE_ID / PLACE_NAME / REGION_NAME
|
||||||
|
全部来自 **AMDaemon 开机时向 ALL.Net 认证服务器要的结果**。
|
||||||
|
游戏服务器(maimai-gm.wahlap.com)**没有任何接口**会返回它们 ——
|
||||||
|
所以本工程此前只能手工从真机报文里抄(见 config.py 里那段吐槽)。
|
||||||
|
|
||||||
|
本脚本把 AMDaemon 的这一步原样重做了一遍。
|
||||||
|
|
||||||
|
【协议出处】—— 全部能在 source/amdaemon.exe 的字符串里对上(数字是文件内偏移)
|
||||||
|
端点 http://at.sys-all.cn/net/initialize @0x788170
|
||||||
|
备用 http://at.sys-allnet.cn/net/initialize 实测同样可用
|
||||||
|
库 alAbEx_lite Ver 4.00.01 (src/lib/alabex_pv4_lite) @0x784a31
|
||||||
|
请求体 "title_id=%s&title_ver=%s&client_id=%s" @0x7882b8
|
||||||
|
请求头 Pragma: DFI @0x7883cc
|
||||||
|
User-Agent: <...>Windows/Lite @0x788508
|
||||||
|
响应字段 result= uri1= uri2= name= nickname= region0=
|
||||||
|
region_name0..3= place_id= setting= country=
|
||||||
|
location_type= client_timezone= res_ver= uri=
|
||||||
|
utc_time= token= @0x784ae8-0x788fd0
|
||||||
|
信封 AES-128-CBC + 16 字节前缀块 + PKCS7
|
||||||
|
(exe @0x63a100 提到 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY))
|
||||||
|
|
||||||
|
【信封细节】实测确认
|
||||||
|
请求 = AES-CBC( 16 字节全 0 前缀 || 明文 ),IV = 16 字节全 0
|
||||||
|
响应 = 16 字节 IV || AES-CBC 密文
|
||||||
|
|
||||||
|
【响应码】
|
||||||
|
result= 1 成功
|
||||||
|
result=-2 机台不存在(client_id 未被收录;小写也会走到这里)
|
||||||
|
result=-3 参数格式错(例如把 15 位完整 Keychip 当 client_id 发出去)
|
||||||
|
|
||||||
|
【用法】
|
||||||
|
python authv1.py A63E01E9580 # 查(打印结果)
|
||||||
|
python authv1.py A63E-01C30471007 # 带连字符的完整 Keychip 也行
|
||||||
|
python authv1.py A63E01E9580 --json # 机器可读
|
||||||
|
python authv1.py A63E01E9580 --write # 强制写回 config.py
|
||||||
|
python authv1.py A63E01E9580 --no-write # 强制不写
|
||||||
|
python authv1.py --batch keychip.csv # 批量查(CSV 第一列是 Keychip)
|
||||||
|
|
||||||
|
★ 是否写回 config.py,默认看 config.py 里的 AUTHV1_WRITE_CONFIG 开关。
|
||||||
|
|
||||||
|
【也当协议库用】
|
||||||
|
from authv1 import allnet_post, initialize, delivery_instruction, parse_keychip
|
||||||
|
netdelivery.py 就是这么用它的。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import ast
|
||||||
|
import csv
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from Crypto.Cipher import AES
|
||||||
|
from Crypto.Util.Padding import pad
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 协议常量
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# ALL.Net 认证服务器(国服 SDGB)。两个域名指向不同机房,实测都能用;
|
||||||
|
# 默认用 sys-allnet.cn(与 maimai-gm.wahlap.com 同机房,链路更短)。
|
||||||
|
ALLNET_HOSTS = (
|
||||||
|
"http://at.sys-allnet.cn",
|
||||||
|
"http://at.sys-all.cn",
|
||||||
|
)
|
||||||
|
DEFAULT_HOST = ALLNET_HOSTS[0]
|
||||||
|
|
||||||
|
PATH_INITIALIZE = "/net/initialize"
|
||||||
|
PATH_DELIVERY_INSTRUCTION = "/net/delivery/instruction"
|
||||||
|
|
||||||
|
TITLE_ID = "SDGB"
|
||||||
|
|
||||||
|
# ALE_ABEX_OPTION_AES_KEY 对应的 16 字节密钥。
|
||||||
|
# 出处:exe @0x63a100 有 alAbExSetOption(ALE_ABEX_OPTION_AES_KEY);
|
||||||
|
# 密钥本体在 exe 里不是明文串,此处取值经真实请求验证有效。
|
||||||
|
AES_KEY = bytes([47, 63, 106, 111, 43, 34, 76, 38, 92, 67, 114, 57, 40, 61, 107, 71])
|
||||||
|
AES_IV = bytes(16)
|
||||||
|
|
||||||
|
# 请求头。依据:exe 里 "Pragma" + "DFI"(@0x7883cc) 与 "Windows/Lite"(@0x788508)。
|
||||||
|
HEADERS = {
|
||||||
|
"User-Agent": "SDGB;Windows/Lite",
|
||||||
|
"Pragma": "DFI",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_TIMEOUT = 20
|
||||||
|
|
||||||
|
# client_id 恒为 11 位大写十六进制(Keychip 15 位去掉连字符后的前 11 位)
|
||||||
|
CLIENT_ID_LEN = 11
|
||||||
|
CLIENT_ID_RE = re.compile(r"^[0-9A-F]{" + str(CLIENT_ID_LEN) + r"}$")
|
||||||
|
|
||||||
|
# 上报给 ALL.Net 的版本号。真机 1.56 ROM 实测仍报 1.55(与 Mai-Encoding 一致);
|
||||||
|
# 默认取 config.MAI_ENCODING,取不到就退回这个字面量。
|
||||||
|
DEFAULT_TITLE_VER = "1.55"
|
||||||
|
|
||||||
|
RESULT_TEXT = {
|
||||||
|
"1": "成功",
|
||||||
|
"-1": "通用错误",
|
||||||
|
"-2": "机台不存在(client_id 未被 ALL.Net 收录)",
|
||||||
|
"-3": "参数格式错误(client_id 必须是 11 位短值)",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AllnetError(RuntimeError):
|
||||||
|
"""ALL.Net 交互失败(网络 / 信封 / 业务码非 1)。"""
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 信封:AES-128-CBC
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def encode_request(text: str) -> bytes:
|
||||||
|
"""明文 -> 线上字节流:16 字节全 0 前缀 + PKCS7 + AES-128-CBC(IV=0)。"""
|
||||||
|
body = AES_IV + text.encode("utf-8")
|
||||||
|
return AES.new(AES_KEY, AES.MODE_CBC, AES_IV).encrypt(pad(body, AES.block_size))
|
||||||
|
|
||||||
|
|
||||||
|
def decode_response(raw: bytes) -> str:
|
||||||
|
"""线上字节流 -> 明文:前 16 字节是 IV,其后是 AES-128-CBC 密文。"""
|
||||||
|
if len(raw) < 32 or len(raw) % AES.block_size:
|
||||||
|
raise AllnetError("响应长度异常:%d 字节(应 >=32 且为 16 的倍数)" % len(raw))
|
||||||
|
plain = AES.new(AES_KEY, AES.MODE_CBC, raw[:16]).decrypt(raw[16:])
|
||||||
|
n = plain[-1]
|
||||||
|
if 1 <= n <= AES.block_size:
|
||||||
|
plain = plain[:-n]
|
||||||
|
return plain.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def allnet_post(path, body, *, host=DEFAULT_HOST, timeout=DEFAULT_TIMEOUT, retries=3):
|
||||||
|
"""
|
||||||
|
POST 一条加密请求,返回解密后的明文(形如 "result=1&place_id=...")。
|
||||||
|
|
||||||
|
★ 实测 at.sys-allnet.cn 偶尔会直接掐断连接(RemoteDisconnected),
|
||||||
|
所以这里对**网络层**失败做退避重试;业务码非 1 不重试。
|
||||||
|
"""
|
||||||
|
url = host.rstrip("/") + path
|
||||||
|
payload = encode_request(body)
|
||||||
|
last_error = None
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, data=payload, headers=HEADERS, timeout=timeout)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt < retries:
|
||||||
|
time.sleep(1.5 * attempt)
|
||||||
|
continue
|
||||||
|
raise AllnetError("请求 %s 失败(已重试 %d 次):%s" % (url, retries, exc)) from exc
|
||||||
|
|
||||||
|
if resp.status_code != 200:
|
||||||
|
if resp.status_code >= 500 and attempt < retries:
|
||||||
|
last_error = "HTTP %d" % resp.status_code
|
||||||
|
time.sleep(1.5 * attempt)
|
||||||
|
continue
|
||||||
|
raise AllnetError("请求 %s 返回 HTTP %d" % (url, resp.status_code))
|
||||||
|
return decode_response(resp.content)
|
||||||
|
|
||||||
|
raise AllnetError("请求 %s 失败:%s" % (url, last_error))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_kv(text: str) -> dict:
|
||||||
|
"""把 "result=1&place_id=146b&name=%E5%AE%89..." 解析成 dict(值做 URL 解码)。"""
|
||||||
|
out = {}
|
||||||
|
for part in text.strip().split("&"):
|
||||||
|
if not part or "=" not in part:
|
||||||
|
continue
|
||||||
|
key, val = part.split("=", 1)
|
||||||
|
out[key.strip()] = urllib.parse.unquote(val)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Keychip <-> client_id
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def parse_keychip(raw: str) -> str:
|
||||||
|
"""
|
||||||
|
把用户给的 Keychip 归一化成 client_id(11 位大写短值)。
|
||||||
|
|
||||||
|
接受三种写法:
|
||||||
|
完整带连字符 "A63E-01C30471007"
|
||||||
|
完整连写 "A63E01C30471007"
|
||||||
|
短值 "A63E01C3047"
|
||||||
|
|
||||||
|
★ 为什么一定要截前 11 位:实测把完整 15 位当 client_id 发出去,
|
||||||
|
ALL.Net 回 result=-3(格式错)。
|
||||||
|
"""
|
||||||
|
s = re.sub(r"[\s\-]", "", str(raw)).upper()
|
||||||
|
if len(s) < CLIENT_ID_LEN:
|
||||||
|
raise ValueError("Keychip 太短:%r(至少 %d 位)" % (raw, CLIENT_ID_LEN))
|
||||||
|
cid = s[:CLIENT_ID_LEN]
|
||||||
|
if not CLIENT_ID_RE.match(cid):
|
||||||
|
raise ValueError("Keychip 非法:%r(应为十六进制大写)" % raw)
|
||||||
|
return cid
|
||||||
|
|
||||||
|
|
||||||
|
def make_keychip(client_id: str, place_id) -> str:
|
||||||
|
"""
|
||||||
|
按本工程约定拼回完整 Keychip:
|
||||||
|
前 4 位 + "-" + 第 5 位起 + placeId
|
||||||
|
例:A63E01C3047 + 1007 -> A63E-01C30471007
|
||||||
|
"""
|
||||||
|
cid = str(client_id).upper()
|
||||||
|
return "%s-%s%d" % (cid[:4], cid[4:], int(place_id))
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 两个接口
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def initialize(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||||
|
timeout=DEFAULT_TIMEOUT):
|
||||||
|
"""POST /net/initialize —— 机台认证,返回原始明文。"""
|
||||||
|
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
|
||||||
|
return allnet_post(PATH_INITIALIZE, body, host=host, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def delivery_instruction(client_id, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||||
|
timeout=DEFAULT_TIMEOUT):
|
||||||
|
"""POST /net/delivery/instruction —— DLI,返回原始明文(含 uri= 配信指示書地址)。"""
|
||||||
|
body = "title_id=%s&title_ver=%s&client_id=%s" % (TITLE_ID, title_ver, client_id)
|
||||||
|
return allnet_post(PATH_DELIVERY_INSTRUCTION, body, host=host, timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
|
def query_keychip(keychip, *, title_ver=DEFAULT_TITLE_VER, host=DEFAULT_HOST,
|
||||||
|
timeout=DEFAULT_TIMEOUT, raw_ok=False):
|
||||||
|
"""
|
||||||
|
查一台机台的身份。返回 dict(含 ok / result / 各字段)。
|
||||||
|
|
||||||
|
★ raw_ok=False 时,result != 1 直接抛 AllnetError(CLI 用);
|
||||||
|
raw_ok=True 时照样返回,由调用方自己看 result(批量用)。
|
||||||
|
"""
|
||||||
|
cid = parse_keychip(keychip)
|
||||||
|
text = initialize(cid, title_ver=title_ver, host=host, timeout=timeout)
|
||||||
|
kv = parse_kv(text)
|
||||||
|
|
||||||
|
result = kv.get("result", "")
|
||||||
|
place_hex = kv.get("place_id", "") or ""
|
||||||
|
try:
|
||||||
|
place_id = int(place_hex, 16) if place_hex else None
|
||||||
|
except ValueError:
|
||||||
|
place_id = None
|
||||||
|
|
||||||
|
region_names = [kv.get("region_name%d" % i, "") for i in range(4)]
|
||||||
|
region_names = [n for n in region_names if n]
|
||||||
|
|
||||||
|
try:
|
||||||
|
region_id = int(kv.get("region0", "") or 0)
|
||||||
|
except ValueError:
|
||||||
|
region_id = None
|
||||||
|
|
||||||
|
info = {
|
||||||
|
"ok": result == "1",
|
||||||
|
"result": result,
|
||||||
|
"result_text": RESULT_TEXT.get(result, "未知返回码 %s" % result),
|
||||||
|
"title_id": TITLE_ID,
|
||||||
|
"title_ver": title_ver,
|
||||||
|
"host": host,
|
||||||
|
# --- 六个值里的五个(KEYCHIP_ID 是推导值)---
|
||||||
|
"client_id": cid,
|
||||||
|
"region_id": region_id,
|
||||||
|
"place_id": place_id,
|
||||||
|
"place_name": kv.get("name", ""),
|
||||||
|
"region_name": region_names[0] if region_names else "",
|
||||||
|
# --- 附送 ---
|
||||||
|
"nickname": kv.get("nickname", ""),
|
||||||
|
"region_names": region_names,
|
||||||
|
"country": kv.get("country", ""),
|
||||||
|
"setting": kv.get("setting", ""),
|
||||||
|
"location_type": kv.get("location_type", ""),
|
||||||
|
"game_server_uri": kv.get("uri1", ""),
|
||||||
|
"auth_token": kv.get("uri2", ""),
|
||||||
|
"token": kv.get("token", ""),
|
||||||
|
"utc_time": kv.get("utc_time", ""),
|
||||||
|
"client_timezone": kv.get("client_timezone", ""),
|
||||||
|
"res_ver": kv.get("res_ver", ""),
|
||||||
|
"raw": text.strip(),
|
||||||
|
}
|
||||||
|
if info["place_id"] is not None:
|
||||||
|
info["keychip"] = make_keychip(cid, info["place_id"])
|
||||||
|
else:
|
||||||
|
info["keychip"] = ""
|
||||||
|
|
||||||
|
if not info["ok"] and not raw_ok:
|
||||||
|
raise AllnetError("ALL.Net 返回 result=%s(%s)" % (result, info["result_text"]))
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 写回 config.py
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
CONFIG_FILENAME = "config.py"
|
||||||
|
|
||||||
|
# 写回哪些键(KEYCHIP_ID 是推导值,不写)
|
||||||
|
WRITEBACK_KEYS = ("CLIENT_ID", "REGION_ID", "PLACE_ID", "PLACE_NAME", "REGION_NAME")
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_assignment(text, name, literal):
|
||||||
|
"""把 config.py 里唯一一行 NAME = ... 换成 NAME = literal。"""
|
||||||
|
pattern = re.compile(r"^(%s[ \t]*=[ \t]*).*$" % re.escape(name), re.M)
|
||||||
|
new_text, count = pattern.subn(lambda m: m.group(1) + literal, text, count=1)
|
||||||
|
if count != 1:
|
||||||
|
raise AllnetError("config.py 里找不到唯一的 %s = ... 赋值行(找到 %d 处)" % (name, count))
|
||||||
|
return new_text
|
||||||
|
|
||||||
|
|
||||||
|
def write_back_config(info, *, config_path=None, quiet=False):
|
||||||
|
"""
|
||||||
|
把查询结果写回 config.py。
|
||||||
|
|
||||||
|
安全措施:
|
||||||
|
1. 先 ast.parse 语法自检,写坏了就不落盘
|
||||||
|
2. 落盘前把原文件备份成 config.py.bak
|
||||||
|
3. 保留原文件的换行风格(LF / CRLF)
|
||||||
|
"""
|
||||||
|
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
CONFIG_FILENAME)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
raise AllnetError("找不到 config.py:%s" % path)
|
||||||
|
|
||||||
|
with open(path, "rb") as fp:
|
||||||
|
raw = fp.read()
|
||||||
|
newline = "\r\n" if b"\r\n" in raw else "\n"
|
||||||
|
text = raw.decode("utf-8").replace("\r\n", "\n")
|
||||||
|
|
||||||
|
pairs = [
|
||||||
|
("CLIENT_ID", json.dumps(info["client_id"], ensure_ascii=False)),
|
||||||
|
("REGION_ID", str(info["region_id"])),
|
||||||
|
("PLACE_ID", str(info["place_id"])),
|
||||||
|
("PLACE_NAME", json.dumps(info["place_name"], ensure_ascii=False)),
|
||||||
|
("REGION_NAME", json.dumps(info["region_name"], ensure_ascii=False)),
|
||||||
|
]
|
||||||
|
for name, literal in pairs:
|
||||||
|
text = _replace_assignment(text, name, literal)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ast.parse(text)
|
||||||
|
except SyntaxError as exc:
|
||||||
|
raise AllnetError("改写后 config.py 语法不合法,已放弃写入:%s" % exc) from exc
|
||||||
|
|
||||||
|
shutil.copy2(path, path + ".bak")
|
||||||
|
with open(path, "wb") as fp:
|
||||||
|
fp.write(text.replace("\n", newline).encode("utf-8"))
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
print(" [已写回] %s" % path)
|
||||||
|
print(" [已备份] %s.bak" % path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def config_switch_enabled(config_path=None):
|
||||||
|
"""读 config.py 里的 AUTHV1_WRITE_CONFIG 开关。读不到就当作关。"""
|
||||||
|
path = config_path or os.path.join(os.path.dirname(os.path.abspath(__file__)),
|
||||||
|
CONFIG_FILENAME)
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fp:
|
||||||
|
tree = ast.parse(fp.read().decode("utf-8"))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
for node in tree.body:
|
||||||
|
if isinstance(node, ast.Assign):
|
||||||
|
for target in node.targets:
|
||||||
|
if isinstance(target, ast.Name) and target.id == "AUTHV1_WRITE_CONFIG":
|
||||||
|
try:
|
||||||
|
return bool(ast.literal_eval(node.value))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 输出
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def format_report(info) -> str:
|
||||||
|
"""人类可读的结果块。"""
|
||||||
|
lines = []
|
||||||
|
lines.append("=== authv1 机台身份查询 ===")
|
||||||
|
lines.append(" Keychip (完整) : %s" % (info["keychip"] or "(查不到)"))
|
||||||
|
lines.append(" clientId (短值) : %s <- 游戏服务器 clientId" % info["client_id"])
|
||||||
|
lines.append(" placeId : %s" % info["place_id"])
|
||||||
|
lines.append(" placeName : %s" % info["place_name"])
|
||||||
|
lines.append(" regionId : %s" % info["region_id"])
|
||||||
|
lines.append(" regionName : %s" % info["region_name"])
|
||||||
|
lines.append(" --- 附送 ---")
|
||||||
|
lines.append(" 游戏服务器 : %s" % info["game_server_uri"])
|
||||||
|
lines.append(" 机台昵称 : %s" % info["nickname"])
|
||||||
|
lines.append(" 国家/地区 : %s" % info["country"])
|
||||||
|
lines.append(" 认证时间(UTC) : %s 时区 %s" % (info["utc_time"], info["client_timezone"]))
|
||||||
|
lines.append(" ALL.Net 返回码 : %s(%s)" % (info["result"], info["result_text"]))
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CLI
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog="authv1.py",
|
||||||
|
description="ALL.Net(alAbEx v4) 机台认证:Keychip -> clientId/regionId/placeId/placeName/regionName",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=(
|
||||||
|
"示例:\n"
|
||||||
|
" python authv1.py A63E01E9580\n"
|
||||||
|
" python authv1.py A63E-01C30471007 --json\n"
|
||||||
|
" python authv1.py A63E01E9580 --write\n"
|
||||||
|
" python authv1.py --batch source/keychip.csv --limit 10\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument("keychip", nargs="?", help="Keychip(完整带连字符 / 完整连写 / 11 位短值都行)")
|
||||||
|
p.add_argument("--batch", metavar="CSV",
|
||||||
|
help="批量查询:CSV 文件,默认读第一列(表头含 Keychip 时自动跳过)")
|
||||||
|
p.add_argument("--limit", type=int, default=0, help="批量模式最多查多少条(0 = 不限)")
|
||||||
|
p.add_argument("--ver", default=None, help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
|
||||||
|
p.add_argument("--host", default=DEFAULT_HOST, help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
|
||||||
|
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="单次请求超时秒数")
|
||||||
|
p.add_argument("--json", action="store_true", help="输出 JSON")
|
||||||
|
g = p.add_mutually_exclusive_group()
|
||||||
|
g.add_argument("--write", action="store_true", help="强制把结果写回 config.py")
|
||||||
|
g.add_argument("--no-write", action="store_true", help="强制不写回 config.py")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_title_ver(cli_value):
|
||||||
|
if cli_value:
|
||||||
|
return str(cli_value)
|
||||||
|
try:
|
||||||
|
import config # noqa: F401 同目录
|
||||||
|
return str(getattr(config, "MAI_ENCODING", DEFAULT_TITLE_VER))
|
||||||
|
except Exception:
|
||||||
|
return DEFAULT_TITLE_VER
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
title_ver = resolve_title_ver(args.ver)
|
||||||
|
|
||||||
|
if args.batch:
|
||||||
|
return run_batch(args, title_ver)
|
||||||
|
|
||||||
|
if not args.keychip:
|
||||||
|
build_parser().print_help()
|
||||||
|
print("\n错误:请给一个 Keychip,或用 --batch 指定 CSV。")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
try:
|
||||||
|
info = query_keychip(args.keychip, title_ver=title_ver,
|
||||||
|
host=args.host, timeout=args.timeout)
|
||||||
|
except (AllnetError, ValueError) as exc:
|
||||||
|
print("查询失败:%s" % exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(json.dumps(info, ensure_ascii=False, indent=1))
|
||||||
|
else:
|
||||||
|
print(format_report(info))
|
||||||
|
|
||||||
|
do_write = decide_write(args)
|
||||||
|
if do_write:
|
||||||
|
try:
|
||||||
|
write_back_config(info)
|
||||||
|
except AllnetError as exc:
|
||||||
|
print("写回失败:%s" % exc)
|
||||||
|
return 1
|
||||||
|
elif not args.json:
|
||||||
|
print(" [未写回] 想让它自动写回:把 config.py 的 AUTHV1_WRITE_CONFIG 改成 True,"
|
||||||
|
"或加 --write")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def decide_write(args):
|
||||||
|
"""--write / --no-write 优先,否则看 config.py 的开关。"""
|
||||||
|
if args.write:
|
||||||
|
return True
|
||||||
|
if args.no_write:
|
||||||
|
return False
|
||||||
|
return config_switch_enabled()
|
||||||
|
|
||||||
|
|
||||||
|
def _disp_width(text):
|
||||||
|
"""按终端显示宽度算长度:中日韩全角字符占 2 列。"""
|
||||||
|
import unicodedata
|
||||||
|
width = 0
|
||||||
|
for ch in str(text):
|
||||||
|
width += 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
||||||
|
return width
|
||||||
|
|
||||||
|
|
||||||
|
def _pad(text, width):
|
||||||
|
text = str(text)
|
||||||
|
return text + " " * max(0, width - _disp_width(text))
|
||||||
|
|
||||||
|
|
||||||
|
def run_batch(args, title_ver):
|
||||||
|
path = args.batch
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
print("找不到 CSV:%s" % path)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
with open(path, "r", encoding="utf-8-sig", newline="") as fp:
|
||||||
|
reader = csv.reader(fp)
|
||||||
|
for row in reader:
|
||||||
|
if not row or not row[0].strip():
|
||||||
|
continue
|
||||||
|
if row[0].strip().lower() in ("keychip", "keychip id", "keychipid"):
|
||||||
|
continue
|
||||||
|
rows.append(row)
|
||||||
|
if args.limit > 0:
|
||||||
|
rows = rows[: args.limit]
|
||||||
|
|
||||||
|
print("=== authv1 批量查询(%d 台,版本 %s)===" % (len(rows), title_ver))
|
||||||
|
header = " ".join([_pad("clientId", 13), _pad("result", 6), _pad("placeId", 8),
|
||||||
|
_pad("regionId", 8), _pad("区域", 8), "店铺名"])
|
||||||
|
print(header)
|
||||||
|
ok = 0
|
||||||
|
results = []
|
||||||
|
for row in rows:
|
||||||
|
try:
|
||||||
|
info = query_keychip(row[0], title_ver=title_ver, host=args.host,
|
||||||
|
timeout=args.timeout, raw_ok=True)
|
||||||
|
except Exception as exc:
|
||||||
|
print("%s ERROR %s" % (_pad(row[0].strip()[:13], 13), exc))
|
||||||
|
continue
|
||||||
|
results.append(info)
|
||||||
|
if info["ok"]:
|
||||||
|
ok += 1
|
||||||
|
print(" ".join([
|
||||||
|
_pad(info["client_id"], 13),
|
||||||
|
_pad(info["result"], 6),
|
||||||
|
_pad(info["place_id"], 8),
|
||||||
|
_pad(info["region_id"], 8),
|
||||||
|
_pad(info["region_name"], 8),
|
||||||
|
str(info["place_name"]),
|
||||||
|
]))
|
||||||
|
print("--- 成功 %d / %d ---" % (ok, len(rows)))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+247
@@ -0,0 +1,247 @@
|
|||||||
|
#!/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())
|
||||||
@@ -0,0 +1,405 @@
|
|||||||
|
# -*- 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",
|
||||||
|
]
|
||||||
@@ -1,487 +0,0 @@
|
|||||||
# 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` | 本文档 |
|
|
||||||
@@ -1,175 +0,0 @@
|
|||||||
# 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
|
|
||||||
```
|
|
||||||
@@ -1,670 +0,0 @@
|
|||||||
# 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()` 的真实返回值,这些在纯日志里根本看不到。
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
# 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 协议层。
|
|
||||||
- 这是**只读**操作,不写任何账号数据。
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"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
-27889
File diff suppressed because it is too large
Load Diff
@@ -1,626 +0,0 @@
|
|||||||
## 顶层字段对比
|
|
||||||
|
|
||||||
| 字段 | 真机 | 本次实跑 | 一致 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `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
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
# 上传成绩(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
@@ -1,302 +0,0 @@
|
|||||||
# 主脚本编写指南
|
|
||||||
|
|
||||||
> 面向「想用这个工程写自己业务流程」的人。
|
|
||||||
> 目标:**你只写业务,日志和协议细节都交给 `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
@@ -1,173 +0,0 @@
|
|||||||
# 功能票使用说明(购买 / 消耗)
|
|
||||||
|
|
||||||
> 本文是 [`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
@@ -1,133 +0,0 @@
|
|||||||
# 实跑验证记录(真实发包 + 落库确认)
|
|
||||||
|
|
||||||
> 本文记录本工程**第一次真实跑通**「扫码登录 → 取数 → 上传成绩 → 落库回查 → 登出」的完整证据。
|
|
||||||
> 执行方式:`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_*` 一致。
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
# 常见坑与注意事项
|
|
||||||
|
|
||||||
> 本文是 [`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` 里的对拍用例。
|
|
||||||
@@ -1,380 +0,0 @@
|
|||||||
# 日志与主脚本指南
|
|
||||||
|
|
||||||
> 模块:`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 收发数据日志 |
|
|
||||||
@@ -1,215 +0,0 @@
|
|||||||
# 真机 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`。
|
|
||||||
@@ -1,586 +0,0 @@
|
|||||||
# 舞萌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
@@ -1,186 +0,0 @@
|
|||||||
# 账号转生说明(`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` 整体覆盖,位串也单独算,不受影响。
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
# 购买功能票 —— 存档与交接文档
|
|
||||||
|
|
||||||
> **状态:暂停(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
@@ -1,477 +0,0 @@
|
|||||||
# 购买功能票流程(登录 → 买票 → 登出)
|
|
||||||
|
|
||||||
> 对应 `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
@@ -1,255 +0,0 @@
|
|||||||
# 舞萌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 后请同步更新第六节清单。*
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,448 @@
|
|||||||
|
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 的硬件标志位
|
||||||
|
///
|
||||||
|
/// 输出:
|
||||||
|
/// <游戏根目录>/MaimaiNetSpy/YYYY-MM-DD_HHmmss.log 人类可读时间线
|
||||||
|
/// <游戏根目录>/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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 514 KiB |
+703
@@ -0,0 +1,703 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
r"""
|
||||||
|
netdelivery.py —— ALL.Net DLI(Download Instruction):查 / 下 .app 与 .opt 更新包
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
【DLI 是什么】
|
||||||
|
DLI = **Download Instruction**(配信指示書)。
|
||||||
|
机台做完 ALL.Net 认证后,AMDaemon 会再问一次「我该下什么包」:
|
||||||
|
|
||||||
|
POST http://at.sys-allnet.cn/net/delivery/instruction
|
||||||
|
body = title_id=SDGB&title_ver=<版本>&client_id=<Keychip 短值>
|
||||||
|
|
||||||
|
服务器回:
|
||||||
|
|
||||||
|
result=1&uri=<patch 指示書 URL>|<option 指示書 URL>
|
||||||
|
|
||||||
|
指示書本身是个 INI(日文注释),真正要下的包写在 INSTALLn= 里:
|
||||||
|
|
||||||
|
[COMMON] INSTALL1=https://.../SDGB_1.56.00_..._1_1.55.01.app
|
||||||
|
[OPTIONAL] INSTALL1=https://.../SDGB_A031_..._0.opt
|
||||||
|
INSTALL2=https://.../SDGB_A005_..._0.opt
|
||||||
|
|
||||||
|
★ 只有 INSTALLn= 才是**要下载的包**;EXISTn= 是「本机应该已经有的文件」,
|
||||||
|
只是文件名,不是下载项。
|
||||||
|
|
||||||
|
【协议出处】—— source/amdaemon.exe 字符串(数字是文件内偏移)
|
||||||
|
端点 http://at.sys-all.cn/net/delivery/instruction @0x788198
|
||||||
|
库 alw_lite/src/system39/alw_api_netdeliver.c @0x63bf80
|
||||||
|
阶段 ALWE_NETDELIVER_PHASE_GET_DLINST_INFO @0x63bde0
|
||||||
|
ALWE_NETDELIVER_PHASE_APP_IMAGE_INFO @0x63be08
|
||||||
|
ALWE_NETDELIVER_PHASE_OPT_IMAGE_INFO @0x63be30
|
||||||
|
日志 "Net deliver param - dli uri : %s." @0x63c3f8
|
||||||
|
状态 ALE_ABEX_STAT_DLINSTR_OK / _ERR_DLINSTR_NG ... @0x638600
|
||||||
|
|
||||||
|
【实测结论】(2026-10-06)
|
||||||
|
title_ver=1.55 -> uri = patch(SDGB_patch_1.56...info) | option(SDGB_option_1.55...A005.info)
|
||||||
|
title_ver=1.56 -> uri = | option(A031) (patch 槽为空 = 本体已最新)
|
||||||
|
title_ver<=1.54 -> uri = null (服务器不认这个版本)
|
||||||
|
指示書里的 INSTALL 直链可以带 Range 断点续传(返回 206 + Content-Range)。
|
||||||
|
★ 已被下架的历史包,源站会回 504 —— 这是服务器状态,不是脚本的问题。
|
||||||
|
|
||||||
|
【用法】
|
||||||
|
python netdelivery.py # 用 config.py 里的 Keychip 查,然后**多选下载**
|
||||||
|
python netdelivery.py A63E01E9580 # 指定 Keychip
|
||||||
|
python netdelivery.py --list # 只列清单,不下载
|
||||||
|
python netdelivery.py --all # 全下(不交互)
|
||||||
|
python netdelivery.py --only 1,3 # 只下第 1、3 项(不交互)
|
||||||
|
python netdelivery.py --ver 1.55 # 指定上报版本(默认 config.MAI_ENCODING)
|
||||||
|
python netdelivery.py --out D:\SDGB2026\update
|
||||||
|
python netdelivery.py --insecure # 关掉 TLS 校验(CDN 证书有问题时用)
|
||||||
|
|
||||||
|
★ 交互选择支持:逗号/空格分隔、区间(如 1-3)、a=全选、n=全不选、q=退出。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import urllib3
|
||||||
|
|
||||||
|
from authv1 import (
|
||||||
|
DEFAULT_HOST,
|
||||||
|
DEFAULT_TIMEOUT,
|
||||||
|
AllnetError,
|
||||||
|
delivery_instruction,
|
||||||
|
parse_keychip,
|
||||||
|
)
|
||||||
|
|
||||||
|
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||||
|
|
||||||
|
# 下载器用的 UA(实测有效)
|
||||||
|
DOWNLOAD_HEADERS = {"User-Agent": "SDGB;Windows/Lite"}
|
||||||
|
|
||||||
|
DEFAULT_OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "download")
|
||||||
|
CHUNK = 1 << 16
|
||||||
|
PROBE_TIMEOUT = 30
|
||||||
|
DOWNLOAD_TIMEOUT = 60
|
||||||
|
|
||||||
|
# 包类型判定(按扩展名)
|
||||||
|
KIND_BY_EXT = {
|
||||||
|
".app": "app",
|
||||||
|
".opt": "opt",
|
||||||
|
".pack": "pack",
|
||||||
|
}
|
||||||
|
|
||||||
|
KIND_TEXT = {
|
||||||
|
"app": "本体/补丁",
|
||||||
|
"opt": "选项包",
|
||||||
|
"pack": "资源包",
|
||||||
|
"?": "未知",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# HTTP
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def http_get(url, *, headers=None, stream=False, timeout=PROBE_TIMEOUT, insecure=False):
|
||||||
|
"""
|
||||||
|
GET,顺带处理 CDN 证书问题。
|
||||||
|
|
||||||
|
先按正常校验发;如果因为证书失败,再自动退回 verify=False 重发一次
|
||||||
|
(只影响这一次请求,不改变全局设置)。
|
||||||
|
"""
|
||||||
|
merged = dict(DOWNLOAD_HEADERS)
|
||||||
|
if headers:
|
||||||
|
merged.update(headers)
|
||||||
|
try:
|
||||||
|
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
|
||||||
|
verify=not insecure)
|
||||||
|
except requests.exceptions.SSLError:
|
||||||
|
if insecure:
|
||||||
|
raise
|
||||||
|
return requests.get(url, headers=merged, stream=stream, timeout=timeout,
|
||||||
|
verify=False)
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 配信指示書解析
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def parse_instruction(text):
|
||||||
|
"""
|
||||||
|
解析配信指示書 INI。
|
||||||
|
|
||||||
|
返回 (common, installs):
|
||||||
|
common = {GAME_DESC: ..., DOWNLOAD_ID: ..., ...}([COMMON] 段)
|
||||||
|
installs = [{"section": "COMMON"/"OPTIONAL", "key": "INSTALL1", "url": ...}, ...]
|
||||||
|
"""
|
||||||
|
common = {}
|
||||||
|
installs = []
|
||||||
|
section = ""
|
||||||
|
for raw_line in text.splitlines():
|
||||||
|
line = raw_line.strip()
|
||||||
|
if not line or line.startswith(";") or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if line.startswith("[") and line.endswith("]"):
|
||||||
|
section = line[1:-1].strip().upper()
|
||||||
|
continue
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
key = key.strip().upper()
|
||||||
|
value = value.strip().strip('"')
|
||||||
|
if section in ("", "COMMON"):
|
||||||
|
common.setdefault(key, value)
|
||||||
|
if key.startswith("INSTALL") and value:
|
||||||
|
installs.append({"section": section or "COMMON", "key": key, "url": value})
|
||||||
|
return common, installs
|
||||||
|
|
||||||
|
|
||||||
|
def guess_kind(url):
|
||||||
|
"""按扩展名猜包类型。"""
|
||||||
|
name = url.split("?")[0].rsplit("/", 1)[-1]
|
||||||
|
ext = os.path.splitext(name)[1].lower()
|
||||||
|
return KIND_BY_EXT.get(ext, "?")
|
||||||
|
|
||||||
|
|
||||||
|
def package_name(url):
|
||||||
|
return url.split("?")[0].rsplit("/", 1)[-1]
|
||||||
|
|
||||||
|
|
||||||
|
def human_size(num):
|
||||||
|
if num is None:
|
||||||
|
return "?"
|
||||||
|
value = float(num)
|
||||||
|
for unit in ("B", "KB", "MB", "GB", "TB"):
|
||||||
|
if value < 1024 or unit == "TB":
|
||||||
|
return ("%d B" % int(value)) if unit == "B" else ("%.2f %s" % (value, unit))
|
||||||
|
value /= 1024.0
|
||||||
|
return "?"
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 采集:DLI -> 指示書 -> 包清单
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def fetch_instruction_urls(client_id, *, title_ver, host, timeout):
|
||||||
|
"""问 DLI,返回 (patch_info_url, option_info_url, raw_text)。"""
|
||||||
|
text = delivery_instruction(client_id, title_ver=title_ver, host=host, timeout=timeout)
|
||||||
|
kv = {}
|
||||||
|
for part in text.strip().split("&"):
|
||||||
|
if "=" in part:
|
||||||
|
k, v = part.split("=", 1)
|
||||||
|
kv[k.strip()] = v
|
||||||
|
result = kv.get("result", "")
|
||||||
|
if result != "1":
|
||||||
|
raise AllnetError("DLI 返回 result=%s(原始:%s)" % (result, text.strip()[:200]))
|
||||||
|
|
||||||
|
uri = kv.get("uri", "").strip()
|
||||||
|
if uri in ("", "null"):
|
||||||
|
raise AllnetError("DLI 返回 uri=%s:这个 title_ver 没有配信指示書"
|
||||||
|
"(换 --ver 试试,例如 1.55)" % (uri or "(空)"))
|
||||||
|
|
||||||
|
parts = uri.split("|")
|
||||||
|
patch_url = parts[0].strip() if len(parts) > 0 else ""
|
||||||
|
option_url = parts[1].strip() if len(parts) > 1 else ""
|
||||||
|
for bad in ("null", "none"):
|
||||||
|
if patch_url.lower() == bad:
|
||||||
|
patch_url = ""
|
||||||
|
if option_url.lower() == bad:
|
||||||
|
option_url = ""
|
||||||
|
return patch_url, option_url, text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def collect_packages(client_id, *, title_ver, host, timeout, insecure=False):
|
||||||
|
"""
|
||||||
|
走完整条链路,返回 (items, meta)。
|
||||||
|
|
||||||
|
items 每一项:
|
||||||
|
{"idx":1, "kind":"app", "section":"COMMON", "key":"INSTALL1",
|
||||||
|
"url":..., "name":..., "size":int|None, "note":"", "info_name":...}
|
||||||
|
"""
|
||||||
|
patch_url, option_url, dli_raw = fetch_instruction_urls(
|
||||||
|
client_id, title_ver=title_ver, host=host, timeout=timeout)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
meta = {"dli_raw": dli_raw, "title_ver": title_ver, "client_id": client_id,
|
||||||
|
"instructions": [], "infos": {}}
|
||||||
|
|
||||||
|
for info_url in (patch_url, option_url):
|
||||||
|
if not info_url:
|
||||||
|
continue
|
||||||
|
info_name = package_name(info_url)
|
||||||
|
resp = None
|
||||||
|
for attempt in range(1, 4):
|
||||||
|
try:
|
||||||
|
resp = http_get(info_url, timeout=timeout, insecure=insecure)
|
||||||
|
if resp.status_code < 500:
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
resp = None
|
||||||
|
time.sleep(1.5 * attempt)
|
||||||
|
if resp is None:
|
||||||
|
meta["instructions"].append({"url": info_url,
|
||||||
|
"error": "拉取失败(已重试 3 次)"})
|
||||||
|
continue
|
||||||
|
if resp.status_code != 200:
|
||||||
|
meta["instructions"].append({"url": info_url, "status": resp.status_code,
|
||||||
|
"error": "HTTP %d" % resp.status_code})
|
||||||
|
continue
|
||||||
|
text = resp.content.decode("utf-8", "replace")
|
||||||
|
common, installs = parse_instruction(text)
|
||||||
|
meta["instructions"].append({"url": info_url, "status": 200,
|
||||||
|
"name": info_name, "common": common})
|
||||||
|
meta["infos"][info_name] = text
|
||||||
|
|
||||||
|
seen = set()
|
||||||
|
for entry in installs:
|
||||||
|
if entry["url"] in seen:
|
||||||
|
continue
|
||||||
|
seen.add(entry["url"])
|
||||||
|
items.append({
|
||||||
|
"kind": guess_kind(entry["url"]),
|
||||||
|
"section": entry["section"],
|
||||||
|
"key": entry["key"],
|
||||||
|
"url": entry["url"],
|
||||||
|
"name": package_name(entry["url"]),
|
||||||
|
"size": None,
|
||||||
|
"note": "",
|
||||||
|
"info_name": info_name,
|
||||||
|
})
|
||||||
|
|
||||||
|
for i, item in enumerate(items, 1):
|
||||||
|
item["idx"] = i
|
||||||
|
return items, meta
|
||||||
|
|
||||||
|
|
||||||
|
def probe_all(items, *, timeout=PROBE_TIMEOUT, insecure=False):
|
||||||
|
"""探每个包的大小 / 可用性(就地写回 item)。"""
|
||||||
|
for item in items:
|
||||||
|
for attempt in range(1, 3):
|
||||||
|
try:
|
||||||
|
resp = http_get(item["url"], headers={"Range": "bytes=0-0"},
|
||||||
|
stream=True, timeout=timeout, insecure=insecure)
|
||||||
|
status = resp.status_code
|
||||||
|
content_range = resp.headers.get("Content-Range", "")
|
||||||
|
content_length = resp.headers.get("Content-Length")
|
||||||
|
resp.close()
|
||||||
|
if status == 206 and "/" in content_range:
|
||||||
|
item["size"] = int(content_range.rsplit("/", 1)[-1])
|
||||||
|
item["note"] = ""
|
||||||
|
elif status == 200 and content_length and content_length.isdigit():
|
||||||
|
item["size"] = int(content_length)
|
||||||
|
item["note"] = ""
|
||||||
|
elif status in (404, 504):
|
||||||
|
item["note"] = "HTTP %d(源站不可用 / 该包已下架)" % status
|
||||||
|
else:
|
||||||
|
item["note"] = "HTTP %d" % status
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
item["note"] = "探测失败:%s" % exc
|
||||||
|
if attempt < 2:
|
||||||
|
time.sleep(1.5)
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 展示与选择
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def print_packages(items):
|
||||||
|
if not items:
|
||||||
|
print(" (指示書里没有任何 INSTALL 项)")
|
||||||
|
return
|
||||||
|
print("")
|
||||||
|
print(" 序号 类型 大小 来源段/键 文件名")
|
||||||
|
print(" " + "-" * 92)
|
||||||
|
for item in items:
|
||||||
|
size = human_size(item["size"]) if item["size"] is not None else (item["note"] or "?")
|
||||||
|
print(" [%2d] %-4s %-12s %-20s %s" % (
|
||||||
|
item["idx"], item["kind"], size,
|
||||||
|
item["section"] + "/" + item["key"], item["name"]))
|
||||||
|
if item["note"] and item["size"] is None:
|
||||||
|
print(" %s" % item["note"])
|
||||||
|
|
||||||
|
|
||||||
|
def prompt_selection(items):
|
||||||
|
"""
|
||||||
|
交互多选。返回选中的 0 基下标列表;用户 q 退出时返回 None。
|
||||||
|
"""
|
||||||
|
print("")
|
||||||
|
print(" 可多选:输入序号,用逗号或空格分隔;支持区间(如 1-3)。")
|
||||||
|
print(" a = 全选 n = 都不选 q = 退出")
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = input(" 请选择要下载的包 > ").strip()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print("")
|
||||||
|
return None
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
low = raw.lower()
|
||||||
|
if low in ("q", "quit", "exit"):
|
||||||
|
return None
|
||||||
|
if low in ("a", "all"):
|
||||||
|
return list(range(len(items)))
|
||||||
|
if low in ("n", "none"):
|
||||||
|
return []
|
||||||
|
|
||||||
|
picked, bad = [], []
|
||||||
|
for token in re.split(r"[,,、\s]+", raw):
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
|
||||||
|
if span:
|
||||||
|
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
|
||||||
|
for i in range(low_i, high_i + 1):
|
||||||
|
if 1 <= i <= len(items):
|
||||||
|
picked.append(i - 1)
|
||||||
|
else:
|
||||||
|
bad.append(str(i))
|
||||||
|
continue
|
||||||
|
if token.isdigit():
|
||||||
|
i = int(token)
|
||||||
|
if 1 <= i <= len(items):
|
||||||
|
picked.append(i - 1)
|
||||||
|
else:
|
||||||
|
bad.append(token)
|
||||||
|
else:
|
||||||
|
bad.append(token)
|
||||||
|
|
||||||
|
if bad:
|
||||||
|
print(" [x] 无效输入:%s(范围是 1-%d)" % (", ".join(bad), len(items)))
|
||||||
|
continue
|
||||||
|
|
||||||
|
ordered, seen = [], set()
|
||||||
|
for i in picked:
|
||||||
|
if i not in seen:
|
||||||
|
seen.add(i)
|
||||||
|
ordered.append(i)
|
||||||
|
if not ordered:
|
||||||
|
print(" [x] 没有选中任何项")
|
||||||
|
continue
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 下载
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def download_file(url, dest, *, timeout=DOWNLOAD_TIMEOUT, retries=3, insecure=False):
|
||||||
|
"""
|
||||||
|
带断点续传的下载。返回 (ok, 说明)。
|
||||||
|
|
||||||
|
★ 先写 dest + ".part",下完再改名 —— 中断了下次能接着下。
|
||||||
|
"""
|
||||||
|
part = dest + ".part"
|
||||||
|
total = None
|
||||||
|
|
||||||
|
for attempt in range(1, retries + 1):
|
||||||
|
done = os.path.getsize(part) if os.path.exists(part) else 0
|
||||||
|
headers = {}
|
||||||
|
if done:
|
||||||
|
headers["Range"] = "bytes=%d-" % done
|
||||||
|
try:
|
||||||
|
resp = http_get(url, headers=headers, stream=True, timeout=timeout,
|
||||||
|
insecure=insecure)
|
||||||
|
except Exception as exc:
|
||||||
|
if attempt == retries:
|
||||||
|
return False, "连接失败:%s" % exc
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if resp.status_code not in (200, 206):
|
||||||
|
resp.close()
|
||||||
|
if resp.status_code in (404, 504):
|
||||||
|
return False, "HTTP %d(源站不可用 / 该包已下架)" % resp.status_code
|
||||||
|
if attempt == retries:
|
||||||
|
return False, "HTTP %d" % resp.status_code
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 服务器忽略 Range 直接给了全量 -> 从头写
|
||||||
|
if done and resp.status_code == 200:
|
||||||
|
done = 0
|
||||||
|
|
||||||
|
content_range = resp.headers.get("Content-Range", "")
|
||||||
|
if "/" in content_range:
|
||||||
|
try:
|
||||||
|
total = int(content_range.rsplit("/", 1)[-1])
|
||||||
|
except ValueError:
|
||||||
|
total = None
|
||||||
|
elif resp.headers.get("Content-Length", "").isdigit():
|
||||||
|
total = done + int(resp.headers["Content-Length"])
|
||||||
|
|
||||||
|
mode = "ab" if done else "wb"
|
||||||
|
started = time.time()
|
||||||
|
written = done
|
||||||
|
last_tick = 0.0
|
||||||
|
last_shown = -1
|
||||||
|
try:
|
||||||
|
with open(part, mode) as fp:
|
||||||
|
for chunk in resp.iter_content(CHUNK):
|
||||||
|
if not chunk:
|
||||||
|
continue
|
||||||
|
fp.write(chunk)
|
||||||
|
written += len(chunk)
|
||||||
|
now = time.time()
|
||||||
|
if now - last_tick >= 0.3:
|
||||||
|
last_tick = now
|
||||||
|
last_shown = written
|
||||||
|
show_progress(written, total, now - started)
|
||||||
|
except Exception as exc:
|
||||||
|
resp.close()
|
||||||
|
if attempt == retries:
|
||||||
|
return False, "写入中断:%s" % exc
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
finally:
|
||||||
|
resp.close()
|
||||||
|
|
||||||
|
if written == last_shown:
|
||||||
|
sys.stdout.write("\n") # 收尾换行,避免重复打一行
|
||||||
|
else:
|
||||||
|
show_progress(written, total, max(time.time() - started, 0.001), final=True)
|
||||||
|
|
||||||
|
if total is not None and written != total:
|
||||||
|
if attempt == retries:
|
||||||
|
return False, "长度不符:期望 %d 字节,实际 %d 字节" % (total, written)
|
||||||
|
time.sleep(2)
|
||||||
|
continue
|
||||||
|
|
||||||
|
if os.path.exists(dest):
|
||||||
|
os.remove(dest)
|
||||||
|
os.replace(part, dest)
|
||||||
|
return True, "%s(%s)" % (human_size(written), dest)
|
||||||
|
|
||||||
|
return False, "重试 %d 次仍失败" % retries
|
||||||
|
|
||||||
|
|
||||||
|
def show_progress(done, total, elapsed, final=False):
|
||||||
|
speed = done / elapsed if elapsed > 0 else 0
|
||||||
|
if total:
|
||||||
|
percent = done * 100.0 / total
|
||||||
|
line = " %6.2f%% %s / %s %s/s" % (percent, human_size(done),
|
||||||
|
human_size(total), human_size(speed))
|
||||||
|
else:
|
||||||
|
line = " %s %s/s" % (human_size(done), human_size(speed))
|
||||||
|
sys.stdout.write("\r" + line + ("\n" if final else ""))
|
||||||
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CLI
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def build_parser():
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog="netdelivery.py",
|
||||||
|
description="ALL.Net DLI:查 / 下 .app 与 .opt 更新包",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog=(
|
||||||
|
"示例:\n"
|
||||||
|
" python netdelivery.py # 查完让你多选\n"
|
||||||
|
" python netdelivery.py --list # 只列清单\n"
|
||||||
|
" python netdelivery.py --all # 全下\n"
|
||||||
|
" python netdelivery.py --only 1,3 # 只下第 1、3 项\n"
|
||||||
|
" python netdelivery.py --ver 1.55 # 指定上报版本\n"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
p.add_argument("keychip", nargs="?",
|
||||||
|
help="Keychip(默认取 config.py 的 KEYCHIP_ID)")
|
||||||
|
p.add_argument("--ver", default=None,
|
||||||
|
help="上报给 ALL.Net 的版本号(默认取 config.MAI_ENCODING)")
|
||||||
|
p.add_argument("--host", default=DEFAULT_HOST,
|
||||||
|
help="ALL.Net 认证服务器(默认 %s)" % DEFAULT_HOST)
|
||||||
|
p.add_argument("--out", default=DEFAULT_OUT_DIR,
|
||||||
|
help="下载目录(默认 <脚本目录>/download)")
|
||||||
|
p.add_argument("--timeout", type=float, default=DEFAULT_TIMEOUT, help="接口超时秒数")
|
||||||
|
p.add_argument("--list", action="store_true", help="只列清单与直链,不下载")
|
||||||
|
p.add_argument("--all", action="store_true", help="全选(不交互)")
|
||||||
|
p.add_argument("--only", default=None, help="只下指定序号,如 1,3 或 1-3(不交互)")
|
||||||
|
p.add_argument("--no-probe", action="store_true", help="跳过包大小探测(更快)")
|
||||||
|
p.add_argument("--insecure", action="store_true", help="关掉 TLS 校验")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_keychip(cli_value):
|
||||||
|
if cli_value:
|
||||||
|
return cli_value
|
||||||
|
try:
|
||||||
|
import config # noqa: F401
|
||||||
|
value = getattr(config, "KEYCHIP_ID", "")
|
||||||
|
if value:
|
||||||
|
return value
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_title_ver(cli_value):
|
||||||
|
if cli_value:
|
||||||
|
return str(cli_value)
|
||||||
|
try:
|
||||||
|
import config # noqa: F401
|
||||||
|
return str(getattr(config, "MAI_ENCODING", "1.55"))
|
||||||
|
except Exception:
|
||||||
|
return "1.55"
|
||||||
|
|
||||||
|
|
||||||
|
def parse_only(text, count):
|
||||||
|
"""把 --only 的文本解析成 0 基下标列表。"""
|
||||||
|
picked, seen = [], set()
|
||||||
|
for token in re.split(r"[,,、\s]+", text or ""):
|
||||||
|
if not token:
|
||||||
|
continue
|
||||||
|
span = re.fullmatch(r"(\d+)\s*-\s*(\d+)", token)
|
||||||
|
if span:
|
||||||
|
low_i, high_i = sorted((int(span.group(1)), int(span.group(2))))
|
||||||
|
candidates = range(low_i, high_i + 1)
|
||||||
|
elif token.isdigit():
|
||||||
|
candidates = [int(token)]
|
||||||
|
else:
|
||||||
|
raise ValueError("--only 里有无效项:%r" % token)
|
||||||
|
for i in candidates:
|
||||||
|
if not (1 <= i <= count):
|
||||||
|
raise ValueError("--only 序号 %d 超出范围(1-%d)" % (i, count))
|
||||||
|
if i - 1 not in seen:
|
||||||
|
seen.add(i - 1)
|
||||||
|
picked.append(i - 1)
|
||||||
|
return picked
|
||||||
|
|
||||||
|
|
||||||
|
def save_instructions(meta, out_dir):
|
||||||
|
"""把配信指示書原样存一份,便于事后核对。"""
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
saved = []
|
||||||
|
for name, text in meta["infos"].items():
|
||||||
|
path = os.path.join(out_dir, name)
|
||||||
|
with open(path, "w", encoding="utf-8", newline="") as fp:
|
||||||
|
fp.write(text)
|
||||||
|
saved.append(path)
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
try:
|
||||||
|
sys.stdout.reconfigure(errors="replace")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
title_ver = resolve_title_ver(args.ver)
|
||||||
|
|
||||||
|
raw_keychip = resolve_keychip(args.keychip)
|
||||||
|
if not raw_keychip:
|
||||||
|
print("错误:没给 Keychip,config.py 里的 KEYCHIP_ID 也是空的。")
|
||||||
|
print(" 先跑一次 python authv1.py <你的Keychip> 把 config.py 填好。")
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
client_id = parse_keychip(raw_keychip)
|
||||||
|
except ValueError as exc:
|
||||||
|
print("Keychip 解析失败:%s" % exc)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print("=== netdelivery (ALL.Net DLI) ===")
|
||||||
|
print(" clientId : %s" % client_id)
|
||||||
|
print(" title_ver : %s" % title_ver)
|
||||||
|
print(" server : %s" % args.host)
|
||||||
|
print(" 正在查询配信指示書 ...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
items, meta = collect_packages(client_id, title_ver=title_ver, host=args.host,
|
||||||
|
timeout=args.timeout, insecure=args.insecure)
|
||||||
|
except (AllnetError, ValueError) as exc:
|
||||||
|
print("查询失败:%s" % exc)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
for entry in meta["instructions"]:
|
||||||
|
if entry.get("status") == 200:
|
||||||
|
common = entry.get("common", {})
|
||||||
|
print(" 指示書 %s" % entry["name"])
|
||||||
|
for key in ("GAME_DESC", "DLFORMAT", "GAME_ID", "DOWNLOAD_ID",
|
||||||
|
"RELEASE_TIME", "RELEASE_TYPE"):
|
||||||
|
if key in common:
|
||||||
|
print(" %-14s %s" % (key + ":", common[key]))
|
||||||
|
else:
|
||||||
|
print(" 指示書 %s -> %s" % (entry["url"], entry.get("error", "?")))
|
||||||
|
|
||||||
|
if not items:
|
||||||
|
print("没有可下载的包。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not args.no_probe:
|
||||||
|
print(" 正在探测包大小 ...")
|
||||||
|
probe_all(items, insecure=args.insecure)
|
||||||
|
|
||||||
|
print_packages(items)
|
||||||
|
|
||||||
|
if args.list:
|
||||||
|
print("")
|
||||||
|
print(" 直链:")
|
||||||
|
for item in items:
|
||||||
|
print(" [%2d] %s" % (item["idx"], item["url"]))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ---- 决定下哪些 ----
|
||||||
|
if args.only:
|
||||||
|
try:
|
||||||
|
chosen = parse_only(args.only, len(items))
|
||||||
|
except ValueError as exc:
|
||||||
|
print("参数错误:%s" % exc)
|
||||||
|
return 2
|
||||||
|
elif args.all:
|
||||||
|
chosen = list(range(len(items)))
|
||||||
|
elif not sys.stdin or not sys.stdin.isatty():
|
||||||
|
print("")
|
||||||
|
print(" 当前不是交互终端,未指定 --all / --only,已只列清单。")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
chosen = prompt_selection(items)
|
||||||
|
if chosen is None:
|
||||||
|
print(" 已取消。")
|
||||||
|
return 0
|
||||||
|
if not chosen:
|
||||||
|
print(" 没有选中任何项,退出。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
out_dir = args.out
|
||||||
|
os.makedirs(out_dir, exist_ok=True)
|
||||||
|
for path in save_instructions(meta, out_dir):
|
||||||
|
print(" [指示書] %s" % path)
|
||||||
|
|
||||||
|
print("")
|
||||||
|
print(" 下载目录:%s" % out_dir)
|
||||||
|
failed = []
|
||||||
|
for index in chosen:
|
||||||
|
item = items[index]
|
||||||
|
dest = os.path.join(out_dir, item["name"])
|
||||||
|
print("")
|
||||||
|
print(" [%d/%d] %s(%s)" % (chosen.index(index) + 1, len(chosen),
|
||||||
|
item["name"], KIND_TEXT.get(item["kind"], "?")))
|
||||||
|
if item["size"] is None and item["note"]:
|
||||||
|
print(" 跳过:%s" % item["note"])
|
||||||
|
failed.append((item, item["note"]))
|
||||||
|
continue
|
||||||
|
started = time.time()
|
||||||
|
ok, message = download_file(item["url"], dest, insecure=args.insecure)
|
||||||
|
if ok:
|
||||||
|
print(" 完成,用时 %.1f 秒 -> %s" % (time.time() - started, message))
|
||||||
|
else:
|
||||||
|
print(" 失败:%s" % message)
|
||||||
|
failed.append((item, message))
|
||||||
|
|
||||||
|
print("")
|
||||||
|
if failed:
|
||||||
|
print(" --- 结束:%d 个成功,%d 个失败 ---" % (len(chosen) - len(failed), len(failed)))
|
||||||
|
for item, message in failed:
|
||||||
|
print(" [%2d] %s -> %s" % (item["idx"], item["name"], message))
|
||||||
|
return 1
|
||||||
|
print(" --- 结束:%d 个全部成功 ---" % len(chosen))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+512
@@ -0,0 +1,512 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
reincarnate.py —— 账号数据迁移(转生):把账号 A 的数据克隆到账号 B
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
┌─ 源账号 A ─────────────────────────────────────────────┐
|
||||||
|
│ 二维码 -> ParseQRCode -> UserLogin -> GetNeedUserData │
|
||||||
|
│ 或:直接读 data/<userId>.json(离线复用) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
↓ 节点数据
|
||||||
|
┌─ 目标账号 B ───────────────────────────────────────────┐
|
||||||
|
│ 二维码 -> ParseQRCode -> UserPreview -> UserLogin │
|
||||||
|
│ 循环: │
|
||||||
|
│ 等待 -> UpsertUserAll(一包曲目) │
|
||||||
|
│ -> UserLogout(type=1) │
|
||||||
|
│ -> UserLogin(isContinue=true) 续关,拿新 loginId │
|
||||||
|
│ 收尾:最后一包登出后结束(不再续关) │
|
||||||
|
└────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
★ 每包几首:1 / 3 / 4 可以,**恰好 2 首会被服务端拒绝**
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
真机一局(一个 credit)最多打 4 首,所以一条 UpsertUserAll 报文里的
|
||||||
|
userPlaylogList / userGamePlaylogList **最多 4 条**。
|
||||||
|
|
||||||
|
★ 但实测:**每包恰好 2 首会被服务端拒绝**(1 / 3 / 4 首都正常)。
|
||||||
|
所以本脚本的分批器会**主动避开 2 首包**:
|
||||||
|
|
||||||
|
10 首 -> [4, 4, 2] -> [4, 3, 3]
|
||||||
|
6 首 -> [4, 2] -> [3, 3]
|
||||||
|
2 首 -> [2] -> [1, 1]
|
||||||
|
|
||||||
|
★ 每包都必须带**非空**的 userPlaylogList —— 踩坑清单第 2 条实测:
|
||||||
|
留空会被服务端当成无效包**静默丢弃**(returnCode=1 但不落库)。
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
★ 为什么需要「续关登录」
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
UpsertUserAll 的 playlogId 必须等于**本次登录**的 loginId
|
||||||
|
(PacketUpsertUserAll.cs:31 / UserAllRequestVO)。
|
||||||
|
一局结束后这个会话就被消费掉了,要继续上传必须重新登录 ——
|
||||||
|
真机走的就是「续关」:
|
||||||
|
|
||||||
|
Process/ContinueProcess.cs:403
|
||||||
|
new PacketUserLogin(userID, accessCode, isContinue: true, ...)
|
||||||
|
|
||||||
|
对应本工程:UserLogin(user_id, token, is_continue=True)
|
||||||
|
它和普通登录的唯一区别就是 UserLoginRequestVO.isContinue = true,
|
||||||
|
会返回**新的 loginId / loginDateTime**,供下一局 UpsertUserAll 使用。
|
||||||
|
|
||||||
|
★ 登出必须用 **type=1(LogoutType.Logout)**。
|
||||||
|
真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog):
|
||||||
|
{"userId":...,"type":1,"delayLog":{...}}
|
||||||
|
续关场景下用 4(TestIn)会出问题 —— 那是「测试登入 / 顶号」的语义。
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
★ playlog 从哪来
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
GetNeedUserData 抓不到 userPlaylogList(那是逐局记录,没有对应的 Get 接口),
|
||||||
|
所以本脚本**从曲目记录反推合成**:
|
||||||
|
|
||||||
|
UserMusicDetail(9 字段) -> UserPlaylog(110 字段)
|
||||||
|
───────────────────────────── ─────────────────────────
|
||||||
|
musicId musicId
|
||||||
|
level level
|
||||||
|
achievement achievement
|
||||||
|
deluxscoreMax deluxscore
|
||||||
|
scoreRank scoreRank / isClear
|
||||||
|
comboStatus comboStatus
|
||||||
|
syncStatus syncStatus
|
||||||
|
其余字段 取自 config.PLAY_*(合理默认值)
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
用法
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
# 源数据来自二维码(会联网抓 A 的数据,并落盘到 data/<A>.json)
|
||||||
|
python reincarnate.py --dst-qr <B的二维码> --src-qr <A的二维码>
|
||||||
|
|
||||||
|
# 源数据来自已保存的 JSON(离线,推荐:不用反复消耗 A 的二维码)
|
||||||
|
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json
|
||||||
|
|
||||||
|
# ★ 真实上传
|
||||||
|
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --send
|
||||||
|
|
||||||
|
# 只看计划,不发包
|
||||||
|
python reincarnate.py --dst-qr <B的二维码> --src-json data/12345678.json --plan
|
||||||
|
|
||||||
|
# 常用开关
|
||||||
|
--limit N 只迁前 N 首(试跑用)
|
||||||
|
--per-packet N 每包曲目数,只能是 1/3/4(默认 4;★ 2 首会被服务端拒绝)
|
||||||
|
--wait N 每次上传前的等待秒数(默认 5.0,0 = 不等)
|
||||||
|
--clone-identity 连 userName / accessCode 一起克隆(默认**保留 B 自己的**)
|
||||||
|
--no-continue 不续关(只发一包,调试用)
|
||||||
|
|
||||||
|
★ 迁移会**真实写入**账号 B(不可撤销)。不传 --send 时只构造报文。
|
||||||
|
★ 二维码**有效期 10 分钟**。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
from api import (
|
||||||
|
setup_logging,
|
||||||
|
teardown_logging,
|
||||||
|
log_error,
|
||||||
|
log_info,
|
||||||
|
log_warning,
|
||||||
|
ParseQRCode,
|
||||||
|
GetUserPreview,
|
||||||
|
UserLogin,
|
||||||
|
UserLogout,
|
||||||
|
LogoutType,
|
||||||
|
GetNeedUserData,
|
||||||
|
normalize_snapshot,
|
||||||
|
NODE_ORDER,
|
||||||
|
NODE_TYPES,
|
||||||
|
ARRAY_TO_BITS,
|
||||||
|
DIFF_PRIMARY_KEYS,
|
||||||
|
PlayData,
|
||||||
|
build_user_playlog,
|
||||||
|
build_user_game_playlog,
|
||||||
|
UpsertUserAll,
|
||||||
|
ConfirmRequiredError,
|
||||||
|
Ping,
|
||||||
|
load_user_data,
|
||||||
|
list_saved_user_ids,
|
||||||
|
DATA_DIR,
|
||||||
|
)
|
||||||
|
from config import PLACE_ID, REGION_ID
|
||||||
|
|
||||||
|
#: 真机一局最多 4 首
|
||||||
|
MAX_TRACKS_PER_CREDIT = 4
|
||||||
|
|
||||||
|
#: ★ 被服务端禁止的每包曲目数(实测:**恰好 2 首会被拒**)
|
||||||
|
#: 1 / 3 / 4 首都正常,只有 2 首不行 —— 所以分批时要避开它。
|
||||||
|
FORBIDDEN_TRACK_COUNTS = (2,)
|
||||||
|
|
||||||
|
#: 允许的 --per-packet 取值
|
||||||
|
ALLOWED_PER_PACKET = (1, 3, 4)
|
||||||
|
|
||||||
|
|
||||||
|
def split_batches(total, per):
|
||||||
|
"""
|
||||||
|
把 total 首切成「每包 <= per」的批次,**并且不产生被禁止的 2 首包**。
|
||||||
|
|
||||||
|
做法:先贪心按 per 切,再修正大小为 2 的批次:
|
||||||
|
|
||||||
|
① 前一批 >= 4 -> 从它借 1 首过来(前一批变 >=3,本批变 3)
|
||||||
|
② 借不到(前一批只有 3,或自己就是第一批)-> 把这个 2 拆成 1 + 1
|
||||||
|
|
||||||
|
★ 只借位是不够的 —— 前一批是 3 时借完自己变 2,等于把 2 挪了个位置。
|
||||||
|
|
||||||
|
例:
|
||||||
|
10 首 per=4 -> [4, 4, 2] -> [4, 3, 3]
|
||||||
|
6 首 per=4 -> [4, 2] -> [3, 3]
|
||||||
|
2 首 per=4 -> [2] -> [1, 1]
|
||||||
|
5 首 per=3 -> [3, 2] -> [3, 1, 1]
|
||||||
|
8 首 per=3 -> [3, 3, 2] -> [3, 3, 1, 1]
|
||||||
|
5 首 per=4 -> [4, 1] (本来就合法,不动)
|
||||||
|
"""
|
||||||
|
if total <= 0:
|
||||||
|
return []
|
||||||
|
per = max(1, int(per))
|
||||||
|
out = []
|
||||||
|
left = total
|
||||||
|
while left > 0:
|
||||||
|
n = min(per, left)
|
||||||
|
out.append(n)
|
||||||
|
left -= n
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(out):
|
||||||
|
if out[i] not in FORBIDDEN_TRACK_COUNTS:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
# ① 前一批 >= 4 才能借(借完前一批 >= 3,本批 = 3)
|
||||||
|
if i > 0 and out[i - 1] >= 4:
|
||||||
|
out[i - 1] -= 1
|
||||||
|
out[i] += 1
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
# ② 借不到 -> 把这个 2 拆成 1 + 1
|
||||||
|
out[i] = 1
|
||||||
|
out.insert(i + 1, 1)
|
||||||
|
i += 2
|
||||||
|
return out
|
||||||
|
|
||||||
|
#: 默认保留目标账号自己的身份字段(不克隆过去,避免把 B 改名)
|
||||||
|
IDENTITY_KEYS = ("userName", "accessCode")
|
||||||
|
|
||||||
|
#: 迁移时**不**逐条传的节点(要么是位串、要么由 UpsertUserAll 自己算)
|
||||||
|
SKIP_NODES = (
|
||||||
|
"userMusicDetailList", # 单独分批
|
||||||
|
"userGamePlaylogList", # 由本脚本按曲目合成
|
||||||
|
"user2pPlaylog", # 2P 记录,迁移无意义
|
||||||
|
"userGetPointList", # 里程/奖励,迁移会凭空发点
|
||||||
|
"userTradeItemList", # 无 Get 来源
|
||||||
|
"userFriendSeasonRankingList", # 无 Get 来源
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
opts = {"dst_qr": "", "src_qr": "", "src_json": "", "send": False,
|
||||||
|
"plan": False, "debug": False, "limit": 0,
|
||||||
|
"per_packet": MAX_TRACKS_PER_CREDIT,
|
||||||
|
"clone_identity": False, "no_continue": False,
|
||||||
|
"wait": 5.0}
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
a = argv[i]
|
||||||
|
if a == "--send":
|
||||||
|
opts["send"] = True
|
||||||
|
elif a == "--plan":
|
||||||
|
opts["plan"] = True
|
||||||
|
elif a == "--debug":
|
||||||
|
opts["debug"] = True
|
||||||
|
elif a == "--clone-identity":
|
||||||
|
opts["clone_identity"] = True
|
||||||
|
elif a == "--no-continue":
|
||||||
|
opts["no_continue"] = True
|
||||||
|
elif a in ("--dst-qr", "--src-qr", "--src-json", "--limit",
|
||||||
|
"--per-packet", "--wait") and i + 1 < len(argv):
|
||||||
|
key = a[2:].replace("-", "_")
|
||||||
|
opts[key] = (float(argv[i + 1]) if key == "wait"
|
||||||
|
else int(argv[i + 1]) if key in ("limit", "per_packet")
|
||||||
|
else argv[i + 1])
|
||||||
|
i += 1
|
||||||
|
i += 1
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 工具
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def _bits_for(node, items, base_items):
|
||||||
|
"""
|
||||||
|
按目标账号 B 的基线算 isNew* 位串:
|
||||||
|
B 里没有该条目 -> "1"(新增)
|
||||||
|
B 里已有 -> "0"(更新)
|
||||||
|
主键表见 upsert_user_all.DIFF_PRIMARY_KEYS。
|
||||||
|
"""
|
||||||
|
keys = DIFF_PRIMARY_KEYS.get(node)
|
||||||
|
if not keys:
|
||||||
|
return None
|
||||||
|
have = set()
|
||||||
|
for b in (base_items or []):
|
||||||
|
if isinstance(b, dict):
|
||||||
|
have.add(tuple(str(b.get(k)) for k in keys))
|
||||||
|
return "".join(
|
||||||
|
"0" if tuple(str(it.get(k)) for k in keys) in have else "1"
|
||||||
|
for it in (items or []) if isinstance(it, dict)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def play_from_detail(detail, base_play=None):
|
||||||
|
"""
|
||||||
|
从一条 UserMusicDetail(9 字段)反推一份 PlayData,用来合成 userPlaylog。
|
||||||
|
|
||||||
|
对不上的字段保留 config.PLAY_* 的默认值(它们不在曲目记录里)。
|
||||||
|
"""
|
||||||
|
p = PlayData() if base_play is None else base_play
|
||||||
|
p.music_id = int(detail.get("musicId", p.music_id) or 0)
|
||||||
|
p.music_level = int(detail.get("level", p.music_level) or 0)
|
||||||
|
p.achievement = int(detail.get("achievement", p.achievement) or 0)
|
||||||
|
p.deluxscore = int(detail.get("deluxscoreMax", p.deluxscore) or 0)
|
||||||
|
rank = int(detail.get("scoreRank", 0) or 0)
|
||||||
|
p.score_rank = rank
|
||||||
|
p.is_clear = rank > 0
|
||||||
|
p.combo_status = int(detail.get("comboStatus", p.combo_status) or 0)
|
||||||
|
p.sync_status = int(detail.get("syncStatus", p.sync_status) or 0)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def collect_nodes(src_nodes, base_nodes, clone_identity):
|
||||||
|
"""把源账号 A 的节点整理成 extra_data(曲目/2P/奖励类除外)。"""
|
||||||
|
out = {}
|
||||||
|
for node in NODE_ORDER:
|
||||||
|
if node in SKIP_NODES:
|
||||||
|
continue
|
||||||
|
if node.startswith("isNew"):
|
||||||
|
continue # 位串单独算
|
||||||
|
kind = NODE_TYPES.get(node)
|
||||||
|
if kind not in ("singleton", "array", "object"):
|
||||||
|
continue
|
||||||
|
val = src_nodes.get(node)
|
||||||
|
if val in (None, [], {}):
|
||||||
|
continue
|
||||||
|
out[node] = json.loads(json.dumps(val)) # 深拷贝
|
||||||
|
# 身份字段:默认保留 B 自己的
|
||||||
|
if not clone_identity and out.get("userData"):
|
||||||
|
for item in out["userData"]:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
for k in IDENTITY_KEYS:
|
||||||
|
item.pop(k, None)
|
||||||
|
# 位串:按 B 的基线算
|
||||||
|
for arr, bits in ARRAY_TO_BITS.items():
|
||||||
|
if arr not in out:
|
||||||
|
continue
|
||||||
|
b = _bits_for(arr, out[arr], base_nodes.get(arr))
|
||||||
|
if b:
|
||||||
|
out[bits] = b
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 主流程
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
def fetch_source_by_qr(qr):
|
||||||
|
"""用源账号 A 的二维码联网抓一份数据(并自动落盘到 data/<A>.json)。"""
|
||||||
|
log_info("=== 源账号:联网抓取 ===")
|
||||||
|
uid, token = ParseQRCode(qr)
|
||||||
|
preview = GetUserPreview(uid, token)
|
||||||
|
if preview.isLogin:
|
||||||
|
raise RuntimeError("源账号处于已登录状态,请先在机台登出")
|
||||||
|
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
try:
|
||||||
|
raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
UserLogout(uid, token, login.loginDateTime, logout_type=LogoutType.TestIn,
|
||||||
|
region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
except Exception as exc:
|
||||||
|
log_warning("源账号登出失败:" + str(exc))
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
opts = parse_args(sys.argv[1:])
|
||||||
|
if not opts["dst_qr"] or not (opts["src_qr"] or opts["src_json"]):
|
||||||
|
print(__doc__)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if opts["debug"]:
|
||||||
|
import config as _cfg
|
||||||
|
_cfg.LOG_LEVEL = "debug"
|
||||||
|
|
||||||
|
if opts["per_packet"] not in ALLOWED_PER_PACKET:
|
||||||
|
print("--per-packet 只能是 " + str(list(ALLOWED_PER_PACKET))
|
||||||
|
+ " 之一(真机一局最多 4 首;★ 实测恰好 2 首会被服务端拒绝)")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
setup_logging("reincarnate")
|
||||||
|
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||||
|
try:
|
||||||
|
# ---------------- 1. 源数据 ----------------
|
||||||
|
if opts["src_json"]:
|
||||||
|
src_raw = json.load(open(opts["src_json"], "r", encoding="utf-8"))
|
||||||
|
log_info("=== 源账号:从文件读取 " + opts["src_json"] + " ===")
|
||||||
|
else:
|
||||||
|
src_raw = fetch_source_by_qr(opts["src_qr"])
|
||||||
|
|
||||||
|
src_nodes = normalize_snapshot(src_raw)
|
||||||
|
musics = src_nodes.get("userMusicDetailList") or []
|
||||||
|
if opts["limit"] > 0:
|
||||||
|
musics = musics[:opts["limit"]]
|
||||||
|
log_info("源数据:节点 " + str(len(src_nodes)) + " 个,曲目记录 " + str(len(musics)) + " 条")
|
||||||
|
|
||||||
|
if not musics:
|
||||||
|
log_error("源数据里没有 userMusicDetailList,没什么可迁的。")
|
||||||
|
return 4
|
||||||
|
|
||||||
|
# ---------------- 2. 目标账号 ----------------
|
||||||
|
Ping()
|
||||||
|
uid, token = ParseQRCode(opts["dst_qr"])
|
||||||
|
session.update(user_id=uid, token=token)
|
||||||
|
preview = GetUserPreview(uid, token)
|
||||||
|
if preview.isLogin:
|
||||||
|
log_error("目标账号处于已登录状态,请先在机台登出或等待会话超时")
|
||||||
|
return 2
|
||||||
|
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||||
|
|
||||||
|
base_raw = GetNeedUserData(uid, token=token, place_id=PLACE_ID, region_id=REGION_ID)
|
||||||
|
base_nodes = normalize_snapshot(base_raw)
|
||||||
|
log_info("目标账号基线已抓取")
|
||||||
|
|
||||||
|
# ---------------- 3. 分批 ----------------
|
||||||
|
per = int(opts["per_packet"])
|
||||||
|
sizes = split_batches(len(musics), per)
|
||||||
|
batches = []
|
||||||
|
_pos = 0
|
||||||
|
for _n in sizes:
|
||||||
|
batches.append(musics[_pos:_pos + _n])
|
||||||
|
_pos += _n
|
||||||
|
log_info("计划:共 " + str(len(musics)) + " 首 -> " + str(len(batches))
|
||||||
|
+ " 包,每包 " + "/".join(str(n) for n in sizes[:12])
|
||||||
|
+ ("..." if len(sizes) > 12 else "") + " 首")
|
||||||
|
log_info(" ★ 已避开 2 首包(实测恰好 2 首会被服务端拒绝;1/3/4 首都正常)")
|
||||||
|
|
||||||
|
if opts["plan"]:
|
||||||
|
for i, b in enumerate(batches):
|
||||||
|
log_info(" 第 " + str(i + 1) + " 包(" + str(len(b)) + " 首):" + ", ".join(
|
||||||
|
str(x.get("musicId")) + "/L" + str(x.get("level")) for x in b))
|
||||||
|
log_info("--plan:只列计划,不发包")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 第一包才带非曲目节点(整账号克隆)
|
||||||
|
shared_nodes = collect_nodes(src_nodes, base_nodes, opts["clone_identity"])
|
||||||
|
log_info("非曲目节点 " + str(len([k for k in shared_nodes if not k.startswith('isNew')]))
|
||||||
|
+ " 个将在第 1 包上传")
|
||||||
|
|
||||||
|
# ---------------- 4. 逐包上传 ----------------
|
||||||
|
# ★ 流程(按使用方口径):
|
||||||
|
# 登录 -> 等待 -> 上传 -> 登出(type=1) -> 续关登录 -> 等待 -> 上传 -> 登出 -> ...
|
||||||
|
#
|
||||||
|
# ★ 登出必须用 type=1(LogoutType.Logout)。
|
||||||
|
# 真机抓包实证 UserLogoutApi 就是 type=1(带 delayLog);
|
||||||
|
# 续关场景下用 4(TestIn)会出问题 —— 那是「测试登入/顶号」的语义。
|
||||||
|
sent = 0
|
||||||
|
for idx, batch in enumerate(batches):
|
||||||
|
log_info("")
|
||||||
|
log_info("========== 第 " + str(idx + 1) + "/" + str(len(batches))
|
||||||
|
+ " 包(" + str(len(batch)) + " 首)==========")
|
||||||
|
|
||||||
|
# ---- 等待 ----
|
||||||
|
if opts["wait"] > 0:
|
||||||
|
log_info("上传前等待 " + str(opts["wait"]) + " 秒 ...")
|
||||||
|
time.sleep(opts["wait"])
|
||||||
|
|
||||||
|
nodes = {}
|
||||||
|
if idx == 0:
|
||||||
|
nodes.update(json.loads(json.dumps(shared_nodes)))
|
||||||
|
nodes["userMusicDetailList"] = json.loads(json.dumps(batch))
|
||||||
|
b = _bits_for("userMusicDetailList", batch,
|
||||||
|
base_nodes.get("userMusicDetailList"))
|
||||||
|
if b:
|
||||||
|
nodes["isNewMusicDetailList"] = b
|
||||||
|
log_info(" isNewMusicDetailList = " + b)
|
||||||
|
|
||||||
|
# 合成 playlog(每首一条)与 game playlog
|
||||||
|
playlogs = []
|
||||||
|
game_playlogs = []
|
||||||
|
for d in batch:
|
||||||
|
p = play_from_detail(d)
|
||||||
|
playlogs.append(build_user_playlog(
|
||||||
|
login.loginId, login.loginDateTime, p, place_id=PLACE_ID))
|
||||||
|
game_playlogs.append(build_user_game_playlog(
|
||||||
|
login.loginId, login.loginDateTime, p))
|
||||||
|
nodes["userGamePlaylogList"] = game_playlogs
|
||||||
|
|
||||||
|
UpsertUserAll(
|
||||||
|
uid, login.loginId,
|
||||||
|
login_date_time=login.loginDateTime,
|
||||||
|
auth_date_time=login.dateTime,
|
||||||
|
token=token,
|
||||||
|
snapshot=base_raw,
|
||||||
|
auto_characters=False,
|
||||||
|
play=play_from_detail(batch[0]),
|
||||||
|
extra_data=nodes,
|
||||||
|
user_playlog_list=playlogs,
|
||||||
|
pre_new_item_list=False,
|
||||||
|
verify_landing=False,
|
||||||
|
dry_run=not opts["send"],
|
||||||
|
confirm=opts["send"],
|
||||||
|
place_id=PLACE_ID, region_id=REGION_ID,
|
||||||
|
)
|
||||||
|
sent += 1
|
||||||
|
|
||||||
|
# ---- ★ 登出(type=1 Logout)----
|
||||||
|
log_info("--- 登出(type=1 Logout)---")
|
||||||
|
try:
|
||||||
|
UserLogout(uid, token, login.loginDateTime,
|
||||||
|
logout_type=LogoutType.Logout,
|
||||||
|
region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
except Exception as exc:
|
||||||
|
log_warning("登出失败(继续下一包):" + str(exc))
|
||||||
|
session["logged_in"] = False
|
||||||
|
|
||||||
|
# ---- ★ 续关登录:拿下一包的 loginId ----
|
||||||
|
if idx < len(batches) - 1:
|
||||||
|
if opts["no_continue"]:
|
||||||
|
log_warning("--no-continue:不再续关,提前结束(只发了 1 包)")
|
||||||
|
break
|
||||||
|
log_info("--- 续关登录(isContinue=true)---")
|
||||||
|
login = UserLogin(uid, token, region_id=REGION_ID, place_id=PLACE_ID,
|
||||||
|
is_continue=True)
|
||||||
|
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||||
|
|
||||||
|
if not opts["send"]:
|
||||||
|
log_info("")
|
||||||
|
log_info("[dry-run] 未发包。想真实迁移请加 --send(★ 会写入账号 B)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
log_info("")
|
||||||
|
log_info("★ 迁移完成:共上传 " + str(sent) + " 包 / "
|
||||||
|
+ str(min(len(musics), sent * per)) + " 首")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except ConfirmRequiredError:
|
||||||
|
return 5
|
||||||
|
except Exception as exc:
|
||||||
|
log_error(type(exc).__name__ + ": " + str(exc))
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if session["logged_in"]:
|
||||||
|
try:
|
||||||
|
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||||
|
logout_type=LogoutType.TestIn,
|
||||||
|
region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
except Exception as exc:
|
||||||
|
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
||||||
|
teardown_logging()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
+1365
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
# tools/ —— 独立小工具
|
||||||
|
|
||||||
|
这个目录放**与游戏协议无关的独立小工具**:纯计算、纯文件处理。
|
||||||
|
它们**不联网**、**不碰游戏服务器**、也**不依赖 `api/` 与 `config.py`**。
|
||||||
|
|
||||||
|
## 约定
|
||||||
|
|
||||||
|
| 约定 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 一个工具一个子目录 | `tools/<工具名>/` |
|
||||||
|
| 自包含 | 工具之间不互相依赖,单独拷走也能跑 |
|
||||||
|
| 纯本地 | 不联网、不写游戏服务器;最多读本机文件 |
|
||||||
|
| 无第三方依赖 | 只用 Python 标准库(除非工具自己的 README 里写明) |
|
||||||
|
| 登记 | 新增工具时,在下面的「已有工具」表里加一行 |
|
||||||
|
|
||||||
|
## 已有工具
|
||||||
|
|
||||||
|
| 工具 | 目录 | 干什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| 谱面达成率计算器 | [`chart_achievement/`](chart_achievement/) | 按判定数算谱面达成率与 DX 分;也可由达成率倒算判定组合 |
|
||||||
|
|
||||||
|
## 怎么跑
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 进工具目录,直接 python 跑(各工具的参数见它自己的说明)
|
||||||
|
cd tools/chart_achievement
|
||||||
|
python chart_achievement.py --help
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
# chart_achievement —— 舞萌DX 谱面达成率 / DX 分计算器
|
||||||
|
|
||||||
|
给定谱面(`.ma2`)和判定数,算出**达成率**与 **DX 分**;反过来,给定目标达成率,
|
||||||
|
倒算出所有能打出该达成率的判定组合。
|
||||||
|
|
||||||
|
纯本地、纯标准库(不需要联网、不写任何游戏文件)。
|
||||||
|
|
||||||
|
## 怎么跑
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd tools/chart_achievement
|
||||||
|
python chart_achievement.py --help
|
||||||
|
python chart_achievement.py # 不给参数 = 交互向导
|
||||||
|
```
|
||||||
|
|
||||||
|
## 谱面从哪来(二选一)
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `--chart <路径>` | 直接给 `.ma2` 文件,如 `.../music/music000008/000008_03.ma2` |
|
||||||
|
| `--game <目录> --music-id <ID>` | 给游戏目录(`Sinmai_Data`、`StreamingAssets`、`A000`、`music` 都能认),按 `musicid` 自动找谱面 |
|
||||||
|
|
||||||
|
> 🔎 **不知道 musicId / 定数?** [maimai.lxns.net/songs](https://maimai.lxns.net/songs)
|
||||||
|
> 可以在线查询谱面信息(曲名、难度、定数等),查到 `musicId` 和难度后再回来跑脚本。
|
||||||
|
|
||||||
|
### 难度(--level)
|
||||||
|
|
||||||
|
难度用**游戏里规定的数字**表示,也就是谱面文件名后缀 `_00` ~ `_04` 的那个数字
|
||||||
|
(对应游戏主数据 `musicDifficulty/MusicDifficulty00000X` 的 `<id>`):
|
||||||
|
|
||||||
|
| 数字 | 难度 | 谱面文件名 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 0 | BASIC | `<id>_00.ma2` |
|
||||||
|
| 1 | ADVANCED | `<id>_01.ma2` |
|
||||||
|
| 2 | EXPERT | `<id>_02.ma2` |
|
||||||
|
| 3 | MASTER | `<id>_03.ma2` |
|
||||||
|
| 4 | RE:MASTER | `<id>_04.ma2` |
|
||||||
|
|
||||||
|
`--level 0` ~ `--level 4` 或 `--level BASIC` / `ADVANCED` / `EXPERT` / `MASTER` / `RE:MASTER`
|
||||||
|
(大小写随意;`--difficulty` 是同一个参数的别名)都行,**默认 3(MASTER)**;
|
||||||
|
不给时会在 stderr 提示用了默认值。
|
||||||
|
|
||||||
|
注意:这里的「等级」指**谱面难度**。谱面的数值等级(如 `12+`)是另一回事,
|
||||||
|
由 `Music.xml` 自动读出,只用于显示。
|
||||||
|
|
||||||
|
## 正向:已知判定数 → 达成率 + DX 分
|
||||||
|
|
||||||
|
达成率是**按音符权重加权**的(Tap 500 / Hold 1000 / Slide 1500 / Break 2500),
|
||||||
|
所以有两种给判定数的方式:
|
||||||
|
|
||||||
|
### ① 精确:按音符类别给(推荐,得到确切数值)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python chart_achievement.py --game "<...>\Sinmai_Data" --music-id 8 --level 3 --tap 260/0/3 --break 6 # 3 = MASTER
|
||||||
|
```
|
||||||
|
|
||||||
|
每个类别写 `CP/Perfect/Great/Good/Miss` 五个数(`/`、`,` 或空格分隔,后面可以省略),
|
||||||
|
**没给满的部分按 CP 算**:
|
||||||
|
|
||||||
|
| 参数 | 类别 | 权重 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `--tap` | Tap / Star / Touch / EX | 500 |
|
||||||
|
| `--hold` | Hold 系 | 1000 |
|
||||||
|
| `--slide` | Slide 系 | 1500 |
|
||||||
|
| `--break` | 绝赞(Break 系) | 2500 |
|
||||||
|
|
||||||
|
绝赞的 Great / Perfect 本身还有档位,需要时可以再细分(不给就按 Great 失分 20%、Perfect 奖励 75 算):
|
||||||
|
|
||||||
|
| 参数 | 含义 |
|
||||||
|
| --- | --- |
|
||||||
|
| `--break-great40 N` | 绝赞里 N 个 Great 是「失分 40%」档(Great2nd) |
|
||||||
|
| `--break-great50 N` | 绝赞里 N 个 Great 是「失分 50%」档(Great3rd) |
|
||||||
|
| `--break-perfect50 N` | 绝赞里 N 个 Perfect 是「奖励分 50」档(Perfect2nd) |
|
||||||
|
|
||||||
|
输出就是一个**确切**的达成率,并附上失分 / 绝赞奖励分的明细:
|
||||||
|
|
||||||
|
```
|
||||||
|
判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)
|
||||||
|
Tap/Star/Touch/EX (500) 263 个:CP 260 · Great 3
|
||||||
|
Hold (1000) 14 个:CP 14
|
||||||
|
Slide (1500) 19 个:CP 19
|
||||||
|
Break (2500) 6 个:CP 6
|
||||||
|
结果
|
||||||
|
达成率 100.8413% (确切值)
|
||||||
|
DX 分 900 / 906 (99.34%)
|
||||||
|
```
|
||||||
|
|
||||||
|
### ② 汇总:只给总判定数(得到估计值 + 区间)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python chart_achievement.py --chart "<...>\000008_03.ma2" --cp 290 --perfect 8 --great 3 --good 1
|
||||||
|
```
|
||||||
|
|
||||||
|
`--cp` / `--perfect` / `--great` / `--good` 都给总数,`--miss` 留空时自动补足
|
||||||
|
(`miss = 总音符数 - CP - Perfect - Great - Good`)。
|
||||||
|
|
||||||
|
这时达成率取决于这些 Great/Good/Miss **落在哪类音符上**(落在绝赞上扣得多、落在 Tap 上扣得少),
|
||||||
|
所以给出的是「按类别比例分摊的估计值」+ 一个区间(下界 = 扣分都落在高权重音符上,
|
||||||
|
上界 = 都落在低权重音符上)。想要一个确切数值就用上面的 ①。
|
||||||
|
|
||||||
|
DX 分在两种方式下都是确定的:`3×CP + 2×Perfect + 1×Great`。
|
||||||
|
|
||||||
|
## 反向:已知达成率 → 判定组合 + DX 分
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --level 3 --achievement 100.75 # 3 = MASTER
|
||||||
|
python chart_achievement.py --game "D:\SDGB2026\Package\Sinmai_Data" --music-id 8 --level 2 --achievement 99 --mode range # 2 = EXPERT
|
||||||
|
```
|
||||||
|
|
||||||
|
两种模式(`--mode`):
|
||||||
|
|
||||||
|
| 模式 | 含义 | 什么时候用 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `exact`(默认) | 只列**真的能打出**该达成率的 (Miss, Good, Great) 组合,每组都做过严格验证 | 数据可信、想知道精确答案 |
|
||||||
|
| `range` | 列达成率**区间覆盖**该值的组合(更快、更宽松) | 判定数只是估算 / 想快速看个大概 |
|
||||||
|
|
||||||
|
`--limit N` 限制最多输出多少组(默认 200)。`--json` 输出 JSON。
|
||||||
|
|
||||||
|
输出里的 `CP+P` 是「Critical Perfect + Perfect 的总数」;`CP 范围` 是其中
|
||||||
|
CP 的物理可能范围(Perfect 只能落在绝赞音符上,所以 CP 不能少于
|
||||||
|
`CP+P - 绝赞音符数`)。CP 越多 DX 分越高,所以 DX 分也是这个范围。
|
||||||
|
|
||||||
|
### 精确模式的限制
|
||||||
|
|
||||||
|
精确验证用一个「坏音符背包 DP」,代价随坏音符个数增长,所以:
|
||||||
|
|
||||||
|
- 只严格验证 `Miss+Good+Great ≤ 12` 的组合;
|
||||||
|
- 坏音符更多的组合不验证(输出里会提示「坏音符超过 12 个的候选组合没有验证」);
|
||||||
|
- 若候选组合全都超过 12 个坏音符(例如 1000+ 绝赞的异常谱面),会自动退回区间模式并说明原因;
|
||||||
|
- DP 有 8 秒时间预算,超时也退回区间模式。
|
||||||
|
|
||||||
|
## 计分模型(与游戏一致)
|
||||||
|
|
||||||
|
达成率按**音符权重**加权,不是按个数平均:
|
||||||
|
|
||||||
|
```
|
||||||
|
达成率 = 100 × Σ(单音符得分) / 理论分W + Σ(绝赞奖励分) / 绝赞理论奖励分B
|
||||||
|
```
|
||||||
|
|
||||||
|
- 理论分 W = Σ 每个计分音符的权重(Tap/Star/Touch 500、Hold 系 1000、Slide 系 1500、Break 系 2500)
|
||||||
|
- 绝赞理论奖励分 B = 100 × 绝赞音符个数(Break / Break(长按判定) / BreakSlide / EXBreak)
|
||||||
|
- 满分 = **101.0000%**(谱面有绝赞)/ **100.0000%**(无绝赞)
|
||||||
|
- 接续滑条节点(`CNSI_` 等 ConnectSlide)**不计分、不计入总数**
|
||||||
|
- DX 分 = `3×CP + 2×Perfect + 1×Great`(与音符类型无关)
|
||||||
|
|
||||||
|
### 各类音符的失分 / 奖励分
|
||||||
|
|
||||||
|
| 音符组(权重) | CP | Perfect | Great | Good | Miss |
|
||||||
|
| --- | --- | --- | --- | --- | --- |
|
||||||
|
| Tap / Star / Touch(500) | 0 | 0 | 100 | 250 | 500 |
|
||||||
|
| Hold 系(1000) | 0 | 0 | 200 | 500 | 1000 |
|
||||||
|
| Slide 系(1500) | 0 | 0 | 300 | 750 | 1500 |
|
||||||
|
| Break(2500,分档) | 0 | 0 | 500 / 1000 / 1250 | 1500 | 2500 |
|
||||||
|
| Break 长按判定(2500) | 0 | 0 | 500 | 1500 | 2500 |
|
||||||
|
| BreakSlide(2500,分档) | 0 | 0 | 500 / 1000 / 1250 | 1500 | 2500 |
|
||||||
|
| EX(500) | 0 | — | — | — | 500 |
|
||||||
|
| EXBreak(2500) | 0 | — | — | — | 2500 |
|
||||||
|
|
||||||
|
(表里是「失分」,即 `权重 - 得分`;CP/Perfect 的失分都是 0,所以
|
||||||
|
**除绝赞外,Critical Perfect 与 Perfect 加的达成率完全一样**。)
|
||||||
|
|
||||||
|
绝赞音符额外有奖励分:
|
||||||
|
|
||||||
|
| 判定 | 奖励分 |
|
||||||
|
| --- | --- |
|
||||||
|
| Critical Perfect | 100 |
|
||||||
|
| Perfect | 75(FastPerfect/LatePerfect)/ 50(Perfect2nd) |
|
||||||
|
| Great | 40 |
|
||||||
|
| Good | 30 |
|
||||||
|
| Miss | 0 |
|
||||||
|
|
||||||
|
这就是绝赞必须区分 CP / Perfect 的原因:普通分一样,但奖励分不同
|
||||||
|
(CP 100、Perfect 75 或 50)。
|
||||||
|
|
||||||
|
### 音符组的划分依据(反编译源码)
|
||||||
|
|
||||||
|
- 走长按结算的 Break(`BRHLD`)判定档位更少(Great 只有一档、Perfect 只有 75 档),所以单独成组。
|
||||||
|
- 滑条系(`BRSI_`/`BRSCL`…)走 SlideOut 判定表,只有 Great 三档、没有 Perfect 档。
|
||||||
|
- EX 音符(`XTP`/`XST`/`BXTAP`/`BXSTR`,即 ExTap/ExStar/ExBreakTap/ExBreakStar)
|
||||||
|
走 ExTap 判定表,非 Critical 的窗口宽度为 0,所以**只能判 Critical 或 Miss**。
|
||||||
|
- `XHO`/`BXHLD`(ExHold/ExBreakHold)头部用 ExTap 表但结果还要过长按结算,
|
||||||
|
等价于普通 Hold / Break(长按判定)。
|
||||||
|
|
||||||
|
## 文件
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
| --- | --- |
|
||||||
|
| `ma2.py` | `.ma2` 解析、指令→音符组映射、`Music.xml` 读取、路径拼接 |
|
||||||
|
| `score.py` | 计分模型、正向求解、反向求解(区间 / 精确) |
|
||||||
|
| `chart_achievement.py` | 命令行界面 / 交互向导 |
|
||||||
|
|
||||||
|
## 已知局限
|
||||||
|
|
||||||
|
- 只按谱面文本计算,不涉及游戏运行时的实际判定时机分布。绝赞的 Great 档位(失分 20% / 40% / 50%)
|
||||||
|
和 Perfect 档位(奖励 75 / 50)需要自己用 `--break-great40` / `--break-great50` /
|
||||||
|
`--break-perfect50` 指定,不给就按 20% / 75 算。
|
||||||
|
- 汇总模式(只给总判定数)只能给区间——这不是精度问题,是信息量不够:同样的判定总数,
|
||||||
|
落在绝赞上和落在 Tap 上扣的分不同。要确切值就按类别给(`--tap` / `--hold` / `--slide` / `--break`)。
|
||||||
|
- 精确反向只验证坏音符数 ≤ 12 的组合(见上文)。
|
||||||
|
- 若 `.ma2` 里的 `T_NUM_ALL` 与解析出的计分音符数不一致,会在输出里给警告
|
||||||
|
(全部 5419 个游戏谱面实测一致)。
|
||||||
@@ -0,0 +1,651 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""舞萌DX 谱面达成率 / DX 分计算器。
|
||||||
|
|
||||||
|
正向:给 谱面 + 各判定数量 -> 算达成率与 DX 分
|
||||||
|
反向:给 谱面 + 目标达成率 -> 倒算所有可行的判定组合与 DX 分
|
||||||
|
|
||||||
|
只依赖 Python 标准库。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from fractions import Fraction
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from ma2 import (DIFFICULTY_NAMES, GROUP_LABEL, GROUP_ORDER, Chart,
|
||||||
|
chart_path, find_music_dir, level_display, parse_difficulty,
|
||||||
|
parse_ma2, parse_music_xml)
|
||||||
|
from score import (CLASS_LABEL, CLASS_ORDER, CLASS_WEIGHT, DX_PER, JUDGE_LABEL,
|
||||||
|
JUDGMENTS, Theory, class_capacity, dx_score,
|
||||||
|
reference_achievement, solve_exact, solve_forward,
|
||||||
|
solve_reverse, theory_from_chart)
|
||||||
|
|
||||||
|
try: # 中文输出到管道时避免 UnicodeEncodeError
|
||||||
|
sys.stdout.reconfigure(errors="replace")
|
||||||
|
sys.stderr.reconfigure(errors="replace")
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
pass
|
||||||
|
|
||||||
|
LINE = "-" * 60
|
||||||
|
|
||||||
|
|
||||||
|
def pct(value) -> str:
|
||||||
|
return "%.4f%%" % float(value)
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg: str, code: int = 2) -> "None":
|
||||||
|
print("[错误] " + msg, file=sys.stderr)
|
||||||
|
raise SystemExit(code)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------- 载入谱面
|
||||||
|
def _enrich(chart: Chart, path: str) -> Chart:
|
||||||
|
"""从同目录的 Music.xml 补全曲名 / 难度 / 等级(拿不到就跳过)。"""
|
||||||
|
base = os.path.basename(path)
|
||||||
|
stem = os.path.splitext(base)[0]
|
||||||
|
if "_" in stem:
|
||||||
|
left, right = stem.split("_", 1)
|
||||||
|
try:
|
||||||
|
chart.music_id = int(left)
|
||||||
|
chart.difficulty_index = int(right)
|
||||||
|
chart.difficulty_name = DIFFICULTY_NAMES[chart.difficulty_index]
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
pass
|
||||||
|
xml_path = os.path.join(os.path.dirname(path), "Music.xml")
|
||||||
|
if os.path.isfile(xml_path):
|
||||||
|
info = parse_music_xml(xml_path)
|
||||||
|
chart.song_name = info.get("name") or chart.song_name
|
||||||
|
notes = info.get("notes") or []
|
||||||
|
idx = chart.difficulty_index
|
||||||
|
if idx is not None and 0 <= idx < len(notes):
|
||||||
|
entry = notes[idx]
|
||||||
|
chart.level_text = level_display(entry.get("level"), entry.get("levelDecimal"))
|
||||||
|
try:
|
||||||
|
chart.max_notes_xml = int(entry.get("maxNotes") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
chart.max_notes_xml = None
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def load_from_file(path: str) -> Chart:
|
||||||
|
path = os.path.abspath(os.path.expanduser(path))
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
die("谱面文件不存在:%s" % path)
|
||||||
|
return _enrich(parse_ma2(path), path)
|
||||||
|
|
||||||
|
|
||||||
|
def load_from_game(game_path: str, music_id: int, difficulty: int) -> Chart:
|
||||||
|
music_dir = find_music_dir(game_path)
|
||||||
|
path = chart_path(music_dir, music_id, difficulty)
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
alt = os.path.join(music_dir, "music%06d" % music_id)
|
||||||
|
hint = ""
|
||||||
|
if os.path.isdir(alt):
|
||||||
|
have = sorted(f for f in os.listdir(alt) if f.lower().endswith(".ma2"))
|
||||||
|
hint = ";该曲目现有谱面:" + (", ".join(have) if have else "无")
|
||||||
|
die("找不到谱面:%s%s" % (path, hint))
|
||||||
|
chart = parse_ma2(path)
|
||||||
|
chart.music_id = music_id
|
||||||
|
chart.difficulty_index = difficulty
|
||||||
|
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
|
||||||
|
chart = _enrich(chart, path)
|
||||||
|
chart.difficulty_index = difficulty
|
||||||
|
chart.difficulty_name = DIFFICULTY_NAMES[difficulty] if 0 <= difficulty <= 4 else "?"
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 展示
|
||||||
|
def print_chart(chart: Chart, th: Theory) -> None:
|
||||||
|
print(LINE)
|
||||||
|
print("谱面")
|
||||||
|
print(" 文件 %s" % chart.path)
|
||||||
|
if chart.song_name:
|
||||||
|
print(" 曲目 %s" % chart.song_name)
|
||||||
|
if chart.music_id is not None:
|
||||||
|
print(" 编号 %d" % chart.music_id)
|
||||||
|
if chart.difficulty_name:
|
||||||
|
print(" 难度 %s (%d)" % (chart.difficulty_name.upper(),
|
||||||
|
chart.difficulty_index if chart.difficulty_index is not None else 0))
|
||||||
|
if chart.level_text:
|
||||||
|
print(" 等级 %s" % chart.level_text)
|
||||||
|
parts = []
|
||||||
|
for g in GROUP_ORDER:
|
||||||
|
n = th.counts.get(g, 0)
|
||||||
|
if n:
|
||||||
|
parts.append("%s %d" % (GROUP_LABEL[g], n))
|
||||||
|
print(" 音符 %s" % " · ".join(parts))
|
||||||
|
print(" 合计 %d 个计分音符" % th.total_notes)
|
||||||
|
if chart.connect_count:
|
||||||
|
print(" (另有 %d 个接续滑条节点,不计分)" % chart.connect_count)
|
||||||
|
bonus = " + 绝赞奖励 %d" % th.break_bonus_score if th.break_bonus_score else ""
|
||||||
|
print(" 理论分 %d%s" % (th.all_perfect_score, bonus))
|
||||||
|
print(" 理论DX %d" % th.max_dx)
|
||||||
|
print(" 满分 %s" % pct(th.max_achievement))
|
||||||
|
for w in chart.warnings:
|
||||||
|
print(" [警告] %s" % w)
|
||||||
|
declared = chart.totals.get("TTM_SCR_SS")
|
||||||
|
if declared is not None and declared != th.all_perfect_score:
|
||||||
|
print(" [警告] 谱面自带 TTM_SCR_SS=%d 与计算出的理论分 %d 不一致"
|
||||||
|
% (declared, th.all_perfect_score))
|
||||||
|
|
||||||
|
|
||||||
|
def print_forward(chart: Chart, result) -> None:
|
||||||
|
th = result.theory
|
||||||
|
print(LINE)
|
||||||
|
print("判定输入(汇总)")
|
||||||
|
for j in JUDGMENTS:
|
||||||
|
print(" %-18s %d" % (JUDGE_LABEL[j], result.judges.get(j, 0)))
|
||||||
|
print(" %-18s %d / %d" % ("合计", sum(result.judges.values()), th.total_notes))
|
||||||
|
print(LINE)
|
||||||
|
print("结果")
|
||||||
|
exact = result.achievement_min == result.achievement_max
|
||||||
|
if exact:
|
||||||
|
print(" 达成率 %s (确切值)" % pct(result.achievement_min))
|
||||||
|
else:
|
||||||
|
print(" 达成率 %s (按音符类别比例分摊的估计值)" % pct(result.achievement_ref))
|
||||||
|
print(" 区间 %s ~ %s"
|
||||||
|
% (pct(result.achievement_min), pct(result.achievement_max)))
|
||||||
|
print(" DX 分 %d / %d (%.2f%%)"
|
||||||
|
% (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0))
|
||||||
|
if not exact:
|
||||||
|
print()
|
||||||
|
print(" 说明:只给汇总判定数时,达成率取决于 Great/Good/Miss 落在哪类音符上——")
|
||||||
|
print(" 区间下界 = 扣分都落在权重最高的音符上,上界 = 都落在权重最低的音符上。")
|
||||||
|
print(" 想要一个确切数值:用 --tap / --hold / --slide / --break 按类别给判定数。")
|
||||||
|
print(LINE)
|
||||||
|
|
||||||
|
|
||||||
|
_EXACT_KEYS = ("cp", "perfect", "perfect50", "great", "great40", "great50", "good", "miss")
|
||||||
|
_EXACT_KEY_LABEL = {
|
||||||
|
"cp": "CP", "perfect": "Perfect", "perfect50": "Perfect(50)",
|
||||||
|
"great": "Great", "great40": "Great(40%)", "great50": "Great(50%)",
|
||||||
|
"good": "Good", "miss": "Miss",
|
||||||
|
}
|
||||||
|
# 打印时用纯 ASCII,避免中文宽字符把表格撑歪
|
||||||
|
_EXACT_CLASS_TITLE = {
|
||||||
|
"tap": "Tap/Star/Touch/EX", "hold": "Hold", "slide": "Slide", "break": "Break",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def print_exact(chart: Chart, result) -> None:
|
||||||
|
th = result.theory
|
||||||
|
print(LINE)
|
||||||
|
print("判定输入(按音符类别;Break = 绝赞;没给的部分按 CP 算)")
|
||||||
|
for cls in CLASS_ORDER:
|
||||||
|
cap = class_capacity(th, cls)
|
||||||
|
if not cap:
|
||||||
|
continue
|
||||||
|
parts = []
|
||||||
|
for key in _EXACT_KEYS:
|
||||||
|
n = result.spec.get(cls, {}).get(key, 0)
|
||||||
|
if n:
|
||||||
|
parts.append("%s %d" % (_EXACT_KEY_LABEL[key], n))
|
||||||
|
print(" %-24s %4d 个:%s"
|
||||||
|
% ("%s (%d)" % (_EXACT_CLASS_TITLE[cls], CLASS_WEIGHT[cls]), cap,
|
||||||
|
" · ".join(parts) if parts else "-"))
|
||||||
|
print(LINE)
|
||||||
|
print("结果")
|
||||||
|
print(" 达成率 %s (确切值)" % pct(result.achievement))
|
||||||
|
print(" DX 分 %d / %d (%.2f%%)"
|
||||||
|
% (result.dx, th.max_dx, 100.0 * result.dx / th.max_dx if th.max_dx else 0.0))
|
||||||
|
if th.all_perfect_score:
|
||||||
|
print(" 失分 %d / %d (%.4f%%)"
|
||||||
|
% (result.loss_total, th.all_perfect_score,
|
||||||
|
100.0 * result.loss_total / th.all_perfect_score))
|
||||||
|
if th.break_bonus_score:
|
||||||
|
print(" 绝赞奖励 %d / %d (%.4f%%)"
|
||||||
|
% (result.bonus_total, th.break_bonus_score,
|
||||||
|
100.0 * result.bonus_total / th.break_bonus_score))
|
||||||
|
print(LINE)
|
||||||
|
|
||||||
|
|
||||||
|
def print_reverse(chart: Chart, outcome) -> None:
|
||||||
|
th = outcome["theory"]
|
||||||
|
rows = outcome["rows"]
|
||||||
|
mode = outcome.get("mode", "exact")
|
||||||
|
print(LINE)
|
||||||
|
print("目标达成率 %s [%s]" % (pct(outcome["target"]),
|
||||||
|
"精确模式" if mode == "exact" else "区间模式"))
|
||||||
|
note = outcome.get("note")
|
||||||
|
if note:
|
||||||
|
print(" [说明] %s" % note)
|
||||||
|
if not rows:
|
||||||
|
if mode == "exact":
|
||||||
|
cap = outcome.get("exact_cap") or 0
|
||||||
|
print(" (在严格验证范围内没有找到解:坏音符 ≤ %d 的组合里没有能精确打出该值的)" % cap)
|
||||||
|
if outcome.get("more_unverified"):
|
||||||
|
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
|
||||||
|
else:
|
||||||
|
print(" (无解:该达成率在这个谱面上打不出来)")
|
||||||
|
print(LINE)
|
||||||
|
return
|
||||||
|
if mode == "exact":
|
||||||
|
print(" Miss Good Great CP+P CP 范围 DX 分范围")
|
||||||
|
else:
|
||||||
|
print(" Miss Good Great CP+P CP 范围 达成率范围 DX 分范围")
|
||||||
|
for r in rows:
|
||||||
|
cp_range = "%d~%d" % (r.cp_min, r.cp_max) if r.cp_min != r.cp_max else str(r.cp_min)
|
||||||
|
dx_range = "%d~%d" % (r.dx_min, r.dx_max) if r.dx_min != r.dx_max else str(r.dx_min)
|
||||||
|
if mode == "exact":
|
||||||
|
print(" %4d %4d %5d %4d %-11s %s"
|
||||||
|
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, dx_range))
|
||||||
|
else:
|
||||||
|
ach = "%s ~ %s" % (pct(r.achievement_min), pct(r.achievement_max))
|
||||||
|
print(" %4d %4d %5d %4d %-11s %-24s %s"
|
||||||
|
% (r.miss, r.good, r.great, r.cp_plus_perfect, cp_range, ach, dx_range))
|
||||||
|
print()
|
||||||
|
if mode == "exact":
|
||||||
|
print(" 共 %d 组能精确打出 %s 的判定组合(CP+P = Critical Perfect 与 Perfect 之和)"
|
||||||
|
% (len(rows), pct(outcome["target"])))
|
||||||
|
cap = outcome.get("exact_cap") or 0
|
||||||
|
print(" 已逐个严格验证 Miss+Good+Great ≤ %d 的组合;每个组合都真的能打出该达成率" % cap)
|
||||||
|
if outcome.get("unverified"):
|
||||||
|
print(" [注意] 另有 %d 组候选(坏音符超过 %d 个)未做精确验证,未列入结果"
|
||||||
|
% (outcome["unverified"], cap))
|
||||||
|
if outcome.get("more_unverified"):
|
||||||
|
print(" [注意] 坏音符超过 %d 个的候选组合没有验证(不保证那里没有解)" % cap)
|
||||||
|
else:
|
||||||
|
print(" 共 %d 组可行组合(CP+P = Critical Perfect 与 Perfect 之和)" % len(rows))
|
||||||
|
print(" 区间模式:只保证达成率落在这个范围内,不保证能恰好打出目标值")
|
||||||
|
if outcome["truncated"]:
|
||||||
|
print(" [注意] 结果被 --limit / 扫描上限截断,可能还有更多组合")
|
||||||
|
print(" DX 分 = 3*CP + 2*Perfect + 1*Great;CP 越靠范围上端,DX 分越高")
|
||||||
|
print(LINE)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ JSON
|
||||||
|
def to_json(chart: Chart, th: Theory, payload: Dict) -> str:
|
||||||
|
data = {
|
||||||
|
"chart": {
|
||||||
|
"path": chart.path,
|
||||||
|
"music_id": chart.music_id,
|
||||||
|
"song_name": chart.song_name,
|
||||||
|
"difficulty": chart.difficulty_name,
|
||||||
|
"difficulty_index": chart.difficulty_index,
|
||||||
|
"level_text": chart.level_text,
|
||||||
|
"notes": th.counts,
|
||||||
|
"total_notes": th.total_notes,
|
||||||
|
"connect_slide": chart.connect_count,
|
||||||
|
"all_perfect_score": th.all_perfect_score,
|
||||||
|
"break_bonus_score": th.break_bonus_score,
|
||||||
|
"max_dx": th.max_dx,
|
||||||
|
"max_achievement": float(th.max_achievement),
|
||||||
|
"warnings": chart.warnings,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
data.update(payload)
|
||||||
|
return json.dumps(data, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 交互
|
||||||
|
def ask(prompt: str, default: str = "") -> str:
|
||||||
|
try:
|
||||||
|
text = input(prompt).strip()
|
||||||
|
except EOFError:
|
||||||
|
text = ""
|
||||||
|
return text or default
|
||||||
|
|
||||||
|
|
||||||
|
def ask_int(prompt: str, default: Optional[int] = None) -> Optional[int]:
|
||||||
|
while True:
|
||||||
|
raw = ask(prompt, "" if default is None else str(default))
|
||||||
|
if raw == "" and default is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except ValueError:
|
||||||
|
print(" 请输入整数")
|
||||||
|
|
||||||
|
|
||||||
|
def interactive(args) -> None:
|
||||||
|
print("=" * 60)
|
||||||
|
print("舞萌DX 谱面达成率 / DX 分 计算器")
|
||||||
|
print("=" * 60)
|
||||||
|
chart = None
|
||||||
|
while chart is None:
|
||||||
|
mode = ask("谱面来源:1) .ma2 文件路径 2) 游戏路径 + musicid [1]: ", "1")
|
||||||
|
if mode.startswith("2"):
|
||||||
|
game = ask("游戏路径(Sinmai_Data 目录): ")
|
||||||
|
if not game:
|
||||||
|
continue
|
||||||
|
mid = ask_int("musicid: ")
|
||||||
|
if mid is None:
|
||||||
|
continue
|
||||||
|
diff = ask("难度 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER [3]: ", "3")
|
||||||
|
try:
|
||||||
|
chart = load_from_game(game, mid, parse_difficulty(diff))
|
||||||
|
except SystemExit:
|
||||||
|
chart = None
|
||||||
|
else:
|
||||||
|
path = ask("谱面文件路径: ")
|
||||||
|
if not path:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
chart = load_from_file(path)
|
||||||
|
except SystemExit:
|
||||||
|
chart = None
|
||||||
|
th = theory_from_chart(chart)
|
||||||
|
print_chart(chart, th)
|
||||||
|
|
||||||
|
mode = ask("计算模式:1) 汇总判定数(估计值 + 区间) 2) 按音符类别给判定数(确切值)"
|
||||||
|
" 3) 由达成率倒算判定数 [1]: ", "1")
|
||||||
|
if mode.startswith("3"):
|
||||||
|
while True:
|
||||||
|
raw = ask("目标达成率(如 99.5 或 100.5): ")
|
||||||
|
if not raw:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
target = Fraction(raw.rstrip("%"))
|
||||||
|
break
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
print(" 请输入数字")
|
||||||
|
mode = ask("反算方式:1) 精确(只列真能打出该达成率的组合) 2) 区间(判定数只是估算时用) [1]: ", "1")
|
||||||
|
args.mode = "range" if mode.startswith("2") else "exact"
|
||||||
|
run_reverse(chart, th, target, args)
|
||||||
|
elif mode.startswith("2"):
|
||||||
|
spec = {}
|
||||||
|
for cls in CLASS_ORDER:
|
||||||
|
cap = class_capacity(th, cls)
|
||||||
|
if not cap:
|
||||||
|
continue
|
||||||
|
raw = ask(" %s(%d 个)CP/Perfect/Great/Good/Miss [%d/0/0/0/0]: "
|
||||||
|
% (CLASS_LABEL[cls], cap, cap))
|
||||||
|
if not raw:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
spec[cls] = parse_class_counts(raw, cls)
|
||||||
|
except ValueError as exc:
|
||||||
|
print(" [错误] %s" % exc)
|
||||||
|
run_exact(chart, th, spec, args)
|
||||||
|
else:
|
||||||
|
values = {}
|
||||||
|
for j in JUDGMENTS:
|
||||||
|
if j == "miss":
|
||||||
|
continue
|
||||||
|
values[j] = ask_int(" %s 数 [0]: " % JUDGE_LABEL[j], 0) or 0
|
||||||
|
values["miss"] = ask_int(" Miss 数(留空 = 总数减去以上判定): ")
|
||||||
|
run_forward(chart, th, values, args)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 执行
|
||||||
|
def normalize_judges(th: Theory, raw: Dict[str, Optional[int]]) -> Dict[str, int]:
|
||||||
|
given = {j: int(raw.get(j) or 0) for j in JUDGMENTS}
|
||||||
|
if raw.get("miss") is None:
|
||||||
|
given["miss"] = th.total_notes - sum(given[j] for j in JUDGMENTS if j != "miss")
|
||||||
|
if given["miss"] < 0:
|
||||||
|
die("CP+Perfect+Great+Good 合计 %d 超过了音符总数 %d"
|
||||||
|
% (sum(given[j] for j in JUDGMENTS if j != "miss"), th.total_notes))
|
||||||
|
total = sum(given.values())
|
||||||
|
if total != th.total_notes:
|
||||||
|
die("判定数之和 %d 与音符总数 %d 不一致(Miss 请留空自动补足)" % (total, th.total_notes))
|
||||||
|
return given
|
||||||
|
|
||||||
|
|
||||||
|
_CLASS_KEYS = ("cp", "perfect", "great", "good", "miss")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_class_counts(text: str, cls: str) -> Dict[str, int]:
|
||||||
|
"""把 "CP/Perfect/Great/Good/Miss"(斜杠、逗号或空格分隔)解析成判定数字典。"""
|
||||||
|
parts = [p for p in re.split(r"[\/,\s]+", str(text).strip()) if p]
|
||||||
|
if len(parts) > len(_CLASS_KEYS):
|
||||||
|
raise ValueError("%s 最多 %d 个数(CP/Perfect/Great/Good/Miss):%s"
|
||||||
|
% (CLASS_LABEL[cls], len(_CLASS_KEYS), text))
|
||||||
|
out: Dict[str, int] = {}
|
||||||
|
for key, raw in zip(_CLASS_KEYS, parts):
|
||||||
|
try:
|
||||||
|
n = int(raw)
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError("%s 里的「%s」不是整数:%s" % (CLASS_LABEL[cls], raw, text))
|
||||||
|
if n < 0:
|
||||||
|
raise ValueError("%s 里不能有负数:%s" % (CLASS_LABEL[cls], text))
|
||||||
|
if n:
|
||||||
|
out[key] = n
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def build_spec(args) -> Dict[str, Dict[str, int]]:
|
||||||
|
"""把 --tap/--hold/--slide/--break 以及绝赞档位参数拼成 solve_exact 的输入。"""
|
||||||
|
spec: Dict[str, Dict[str, int]] = {}
|
||||||
|
for cls in CLASS_ORDER:
|
||||||
|
text = getattr(args, cls, None)
|
||||||
|
if text:
|
||||||
|
spec[cls] = parse_class_counts(text, cls)
|
||||||
|
brk = dict(spec.get("break") or {})
|
||||||
|
perfect_total = brk.get("perfect", 0)
|
||||||
|
great_total = brk.get("great", 0)
|
||||||
|
p50 = int(args.break_perfect50 or 0)
|
||||||
|
g40 = int(args.break_great40 or 0)
|
||||||
|
g50 = int(args.break_great50 or 0)
|
||||||
|
if p50 or g40 or g50:
|
||||||
|
if p50 > perfect_total:
|
||||||
|
raise ValueError("--break-perfect50 %d 超过绝赞 Perfect 总数 %d"
|
||||||
|
"(先在 --break 里写 CP/Perfect/Great/Good/Miss)" % (p50, perfect_total))
|
||||||
|
if g40 + g50 > great_total:
|
||||||
|
raise ValueError("--break-great40 + --break-great50 = %d 超过绝赞 Great 总数 %d"
|
||||||
|
% (g40 + g50, great_total))
|
||||||
|
if p50:
|
||||||
|
brk["perfect"] = perfect_total - p50
|
||||||
|
brk["perfect50"] = p50
|
||||||
|
if g40:
|
||||||
|
brk["great40"] = g40
|
||||||
|
if g50:
|
||||||
|
brk["great50"] = g50
|
||||||
|
if great_total - g40 - g50:
|
||||||
|
brk["great"] = great_total - g40 - g50
|
||||||
|
else:
|
||||||
|
brk.pop("great", None)
|
||||||
|
brk = {k: v for k, v in brk.items() if v}
|
||||||
|
spec["break"] = brk
|
||||||
|
return spec
|
||||||
|
|
||||||
|
|
||||||
|
def run_exact(chart: Chart, th: Theory, spec: Dict[str, Dict[str, int]], args) -> None:
|
||||||
|
try:
|
||||||
|
result = solve_exact(th, spec)
|
||||||
|
except ValueError as exc:
|
||||||
|
die(str(exc))
|
||||||
|
if args.json:
|
||||||
|
print(to_json(chart, th, {
|
||||||
|
"mode": "forward_exact",
|
||||||
|
"judges_by_class": result.spec,
|
||||||
|
"achievement": float(result.achievement),
|
||||||
|
"dx_score": result.dx,
|
||||||
|
"loss_total": result.loss_total,
|
||||||
|
"bonus_total": result.bonus_total,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
print_chart(chart, th)
|
||||||
|
print_exact(chart, result)
|
||||||
|
|
||||||
|
|
||||||
|
def run_forward(chart: Chart, th: Theory,
|
||||||
|
raw: Dict[str, Optional[int]], args) -> None:
|
||||||
|
judges = normalize_judges(th, raw)
|
||||||
|
result = solve_forward(th, judges)
|
||||||
|
if args.json:
|
||||||
|
print(to_json(chart, th, {
|
||||||
|
"mode": "forward",
|
||||||
|
"judges": judges,
|
||||||
|
"achievement_min": float(result.achievement_min),
|
||||||
|
"achievement_max": float(result.achievement_max),
|
||||||
|
"achievement_reference": float(result.achievement_ref),
|
||||||
|
"dx_score": result.dx,
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
print_chart(chart, th)
|
||||||
|
print_forward(chart, result)
|
||||||
|
|
||||||
|
|
||||||
|
def run_reverse(chart: Chart, th: Theory,
|
||||||
|
target: Fraction, args) -> None:
|
||||||
|
outcome = solve_reverse(th, target, limit=args.limit, mode=args.mode)
|
||||||
|
if args.json:
|
||||||
|
print(to_json(chart, th, {
|
||||||
|
"mode": "reverse",
|
||||||
|
"search_mode": outcome["mode"],
|
||||||
|
"target": float(target),
|
||||||
|
"truncated": outcome["truncated"],
|
||||||
|
"exact_cap": outcome.get("exact_cap"),
|
||||||
|
"unverified": outcome.get("unverified", 0),
|
||||||
|
"more_unverified": outcome.get("more_unverified", False),
|
||||||
|
"note": outcome.get("note"),
|
||||||
|
"solutions": [{
|
||||||
|
"miss": r.miss, "good": r.good, "great": r.great,
|
||||||
|
"critical_perfect_plus_perfect": r.cp_plus_perfect,
|
||||||
|
"critical_perfect_min": r.cp_min, "critical_perfect_max": r.cp_max,
|
||||||
|
"achievement_min": float(r.achievement_min),
|
||||||
|
"achievement_max": float(r.achievement_max),
|
||||||
|
"dx_min": r.dx_min, "dx_max": r.dx_max,
|
||||||
|
} for r in outcome["rows"]],
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
print_chart(chart, th)
|
||||||
|
print_reverse(chart, outcome)
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(
|
||||||
|
prog="chart_achievement.py",
|
||||||
|
description="舞萌DX 谱面达成率 / DX 分计算器(正向计算 + 由达成率倒算判定数)",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""示例:
|
||||||
|
# 直接给谱面文件,正向计算(汇总判定数 -> 估计值 + 区间)
|
||||||
|
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --cp 290 --perfect 10 --great 2
|
||||||
|
|
||||||
|
# 按音符类别给判定数 -> 确切的达成率(CP/Perfect/Great/Good/Miss,可省略后面的)
|
||||||
|
python chart_achievement.py --chart "D:\\SDGB2026\\Package\\Sinmai_Data\\StreamingAssets\\A000\\music\\music000008\\000008_03.ma2" --tap 260/0/3 --break 6
|
||||||
|
|
||||||
|
# 给游戏路径 + musicid + 难度(难度用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)
|
||||||
|
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --cp 300 --perfect 2
|
||||||
|
|
||||||
|
# 由达成率倒算判定组合(精确模式:只列真能打出该值的组合)
|
||||||
|
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5
|
||||||
|
|
||||||
|
# 判定数只是估算时,用区间模式
|
||||||
|
python chart_achievement.py --game "D:\\SDGB2026\\Package\\Sinmai_Data" --music-id 8 --level 3 --achievement 99.5 --mode range
|
||||||
|
""")
|
||||||
|
src = p.add_argument_group("谱面来源(二选一)")
|
||||||
|
src.add_argument("--chart", metavar="PATH", help=".ma2 谱面文件路径")
|
||||||
|
src.add_argument("--game", metavar="PATH", help="游戏目录(Sinmai_Data / StreamingAssets / A000 / music 均可)")
|
||||||
|
src.add_argument("--music-id", type=int, metavar="ID", help="曲目编号(配合 --game 使用)")
|
||||||
|
src.add_argument("--level", "--difficulty", dest="level", default=None, metavar="D",
|
||||||
|
help="谱面难度,用游戏里的数字:0=BASIC 1=ADVANCED 2=EXPERT "
|
||||||
|
"3=MASTER 4=RE:MASTER(也可直接写名字;默认 3=MASTER)")
|
||||||
|
|
||||||
|
fw = p.add_argument_group("正向计算(汇总):只给总判定数,得到估计值 + 区间")
|
||||||
|
fw.add_argument("--cp", type=int, metavar="N", help="Critical Perfect 个数")
|
||||||
|
fw.add_argument("--perfect", type=int, metavar="N", help="Perfect 个数")
|
||||||
|
fw.add_argument("--great", type=int, metavar="N", help="Great 个数")
|
||||||
|
fw.add_argument("--good", type=int, metavar="N", help="Good 个数")
|
||||||
|
fw.add_argument("--miss", type=int, metavar="N", help="Miss 个数(留空 = 总数减去其它判定)")
|
||||||
|
|
||||||
|
ex = p.add_argument_group(
|
||||||
|
"正向计算(精确):按音符类别给判定数,得到确切的达成率",
|
||||||
|
"每个类别写 CP/Perfect/Great/Good/Miss 五个数(斜杠或空格分隔,后面可以省略),"
|
||||||
|
"没给满的部分按 CP 算。")
|
||||||
|
ex.add_argument("--tap", metavar="CP/P/G/GD/M",
|
||||||
|
help="Tap/Star/Touch/EX 类(权重 500)")
|
||||||
|
ex.add_argument("--hold", metavar="CP/P/G/GD/M", help="Hold 系(权重 1000)")
|
||||||
|
ex.add_argument("--slide", metavar="CP/P/G/GD/M", help="Slide 系(权重 1500)")
|
||||||
|
ex.add_argument("--break", dest="break", metavar="CP/P/G/GD/M",
|
||||||
|
help="绝赞(权重 2500;Great/Perfect 档位见下面三个参数)")
|
||||||
|
ex.add_argument("--break-perfect50", type=int, metavar="N",
|
||||||
|
help="绝赞 Perfect 里奖励分只有 50 的个数(Perfect2nd)")
|
||||||
|
ex.add_argument("--break-great40", type=int, metavar="N",
|
||||||
|
help="绝赞 Great 里失分 40%% 的个数(Great2nd)")
|
||||||
|
ex.add_argument("--break-great50", type=int, metavar="N",
|
||||||
|
help="绝赞 Great 里失分 50%% 的个数(Great3rd)")
|
||||||
|
|
||||||
|
rv = p.add_argument_group("反向计算:已知达成率")
|
||||||
|
rv.add_argument("--achievement", metavar="A", help="目标达成率(如 99.5 / 100.5)")
|
||||||
|
rv.add_argument("--mode", choices=("exact", "range"), default="exact",
|
||||||
|
help="exact(默认):只列真的能打出该达成率的组合;"
|
||||||
|
"range:列出达成率区间覆盖该值的组合(更快,输入不精确时用)")
|
||||||
|
rv.add_argument("--limit", type=int, default=200, metavar="N",
|
||||||
|
help="反向计算最多输出多少组解(默认 200)")
|
||||||
|
|
||||||
|
p.add_argument("--json", action="store_true", help="以 JSON 输出结果")
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Optional[List[str]] = None) -> int:
|
||||||
|
args = build_parser().parse_args(argv)
|
||||||
|
|
||||||
|
if args.chart and args.game:
|
||||||
|
die("--chart 与 --game 只能给一个")
|
||||||
|
if not args.chart and not args.game:
|
||||||
|
interactive(args)
|
||||||
|
return 0
|
||||||
|
if args.chart:
|
||||||
|
chart = load_from_file(args.chart)
|
||||||
|
if args.level is not None:
|
||||||
|
try:
|
||||||
|
want = parse_difficulty(args.level)
|
||||||
|
except ValueError as exc:
|
||||||
|
die(str(exc))
|
||||||
|
if chart.difficulty_index is not None and want != chart.difficulty_index:
|
||||||
|
print("[注意] --level 给的是 %s,但谱面文件名里是 %s,按文件名算"
|
||||||
|
% (DIFFICULTY_NAMES[want], DIFFICULTY_NAMES[chart.difficulty_index]),
|
||||||
|
file=sys.stderr)
|
||||||
|
else:
|
||||||
|
if args.music_id is None:
|
||||||
|
die("使用 --game 时必须同时给 --music-id")
|
||||||
|
if args.level is None:
|
||||||
|
print("[提示] 没有给 --level,按默认难度 3(MASTER)计算"
|
||||||
|
"(0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER)", file=sys.stderr)
|
||||||
|
try:
|
||||||
|
difficulty = parse_difficulty(args.level if args.level is not None else "master")
|
||||||
|
except ValueError as exc:
|
||||||
|
die(str(exc))
|
||||||
|
chart = load_from_game(args.game, args.music_id, difficulty)
|
||||||
|
|
||||||
|
th = theory_from_chart(chart)
|
||||||
|
if th.total_notes == 0:
|
||||||
|
die("谱面里没有解析到任何计分音符:%s" % chart.path)
|
||||||
|
|
||||||
|
if args.achievement:
|
||||||
|
try:
|
||||||
|
target = Fraction(str(args.achievement).rstrip("%"))
|
||||||
|
except (ValueError, ZeroDivisionError):
|
||||||
|
die("--achievement 需要是数字,如 99.5")
|
||||||
|
if target > th.max_achievement:
|
||||||
|
die("目标达成率 %s 超过该谱面上限 %s" % (pct(target), pct(th.max_achievement)))
|
||||||
|
if target < 0:
|
||||||
|
die("目标达成率不能为负")
|
||||||
|
run_reverse(chart, th, target, args)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class_flags = {"tap": args.tap, "hold": args.hold, "slide": args.slide,
|
||||||
|
"break": getattr(args, "break", None),
|
||||||
|
"perfect50": args.break_perfect50,
|
||||||
|
"great40": args.break_great40, "great50": args.break_great50}
|
||||||
|
if any(v for v in class_flags.values()):
|
||||||
|
if any(v is not None for v in (args.cp, args.perfect, args.great, args.good, args.miss)):
|
||||||
|
die("汇总判定数(--cp/--perfect/--great/--good/--miss)"
|
||||||
|
"和按类别判定数(--tap/--hold/--slide/--break)只能给一种")
|
||||||
|
try:
|
||||||
|
spec = build_spec(args)
|
||||||
|
except ValueError as exc:
|
||||||
|
die(str(exc))
|
||||||
|
run_exact(chart, th, spec, args)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
raw = {"cp": args.cp, "perfect": args.perfect, "great": args.great,
|
||||||
|
"good": args.good, "miss": args.miss}
|
||||||
|
if all(v is None for v in raw.values()):
|
||||||
|
die("请给判定数:汇总用 --cp/--perfect/--great/--good,"
|
||||||
|
"精确用 --tap/--hold/--slide/--break;或用 --achievement 做反向计算")
|
||||||
|
run_forward(chart, th, raw, args)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""舞萌DX (maimai DX) .ma2 谱面解析。
|
||||||
|
|
||||||
|
指令 -> 音符类型 的对应表来自反编译源码
|
||||||
|
source/Assembly-CSharp/Manager/Ma2fileRecordID.cs
|
||||||
|
(s_Ma2fileRecord_Data 里 Ma2Category.MA2_Note 的 102 条记录)
|
||||||
|
计分类别的划分复刻
|
||||||
|
source/Assembly-CSharp/Manager/GamePlayManager.cs NoteType2ScoreType()
|
||||||
|
理论分权重来自
|
||||||
|
source/Assembly-CSharp/Manager/NotesReader.cs calcTotal()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 计分权重
|
||||||
|
WEIGHT_TAP = 500 # TAP / XTP / STR / XST / TTP(Touch 与 Tap 同为 500)
|
||||||
|
WEIGHT_HOLD = 1000 # HLD / XHO / THO
|
||||||
|
WEIGHT_SLIDE = 1500 # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_
|
||||||
|
WEIGHT_BREAK = 2500 # BRK / BST / BRTAP / BRHLD / BRSTR / BRSI_ ... 以及 ExBreak 系
|
||||||
|
BREAK_BONUS_THEORY = 100 # 每个绝赞(Break)的理论奖励分
|
||||||
|
|
||||||
|
CATEGORY_TAP = "Tap"
|
||||||
|
CATEGORY_HOLD = "Hold"
|
||||||
|
CATEGORY_SLIDE = "Slide"
|
||||||
|
CATEGORY_BREAK = "Break"
|
||||||
|
CATEGORY_CONNECT = "ConnectSlide"
|
||||||
|
|
||||||
|
CATEGORY_ORDER = (CATEGORY_TAP, CATEGORY_HOLD, CATEGORY_SLIDE, CATEGORY_BREAK)
|
||||||
|
CATEGORY_WEIGHT = {
|
||||||
|
CATEGORY_TAP: WEIGHT_TAP,
|
||||||
|
CATEGORY_HOLD: WEIGHT_HOLD,
|
||||||
|
CATEGORY_SLIDE: WEIGHT_SLIDE,
|
||||||
|
CATEGORY_BREAK: WEIGHT_BREAK,
|
||||||
|
}
|
||||||
|
CATEGORY_LABEL = {
|
||||||
|
CATEGORY_TAP: "Tap/Touch",
|
||||||
|
CATEGORY_HOLD: "Hold",
|
||||||
|
CATEGORY_SLIDE: "Slide",
|
||||||
|
CATEGORY_BREAK: "Break",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------- ma2 指令 -> NotesTypeID.Def
|
||||||
|
NOTE_COMMAND_TYPES: Dict[str, str] = {
|
||||||
|
# --- 现行指令 ---
|
||||||
|
"TAP": "Begin", "BRK": "Break", "XTP": "ExTap", "HLD": "Hold", "XHO": "ExHold",
|
||||||
|
"STR": "Star", "BST": "BreakStar", "XST": "ExStar",
|
||||||
|
"TTP": "TouchTap", "THO": "TouchHold",
|
||||||
|
"SI_": "Slide", "SCL": "Slide", "SCR": "Slide", "SUL": "Slide", "SUR": "Slide",
|
||||||
|
"SSL": "Slide", "SSR": "Slide", "SV_": "Slide", "SXL": "Slide", "SXR": "Slide",
|
||||||
|
"SLL": "Slide", "SLR": "Slide", "SF_": "Slide",
|
||||||
|
# --- 旧式别名(游戏数据里真实存在)---
|
||||||
|
"NMTAP": "Begin", "BRTAP": "Break", "EXTAP": "ExTap", "BXTAP": "ExBreakTap",
|
||||||
|
"NMHLD": "Hold", "BRHLD": "BreakHold", "EXHLD": "ExHold", "BXHLD": "ExBreakHold",
|
||||||
|
"NMSTR": "Star", "BRSTR": "BreakStar", "EXSTR": "ExStar", "BXSTR": "ExBreakStar",
|
||||||
|
"NMTTP": "TouchTap", "NMTHO": "TouchHold",
|
||||||
|
"NMSI_": "Slide", "BRSI_": "BreakSlide", "EXSI_": "ExSlide", "BXSI_": "ExBreakSlide", "CNSI_": "ConnectSlide",
|
||||||
|
"NMSCL": "Slide", "BRSCL": "BreakSlide", "EXSCL": "ExSlide", "BXSCL": "ExBreakSlide", "CNSCL": "ConnectSlide",
|
||||||
|
"NMSCR": "Slide", "BRSCR": "BreakSlide", "EXSCR": "ExSlide", "BXSCR": "ExBreakSlide", "CNSCR": "ConnectSlide",
|
||||||
|
"NMSUL": "Slide", "BRSUL": "BreakSlide", "EXSUL": "ExSlide", "BXSUL": "ExBreakSlide", "CNSUL": "ConnectSlide",
|
||||||
|
"NMSUR": "Slide", "BRSUR": "BreakSlide", "EXSUR": "ExSlide", "BXSUR": "ExBreakSlide", "CNSUR": "ConnectSlide",
|
||||||
|
"NMSSL": "Slide", "BRSSL": "BreakSlide", "EXSSL": "ExSlide", "BXSSL": "ExBreakSlide", "CNSSL": "ConnectSlide",
|
||||||
|
"NMSSR": "Slide", "BRSSR": "BreakSlide", "EXSSR": "ExSlide", "BXSSR": "ExBreakSlide", "CNSSR": "ConnectSlide",
|
||||||
|
"NMSV_": "Slide", "BRSV_": "BreakSlide", "EXSV_": "ExSlide", "BXSV_": "ExBreakSlide", "CNSV_": "ConnectSlide",
|
||||||
|
"NMSXL": "Slide", "BRSXL": "BreakSlide", "EXSXL": "ExSlide", "BXSXL": "ExBreakSlide", "CNSXL": "ConnectSlide",
|
||||||
|
"NMSXR": "Slide", "BRSXR": "BreakSlide", "EXSXR": "ExSlide", "BXSXR": "ExBreakSlide", "CNSXR": "ConnectSlide",
|
||||||
|
"NMSLL": "Slide", "BRSLL": "BreakSlide", "EXSLL": "ExSlide", "BXSLL": "ExBreakSlide", "CNSLL": "ConnectSlide",
|
||||||
|
"NMSLR": "Slide", "BRSLR": "BreakSlide", "EXSLR": "ExSlide", "BXSLR": "ExBreakSlide", "CNSLR": "ConnectSlide",
|
||||||
|
"NMSF_": "Slide", "BRSF_": "BreakSlide", "EXSF_": "ExSlide", "BXSF_": "ExBreakSlide", "CNSF_": "ConnectSlide",
|
||||||
|
}
|
||||||
|
|
||||||
|
_BREAK_DEFS = frozenset((
|
||||||
|
"Break", "BreakHold", "BreakStar", "BreakSlide",
|
||||||
|
"ExBreakTap", "ExBreakHold", "ExBreakStar", "ExBreakSlide",
|
||||||
|
))
|
||||||
|
_SLIDE_DEFS = frozenset(("Slide", "BreakSlide", "ExSlide", "ExBreakSlide"))
|
||||||
|
_HOLD_DEFS = frozenset(("Hold", "BreakHold", "ExHold", "ExBreakHold", "TouchHold"))
|
||||||
|
_TOUCH_DEFS = frozenset(("TouchTap", "TouchHold"))
|
||||||
|
_CONNECT_DEFS = frozenset(("ConnectSlide",))
|
||||||
|
|
||||||
|
# 判定时机只有 Critical / TooLate 的 EX 音符(ExTap/ExStar/ExBreakTap/ExBreakStar)
|
||||||
|
EX_DEFS = frozenset(("ExTap", "ExStar", "ExBreakTap", "ExBreakStar"))
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 音符组
|
||||||
|
# 「音符组」= 计分行为完全相同的一类音符:权重、可选判定、失分档位、绝赞奖励档位都一样。
|
||||||
|
# 划分依据(反编译源码):
|
||||||
|
# * 计分类型 NoteScore.EScoreType 由 GamePlayManager.NoteType2ScoreType() 决定
|
||||||
|
# (Break 系 -> Break,滑条 -> Slide,长按 -> Hold,其余 -> Tap/Touch)
|
||||||
|
# * 判定时机表 NoteJudge.EJudgeType 由各音符类决定:
|
||||||
|
# TapNote/StarNote -> Tap(或 ExNote 时 ExTap)
|
||||||
|
# HoldNote/BreakHoldNote/TouchHoldC -> 长按结算 JudgeHoldTotal(不分 2nd/3rd 档)
|
||||||
|
# SlideRoot/SlideFan -> SlideOut(不分 2nd/3rd 档,也没有非 Critical 的 Perfect)
|
||||||
|
# BreakNote/BreakStarNote -> Break 表(Great 有 1250/1500/2000 三档)
|
||||||
|
# * ExTap 判定表 [-9,-9,-9,-9,-9,-9,-9,0,9,9,9,9,9,9,9] 使非 Critical 的档位全部不可达
|
||||||
|
# -> ExTap/ExStar(含 ExBreak 版)只能 Critical 或 Miss
|
||||||
|
# * ExHold/ExBreakHold 的头部用 ExTap 表,但结果还要过 JudgeHoldTotal 重映射,
|
||||||
|
# 最终仍可得到 Critical/Perfect/Great/Good/Miss -> 与普通 Hold / BreakHold 相同
|
||||||
|
# * ExSlide / ExBreakSlide 的 JudgeType 恒为 SlideOut,且 isBreak() 不含 ExBreakSlide
|
||||||
|
# -> 计分与普通 Slide 完全一致(游戏数据里这两种指令出现次数为 0)
|
||||||
|
GROUP_TAP = "tap" # TAP NMTAP STR NMSTR TTP NMTTP 权重 500
|
||||||
|
GROUP_HOLD = "hold" # HLD NMHLD XHO EXHLD THO NMTHO 权重 1000
|
||||||
|
GROUP_SLIDE = "slide" # SI_ SCL SCR SUL SUR SSL SSR SV_ SXL SXR SLL SLR SF_ + NM/EX/CN 变体 权重 1500
|
||||||
|
GROUP_BREAK = "break" # BRK BRTAP BST BRSTR 权重 2500,Great 三档、Perfect 奖励两档
|
||||||
|
GROUP_BREAK_FLAT = "break_flat" # BRHLD BXHLD 权重 2500,长按判定,不分档
|
||||||
|
GROUP_BREAK_SLIDE = "break_slide" # BRSI_ BRSCL BRSCR BRSUL BRSUR BRSSL BRSSR BRSV_ BRSXL BRSXR BRSLL BRSLR BRSF_
|
||||||
|
GROUP_EX = "ex" # XTP EXTAP XST EXSTR 权重 500,只能 Critical / Miss
|
||||||
|
GROUP_EX_BREAK = "ex_break" # BXTAP BXSTR 权重 2500,只能 Critical / Miss
|
||||||
|
|
||||||
|
GROUP_ORDER = (GROUP_TAP, GROUP_HOLD, GROUP_SLIDE, GROUP_BREAK,
|
||||||
|
GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX, GROUP_EX_BREAK)
|
||||||
|
GROUP_LABEL = {
|
||||||
|
GROUP_TAP: "Tap/Star/Touch",
|
||||||
|
GROUP_HOLD: "Hold",
|
||||||
|
GROUP_SLIDE: "Slide",
|
||||||
|
GROUP_BREAK: "Break",
|
||||||
|
GROUP_BREAK_FLAT: "Break(长按判定)",
|
||||||
|
GROUP_BREAK_SLIDE: "Break(滑条判定)",
|
||||||
|
GROUP_EX: "EX Tap/Star",
|
||||||
|
GROUP_EX_BREAK: "EX Break",
|
||||||
|
}
|
||||||
|
GROUP_WEIGHT = {
|
||||||
|
GROUP_TAP: WEIGHT_TAP,
|
||||||
|
GROUP_HOLD: WEIGHT_HOLD,
|
||||||
|
GROUP_SLIDE: WEIGHT_SLIDE,
|
||||||
|
GROUP_BREAK: WEIGHT_BREAK,
|
||||||
|
GROUP_BREAK_FLAT: WEIGHT_BREAK,
|
||||||
|
GROUP_BREAK_SLIDE: WEIGHT_BREAK,
|
||||||
|
GROUP_EX: WEIGHT_TAP,
|
||||||
|
GROUP_EX_BREAK: WEIGHT_BREAK,
|
||||||
|
}
|
||||||
|
GROUP_IS_BREAK = frozenset((GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK))
|
||||||
|
|
||||||
|
_GROUP_BY_DEF = {
|
||||||
|
"Begin": GROUP_TAP, "Star": GROUP_TAP, "TouchTap": GROUP_TAP,
|
||||||
|
"Hold": GROUP_HOLD, "ExHold": GROUP_HOLD, "TouchHold": GROUP_HOLD,
|
||||||
|
"Slide": GROUP_SLIDE, "ExSlide": GROUP_SLIDE, "ExBreakSlide": GROUP_SLIDE,
|
||||||
|
"Break": GROUP_BREAK, "BreakStar": GROUP_BREAK,
|
||||||
|
"BreakHold": GROUP_BREAK_FLAT, "ExBreakHold": GROUP_BREAK_FLAT,
|
||||||
|
"BreakSlide": GROUP_BREAK_SLIDE,
|
||||||
|
"ExTap": GROUP_EX, "ExStar": GROUP_EX,
|
||||||
|
"ExBreakTap": GROUP_EX_BREAK, "ExBreakStar": GROUP_EX_BREAK,
|
||||||
|
}
|
||||||
|
_CATEGORY_BY_GROUP = {
|
||||||
|
GROUP_TAP: CATEGORY_TAP, GROUP_HOLD: CATEGORY_HOLD, GROUP_SLIDE: CATEGORY_SLIDE,
|
||||||
|
GROUP_BREAK: CATEGORY_BREAK, GROUP_BREAK_FLAT: CATEGORY_BREAK,
|
||||||
|
GROUP_BREAK_SLIDE: CATEGORY_BREAK, GROUP_EX: CATEGORY_TAP, GROUP_EX_BREAK: CATEGORY_BREAK,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def note_group(def_name: str) -> Optional[str]:
|
||||||
|
"""NotesTypeID.Def 名 -> 音符组;接续滑条等不计分音符返回 None。"""
|
||||||
|
return _GROUP_BY_DEF.get(def_name)
|
||||||
|
|
||||||
|
|
||||||
|
def score_category(def_name: str) -> Optional[str]:
|
||||||
|
"""复刻 GamePlayManager.NoteType2ScoreType() 的粗分类(Tap/Hold/Slide/Break)。"""
|
||||||
|
grp = note_group(def_name)
|
||||||
|
if grp is None:
|
||||||
|
return None
|
||||||
|
return _CATEGORY_BY_GROUP[grp]
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 谱面对象
|
||||||
|
@dataclass
|
||||||
|
class Chart:
|
||||||
|
path: str = "<memory>"
|
||||||
|
command_counts: Dict[str, int] = field(default_factory=dict)
|
||||||
|
category_counts: Dict[str, int] = field(default_factory=dict)
|
||||||
|
group_counts: Dict[str, int] = field(default_factory=dict)
|
||||||
|
connect_count: int = 0
|
||||||
|
header: Dict[str, str] = field(default_factory=dict)
|
||||||
|
totals: Dict[str, int] = field(default_factory=dict)
|
||||||
|
warnings: List[str] = field(default_factory=list)
|
||||||
|
# 外部补充的信息(游戏路径模式)
|
||||||
|
music_id: Optional[int] = None
|
||||||
|
difficulty_index: Optional[int] = None
|
||||||
|
difficulty_name: Optional[str] = None
|
||||||
|
song_name: Optional[str] = None
|
||||||
|
level_text: Optional[str] = None
|
||||||
|
max_notes_xml: Optional[int] = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def total_notes(self) -> int:
|
||||||
|
"""计分音符总数 N(不含接续滑条)。"""
|
||||||
|
return sum(self.group_counts.get(g, 0) for g in GROUP_ORDER)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def declared_total(self) -> Optional[int]:
|
||||||
|
return self.totals.get("T_NUM_ALL")
|
||||||
|
|
||||||
|
def counts(self) -> Dict[str, int]:
|
||||||
|
"""按音符组统计(供计分模型使用)。"""
|
||||||
|
return {g: self.group_counts.get(g, 0) for g in GROUP_ORDER}
|
||||||
|
|
||||||
|
def category_summary(self) -> Dict[str, int]:
|
||||||
|
return {c: self.category_counts.get(c, 0) for c in CATEGORY_ORDER}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ma2(path: str) -> Chart:
|
||||||
|
with open(path, "rb") as fp:
|
||||||
|
raw = fp.read()
|
||||||
|
chart = parse_ma2_bytes(raw, path)
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ma2_bytes(raw: bytes, path: str = "<memory>") -> Chart:
|
||||||
|
chart = Chart(path=path)
|
||||||
|
text = raw.decode("latin1")
|
||||||
|
unknown = set()
|
||||||
|
for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
parts = line.split("\t")
|
||||||
|
if len(parts) == 1:
|
||||||
|
parts = line.split()
|
||||||
|
if not parts:
|
||||||
|
continue
|
||||||
|
cmd = parts[0]
|
||||||
|
args = parts[1:]
|
||||||
|
|
||||||
|
def_name = NOTE_COMMAND_TYPES.get(cmd)
|
||||||
|
if def_name is not None:
|
||||||
|
chart.command_counts[cmd] = chart.command_counts.get(cmd, 0) + 1
|
||||||
|
grp = note_group(def_name)
|
||||||
|
if grp is None:
|
||||||
|
chart.connect_count += 1
|
||||||
|
else:
|
||||||
|
chart.group_counts[grp] = chart.group_counts.get(grp, 0) + 1
|
||||||
|
cat = score_category(def_name)
|
||||||
|
chart.category_counts[cat] = chart.category_counts.get(cat, 0) + 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd.startswith("T_") or cmd.startswith("TTM_"):
|
||||||
|
if args:
|
||||||
|
try:
|
||||||
|
chart.totals[cmd] = int(args[0])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
continue
|
||||||
|
|
||||||
|
if cmd not in chart.header and args:
|
||||||
|
chart.header[cmd] = "\t".join(args)
|
||||||
|
elif not args and cmd.isupper() and len(cmd) > 2:
|
||||||
|
unknown.add(cmd)
|
||||||
|
|
||||||
|
declared = chart.declared_total
|
||||||
|
if declared is not None and declared != chart.total_notes:
|
||||||
|
chart.warnings.append(
|
||||||
|
"T_NUM_ALL=%d 与解析出的计分音符数 %d 不一致" % (declared, chart.total_notes)
|
||||||
|
)
|
||||||
|
return chart
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 游戏路径解析
|
||||||
|
DIFFICULTY_NAMES = ("Basic", "Advanced", "Expert", "Master", "Re:Master")
|
||||||
|
_DIFFICULTY_ALIASES = {
|
||||||
|
"basic": 0, "bas": 0, "bsc": 0, "0": 0, "00": 0,
|
||||||
|
"advanced": 1, "adv": 1, "1": 1, "01": 1,
|
||||||
|
"expert": 2, "exp": 2, "2": 2, "02": 2,
|
||||||
|
"master": 3, "mas": 3, "mst": 3, "3": 3, "03": 3,
|
||||||
|
"remaster": 4, "re:master": 4, "remas": 4, "re": 4, "4": 4, "04": 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def parse_difficulty(text) -> int:
|
||||||
|
if isinstance(text, int):
|
||||||
|
if 0 <= text <= 4:
|
||||||
|
return text
|
||||||
|
raise ValueError("难度序号必须是 0~4")
|
||||||
|
key = str(text).strip().lower()
|
||||||
|
if key in _DIFFICULTY_ALIASES:
|
||||||
|
return _DIFFICULTY_ALIASES[key]
|
||||||
|
raise ValueError("无法识别的难度:%s(可用 0=BASIC 1=ADVANCED 2=EXPERT 3=MASTER 4=RE:MASTER,"
|
||||||
|
"或直接写名字)" % text)
|
||||||
|
|
||||||
|
|
||||||
|
def find_music_dir(game_path: str) -> str:
|
||||||
|
"""把「游戏根目录 / Sinmai_Data / StreamingAssets / A000」都归一化到 A000/music。"""
|
||||||
|
p = os.path.abspath(os.path.expanduser(game_path))
|
||||||
|
if not os.path.isdir(p):
|
||||||
|
raise FileNotFoundError("路径不存在:%s" % p)
|
||||||
|
if os.path.basename(p).lower() == "music" and os.path.basename(os.path.dirname(p)).upper() == "A000":
|
||||||
|
return p
|
||||||
|
candidates = [
|
||||||
|
p,
|
||||||
|
os.path.join(p, "music"),
|
||||||
|
os.path.join(p, "A000", "music"),
|
||||||
|
os.path.join(p, "StreamingAssets", "A000", "music"),
|
||||||
|
os.path.join(p, "Sinmai_Data", "StreamingAssets", "A000", "music"),
|
||||||
|
os.path.join(p, "Package", "Sinmai_Data", "StreamingAssets", "A000", "music"),
|
||||||
|
]
|
||||||
|
for c in candidates:
|
||||||
|
if os.path.isdir(c) and os.path.basename(c).lower() == "music":
|
||||||
|
return c
|
||||||
|
raise FileNotFoundError("在 %s 下找不到 A000/music 目录" % p)
|
||||||
|
|
||||||
|
|
||||||
|
def music_dir_path(music_dir: str, music_id: int) -> str:
|
||||||
|
return os.path.join(music_dir, "music%06d" % music_id)
|
||||||
|
|
||||||
|
|
||||||
|
def chart_path(music_dir: str, music_id: int, difficulty: int) -> str:
|
||||||
|
return os.path.join(music_dir_path(music_dir, music_id), "%06d_%02d.ma2" % (music_id, difficulty))
|
||||||
|
|
||||||
|
|
||||||
|
def parse_music_xml(path: str) -> Dict[str, object]:
|
||||||
|
"""读取 Music.xml(只用标准库),取出曲名与各难度信息。"""
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
info: Dict[str, object] = {"notes": []}
|
||||||
|
try:
|
||||||
|
root = ET.parse(path).getroot()
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
return {"error": str(exc), "notes": []}
|
||||||
|
|
||||||
|
def text_of(node, tag):
|
||||||
|
child = node.find(tag)
|
||||||
|
if child is None:
|
||||||
|
return None
|
||||||
|
if child.text and child.text.strip():
|
||||||
|
return child.text.strip()
|
||||||
|
sub = child.find("str")
|
||||||
|
if sub is not None and sub.text and sub.text.strip():
|
||||||
|
return sub.text.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
info["name"] = text_of(root, "name")
|
||||||
|
info["artist"] = text_of(root, "artistName")
|
||||||
|
info["genre"] = text_of(root, "genreName")
|
||||||
|
info["bpm"] = text_of(root, "bpm")
|
||||||
|
notes_data = root.find("notesData")
|
||||||
|
notes = []
|
||||||
|
if notes_data is not None:
|
||||||
|
for node in notes_data.findall("Notes"):
|
||||||
|
f = node.find("file/path")
|
||||||
|
notes.append({
|
||||||
|
"file": f.text.strip() if f is not None and f.text else None,
|
||||||
|
"level": text_of(node, "level"),
|
||||||
|
"levelDecimal": text_of(node, "levelDecimal"),
|
||||||
|
"musicLevelID": text_of(node, "musicLevelID"),
|
||||||
|
"maxNotes": text_of(node, "maxNotes"),
|
||||||
|
"designer": text_of(node, "notesDesigner"),
|
||||||
|
"isEnable": text_of(node, "isEnable"),
|
||||||
|
})
|
||||||
|
info["notes"] = notes
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def level_display(level, level_decimal) -> str:
|
||||||
|
"""12 + levelDecimal 4 -> '12+';10 + 2 -> '10+';7 + 0 -> '7'。"""
|
||||||
|
try:
|
||||||
|
lv = int(level)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
dec = int(level_decimal or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
dec = 0
|
||||||
|
if dec >= 3:
|
||||||
|
return "%d+" % lv
|
||||||
|
if dec > 0:
|
||||||
|
return "%d.%d" % (lv, dec)
|
||||||
|
return "%d" % lv
|
||||||
@@ -0,0 +1,949 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""舞萌DX 达成率 / DX 分计算模型(按音符组精确建模)。
|
||||||
|
|
||||||
|
达成率(source/Assembly-CSharp/Manager/GameScoreList.cs:1238-1310 CalcUpdate())::
|
||||||
|
|
||||||
|
Achivement = num / _allPerfectScore * 100 + num2 / _breakBonusScore
|
||||||
|
|
||||||
|
其中 num = Σ 普通得分 NoteScore.GetJudgeScore(timing, 计分类型)
|
||||||
|
num2 = Σ 绝赞奖励分(只有绝赞音符有)NoteScore.GetJudgeScore(timing, BreakBonus)
|
||||||
|
_allPerfectScore = Σ 每个音符的满权重(NotesReader.calcTotal())
|
||||||
|
_breakBonusScore = 100 × 绝赞音符数
|
||||||
|
满分 = 101.0000%(有绝赞)/ 100.0000%(无绝赞)。
|
||||||
|
|
||||||
|
DX 分(JudgeResultSt.UpdateScore + NoteJudge.ConvertJudge)::
|
||||||
|
|
||||||
|
DX = 3×CriticalPerfect + 2×Perfect + 1×Great(Good/Miss 不加分)
|
||||||
|
|
||||||
|
与音符类型无关,理论 DX = 3 × 计分音符数。
|
||||||
|
|
||||||
|
模型要点
|
||||||
|
--------
|
||||||
|
* 音符按「组」划分,同一组内计分行为完全一致(权重、可选判定、失分档位、绝赞奖励档位)。
|
||||||
|
* 绝赞(Break 类)的 Great 有 3 档失分、Perfect 奖励有 2 档(75 / 50),滑条判定的绝赞
|
||||||
|
没有 Perfect 档,长按判定的绝赞不分档;EX 判定(ExTap 表)只能 Critical 或 Miss。
|
||||||
|
* 因此「给定各判定的总个数」并不能唯一确定达成率:达成率依赖这些判定落在哪些音符上。
|
||||||
|
本模块用最小费用流求达成率的精确上下界,并可严格判定「能否恰好打出某个达成率」。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from collections import deque
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from fractions import Fraction
|
||||||
|
from typing import Dict, List, Optional, Sequence, Tuple
|
||||||
|
|
||||||
|
from ma2 import (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX,
|
||||||
|
GROUP_EX_BREAK, GROUP_HOLD, GROUP_LABEL, GROUP_ORDER,
|
||||||
|
GROUP_SLIDE, GROUP_TAP, GROUP_WEIGHT, Chart)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 常量
|
||||||
|
JUDGMENTS = ("cp", "perfect", "great", "good", "miss")
|
||||||
|
JUDGE_LABEL = {
|
||||||
|
"cp": "Critical Perfect",
|
||||||
|
"perfect": "Perfect",
|
||||||
|
"great": "Great",
|
||||||
|
"good": "Good",
|
||||||
|
"miss": "Miss",
|
||||||
|
}
|
||||||
|
DX_PER = {"cp": 3, "perfect": 2, "great": 1, "good": 0, "miss": 0}
|
||||||
|
|
||||||
|
#: 每个音符组里,某判定实际可能出现的 (失分, 绝赞奖励分) 组合。
|
||||||
|
#: 失分 = 该音符满权重 - 实际得分;奖励分只对绝赞(计分类型 Break)音符存在。
|
||||||
|
GROUP_OPTIONS: Dict[str, Dict[str, Tuple[Tuple[int, int], ...]]] = {
|
||||||
|
GROUP_TAP: {
|
||||||
|
"cp": ((0, 0),),
|
||||||
|
"perfect": ((0, 0),),
|
||||||
|
"great": ((100, 0),), # 400
|
||||||
|
"good": ((250, 0),), # 250
|
||||||
|
"miss": ((500, 0),), # 0
|
||||||
|
},
|
||||||
|
GROUP_HOLD: {
|
||||||
|
"cp": ((0, 0),),
|
||||||
|
"perfect": ((0, 0),),
|
||||||
|
"great": ((200, 0),), # 800
|
||||||
|
"good": ((500, 0),), # 500
|
||||||
|
"miss": ((1000, 0),), # 0
|
||||||
|
},
|
||||||
|
GROUP_SLIDE: {
|
||||||
|
"cp": ((0, 0),),
|
||||||
|
"perfect": ((0, 0),),
|
||||||
|
"great": ((300, 0),), # 1200
|
||||||
|
"good": ((750, 0),), # 750
|
||||||
|
"miss": ((1500, 0),), # 0
|
||||||
|
},
|
||||||
|
GROUP_BREAK: {
|
||||||
|
"cp": ((0, 100),),
|
||||||
|
"perfect": ((0, 75), (0, 50)), # FastPerfect/LatePerfect 与 2nd 档
|
||||||
|
"great": ((500, 40), (1000, 40), (1250, 40)), # 2000 / 1500 / 1250
|
||||||
|
"good": ((1500, 30),), # 1000
|
||||||
|
"miss": ((2500, 0),), # 0
|
||||||
|
},
|
||||||
|
GROUP_BREAK_FLAT: {
|
||||||
|
"cp": ((0, 100),),
|
||||||
|
"perfect": ((0, 75),), # 长按判定没有 2nd 档
|
||||||
|
"great": ((500, 40),), # 只有 2000 一档
|
||||||
|
"good": ((1500, 30),),
|
||||||
|
"miss": ((2500, 0),),
|
||||||
|
},
|
||||||
|
GROUP_BREAK_SLIDE: {
|
||||||
|
"cp": ((0, 100),),
|
||||||
|
# 滑条判定没有非 Critical 的 Perfect
|
||||||
|
"great": ((500, 40), (1000, 40), (1250, 40)),
|
||||||
|
"good": ((1500, 30),),
|
||||||
|
"miss": ((2500, 0),),
|
||||||
|
},
|
||||||
|
GROUP_EX: {
|
||||||
|
"cp": ((0, 0),),
|
||||||
|
"miss": ((500, 0),), # ExTap 判定表只有 Critical / TooFast / TooLate
|
||||||
|
},
|
||||||
|
GROUP_EX_BREAK: {
|
||||||
|
"cp": ((0, 100),),
|
||||||
|
"miss": ((2500, 0),),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_BAD_JUDGMENTS = ("great", "good", "miss")
|
||||||
|
|
||||||
|
#: 坏音符落在该组时,对 (可出现的绝赞音符数 bad_b, 加权和 slo) 的贡献。
|
||||||
|
#: slo = 2×bad_break + 3×bad_break_flat + 4×bad_break_slide + 4×bad_ex_break,
|
||||||
|
#: 用于还原「绝赞音符里 cp/perfect 的奖励分取值范围」。
|
||||||
|
_GROUP_BAD_STEP = {
|
||||||
|
GROUP_BREAK: (1, 2),
|
||||||
|
GROUP_BREAK_FLAT: (1, 3),
|
||||||
|
GROUP_BREAK_SLIDE: (1, 4),
|
||||||
|
GROUP_EX_BREAK: (1, 4),
|
||||||
|
}
|
||||||
|
|
||||||
|
#: 精确模式严格验证的最大坏音符数(Miss+Good+Great 总和)。超过则只做区间判定。
|
||||||
|
EXACT_MAX_BAD = 12 # 精确验证时最多考虑多少个坏音符(Miss+Good+Great)
|
||||||
|
EXACT_TIME_BUDGET = 8.0 # 精确验证 DP 的时间预算(秒),超时退回区间模式
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 理论值
|
||||||
|
@dataclass
|
||||||
|
class Theory:
|
||||||
|
"""一个谱面的计分理论值。"""
|
||||||
|
|
||||||
|
counts: Dict[str, int] # 音符组 -> 音符数
|
||||||
|
weights: Dict[str, int] # 音符组 -> 单音符满权重
|
||||||
|
all_perfect_score: int # _allPerfectScore
|
||||||
|
break_bonus_score: int # _breakBonusScore = 100 × 绝赞音符数
|
||||||
|
total_notes: int # 计分音符总数 N(不含接续滑条)
|
||||||
|
max_dx: int # 理论 DX = 3N
|
||||||
|
lattice: int # 所有单音符失分-奖励组合的最大公约数
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_break(self) -> bool:
|
||||||
|
return self.break_bonus_score > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def break_notes(self) -> int:
|
||||||
|
"""绝赞音符总数(计分类型为 Break 的音符)。"""
|
||||||
|
return sum(self.counts.get(g, 0) for g in _GROUP_BAD_STEP)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def denominator(self) -> int:
|
||||||
|
"""达成率公式的分母:100 - Σc/D 中的 D。"""
|
||||||
|
if self.break_bonus_score:
|
||||||
|
return self.all_perfect_score * self.break_bonus_score
|
||||||
|
return self.all_perfect_score
|
||||||
|
|
||||||
|
@property
|
||||||
|
def max_achievement(self) -> Fraction:
|
||||||
|
return Fraction(101 if self.break_bonus_score else 100)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def perfect_capacity(self) -> int:
|
||||||
|
"""允许出现 Perfect 的绝赞音符数(Break 与 Break(长按判定))。"""
|
||||||
|
return (self.counts.get(GROUP_BREAK, 0)
|
||||||
|
+ self.counts.get(GROUP_BREAK_FLAT, 0))
|
||||||
|
|
||||||
|
|
||||||
|
def _lattice_of(th: "Theory") -> int:
|
||||||
|
g = 0
|
||||||
|
for group in GROUP_ORDER:
|
||||||
|
for opts in GROUP_OPTIONS[group].values():
|
||||||
|
for loss, bonus in opts:
|
||||||
|
g = math.gcd(g, _cost(th, loss, bonus))
|
||||||
|
return g
|
||||||
|
|
||||||
|
|
||||||
|
def theory_from_counts(counts: Dict[str, int]) -> Theory:
|
||||||
|
"""由「音符组 -> 音符数」构造理论值。"""
|
||||||
|
clean: Dict[str, int] = {}
|
||||||
|
for key, value in (counts or {}).items():
|
||||||
|
if key not in GROUP_OPTIONS:
|
||||||
|
raise ValueError("未知的音符组:%r" % (key,))
|
||||||
|
value = int(value or 0)
|
||||||
|
if value < 0:
|
||||||
|
raise ValueError("音符数不能为负:%s=%d" % (key, value))
|
||||||
|
if value:
|
||||||
|
clean[key] = value
|
||||||
|
all_perfect = 0
|
||||||
|
break_notes = 0
|
||||||
|
total = 0
|
||||||
|
for group in GROUP_ORDER:
|
||||||
|
n = clean.get(group, 0)
|
||||||
|
if not n:
|
||||||
|
continue
|
||||||
|
weight = GROUP_WEIGHT[group]
|
||||||
|
all_perfect += weight * n
|
||||||
|
total += n
|
||||||
|
if group in _GROUP_BAD_STEP:
|
||||||
|
break_notes += n
|
||||||
|
th = Theory(
|
||||||
|
counts={g: clean.get(g, 0) for g in GROUP_ORDER},
|
||||||
|
weights={g: GROUP_WEIGHT[g] for g in GROUP_ORDER},
|
||||||
|
all_perfect_score=all_perfect,
|
||||||
|
break_bonus_score=100 * break_notes,
|
||||||
|
total_notes=total,
|
||||||
|
max_dx=3 * total,
|
||||||
|
lattice=0,
|
||||||
|
)
|
||||||
|
th.lattice = _lattice_of(th)
|
||||||
|
return th
|
||||||
|
|
||||||
|
|
||||||
|
def theory_from_chart(chart: Chart) -> Theory:
|
||||||
|
return theory_from_counts(chart.counts())
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 单音符代价
|
||||||
|
def _cost(th: Theory, loss: int, bonus: int) -> int:
|
||||||
|
"""把 (失分, 奖励分) 折算成一个整数代价 c,使达成率 = 100 - Σc / D。"""
|
||||||
|
if th.break_bonus_score:
|
||||||
|
return 100 * th.break_bonus_score * loss - th.all_perfect_score * bonus
|
||||||
|
return 100 * loss
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_cost(th: Theory, group: str, judge: str, want_max: bool) -> Optional[int]:
|
||||||
|
opts = GROUP_OPTIONS.get(group, {}).get(judge)
|
||||||
|
if not opts:
|
||||||
|
return None
|
||||||
|
values = [_cost(th, loss, bonus) for loss, bonus in opts]
|
||||||
|
# want_max 表示「追求最大达成率」,而达成率 = 100 - Σc/D,所以要取最小代价。
|
||||||
|
return min(values) if want_max else max(values)
|
||||||
|
|
||||||
|
|
||||||
|
def _flex_edge_cost(th: Theory, group: str, judge: str, want_max: bool) -> Optional[int]:
|
||||||
|
"""与 _edge_cost 相同,但「好音符」(CP/Perfect)可以在 CP 与 Perfect 之间自由选择。
|
||||||
|
|
||||||
|
只在反向求解的上下界里用:用户没给 CP/Perfect 拆分时,这个选择会影响达成率
|
||||||
|
(绝赞的 CP 奖励 100、Perfect 只有 75/50)。
|
||||||
|
"""
|
||||||
|
if judge == "cp":
|
||||||
|
# 「其余音符」这一档:既可以是 Critical Perfect,也可以是 Perfect
|
||||||
|
# (Perfect 只能落在绝赞音符上,交给流自己挑)
|
||||||
|
opts = (tuple(GROUP_OPTIONS.get(group, {}).get("cp", ()))
|
||||||
|
+ tuple(GROUP_OPTIONS.get(group, {}).get("perfect", ())))
|
||||||
|
else:
|
||||||
|
opts = GROUP_OPTIONS.get(group, {}).get(judge)
|
||||||
|
if not opts:
|
||||||
|
return None
|
||||||
|
values = [_cost(th, loss, bonus) for loss, bonus in opts]
|
||||||
|
return min(values) if want_max else max(values)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 最小费用流
|
||||||
|
class _MinCostFlow:
|
||||||
|
"""连续最短路 + 整条增广(边数很少,够快)。"""
|
||||||
|
|
||||||
|
def __init__(self, n: int) -> None:
|
||||||
|
self.n = n
|
||||||
|
self.graph: List[List[List[int]]] = [[] for _ in range(n)]
|
||||||
|
|
||||||
|
def add_edge(self, u: int, v: int, cap: int, cost: int) -> None:
|
||||||
|
if cap <= 0:
|
||||||
|
return
|
||||||
|
self.graph[u].append([v, cap, cost, len(self.graph[v])])
|
||||||
|
self.graph[v].append([u, 0, -cost, len(self.graph[u]) - 1])
|
||||||
|
|
||||||
|
def min_cost(self, s: int, t: int, need: int) -> Optional[int]:
|
||||||
|
if need <= 0:
|
||||||
|
return 0
|
||||||
|
total = 0
|
||||||
|
flow = 0
|
||||||
|
INF = float("inf")
|
||||||
|
while flow < need:
|
||||||
|
dist = [INF] * self.n
|
||||||
|
prev_v = [-1] * self.n
|
||||||
|
prev_e = [-1] * self.n
|
||||||
|
in_queue = [False] * self.n
|
||||||
|
dist[s] = 0
|
||||||
|
queue = deque([s])
|
||||||
|
in_queue[s] = True
|
||||||
|
while queue:
|
||||||
|
u = queue.popleft()
|
||||||
|
in_queue[u] = False
|
||||||
|
du = dist[u]
|
||||||
|
for i, edge in enumerate(self.graph[u]):
|
||||||
|
v, cap, cost, _rev = edge
|
||||||
|
if cap > 0 and du + cost < dist[v]:
|
||||||
|
dist[v] = du + cost
|
||||||
|
prev_v[v] = u
|
||||||
|
prev_e[v] = i
|
||||||
|
if not in_queue[v]:
|
||||||
|
in_queue[v] = True
|
||||||
|
queue.append(v)
|
||||||
|
if dist[t] == INF:
|
||||||
|
return None
|
||||||
|
add = need - flow
|
||||||
|
v = t
|
||||||
|
while v != s:
|
||||||
|
add = min(add, self.graph[prev_v[v]][prev_e[v]][1])
|
||||||
|
v = prev_v[v]
|
||||||
|
v = t
|
||||||
|
while v != s:
|
||||||
|
edge = self.graph[prev_v[v]][prev_e[v]]
|
||||||
|
edge[1] -= add
|
||||||
|
self.graph[v][edge[3]][1] += add
|
||||||
|
v = prev_v[v]
|
||||||
|
flow += add
|
||||||
|
total += add * dist[t]
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _extreme(th: Theory, judges: Dict[str, int], want_max: bool,
|
||||||
|
flex_good: bool = False) -> Optional[Fraction]:
|
||||||
|
"""在给定各判定总数的前提下,求达成率的极大值 / 极小值。
|
||||||
|
|
||||||
|
flex_good=True 时,「good」这一类里的 CP/Perfect 拆分由流自己挑
|
||||||
|
(反向求解用:只给 Miss/Good/Great 时,CP 与 Perfect 的拆分是自由的)。
|
||||||
|
"""
|
||||||
|
total = sum(int(judges.get(j, 0)) for j in JUDGMENTS)
|
||||||
|
if total != th.total_notes:
|
||||||
|
raise ValueError("判定总数 %d 与谱面音符数 %d 不一致" % (total, th.total_notes))
|
||||||
|
if total == 0:
|
||||||
|
return Fraction(0)
|
||||||
|
groups = [g for g in GROUP_ORDER if th.counts.get(g, 0)]
|
||||||
|
src = 0
|
||||||
|
gbase = 1
|
||||||
|
jbase = gbase + len(groups)
|
||||||
|
sink = jbase + len(JUDGMENTS)
|
||||||
|
mcf = _MinCostFlow(sink + 1)
|
||||||
|
for i, group in enumerate(groups):
|
||||||
|
mcf.add_edge(src, gbase + i, th.counts[group], 0)
|
||||||
|
for j, judge in enumerate(JUDGMENTS):
|
||||||
|
count = int(judges.get(judge, 0))
|
||||||
|
if count:
|
||||||
|
mcf.add_edge(jbase + j, sink, count, 0)
|
||||||
|
for i, group in enumerate(groups):
|
||||||
|
for j, judge in enumerate(JUDGMENTS):
|
||||||
|
count = int(judges.get(judge, 0))
|
||||||
|
if not count:
|
||||||
|
continue
|
||||||
|
cost = (_flex_edge_cost(th, group, judge, want_max) if flex_good
|
||||||
|
else _edge_cost(th, group, judge, want_max))
|
||||||
|
if cost is None:
|
||||||
|
continue # 该判定不可能落在这类音符上:不连边,交给流去判定可行性
|
||||||
|
# 最小费用流只会最小化总代价:求最小达成率时把代价取负,最后再翻回来。
|
||||||
|
mcf.add_edge(gbase + i, jbase + j, min(th.counts[group], count),
|
||||||
|
cost if want_max else -cost)
|
||||||
|
total_cost = mcf.min_cost(src, sink, total)
|
||||||
|
if total_cost is None:
|
||||||
|
return None
|
||||||
|
if not want_max:
|
||||||
|
total_cost = -total_cost
|
||||||
|
return Fraction(100) - Fraction(total_cost, th.denominator)
|
||||||
|
|
||||||
|
|
||||||
|
def achievement_bounds(th: Theory, judges: Dict[str, int]) -> Tuple[Fraction, Fraction]:
|
||||||
|
"""返回 (达成率下界, 达成率上界)。"""
|
||||||
|
lo = _extreme(th, judges, want_max=False)
|
||||||
|
hi = _extreme(th, judges, want_max=True)
|
||||||
|
if lo is None or hi is None:
|
||||||
|
raise ValueError("这些判定数无法落在该谱面上(例如把 Perfect 给了 EX 音符)")
|
||||||
|
return lo, hi
|
||||||
|
|
||||||
|
|
||||||
|
def reference_achievement(th: Theory, judges: Dict[str, int]) -> Fraction:
|
||||||
|
"""按比例分摊的参考达成率:各判定按总数比例摊到每个音符组,档位取中值。"""
|
||||||
|
n = th.total_notes
|
||||||
|
if n == 0:
|
||||||
|
return Fraction(0)
|
||||||
|
loss = Fraction(0)
|
||||||
|
bonus = Fraction(0)
|
||||||
|
for group in GROUP_ORDER:
|
||||||
|
count = th.counts.get(group, 0)
|
||||||
|
if not count:
|
||||||
|
continue
|
||||||
|
for judge in JUDGMENTS:
|
||||||
|
given = int(judges.get(judge, 0))
|
||||||
|
if not given:
|
||||||
|
continue
|
||||||
|
opts = GROUP_OPTIONS[group].get(judge)
|
||||||
|
if not opts:
|
||||||
|
continue
|
||||||
|
losses = sorted({o[0] for o in opts})
|
||||||
|
bonuses = sorted({o[1] for o in opts})
|
||||||
|
share = Fraction(given * count, n)
|
||||||
|
loss += share * Fraction(losses[0] + losses[-1], 2)
|
||||||
|
bonus += share * Fraction(bonuses[0] + bonuses[-1], 2)
|
||||||
|
result = Fraction(100) - 100 * loss / th.all_perfect_score
|
||||||
|
if th.break_bonus_score:
|
||||||
|
result += bonus / th.break_bonus_score
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dx_score(judges: Dict[str, int]) -> int:
|
||||||
|
return sum(DX_PER[j] * int(judges.get(j, 0)) for j in JUDGMENTS)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 正向
|
||||||
|
@dataclass
|
||||||
|
class ForwardResult:
|
||||||
|
theory: Theory
|
||||||
|
judges: Dict[str, int]
|
||||||
|
achievement_min: Fraction
|
||||||
|
achievement_max: Fraction
|
||||||
|
achievement_ref: Fraction
|
||||||
|
dx: int
|
||||||
|
|
||||||
|
@property
|
||||||
|
def exact(self) -> bool:
|
||||||
|
return self.achievement_min == self.achievement_max
|
||||||
|
|
||||||
|
@property
|
||||||
|
def achievement(self) -> Fraction:
|
||||||
|
return self.achievement_min
|
||||||
|
|
||||||
|
|
||||||
|
def solve_forward(th: Theory, judges: Dict[str, int]) -> ForwardResult:
|
||||||
|
clean = {j: int(judges.get(j, 0) or 0) for j in JUDGMENTS}
|
||||||
|
total = sum(clean.values())
|
||||||
|
if total != th.total_notes:
|
||||||
|
raise ValueError("判定总数 %d 与谱面音符数 %d 不一致" % (total, th.total_notes))
|
||||||
|
lo, hi = achievement_bounds(th, clean)
|
||||||
|
return ForwardResult(
|
||||||
|
theory=th,
|
||||||
|
judges=clean,
|
||||||
|
achievement_min=lo,
|
||||||
|
achievement_max=hi,
|
||||||
|
achievement_ref=reference_achievement(th, clean),
|
||||||
|
dx=dx_score(clean),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 正向:按音符类别精确计算
|
||||||
|
# 达成率是按「音符权重」加权的,只给汇总判定数时只能得到区间;
|
||||||
|
# 把判定数按权重类别分开给(绝赞单独一列),就能算出确切的数值。
|
||||||
|
CLASS_ORDER = ("tap", "hold", "slide", "break")
|
||||||
|
CLASS_LABEL = {
|
||||||
|
"tap": "Tap/Star/Touch/EX",
|
||||||
|
"hold": "Hold 系",
|
||||||
|
"slide": "Slide 系",
|
||||||
|
"break": "绝赞(Break 系)",
|
||||||
|
}
|
||||||
|
CLASS_WEIGHT = {"tap": 500, "hold": 1000, "slide": 1500, "break": 2500}
|
||||||
|
CLASS_GROUPS = {
|
||||||
|
"tap": (GROUP_TAP, GROUP_EX),
|
||||||
|
"hold": (GROUP_HOLD,),
|
||||||
|
"slide": (GROUP_SLIDE,),
|
||||||
|
"break": (GROUP_BREAK, GROUP_BREAK_FLAT, GROUP_BREAK_SLIDE, GROUP_EX_BREAK),
|
||||||
|
}
|
||||||
|
# 每类里每个判定键的 (失分, 奖励分);绝赞的 Great / Perfect 有多个档位
|
||||||
|
CLASS_OPTIONS = {
|
||||||
|
"tap": {"cp": (0, 0), "perfect": (0, 0), "great": (100, 0),
|
||||||
|
"good": (250, 0), "miss": (500, 0)},
|
||||||
|
"hold": {"cp": (0, 0), "perfect": (0, 0), "great": (200, 0),
|
||||||
|
"good": (500, 0), "miss": (1000, 0)},
|
||||||
|
"slide": {"cp": (0, 0), "perfect": (0, 0), "great": (300, 0),
|
||||||
|
"good": (750, 0), "miss": (1500, 0)},
|
||||||
|
"break": {"cp": (0, 100), "perfect": (0, 75), "perfect50": (0, 50),
|
||||||
|
"great": (500, 40), "great40": (1000, 40), "great50": (1250, 40),
|
||||||
|
"good": (1500, 30), "miss": (2500, 0)},
|
||||||
|
}
|
||||||
|
_JUDGE_OF_KEY = {
|
||||||
|
"cp": "cp", "perfect": "perfect", "perfect50": "perfect",
|
||||||
|
"great": "great", "great40": "great", "great50": "great",
|
||||||
|
"good": "good", "miss": "miss",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def class_capacity(th: Theory, cls: str) -> int:
|
||||||
|
"""这一类音符在谱面里有多少个。"""
|
||||||
|
return sum(th.counts.get(g, 0) for g in CLASS_GROUPS[cls])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExactResult:
|
||||||
|
theory: Theory
|
||||||
|
spec: Dict[str, Dict[str, int]] # 实际参与计算的判定数(已补足 CP)
|
||||||
|
achievement: Fraction
|
||||||
|
dx: int
|
||||||
|
loss_total: int
|
||||||
|
bonus_total: int
|
||||||
|
breakdown: Dict[str, Tuple[int, int, int]] # 类别 -> (音符数, 失分, 奖励分)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def score(self) -> int:
|
||||||
|
return self.theory.all_perfect_score - self.loss_total
|
||||||
|
|
||||||
|
|
||||||
|
def solve_exact(th: Theory, spec: Dict[str, Dict[str, int]]) -> ExactResult:
|
||||||
|
"""按音符类别给判定数,算出**确切**的达成率与 DX 分。
|
||||||
|
|
||||||
|
spec: {"tap": {"cp": 250, "great": 2}, "break": {"cp": 4, "great": 1}, ...}
|
||||||
|
每类没给满的部分按 CP 补足(非绝赞 CP 与 Perfect 同分;绝赞按 CP 计奖励 100)。
|
||||||
|
"""
|
||||||
|
filled: Dict[str, Dict[str, int]] = {}
|
||||||
|
loss_total = 0
|
||||||
|
bonus_total = 0
|
||||||
|
dx = 0
|
||||||
|
breakdown: Dict[str, Tuple[int, int, int]] = {}
|
||||||
|
for cls in CLASS_ORDER:
|
||||||
|
cap = class_capacity(th, cls)
|
||||||
|
given = {k: int(v) for k, v in (spec.get(cls) or {}).items() if int(v)}
|
||||||
|
for key in given:
|
||||||
|
if key not in CLASS_OPTIONS[cls]:
|
||||||
|
raise ValueError("「%s」没有「%s」这个判定" % (CLASS_LABEL[cls], key))
|
||||||
|
used = sum(given.values())
|
||||||
|
if used > cap:
|
||||||
|
raise ValueError("%s 只有 %d 个音符,但给了 %d 个判定"
|
||||||
|
% (CLASS_LABEL[cls], cap, used))
|
||||||
|
if cap > used:
|
||||||
|
given["cp"] = given.get("cp", 0) + (cap - used)
|
||||||
|
filled[cls] = given
|
||||||
|
cls_loss = 0
|
||||||
|
cls_bonus = 0
|
||||||
|
for key, n in given.items():
|
||||||
|
dl, db = CLASS_OPTIONS[cls][key]
|
||||||
|
cls_loss += dl * n
|
||||||
|
cls_bonus += db * n
|
||||||
|
dx += DX_PER[_JUDGE_OF_KEY[key]] * n
|
||||||
|
loss_total += cls_loss
|
||||||
|
bonus_total += cls_bonus
|
||||||
|
breakdown[cls] = (cap, cls_loss, cls_bonus)
|
||||||
|
|
||||||
|
w = th.all_perfect_score
|
||||||
|
if w <= 0:
|
||||||
|
raise ValueError("谱面里没有计分音符")
|
||||||
|
achievement = Fraction(100) - Fraction(100 * loss_total, w)
|
||||||
|
if th.break_bonus_score:
|
||||||
|
achievement += Fraction(bonus_total, th.break_bonus_score)
|
||||||
|
return ExactResult(
|
||||||
|
theory=th, spec=filled, achievement=achievement, dx=dx,
|
||||||
|
loss_total=loss_total, bonus_total=bonus_total, breakdown=breakdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 反向:工具函数
|
||||||
|
def _judges_for(th: Theory, miss: int, good: int, great: int,
|
||||||
|
cp: int, perfect: int) -> Dict[str, int]:
|
||||||
|
return {"cp": cp, "perfect": perfect, "great": great, "good": good, "miss": miss}
|
||||||
|
|
||||||
|
|
||||||
|
def _best(th: Theory, miss: int, good: int, great: int) -> Optional[Fraction]:
|
||||||
|
"""给定 Miss/Good/Great 数时,达成率能达到的最大值(CP/Perfect 拆分自由)。"""
|
||||||
|
s = th.total_notes - miss - good - great
|
||||||
|
if s < 0:
|
||||||
|
return None
|
||||||
|
return _extreme(th, _judges_for(th, miss, good, great, s, 0), want_max=True,
|
||||||
|
flex_good=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _worst(th: Theory, miss: int, good: int, great: int) -> Optional[Fraction]:
|
||||||
|
"""给定 Miss/Good/Great 数时,达成率能达到的最小值(CP/Perfect 拆分自由)。"""
|
||||||
|
s = th.total_notes - miss - good - great
|
||||||
|
if s < 0:
|
||||||
|
return None
|
||||||
|
return _extreme(th, _judges_for(th, miss, good, great, s, 0), want_max=False,
|
||||||
|
flex_good=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _max_true(lo: int, hi: int, pred) -> int:
|
||||||
|
"""pred 在 [lo, hi] 上单调不增时,求满足 pred 的最大值(无则返回 lo-1)。"""
|
||||||
|
best = lo - 1
|
||||||
|
while lo <= hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
if pred(mid):
|
||||||
|
best = mid
|
||||||
|
lo = mid + 1
|
||||||
|
else:
|
||||||
|
hi = mid - 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
def _min_true(lo: int, hi: int, pred) -> int:
|
||||||
|
"""pred 在 [lo, hi] 上单调不增时,求满足 pred 的最小值(无则返回 hi+1)。"""
|
||||||
|
best = hi + 1
|
||||||
|
while lo <= hi:
|
||||||
|
mid = (lo + hi) // 2
|
||||||
|
if pred(mid):
|
||||||
|
best = mid
|
||||||
|
hi = mid - 1
|
||||||
|
else:
|
||||||
|
lo = mid + 1
|
||||||
|
return best
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 反向:候选行
|
||||||
|
def _candidate_rows(th: Theory, target: Fraction, max_scan: int,
|
||||||
|
bad_limit: Optional[int] = None, stats: Optional[Dict] = None):
|
||||||
|
"""生成「可能覆盖 target」的 (miss, good, great) 组合,顺序由少到多。
|
||||||
|
|
||||||
|
bad_limit 非空时只产出 Miss+Good+Great <= bad_limit 的组合,
|
||||||
|
并在 stats["clipped"] 里记下「还有更多(坏音符更多的)组合没产出」。
|
||||||
|
"""
|
||||||
|
n = th.total_notes
|
||||||
|
scanned = 0
|
||||||
|
|
||||||
|
def best_m(m):
|
||||||
|
v = _best(th, m, 0, 0)
|
||||||
|
return v is not None and v >= target
|
||||||
|
|
||||||
|
miss_max = _max_true(0, n, best_m)
|
||||||
|
if miss_max < 0:
|
||||||
|
return
|
||||||
|
if bad_limit is not None and miss_max > bad_limit and stats is not None:
|
||||||
|
stats["clipped"] = True
|
||||||
|
miss_hi = miss_max if bad_limit is None else min(miss_max, bad_limit)
|
||||||
|
for miss in range(miss_hi + 1):
|
||||||
|
def best_g(g):
|
||||||
|
v = _best(th, miss, g, 0)
|
||||||
|
return v is not None and v >= target
|
||||||
|
|
||||||
|
good_max = _max_true(0, n - miss, best_g)
|
||||||
|
if good_max < 0:
|
||||||
|
continue
|
||||||
|
if bad_limit is not None and good_max > bad_limit - miss:
|
||||||
|
if stats is not None:
|
||||||
|
stats["clipped"] = True
|
||||||
|
good_max = bad_limit - miss
|
||||||
|
for good in range(good_max + 1):
|
||||||
|
room = n - miss - good
|
||||||
|
|
||||||
|
def best_gr(gr):
|
||||||
|
v = _best(th, miss, good, gr)
|
||||||
|
return v is not None and v >= target
|
||||||
|
|
||||||
|
def worst_gr(gr):
|
||||||
|
v = _worst(th, miss, good, gr)
|
||||||
|
return v is not None and v <= target
|
||||||
|
|
||||||
|
# 先求「Great 放得下」的最大个数:超过它整行不可行(例如 EX 音符不能判 Great),
|
||||||
|
# 否则不可行的高端会让 worst_gr 不再单调,二分就会找错。
|
||||||
|
feasible_hi = _max_true(0, room, lambda gr: _worst(th, miss, good, gr) is not None)
|
||||||
|
if feasible_hi < 0:
|
||||||
|
continue
|
||||||
|
g_high = _max_true(0, feasible_hi, best_gr)
|
||||||
|
g_low = _min_true(0, feasible_hi, worst_gr)
|
||||||
|
if bad_limit is not None and g_high > bad_limit - miss - good:
|
||||||
|
if stats is not None:
|
||||||
|
stats["clipped"] = True
|
||||||
|
g_high = bad_limit - miss - good
|
||||||
|
if g_low > g_high:
|
||||||
|
continue
|
||||||
|
for great in range(g_low, g_high + 1):
|
||||||
|
scanned += 1
|
||||||
|
if scanned > max_scan:
|
||||||
|
if stats is not None:
|
||||||
|
stats["clipped"] = True
|
||||||
|
return
|
||||||
|
yield miss, good, great
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ReverseRow:
|
||||||
|
miss: int
|
||||||
|
good: int
|
||||||
|
great: int
|
||||||
|
cp_plus_perfect: int
|
||||||
|
cp_min: int
|
||||||
|
cp_max: int
|
||||||
|
achievement_min: Fraction
|
||||||
|
achievement_max: Fraction
|
||||||
|
dx_min: int = 0
|
||||||
|
dx_max: int = 0
|
||||||
|
verified: bool = True # 精确模式下是否已严格验证
|
||||||
|
exact: bool = False # 是否恰好等于目标达成率
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
return ("miss %d · good %d · great %d · cp+perfect %d"
|
||||||
|
% (self.miss, self.good, self.great, self.cp_plus_perfect))
|
||||||
|
|
||||||
|
|
||||||
|
def _row_dx_bounds(th: Theory, row: ReverseRow) -> None:
|
||||||
|
base = 2 * row.cp_plus_perfect + row.great
|
||||||
|
row.dx_min = base + row.cp_min
|
||||||
|
row.dx_max = base + row.cp_max
|
||||||
|
|
||||||
|
|
||||||
|
def _cp_bounds_simple(th: Theory, s: int) -> Tuple[int, int]:
|
||||||
|
"""cp 的物理取值范围:Perfect 只能出现在 Break / Break(长按判定) 上。"""
|
||||||
|
cp_min = max(0, s - th.perfect_capacity)
|
||||||
|
return cp_min, s
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 反向:区间模式
|
||||||
|
def solve_reverse_range(th: Theory, target: Fraction, limit: int = 300,
|
||||||
|
max_scan: int = 200000) -> Dict[str, object]:
|
||||||
|
rows: List[ReverseRow] = []
|
||||||
|
scanned = 0
|
||||||
|
truncated = False
|
||||||
|
for miss, good, great in _candidate_rows(th, target, max_scan):
|
||||||
|
scanned += 1
|
||||||
|
lo = _worst(th, miss, good, great)
|
||||||
|
hi = _best(th, miss, good, great)
|
||||||
|
if lo is None or hi is None:
|
||||||
|
continue
|
||||||
|
s = th.total_notes - miss - good - great
|
||||||
|
cp_min, cp_max = _cp_bounds_simple(th, s)
|
||||||
|
row = ReverseRow(
|
||||||
|
miss=miss, good=good, great=great, cp_plus_perfect=s,
|
||||||
|
cp_min=cp_min, cp_max=cp_max,
|
||||||
|
achievement_min=lo, achievement_max=hi,
|
||||||
|
)
|
||||||
|
_row_dx_bounds(th, row)
|
||||||
|
rows.append(row)
|
||||||
|
if len(rows) >= limit:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"target": target,
|
||||||
|
"mode": "range",
|
||||||
|
"theory": th,
|
||||||
|
"rows": rows,
|
||||||
|
"truncated": truncated,
|
||||||
|
"scanned": scanned,
|
||||||
|
"exact_cap": EXACT_MAX_BAD,
|
||||||
|
"unverified": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 反向:精确模式
|
||||||
|
def _bad_options(group: str) -> Dict[str, Tuple[Tuple[int, int], ...]]:
|
||||||
|
return {j: GROUP_OPTIONS[group][j] for j in _BAD_JUDGMENTS
|
||||||
|
if j in GROUP_OPTIONS[group]}
|
||||||
|
|
||||||
|
|
||||||
|
def _dx_delta(th: Theory, loss: int, bonus: int) -> int:
|
||||||
|
"""一个坏音符对「目标量 X」的贡献;X = rhs 等价于达成率等于目标。"""
|
||||||
|
if th.break_bonus_score:
|
||||||
|
return 100 * th.break_bonus_score * loss - th.all_perfect_score * bonus
|
||||||
|
return 100 * loss
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_suffix(th: Theory, groups: List[str]) -> List[Tuple[int, int, int, int]]:
|
||||||
|
"""每个后缀(含自身)的 (dX 负向下界, dX 上界, slo 上界, bad 上界),用于剪枝。
|
||||||
|
|
||||||
|
注意 dX 下界必须按「最多只有这些音符是坏音符、也可以是 0 个」来算,
|
||||||
|
所以只累计负的贡献(min(0, ...)·个数),否则会错误剪掉合法解。
|
||||||
|
"""
|
||||||
|
suf: List[Tuple[int, int, int, int]] = [(0, 0, 0, 0)] * (len(groups) + 1)
|
||||||
|
for i in range(len(groups) - 1, -1, -1):
|
||||||
|
group = groups[i]
|
||||||
|
n = th.counts.get(group, 0)
|
||||||
|
opts = _bad_options(group)
|
||||||
|
dxs = [_dx_delta(th, dl, db)
|
||||||
|
for choices in opts.values() for dl, db in choices]
|
||||||
|
d_bad, d_slo = _GROUP_BAD_STEP.get(group, (0, 0))
|
||||||
|
pl, ph, ps, pn = suf[i + 1]
|
||||||
|
suf[i] = (pl + n * min(0, min(dxs)), ph + n * max(dxs), ps + n * d_slo,
|
||||||
|
pn + n * (1 if d_bad else 0))
|
||||||
|
return suf
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_table(th: Theory, max_bad: int, rhs: Fraction,
|
||||||
|
set_cap: int = 20000, total_cap: int = 1500000,
|
||||||
|
deadline: Optional[float] = None):
|
||||||
|
"""DP:状态 (miss, good, great) -> 可达的 (X, bad_b, slo) 集合。
|
||||||
|
|
||||||
|
X = 100*B*失分和 - W*坏音符奖励分(无绝赞时 X = 100*失分和),与达成率一一对应;
|
||||||
|
只统计「坏音符」(Great/Good/Miss);绝赞音符里 cp/perfect 的奖励分用 slo 还原成区间。
|
||||||
|
每个候选元组都用「剩余音符最大/最小贡献」做目标剪枝(只对当前 rhs 有效)。
|
||||||
|
超出预算时返回 None(调用方退回区间判定)。
|
||||||
|
"""
|
||||||
|
groups = [g for g in GROUP_ORDER if th.counts.get(g, 0) and _bad_options(g)]
|
||||||
|
suf = _exact_suffix(th, groups)
|
||||||
|
w = th.all_perfect_score
|
||||||
|
b = th.break_bonus_score
|
||||||
|
klo_const = (2 * th.counts.get(GROUP_BREAK, 0)
|
||||||
|
+ 3 * th.counts.get(GROUP_BREAK_FLAT, 0)
|
||||||
|
+ 4 * th.counts.get(GROUP_BREAK_SLIDE, 0)
|
||||||
|
+ 4 * th.counts.get(GROUP_EX_BREAK, 0))
|
||||||
|
break_notes = sum(th.counts.get(g, 0) for g in _GROUP_BAD_STEP)
|
||||||
|
|
||||||
|
table: Dict[Tuple[int, int, int], set] = {(0, 0, 0): {(0, 0, 0)}}
|
||||||
|
for gi, group in enumerate(groups):
|
||||||
|
n = th.counts.get(group, 0)
|
||||||
|
opts = _bad_options(group)
|
||||||
|
d_bad, d_slo = _GROUP_BAD_STEP.get(group, (0, 0))
|
||||||
|
deltas = {j: [_dx_delta(th, dl, db) for dl, db in choices]
|
||||||
|
for j, choices in opts.items()}
|
||||||
|
dxmin_g = min(v for vs in deltas.values() for v in vs)
|
||||||
|
dxmax_g = max(v for vs in deltas.values() for v in vs)
|
||||||
|
rest_after = suf[gi + 1]
|
||||||
|
slots = min(n, max_bad)
|
||||||
|
for k in range(slots):
|
||||||
|
if deadline is not None and time.monotonic() > deadline:
|
||||||
|
return None
|
||||||
|
new: Dict[Tuple[int, int, int], set] = {}
|
||||||
|
for state, tset in table.items():
|
||||||
|
carried = new.setdefault(state, set())
|
||||||
|
carried.update(tset)
|
||||||
|
if len(carried) > set_cap:
|
||||||
|
return None
|
||||||
|
if state[0] + state[1] + state[2] >= max_bad:
|
||||||
|
continue
|
||||||
|
for judge, vs in deltas.items():
|
||||||
|
nxt = (state[0] + (1 if judge == "miss" else 0),
|
||||||
|
state[1] + (1 if judge == "good" else 0),
|
||||||
|
state[2] + (1 if judge == "great" else 0))
|
||||||
|
bucket = new.setdefault(nxt, set())
|
||||||
|
rem = min(slots - k - 1, max_bad - sum(nxt))
|
||||||
|
dmin = rem * min(0, dxmin_g) + rest_after[0]
|
||||||
|
dmax = rem * dxmax_g + rest_after[1]
|
||||||
|
slomax = rem * d_slo + rest_after[2]
|
||||||
|
if b:
|
||||||
|
need_lo = rhs + 25 * w * (klo_const - slomax)
|
||||||
|
need_hi = rhs + 25 * w * 4 * break_notes
|
||||||
|
for x, bad_b, slo in tset:
|
||||||
|
cbb = bad_b + d_bad
|
||||||
|
cs = slo + d_slo
|
||||||
|
for dx in vs:
|
||||||
|
cx = x + dx
|
||||||
|
if b:
|
||||||
|
if cx + dmax < need_lo - 25 * w * cs:
|
||||||
|
continue
|
||||||
|
if cx + dmin > need_hi - 25 * w * 4 * cbb:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
if cx + dmax < rhs or cx + dmin > rhs:
|
||||||
|
continue
|
||||||
|
bucket.add((cx, cbb, cs))
|
||||||
|
if len(bucket) > set_cap:
|
||||||
|
return None
|
||||||
|
table = new
|
||||||
|
if sum(len(s) for s in table.values()) > total_cap:
|
||||||
|
return None
|
||||||
|
return table
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_row_ok(th: Theory, table, miss: int, good: int, great: int,
|
||||||
|
rhs: Fraction) -> bool:
|
||||||
|
"""严格判定:能否把 (miss, good, great) 判定恰好打出达成率 target。"""
|
||||||
|
tset = table.get((miss, good, great))
|
||||||
|
if not tset:
|
||||||
|
return False
|
||||||
|
w = th.all_perfect_score
|
||||||
|
b = th.break_bonus_score
|
||||||
|
klo_const = (2 * th.counts.get(GROUP_BREAK, 0)
|
||||||
|
+ 3 * th.counts.get(GROUP_BREAK_FLAT, 0)
|
||||||
|
+ 4 * th.counts.get(GROUP_BREAK_SLIDE, 0)
|
||||||
|
+ 4 * th.counts.get(GROUP_EX_BREAK, 0))
|
||||||
|
break_notes = sum(th.counts.get(g, 0) for g in _GROUP_BAD_STEP)
|
||||||
|
if not b:
|
||||||
|
# 没有绝赞:达成率只由失分决定,100 * 失分和 == 分母 * (100 - 目标)
|
||||||
|
for x, _bad_b, _slo in tset:
|
||||||
|
if x == rhs:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
for x, bad_b, slo in tset:
|
||||||
|
num = x - rhs
|
||||||
|
if num % (25 * w):
|
||||||
|
continue
|
||||||
|
k = num // (25 * w)
|
||||||
|
if klo_const - slo <= k <= 4 * (break_notes - bad_b):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def solve_reverse_exact(th: Theory, target: Fraction, limit: int = 300,
|
||||||
|
max_scan: int = 200000) -> Dict[str, object]:
|
||||||
|
d = th.denominator
|
||||||
|
rhs = Fraction(d) * (100 - target)
|
||||||
|
note = None
|
||||||
|
table = None
|
||||||
|
max_bad_used = 0
|
||||||
|
pending: List[Tuple[int, int, int]] = []
|
||||||
|
scanned = 0
|
||||||
|
|
||||||
|
if rhs.denominator != 1:
|
||||||
|
note = ("该达成率不是本谱面能精确打出的值(达成率只能取 100 - 整数/分母 的形式),"
|
||||||
|
"下面给出区间判定结果。")
|
||||||
|
elif th.lattice and rhs.numerator % th.lattice:
|
||||||
|
note = ("该达成率不满足谱面的失分格点(失分只能是 %d 的倍数),"
|
||||||
|
"下面给出区间判定结果。" % th.lattice)
|
||||||
|
stats: Dict[str, bool] = {}
|
||||||
|
if note is None:
|
||||||
|
# 只枚举「坏音符不超过 EXACT_MAX_BAD」的组合:超出的组合没法精确验证,
|
||||||
|
# 全枚举它们会白花很多时间(大谱面可能有几十万组)。
|
||||||
|
for miss, good, great in _candidate_rows(th, target, max_scan,
|
||||||
|
EXACT_MAX_BAD, stats):
|
||||||
|
scanned += 1
|
||||||
|
pending.append((miss, good, great))
|
||||||
|
if pending:
|
||||||
|
max_bad_used = min(max(sum(p) for p in pending), EXACT_MAX_BAD)
|
||||||
|
table = _exact_table(th, max_bad_used, rhs,
|
||||||
|
deadline=time.monotonic() + EXACT_TIME_BUDGET)
|
||||||
|
if table is None:
|
||||||
|
note = ("判定数较大,超出精确验证的预算(最多 %d 个 Miss+Good+Great),"
|
||||||
|
"下面给出区间判定结果。" % EXACT_MAX_BAD)
|
||||||
|
if note is not None:
|
||||||
|
fallback = solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
|
||||||
|
fallback["note"] = note
|
||||||
|
fallback["exact_cap"] = max_bad_used or EXACT_MAX_BAD
|
||||||
|
fallback["more_unverified"] = bool(stats.get("clipped"))
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
if not pending and stats.get("clipped"):
|
||||||
|
# 一组候选都没法精确验证(坏音符都超过上限),退回区间模式并说明原因
|
||||||
|
fallback = solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
|
||||||
|
fallback["note"] = ("这个谱面/达成率下,坏音符(Miss+Good+Great)超过 %d 个的组合太多,"
|
||||||
|
"精确验证代价过高,下面给出区间模式结果。" % EXACT_MAX_BAD)
|
||||||
|
fallback["exact_cap"] = EXACT_MAX_BAD
|
||||||
|
fallback["more_unverified"] = True
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
rows: List[ReverseRow] = []
|
||||||
|
truncated = False
|
||||||
|
unverified = 0
|
||||||
|
for miss, good, great in pending:
|
||||||
|
bad = miss + good + great
|
||||||
|
if bad > max_bad_used:
|
||||||
|
unverified += 1
|
||||||
|
continue
|
||||||
|
if not _exact_row_ok(th, table, miss, good, great, rhs):
|
||||||
|
continue
|
||||||
|
s = th.total_notes - bad
|
||||||
|
cp_min, cp_max = _cp_bounds_simple(th, s)
|
||||||
|
row = ReverseRow(
|
||||||
|
miss=miss, good=good, great=great, cp_plus_perfect=s,
|
||||||
|
cp_min=cp_min, cp_max=cp_max,
|
||||||
|
achievement_min=target, achievement_max=target,
|
||||||
|
verified=True, exact=True,
|
||||||
|
)
|
||||||
|
_row_dx_bounds(th, row)
|
||||||
|
rows.append(row)
|
||||||
|
if len(rows) >= limit:
|
||||||
|
truncated = True
|
||||||
|
break
|
||||||
|
return {
|
||||||
|
"target": target,
|
||||||
|
"mode": "exact",
|
||||||
|
"theory": th,
|
||||||
|
"rows": rows,
|
||||||
|
"truncated": truncated,
|
||||||
|
"scanned": scanned,
|
||||||
|
"exact_cap": max_bad_used,
|
||||||
|
"unverified": unverified,
|
||||||
|
"more_unverified": bool(stats.get("clipped")),
|
||||||
|
"note": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def solve_reverse(th: Theory, target: Fraction, limit: int = 300,
|
||||||
|
max_scan: int = 200000, mode: str = "exact") -> Dict[str, object]:
|
||||||
|
target = Fraction(target)
|
||||||
|
if target > th.max_achievement:
|
||||||
|
raise ValueError("目标达成率 %s 超过该谱面上限 %s"
|
||||||
|
% (target, th.max_achievement))
|
||||||
|
if mode == "range":
|
||||||
|
return solve_reverse_range(th, target, limit=limit, max_scan=max_scan)
|
||||||
|
if mode != "exact":
|
||||||
|
raise ValueError("mode 只能是 exact 或 range")
|
||||||
|
return solve_reverse_exact(th, target, limit=limit, max_scan=max_scan)
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
unlockmusic.py —— 主脚本:扫码登录 → 解锁(解禁)歌曲 → 登出
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
**它没有自己的协议实现** —— 整个流程直接复用 `upsert.py` 的 `run()`:
|
||||||
|
扫码 → 预览 → 登录 → UpsertUserAll → 落库回查 → 登出,一模一样。
|
||||||
|
唯一多出来的东西是"要解锁哪些歌"这个入参。
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
为什么解锁不用单独的接口
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
游戏里解锁不是一次独立的网络请求,而是**往 userItemList 里补几条 UserItem**:
|
||||||
|
|
||||||
|
itemKind = 5 歌曲本体解锁(MusicUnlockList)
|
||||||
|
itemKind = 6 Master 难度解锁(MusicUnlockMasterList)
|
||||||
|
itemKind = 7 Re:Master 难度解锁(MusicUnlockReMasterList)
|
||||||
|
|
||||||
|
依据 source/Assembly-CSharp/Net.VO.Mai2/VOExtensions.cs:981-1008
|
||||||
|
(UserData.cs:132-138 那四个解锁列表不是独立节点,是并进 userItemList 的)。
|
||||||
|
|
||||||
|
条目长这样:
|
||||||
|
|
||||||
|
{"itemKind": 5, "itemId": 11414, "stock": 1, "isValid": true}
|
||||||
|
|
||||||
|
★ 标准谱(musicId < 10000)的 Master / Re:Master **天生解锁**,不会写 kind6/7
|
||||||
|
(source/Assembly-CSharp/Manager/NotesListManager.cs:415/455 开头就是
|
||||||
|
`if (id < 10000) return true;`);只有 DX 谱(>= 10000)才需要额外解禁。
|
||||||
|
★ 服务器上已经有的条目**自动跳过** —— 重复插入会让服务端直接 HTTP 500。
|
||||||
|
★ Re:Master 还受活动开关约束(IsUnlockReMaster 里的 IsOpenEvent),
|
||||||
|
那是机台侧开关,脚本改不了;写了也可能进游戏仍是灰的。
|
||||||
|
★ 本功能**没有真机抓包可对照**(机台侧的解锁不上报),是按源码实现的。
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
用法
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
python unlockmusic.py <二维码> --music 11414 # 预检,不发包
|
||||||
|
python unlockmusic.py <二维码> --music 11414 --send # ★ 真实解锁
|
||||||
|
python unlockmusic.py <二维码> --music 1085,11414 --send # 多首,逗号分隔
|
||||||
|
python unlockmusic.py <二维码> --music 11414 --send --no-unlock-remaster
|
||||||
|
|
||||||
|
# 只要歌曲本体,不解禁任何难度
|
||||||
|
python unlockmusic.py <二维码> --music 11414 --send --no-unlock-master --no-unlock-remaster
|
||||||
|
|
||||||
|
# 和其它主脚本一样的通用开关
|
||||||
|
python unlockmusic.py <二维码> --music 11414 --send --debug
|
||||||
|
python unlockmusic.py <二维码> --music 11414 --send --cooldown 0
|
||||||
|
python unlockmusic.py <二维码> --music 11414 --send --no-verify
|
||||||
|
|
||||||
|
| 参数 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| `--music ID[,ID...]` | 要解锁的 musicId,可重复、可逗号分隔(`--unlock-music` 也认) |
|
||||||
|
| `--send` | 真正发包(不加就是预检,零副作用) |
|
||||||
|
| `--no-unlock-master` | 不解禁 Master |
|
||||||
|
| `--no-unlock-remaster` | 不解禁 Re:Master |
|
||||||
|
| `--cooldown N` | 发包前冷却秒数,默认取 config 的 15 秒 |
|
||||||
|
| `--no-verify` | 跳过上传后的落库回查 |
|
||||||
|
| `--debug` | 本次运行打印完整收发报文 |
|
||||||
|
|
||||||
|
日志同样是 logs/latest.log(覆盖)+ logs/YYYY-MM-DD.log(追加)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import upsert
|
||||||
|
from api import log_error, setup_logging, teardown_logging
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
"""极简命令行解析:第一个非 -- 开头的参数是二维码。"""
|
||||||
|
opts = {"qr": "", "send": False, "debug": False, "no_verify": False,
|
||||||
|
"cooldown": None, "extra_data": [],
|
||||||
|
"unlock_music": [], "unlock_master": True, "unlock_remaster": True}
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
a = argv[i]
|
||||||
|
if a == "--send":
|
||||||
|
opts["send"] = True
|
||||||
|
elif a == "--debug":
|
||||||
|
opts["debug"] = True
|
||||||
|
elif a == "--no-verify":
|
||||||
|
opts["no_verify"] = True
|
||||||
|
elif a == "--extra-data" and i + 1 < len(argv):
|
||||||
|
opts["extra_data"].append(argv[i + 1])
|
||||||
|
i += 1
|
||||||
|
elif a == "--cooldown" and i + 1 < len(argv):
|
||||||
|
opts["cooldown"] = float(argv[i + 1])
|
||||||
|
i += 1
|
||||||
|
elif a in ("--music", "--unlock-music") and i + 1 < len(argv):
|
||||||
|
# 可重复;也支持逗号分隔(中英文逗号都认)
|
||||||
|
for part in str(argv[i + 1]).replace(",", ",").split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
opts["unlock_music"].append(int(part))
|
||||||
|
except ValueError:
|
||||||
|
opts["unlock_bad"] = part
|
||||||
|
i += 1
|
||||||
|
elif a == "--no-unlock-master":
|
||||||
|
opts["unlock_master"] = False
|
||||||
|
elif a == "--no-unlock-remaster":
|
||||||
|
opts["unlock_remaster"] = False
|
||||||
|
elif not a.startswith("--") and not opts["qr"]:
|
||||||
|
opts["qr"] = a
|
||||||
|
i += 1
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
opts = parse_args(sys.argv[1:])
|
||||||
|
if not opts["qr"] or not opts["unlock_music"]:
|
||||||
|
# 没有二维码、或者没有指定要解锁哪首 —— 直接把用法打出来
|
||||||
|
print(__doc__)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if opts.get("unlock_bad"):
|
||||||
|
# 参数本身就不对,连扫码都不用做
|
||||||
|
setup_logging("unlockmusic")
|
||||||
|
log_error("--music 里有非法 musicId:" + str(opts["unlock_bad"]))
|
||||||
|
teardown_logging()
|
||||||
|
return 6
|
||||||
|
|
||||||
|
# ★ 整个流程复用 upsert.run(),这里只多传三个解锁参数
|
||||||
|
return upsert.run(
|
||||||
|
opts,
|
||||||
|
unlock_music=opts["unlock_music"],
|
||||||
|
unlock_master=opts["unlock_master"],
|
||||||
|
unlock_remaster=opts["unlock_remaster"],
|
||||||
|
log_name="unlockmusic",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
#!/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
@@ -0,0 +1,287 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""
|
||||||
|
use_ticket.py —— 消耗账号当前持有的功能券(用 UpsertUserAll 表达「用票打了一局」)
|
||||||
|
================================================================================
|
||||||
|
|
||||||
|
一条完整的「用票」链路:
|
||||||
|
|
||||||
|
ParseQRCode -> GetUserPreview -> UserLogin -> GetUserCharge
|
||||||
|
-> GetNeedUserData(基线快照)-> UpsertUserAll -> GetUserCharge(复查)-> UserLogout
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
★ 为什么可以用 UpsertUserAll 消耗票券
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
GetUserChargeApi 与 UpsertUserAllApi 里的 userChargeList **格式完全相同**
|
||||||
|
(UserCharge.cs:chargeId / stock / purchaseDate / validDate),
|
||||||
|
所以只要把 UpsertUserAll 的 userChargeList 节点改成「该票 stock - 1」,
|
||||||
|
服务端就会认为这张票被这一局用掉了。
|
||||||
|
|
||||||
|
真机实证(gamehook 抓包 2026-10-06 22:23 那一局,正好用了 2 倍券):
|
||||||
|
|
||||||
|
userChargeList: [
|
||||||
|
{"chargeId":2,"stock":0,...}, <- 买来时是 1,本局用掉变成 0
|
||||||
|
{"chargeId":3,"stock":0,...},
|
||||||
|
{"chargeId":5,"stock":0,...}
|
||||||
|
]
|
||||||
|
userGamePlaylogList[0].useTicketId = 2 <- ★ 用了哪张票
|
||||||
|
userGamePlaylogList[0].playCredit = 2 <- ★ 本局消耗的 credit
|
||||||
|
userPlaylogList[0].playCredit = 2
|
||||||
|
userData.lastPlayCredit = 2
|
||||||
|
|
||||||
|
★ playCredit 的算法(source/.../Net.VO.Mai2/VOExtensions.cs:838-845):
|
||||||
|
|
||||||
|
playCredit = Credit.GameCostEnoughPlay() // 投币数,普通局 = 1
|
||||||
|
+ TicketManager.GetTicketCredit(ticketId) // 票的 creditNum
|
||||||
|
|
||||||
|
2 倍券的 creditNum = 1 -> playCredit = 1 + 1 = 2 (与真机抓包一致)
|
||||||
|
creditNum 表见 TICKET_CREDIT_NUM。
|
||||||
|
|
||||||
|
★ 真机报文里**没有** isNewChargeList 这个节点(它不在 36 个节点里),
|
||||||
|
所以这里也不需要给位串。
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
用法
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
python use_ticket.py <二维码> # 预检:只看报文,不发包
|
||||||
|
python use_ticket.py <二维码> --list # 只列当前持有的票,不消耗
|
||||||
|
python use_ticket.py <二维码> --ticket-id 2 # 指定要消耗哪张(默认第一张持有的)
|
||||||
|
python use_ticket.py <二维码> --ticket-id 2 --send # ★ 真实消耗
|
||||||
|
python use_ticket.py <二维码> --send --music-id 143 --level 4
|
||||||
|
python use_ticket.py <二维码> --send --skip-entry # 跳过入场数据下载
|
||||||
|
|
||||||
|
★ 消耗会写入服务器(不可撤销)。不传 --send 时只构造报文。
|
||||||
|
★ 二维码**有效期 10 分钟**。
|
||||||
|
★ 账号当前**没有**持有功能票时直接拒绝(没有可消耗的东西)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from api import (
|
||||||
|
setup_logging,
|
||||||
|
teardown_logging,
|
||||||
|
log_error,
|
||||||
|
log_info,
|
||||||
|
log_warning,
|
||||||
|
ParseQRCode,
|
||||||
|
GetUserPreview,
|
||||||
|
UserLogin,
|
||||||
|
UserLogout,
|
||||||
|
LogoutType,
|
||||||
|
GetUserCharge,
|
||||||
|
GetNeedUserData,
|
||||||
|
summarize_snapshot,
|
||||||
|
UpsertUserAll,
|
||||||
|
PlayData,
|
||||||
|
ConfirmRequiredError,
|
||||||
|
ticket_name,
|
||||||
|
Ping,
|
||||||
|
# ★ 原先在 config.py,已下沉到 api/UpsertUserChargelog.py
|
||||||
|
TICKET_CREDIT_NUM,
|
||||||
|
)
|
||||||
|
from config import (
|
||||||
|
PLACE_ID,
|
||||||
|
REGION_ID,
|
||||||
|
PLAY_MUSIC_ID,
|
||||||
|
PLAY_MUSIC_LEVEL,
|
||||||
|
PLAY_CREDIT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def read_charges(user_id, label):
|
||||||
|
"""调 GetUserChargeApi,打印票券状态并返回 (全部, 持有的)。"""
|
||||||
|
raw = GetUserCharge(user_id)
|
||||||
|
lst = (raw.get("userChargeList") if isinstance(raw, dict) else None) or []
|
||||||
|
log_info("[" + label + "] 当前票券 " + str(len(lst)) + " 条:")
|
||||||
|
if not lst:
|
||||||
|
log_info(" (空)")
|
||||||
|
for c in lst:
|
||||||
|
cid = c.get("chargeId")
|
||||||
|
stock = int(c.get("stock", 0) or 0)
|
||||||
|
log_info(" chargeId=" + str(cid) + " " + ticket_name(cid)
|
||||||
|
+ " stock=" + str(stock)
|
||||||
|
+ " purchaseDate=" + str(c.get("purchaseDate"))
|
||||||
|
+ " validDate=" + str(c.get("validDate"))
|
||||||
|
+ (" ← ★ 持有中" if stock > 0 else ""))
|
||||||
|
held = [c for c in lst if isinstance(c, dict) and int(c.get("stock", 0) or 0) > 0]
|
||||||
|
log_info("[" + label + "] 持有中的功能票:" + str(len(held)) + " 张")
|
||||||
|
return lst, held
|
||||||
|
|
||||||
|
|
||||||
|
def parse_args(argv):
|
||||||
|
opts = {"qr": "", "ticket_id": None, "send": False, "list": False,
|
||||||
|
"debug": False, "skip_entry": False,
|
||||||
|
"music_id": PLAY_MUSIC_ID, "level": PLAY_MUSIC_LEVEL}
|
||||||
|
i = 0
|
||||||
|
while i < len(argv):
|
||||||
|
a = argv[i]
|
||||||
|
if a == "--send":
|
||||||
|
opts["send"] = True
|
||||||
|
elif a == "--list":
|
||||||
|
opts["list"] = True
|
||||||
|
elif a == "--debug":
|
||||||
|
opts["debug"] = True
|
||||||
|
elif a == "--skip-entry":
|
||||||
|
opts["skip_entry"] = True
|
||||||
|
elif a == "--ticket-id" and i + 1 < len(argv):
|
||||||
|
opts["ticket_id"] = int(argv[i + 1])
|
||||||
|
i += 1
|
||||||
|
elif a == "--music-id" and i + 1 < len(argv):
|
||||||
|
opts["music_id"] = int(argv[i + 1])
|
||||||
|
i += 1
|
||||||
|
elif a == "--level" and i + 1 < len(argv):
|
||||||
|
opts["level"] = int(argv[i + 1])
|
||||||
|
i += 1
|
||||||
|
elif not a.startswith("--") and not opts["qr"]:
|
||||||
|
opts["qr"] = a
|
||||||
|
i += 1
|
||||||
|
return opts
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
opts = parse_args(sys.argv[1:])
|
||||||
|
if not opts["qr"]:
|
||||||
|
print(__doc__)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if opts["debug"]:
|
||||||
|
import config as _cfg
|
||||||
|
_cfg.LOG_LEVEL = "debug"
|
||||||
|
|
||||||
|
setup_logging("use_ticket")
|
||||||
|
session = {"user_id": 0, "token": "", "login_dt": 0, "logged_in": False}
|
||||||
|
try:
|
||||||
|
# ---- 心跳:决定后续所有请求的 number 头 ----
|
||||||
|
Ping()
|
||||||
|
|
||||||
|
user_id, token = ParseQRCode(opts["qr"])
|
||||||
|
session.update(user_id=user_id, token=token)
|
||||||
|
|
||||||
|
preview = GetUserPreview(user_id, token)
|
||||||
|
if preview.isLogin:
|
||||||
|
log_error("账号处于已登录状态,请先在机台登出或等待会话超时")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
login = UserLogin(user_id, token, region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
session.update(login_dt=login.loginDateTime, logged_in=True)
|
||||||
|
|
||||||
|
# ---- ★ 读当前持有的功能券 ----
|
||||||
|
all_charges, held = read_charges(user_id, "消耗前")
|
||||||
|
|
||||||
|
if opts["list"]:
|
||||||
|
log_info("--list:只列票券,不做任何写操作")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if not held:
|
||||||
|
log_error("账号当前**没有持有任何功能票**,没有可消耗的东西。")
|
||||||
|
log_error(" 想买票请用 buy_ticket.py;已持有的票会在 userChargeList 里 stock > 0。")
|
||||||
|
return 4
|
||||||
|
|
||||||
|
# ---- 选定目标票 ----
|
||||||
|
target = None
|
||||||
|
if opts["ticket_id"] is not None:
|
||||||
|
for c in held:
|
||||||
|
if int(c.get("chargeId", -1)) == int(opts["ticket_id"]):
|
||||||
|
target = c
|
||||||
|
break
|
||||||
|
if target is None:
|
||||||
|
log_error("指定的 chargeId=" + str(opts["ticket_id"])
|
||||||
|
+ " 当前并不持有;持有的有:"
|
||||||
|
+ ", ".join(str(c.get("chargeId")) for c in held))
|
||||||
|
return 4
|
||||||
|
else:
|
||||||
|
target = held[0]
|
||||||
|
|
||||||
|
ticket_id = int(target.get("chargeId"))
|
||||||
|
credit_num = int(TICKET_CREDIT_NUM.get(ticket_id, 0) or 0)
|
||||||
|
play_credit = int(PLAY_CREDIT) + credit_num
|
||||||
|
|
||||||
|
log_info("选定要消耗的票:chargeId=" + str(ticket_id) + " " + ticket_name(ticket_id)
|
||||||
|
+ " stock=" + str(target.get("stock"))
|
||||||
|
+ " creditNum=" + str(credit_num)
|
||||||
|
+ " -> playCredit=" + str(PLAY_CREDIT) + "+" + str(credit_num)
|
||||||
|
+ "=" + str(play_credit))
|
||||||
|
|
||||||
|
# ---- ★ 构造新的 userChargeList:目标票 stock - 1,其余原样 ----
|
||||||
|
new_stock = int(target.get("stock", 0) or 0) - 1
|
||||||
|
if new_stock < 0:
|
||||||
|
new_stock = 0
|
||||||
|
new_list = []
|
||||||
|
for c in all_charges:
|
||||||
|
if not isinstance(c, dict):
|
||||||
|
continue
|
||||||
|
item = dict(c)
|
||||||
|
if int(item.get("chargeId", -1)) == ticket_id:
|
||||||
|
item["stock"] = new_stock
|
||||||
|
log_info(" userChargeList 里 chargeId=" + str(ticket_id)
|
||||||
|
+ " 的 stock:" + str(target.get("stock")) + " -> " + str(new_stock))
|
||||||
|
new_list.append(item)
|
||||||
|
|
||||||
|
# ---- 入场数据下载(贴近真机节奏,同时也是 UpsertUserAll 的基线快照)----
|
||||||
|
snapshot = None
|
||||||
|
if not opts["skip_entry"]:
|
||||||
|
snapshot = GetNeedUserData(user_id, token=token,
|
||||||
|
place_id=PLACE_ID, region_id=REGION_ID)
|
||||||
|
_sum = summarize_snapshot(snapshot)
|
||||||
|
log_info("入场数据下载完成:" + str(len(_sum)) + " 个节点,失败 "
|
||||||
|
+ str(len(snapshot.get("_failed") or [])) + " 个")
|
||||||
|
|
||||||
|
# ---- ★ 用 UpsertUserAll 表达「用这张票打了一局」----
|
||||||
|
# play.use_ticket_id -> userGamePlaylogList[0].useTicketId
|
||||||
|
# play.play_credit -> userGamePlaylogList[0].playCredit / userPlaylogList[].playCredit
|
||||||
|
# / userData.lastPlayCredit
|
||||||
|
# extra_data.userChargeList -> 覆盖节点,把票的 stock 减 1
|
||||||
|
play = PlayData(
|
||||||
|
music_id=opts["music_id"],
|
||||||
|
music_level=opts["level"],
|
||||||
|
use_ticket_id=ticket_id,
|
||||||
|
play_credit=play_credit,
|
||||||
|
)
|
||||||
|
UpsertUserAll(
|
||||||
|
user_id, login.loginId,
|
||||||
|
login_date_time=login.loginDateTime,
|
||||||
|
auth_date_time=login.dateTime,
|
||||||
|
token=token,
|
||||||
|
snapshot=snapshot,
|
||||||
|
play=play,
|
||||||
|
extra_data={"userChargeList": new_list},
|
||||||
|
dry_run=not opts["send"],
|
||||||
|
confirm=opts["send"],
|
||||||
|
verify_landing=bool(opts["send"]),
|
||||||
|
place_id=PLACE_ID, region_id=REGION_ID,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---- ★ 复查:票券 stock 应该减 1 ----
|
||||||
|
_, held_after = read_charges(user_id, "消耗后")
|
||||||
|
if not opts["send"]:
|
||||||
|
log_info("[dry-run] 未发包。想真实消耗请加 --send(★ 会写入服务器)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
still = [c for c in held_after if int(c.get("chargeId", -1)) == ticket_id]
|
||||||
|
if still:
|
||||||
|
log_warning("chargeId=" + str(ticket_id) + " 仍然持有(stock="
|
||||||
|
+ str(still[0].get("stock")) + ")—— 服务端可能没落库")
|
||||||
|
return 6
|
||||||
|
log_info("★ 票券已消耗:chargeId=" + str(ticket_id) + " 不再持有 ✅")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
except ConfirmRequiredError:
|
||||||
|
return 5
|
||||||
|
except Exception as exc:
|
||||||
|
log_error(type(exc).__name__ + ": " + str(exc))
|
||||||
|
return 1
|
||||||
|
finally:
|
||||||
|
if session["logged_in"]:
|
||||||
|
try:
|
||||||
|
UserLogout(session["user_id"], session["token"], session["login_dt"],
|
||||||
|
logout_type=LogoutType.TestIn,
|
||||||
|
region_id=REGION_ID, place_id=PLACE_ID)
|
||||||
|
except Exception as exc:
|
||||||
|
log_error("[收尾] 强制登出失败(会话可能需等待服务端超时):" + str(exc))
|
||||||
|
teardown_logging()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user