Files
Astral/docs/真机Hook对照报告.md
T
Txiaochen 41909b9169 docs: WaHappy 开发文档
孤儿分支,只含 docs/。内容包括:
- 项目总览 / 主脚本编写指南 / 日志指南
- 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条)
- 真机 Hook 对照报告 / 实跑验证记录
- 购买功能票流程(含被服务端拒绝的排查过程)
  · 扫码登录协议技术规范
2026-10-06 14:42:32 +08:00

216 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 真机 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`。