孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
671 lines
27 KiB
Markdown
671 lines
27 KiB
Markdown
# 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()` 的真实返回值,这些在纯日志里根本看不到。
|