# 真机 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)
② **修掉 `characterLevelN` 加经验的回归**,改为游玩前快照
③ 新增 `derive_play_characters(snapshot)`:从服务器快照推游玩前角色
④ 新增 `play_date_time` 参数,把「登录时刻」与「游玩/上报时刻」拆开
⑤ `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`。