孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
11 KiB
真机 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」。
核实过程(两步,第二步推翻了我自己的第一步结论):
-
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」的意思。 -
但 Cookie 机制本身是真实存在的,而且很关键。 用
Ping探针看不到 Set-Cookie(Ping 响应头只有Date / Content-Type / Content-Length / Connection / Pragma / Cache-Control), 于是我先得出了「服务器从不下发 Cookie」的结论 —— 这是错的。 后来跑完整登录链路时,UserLoginApi的响应头里明确有:Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnlyPacket.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。