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

11 KiB
Raw Blame History

真机 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 —— 这是网络环境差异,不是实现差异。


抓包日志里从 #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:

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。

流程与真机一致:

GetGameSetting -> Ping -> ParseQRCode -> GetUserPreview -> UserLogin
  -> fetch_upsert_snapshot(19 个 Get 节点)
  -> GetUserNewItemList(游玩后档案)
  -> UpsertUserAll(★ 有副作用)
  -> UserLogout

安全闸门保持默认关闭:不传 --send 时只构造报文(dry_run), UpsertUserAll(confirm=None) 会读取 config.UPSERT_CONFIRM = False 并抛 ConfirmRequiredError。