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