孤儿分支,只含 docs/。内容包括: - 项目总览 / 主脚本编写指南 / 日志指南 - 上传成绩开发文档 / UpsertUserAll 踩坑清单(28 条) - 真机 Hook 对照报告 / 实跑验证记录 - 购买功能票流程(含被服务端拒绝的排查过程) · 扫码登录协议技术规范
27 KiB
UpsertUserAll 踩坑清单
这份文档只讲「坑」 —— 每一个都是我实际踩过、并且用真机数据或反编译源码验证过的。 想看接口全貌请读
上传成绩开发文档.md; 想看真实跑通证据请读实跑验证记录.md。
判据来源(全部可查):
来源 内容 source/2026-10-05.log:123真机 00:20 会话的 UpsertUserAll 报文 source/2026-10-05-new.log:123真机 19:21 会话的 UpsertUserAll 报文 MaimaiNetSpy/raw/068_*.json真机 22:31 会话的 UpsertUserAll 报文(MelonLoader hook 抓) source/Assembly-CSharp/反编译源码(每个坑都标了行号) 三个真机会话的横向对照是本文最有力的工具 —— 很多坑只有横向比才看得出来。
零、先记住这句话
★ 服务端返回
returnCode=1只代表「报文格式合法」,不代表数据被应用。
「returnCode=1 但 playCount 纹丝不动」是这条路线上最主要的失败模式,
而且它有至少 4 个完全不同的成因(坑 1 / 2 / 3 / 4)。所以:
唯一的验收标准是「发包后重新拉一次服务器数据,看数字有没有动」。
upsert.py --send 已经内置了这个回查(第 7.5 步),会打印 【落库成功】 或 【疑似未落库】。
不要只看 returnCode。
一、致命坑:会让「回码成功但什么都没发生」的
坑 1:userData 是整表覆盖语义,不是「只写你改的字段」
现象:把 GetUserDataApi 拿到的 userData 原样塞进 Upsert 发出去,returnCode=1,
回查 playCount / point 完全不变。
根因:服务端对 userData 这个单元素数组是整表覆盖。
你回传旧值 = 覆盖成旧值 = 「看起来什么都没发生」。
正确做法:必须先按本局演算出新值再整表回传。
| 字段 | Get 侧(登录时) | 必须回传的 Upsert 侧 |
|---|---|---|
playCount |
1257 | 1258(+1) |
currentPlayCount |
185 | 186(+1) |
lastPlayDate |
2026-10-04 18:46:31 | 2026-10-05 00:20:57.0(本局上报时刻) |
lastPlayMode |
2 | 0 |
lastLoginDate |
2026-10-04 18:34:22 | 2026-10-05 00:19:37.0(本次登录时刻) |
lastPlaceId |
0 | 1000 |
lastRegionId |
11 | 5 |
dateTime |
null | 1791134329(登录请求里的 dateTime) |
lastGameId |
null | "SDGB" |
accessCode |
null | "" |
对应实现:apply_play_effects()(api/upsert_user_all.py)。
自检:validate_before_send() 会检查
playCount / currentPlayCount 是否为 0、dateTime 是否为 0、
lastPlayDate / lastLoginDate 是否为空。
坑 2:差分节点全空 = 服务端认为「本局什么都没变」★ 已用 A/B 实验证实
现象:userData 改对了,但 userMusicDetailList / userMapList / userCharacterList
全是 [],回码成功但战绩表没多一条。
根因:这些节点是差分语义 —— 只回传「本局发生变化的条目」。 全空等于告诉服务端「没有任何条目变化」。
★★ 2026-10-06 的 A/B 对照实验(决定性证据)
同一天、同一账号、同一份代码,唯一差别是 userMusicDetailList 空不空:
| 时间 | userMusicDetailList |
其他差分节点 | userData.playCount |
结果 |
|---|---|---|---|---|
| 12:52 | [] |
都空 | 1261 → 1261 | ❌ returnCode=1 但没落库 |
| 12:57 | 1 条(次数不变) | 都空 | 1261 → 1262 | ✅ 落库 |
两次的 returnCode 都是 1、apiName 都是 com.sega.maimai.api.UpsertUserAllApi,
唯一变量就是这个节点。
推论(已实测): 只要
userPlaylogList里有本局成绩,userMusicDetailList就必须回传本局那条。 「有 playlog 却没有任何曲目明细」会被服务端当成无效包静默丢弃。注意这里的关键是节点非空,不是次数必须 +1 —— 回传条目但
playCount保持不变(+0)同样能落库(12:57 那次就是)。 所以「不想让乐曲 playCount 增加」应该把增量设成 0, 而不是把整个节点清空。见config.PLAY_MUSIC_PLAY_COUNT_GAIN。
真机对照(同一账号 Get 侧 vs Upsert 侧):
| 节点 | Get 侧条数 | Upsert 侧条数 | 是差分吗 |
|---|---|---|---|
userMusicDetailList |
739 组 | 1 | 是(本局那条 playCount +1) |
userMapList |
104 | 1 | 是(选中地图 distance +11000) |
userCharacterList |
312 | 5 | 是(组队 5 只各 +1 级) |
userItemList |
1070 | 0 | 是(无变化 → 空) |
userCourseList |
16 | 0 | 是(无变化 → 空) |
userChargeList |
3 | 3 | ★ 否!整表回传 |
正确做法:build_user_all() 用 DIFF_PRIMARY_KEYS 对每个差分节点算 (新 − 基线)。
★ 反例警告 1:userChargeList 不是差分节点,真机原样发了全部 3 条。
如果把它当差分处理(且本局没买票),会被误收敛成 [] —— 这是我踩过的具体坑。
★ 反例警告 2(本次新增):差分收敛会把「和基线完全相同」的条目剔掉。
PLAY_MUSIC_PLAY_COUNT_GAIN = 0 时本局那条和基线一样,会被剔光 → 节点变空 → 整包失效。
所以 build_user_all() 里对这个节点做了兜底:
apply_play_effects 会把本局那条暂存到 _played_music_entry,
差分收敛后如果节点空了就再塞回去,并把 isNewMusicDetailList 置 "0"(存量更新)。
坑 3:playlogId / loginDateTime 必须逐字节等于 UserLoginApi 的返回值
现象:playlogId 自己造一个、或用了上一局的 loginId → 服务端静默拒收。
根因(PacketUpsertUserAll.cs:31):
这两个值不是本接口产生的,而是登录会话的标识。服务端拿它们找会话,
找不到就不应用。
正确做法:
login = UserLogin(user_id, token, ...) # 先登录
UpsertUserAll(user_id, login.loginId, # ★ 必须是它
login_date_time=login.loginDateTime, # ★ 必须是它
auth_date_time=login.dateTime, # userData.dateTime 用这个
...)
★ 容易混的三个时间:
| 变量 | 是什么 | 用在哪 |
|---|---|---|
login.loginDateTime |
「本地墙上时间当 UTC」(= 标准 Unix 秒 + 28800) | 顶层 loginDateTime 字段 |
login.dateTime |
登录请求里发的 Auth.AuthTime |
userData.dateTime |
loginDate(playlog 里) |
标准 Unix 秒 | userPlaylogList[].loginDate |
中途重新登录必须重取这两个值 —— 换了会话还发旧 loginId 一定失败。
自检:validate_before_send() 会检查 playlogId / loginDateTime 是否为 0。
坑 4:confirm 参数漏传 —— 闸门根本没开,但你以为开了
现象:upsert.py --send 跑完显示「上传成功」,其实一个包都没发。
根因:UpsertUserAll 的闸门规则是
confirm=None 时取 config.UPSERT_CONFIRM(默认 False)。
所以只写 dry_run=False 不够,必须同时显式给 confirm=True。
# ✗ 错的:dry_run=False 但 confirm 没给 → 走 config 的 False → 被拦
UpsertUserAll(..., dry_run=False)
# ✓ 对的
UpsertUserAll(..., dry_run=False, confirm=True)
这是「排查了半天以为服务端不收,其实是自己没发」的经典案例。
二、结构坑:报文对不上
坑 5:userWeeklyData / user2pPlaylog 是对象,不是数组
36 个节点里有 2 个 JSON 形态是 {} 而不是 []:
"userWeeklyData": { "lastLoginWeek": "...", "beforeLoginWeek": "...", "friendBonusFlag": true }
"user2pPlaylog" : { "userId1": 0, ..., "user2pPlaylogDetailList": [] }
传成数组会被 coerce_node_value() 直接抛 PatchError(刻意的防呆)。
四种形态总览:
| 类别 | 数量 | JSON 形态 |
|---|---|---|
| 单元素数组(userData/userExtend/userOption/userRatingList) | 4 | [{...}] |
| 对象 | 2 | {...} ★ |
| 普通数组 | 19 | [...] |
| 位串 | 11 | "0101..." |
4 + 2 + 19 + 11 = 36。
坑 6:11 条位串的长度必须等于对应数组的条数
isNew* 与同名数组一一对应。长度不符 → 服务端误判「哪些是新增」。
build_user_all() 末尾用 sync_bits_to_arrays() 自动补齐,但如果你手工 patch 了数组,
位串也要跟着对。
收藏夹是特例(见坑 12):位串取值不是 0/1 而是 1/2。
坑 7:userExtend 在 Upsert 侧多出 2 个字段
| Get 侧 | Upsert 侧 | |
|---|---|---|
| 键数 | 14 | 16 |
| 多出 | — | isPhotoAgree、isGotoCodeRead |
这两个字段只在 Upsert 侧出现(Get 响应里没有),必须手工补上, 否则报文比真机少 2 个键。
对应实现:USER_EXTEND_EXTRA_KEYS。
坑 8:userRating.udemae 有一批首字母大写的重复字段,必须裁掉
现象:GetUserRatingApi 的响应里 npcWinNum 和 NpcWinNum 同时存在(23 键)。
// Get 侧(source/2026-10-05.log:87)—— 23 键
{ "npcWinNum": 0, ..., "NpcWinNum": 0, "NpcLoseNum": 0, "MaxLoseNum": 0, ... }
// Upsert 侧(source/2026-10-05.log:123)—— 只有小写 16 键
{ "npcWinNum": 0, ... }
根因:源码 Net.VO.Mai2/UserUdemae.cs 只声明了小写的 16 个字段,
大写那批是服务端的冗余输出。
影响:不裁掉的话 ① 报文里出现源码不存在的字段 ② 请求体比真机大 119 字节。
对应实现:USER_RATING_UDEMAE_DROP_KEYS。
★ 这个坑只有做深度递归值对比才能发现 —— 光看键数或报文长度是看不出来的。
坑 9:userCharacterList 要按 characterId 升序,不是 charaSlot 顺序
| 顺序 | |
|---|---|
userData.charaSlot |
[395, 101, 400402, 400401, 200401] |
真机 userCharacterList |
[101, 395, 200401, 400401, 400402] |
两者顺序不同 —— 回传时按 characterId 升序排。
对应实现:grown.sort(key=lambda c: int(c.get("characterId", 0)))。
坑 10:字段裁剪 —— Get 侧有的,Upsert 侧不一定有
| 节点 | 要丢掉的字段 |
|---|---|
userData |
friendCode、nameplateId、trophyId、cmLastEmoneyCredit、cmLastEmoneyBrand |
userOption |
tempoVolume |
userCharacterList |
只保留 characterId/level/awakening/useCount |
userMusicDetailList |
extNum2 |
userChargeList |
extNum1 |
userMissionDataList / userWeeklyData |
userId |
坑 10.1:banState 的位置会「漂移」
- Get 侧:
banState是userData的同级顶层字段 - Upsert 侧:它被移进了
userData内部
_extract_from_get_responses() 会自动搬运,但如果你自己手工拼快照,别忘了。
三、字段值坑:结构对了但值错了
坑 11:characterLevelN 是游玩前等级,不要加经验
源码(VOExtensions.cs:1361):
result.characterLevel1 = (int)gameScore.CharaLevel[0];
gameScore.CharaLevel 是本次开始游玩时灌进去的快照,整局不变。
三个真机会话的三方对照(这是唯一能定论的方式):
| 会话 | Get 侧 userCharacterList |
userPlaylogList[].characterLevel |
Upsert 侧 userCharacterList |
|---|---|---|---|
| 00:20 | 3800/4455/134 | 3800/4455/134 | 3801/4456/135 |
| 19:21 | 3801/4456/135 | 3801/4456/135 | 3802/4457/136 |
| 22:31 | 3802/4457/136 | 3802/4457/136 | 3803/4458/137 |
结论:playlog.characterLevel 恒等于 Get 侧(游玩前),
upsertUserAll.userCharacterList 恒为 +1(游玩后)。
⚠ 我一度写成
level + character_exp_gain,因为只看了单个会话、 把「Get 侧」和「Upsert 侧」的数值记混了。横向比三个会话立刻暴露。
推荐做法:别手填 config.PLAY_CHARACTERS,直接用
derive_play_characters(snapshot) 从服务器快照推(upsert.py 已接上)。
账号一升级手填值就过期,会出现「playlog 与 userCharacterList 对不上」的诡异现象。
坑 12:收藏夹不做剔除,且位串取值是 1/2 不是 0/1
build_favorite_delta() 复刻 VOExtensions.cs:345-367:
- 恒返回完整 5 条(不做差分剔除)
- 位串按真机
"22212"反推:"2"= 服务器侧有该 itemKind,"1"= 查不到
★ 必须用未归一化的原始 Get 响应作基线:
真机里 itemKind=10(搭档)的 Get 响应回的是 {"itemKind": null},
正因为查不到才产生那个 "1"。
但 user_favorite.py 会「补 itemKind」,补完之后就看不出原始 null 了。
两个真机会话都是 "22212" —— 第 4 位(itemKind=10)稳定为 "1",
说明服务端对该 kind 恒回 null。
实现上的处理:
- 有原始响应 → 用它建索引,自然得到
"22212" - 只有归一化基线 → 传
assume_missing=FAVORITE_MISSING_KINDS(=(10,))兜底
坑 13:loginDate 是标准 Unix 秒,不是 loginDateTime
loginDateTime = 1791239482 (「墙上时间当 UTC」= 标准 Unix 秒 + 28800)
loginDate = 1791210683 (标准 Unix 秒)
三个真机会话的实际差值:
| 会话 | loginDateTime |
loginDate |
差 |
|---|---|---|---|
| 00:20 | 1791159576 | 1791130777 | 28799 |
| 19:21 | 1791228113 | 1791199315 | 28798 |
| 22:31 | 1791239482 | 1791210683 | 28799 |
注意不是整 28800! 原因:loginDate 来自
TimeManager.GetUnixTime(src.Detail.LastLoginDate)(VOExtensions.cs:1290),
而 lastLoginDate 是客户端在登录响应处理完那一刻写的,比 loginDateTime 晚 1~2 秒。
本工程用 loginDateTime − 28800,稳定差 1~2 秒。
服务端两次都接受了 → 它不对这几个时刻做等式校验。知道这一点可以省掉一轮无谓排查。
坑 14:playDate 是日志日(7 点分界),不是自然日
源码 TimeManager.cs:77-85:
public static DateTime ToLogDateTime(DateTime dateTime, int hour = 0)
{
DateTime result = new(dateTime.Year, dateTime.Month, dateTime.Day, hour, 0, 0);
if (7 > dateTime.Hour) return result.AddDays(-1.0); // ★ 7 点前回退一天
return result;
}
真机验证:00:20 那一局的 userPlaylogList[0].playDate = "2026-10-04"
(自然日是 10-05,因为 00:20 < 07:00 所以回退)。19:21 那局则是 "2026-10-05"。
playDate 是日期串(YYYY-MM-DD),userPlayDate 才是完整时间串,别搞混。
坑 15:lastLoginDate 与 lastPlayDate 是两个不同的时刻
现象:所有时间字段都填成 loginDateTime → 数值上「能用」,但和真机对不上。
真机规律(三个会话全部成立):
| 字段 | 语义 | 00:20 会话 | 19:21 会话 | 22:31 会话 |
|---|---|---|---|---|
userData.lastLoginDate |
登录时刻 | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 |
userData.eventWatchedDate |
与 lastLoginDate 同值 | 00:19:37.0 | 19:21:55.0 | 22:31:23.0 |
userData.lastPlayDate |
上报时刻 | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 |
userGamePlaylogList[0].playDate |
与 lastPlayDate 同值 | 00:20:57.0 | 19:23:02.0 | 22:32:21.0 |
userPlaylogList[0].userPlayDate |
游玩时刻(更早) | 00:20:30.0 | 19:22:36.0 | 22:31:59.0 |
即:登录 → 游玩 → 上报是三个时刻,真机分别是 「loginDateTime +1~2s」「userPlayDate」「上报那一刻」。
本工程把「游玩/上报时刻」抽成参数 play_date_time(不传则退回 login_date_time),
upsert.py 传 login_date_time_now()。
坑 16:userGetPointList / userLoginBonusList 是登录奖励才产生的
真机证据:
| 会话 | userData.point |
userGetPointList |
userLoginBonusList |
|---|---|---|---|
| 00:20 | 4110 | [] |
[] |
| 19:21 | 4460 | [{"getKind":1,"point":200},{"getKind":2,"point":150}] |
[{"bonusId":61,"point":1,"isCurrent":true,...}] |
| 22:31 | 4460 | [] |
[] |
规律:这两个节点非空 ⟺ 本次登录触发了奖励发放。
留空则 point 保持基线值不变 —— 这是「不发放点数」的必然结果,不是 bug。
★ 排查陷阱:如果你把 point 手工 +200 但 userGetPointList 留空,
会出现「数字对不上」的矛盾。要么两个一起改,要么两个都不动。
对应实现:DEFAULT_EMPTY_NODES。
坑 17:userGamePlaylogList[].playCount 是机台本地计数器,服务器不返回
真机值:00:20 会话 = 8、19:21 = 9、22:31 = 10(每打一局 +1)。
源码 VOExtensions.cs:881:result.playCount = Backup.localParameter.playCount。
任何 Get 接口都不返回它 —— 既推不出来,服务端也不会纠正。
填错只是统计数字不对,不影响落库。放在 config.PLAY_LOCAL_PLAY_COUNT。
坑 18:playSpecial 不是 0,是一枚随机位掩码
源码 VOExtensions.cs:846-855:
int max = 1037933;
int num2 = UnityEngine.Random.Range(1, max) * 2069;
num2 += GameManager.CalcSpecialNum();
int num3 = 0;
for (int i = 0; i < 32; i++) { num3 <<= 1; num3 += num2 % 2; num2 >>= 1; }
result.playSpecial = num3;
CalcSpecialNum() 是机台硬件标志位掩码(GameManager.cs:332-378),
逐位含义:SpecialAP、IsAutoPlay()、键鼠数量、IsDevelop、
以及三条否定条件(不含 "ACA" / 不含 "GeForce GTX" / 不含 "Core(TM) i3-8100"),
最后 num |= 0x400 无条件置位。
真机实测:[CalcSpecialNum] = 1024(= 0x400,标准 ACA 机台其余位恰好全 0)。
反推验证(这一步很有说服力):
真机 playSpecial = 892014272
位反转32(892014272) = 57726124 = 27900 × 2069 + 1024
└───┬───┘ └─┬─┘
随机系数 余数 = CalcSpecialNum ✅
配置项:config.PLAY_SPECIAL_NUM = 1024。
注意位反转后可能超过 int32 变成负数 —— 真机 19:21 那局就是
-603697438,这是正常的。
坑 19:extBool1/2/3 是 bool 但名字不带 is 前缀
用 name.startswith("is") 判断布尔字段会漏掉这三个,
导致序列化成 0 而不是 false。
同理字符串字段(vsUserName、playedUserName1..3、placeName、playDate、userPlayDate)
默认值必须是 "" 而不是 0。
对应实现:显式集合 PLAYLOG_BOOL_FIELDS / PLAYLOG_STRING_FIELDS。
坑 20:110 个字段一个都不能省
把真机报文的 userPlaylogList[0] 与 Net.VO.Mai2/UserPlaylog.cs 做双向集合比对:
| 方向 | 差异 |
|---|---|
| C# 声明但报文未出现 | 0 |
| 报文有但 C# 未声明 | 0 |
能精简的是「填写的内容要真实」,而不是删字段。
四、网络层坑
坑 21:number 头只由 Ping 心跳刷新,不是每个请求都更新
真机 70 个请求里它只变过一次:
#1 ~ #10 number = 0 ← 还没跑过心跳
#11 ~ #17 Ping 包(按 Packet.cs:187 **不带** number 头)
#18 ~ #70 number = 63 ← 心跳往返 63ms,此后 53 个业务请求恒定
源码链路:
PingChecker.cs:166 StartPacket(new PacketPing(PingSuccess, PingError))
PacketPing.cs:34 _onDone((int)sw.ElapsedMilliseconds) ← 值 = Ping 包往返毫秒
PingChecker.cs:227 GameManager.PingMsec = pingMsec
Packet.cs:189 Client.AddHeader("number", GameManager.PingMsec.ToString())
我原来的错法:每个请求结束后把「本请求耗时」写进 ping_msec,
于是 number 随每个包抖动。已改成只有 Ping 包更新。
调用方注意:upsert.py 在取完服务器设置后加了一步 Ping(),否则 number 恒为 0。
坑 22:Cookie —— 登录响应会下发 JSESSIONID,而且必须带着
这个坑我踩了两次(先误判、后自我推翻):
- hook 日志里显示
Cookie : userId=12345678—— 这是我 hook 自己拼的假串 (gamehook/Main.cs:196-199的_cookie = "userId=" + userId),不是真实 Cookie 头。 - 于是我用
Ping探针去验证,看到响应头里没有 Set-Cookie, 就得出「服务器从不下发 Cookie,无需处理」的结论 —— 这是错的。 - 后来跑完整登录链路,
UserLoginApi的响应头里明确有:Set-Cookie: JSESSIONID=FC9D0F122F7F24A522CD623BA3B76B74; Path=/Maimai2Servlet; HttpOnly
源码(Packet.cs:228-234):只在 UserLogin 响应上把 Set-Cookie 收进
CookieContainer;之后 Packet.cs:190-193 每个带 userId 的请求都挂回去。
结论:这是真实的会话粘滞。
requests.Session 会自动处理,但前提是全程复用同一个 MaimaiNetClient
(本工程各 api 函数在 client=None 时都走 get_default_client() 单例)。
如果谁手动 new 一个 client 去发结算包,就会丢掉 JSESSIONID。
教训:用「只读探针」验证一件事时,要确认探针覆盖了完整链路 —— 我只探了 Ping,就下了关于整个服务器的结论。
坑 23:Accept-Encoding 必须显式置空
requests 默认发 Accept-Encoding: gzip, deflate。
本协议响应体是 AES 密文(二进制),服务端若据此做内容编码会返回空 body。
实测:不发该头 → HTTP 200 / 0 字节;显式置空 → 正常返回。
C# 端不需要是因为 HttpWebRequest 默认 AutomaticDecompression = None。
坑 24:扫码 chipID 用完整形式,游戏服务器用短值
| 用途 | 正确值 | 源码依据 |
|---|---|---|
| aimedb 扫码 | XXXX-XXXXXXXXXXX(带连字符) |
ChimeReaderManager.cs:250 KeychipId.Value |
游戏服务器 clientId |
TESTCLIENT1(短值) |
PacketGetGameSetting.cs:23 KeychipId.ShortValue |
我原来扫码也用了短值,导致 aimedb 返回错误。
五、工程/流程坑
坑 25:不做落库回查,你永远不知道有没有写进去
returnCode=1 完全不可信(见坑 1~4)。必须发包后重新拉一次数据比对。
upsert.py --send 的第 7.5 步已经内置:
7.5 落库回查(重新拉服务器数据)
playCount 1260 -> 1261
currentPlayCount 188 -> 189
lastPlayDate 2026-10-05 22:32:21 -> 2026-10-06 01:28:04
musicId=143 level=4 playCount -> 11
【落库成功】userData.playCount 已 +1
为什么在登出前回查:同一个登录会话内 GetUserDataApi 立刻能读到新值,
不用再开一次会话(也就不会消耗第二个二维码)。
坑 26:报文体长度差异要逐节点看,不要只看总长
总长差几字节可能来自随机数位数(playSpecial),也可能来自真实的结构错误。
逐节点比对字节数才能定位:
节点 真机068 本次 差
userData 1985 1985 0
...
userGamePlaylogList 294 295 1 <<< 只有它差(playSpecial 随机数)
(36 节点合计) 14255 14256 1
35 个节点 0 差异才是「结构完全对齐」的证据。
坑 27:--debug 日志会很大
DEBUG 模式会完整打印每个请求的头 + 体 + 响应体, 一次结算流程(含 19 节点快照 + 739 组曲目分页)能到 18 MB。
只想看流程就别加 --debug;想减小体积调 config.LOG_VALUE_MAX(0 = 不截断)。
六、发包前 Checklist
按顺序过一遍,能挡掉上面绝大多数坑:
- 闸门:
confirm=True(或config.UPSERT_CONFIRM=True) - 会话:
playlogId == login.loginId,loginDateTime == login.loginDateTime - 同会话:中途没重新登录过(重登必须重取上面两个值)
- 快照:
fetch_upsert_snapshot()抓了 19 个节点,失败 0 个 - userData 演算:
playCount/currentPlayCount+1,lastPlayDate是上报时刻,lastLoginDate/eventWatchedDate是登录时刻,dateTime非 0 - 差分节点:
userMusicDetailList至少 1 条(本局曲目战绩);userMapList/userCharacterList按 config 增量决定 ——PLAY_MAP_DISTANCE_GAIN/PLAY_CHARACTER_EXP_GAIN默认都是 0, 此时它们为空是预期行为;只有把增量设成非 0 时才必须有条目 userChargeList是整表回传,没被当差分收敛成空- 位串:11 条长度等于对应数组条数;
isNewFavoriteList应为"22212" - playlog:110 个字段齐全;
characterLevelN是游玩前等级 - playSpecial:非 0,且余数 =
config.PLAY_SPECIAL_NUM - 网络:跑过
Ping()(number 头),同一个 client(Cookie) - 自检:
validate_before_send()返回 0 个问题 - 回查:发包后确认
playCount真的 +1
六点五、关于 userMapList / userCharacterList 的默认空值
本工程默认:
PLAY_MAP_DISTANCE_GAIN = 0 # 本局不推进地图 -> userMapList 回传 []
PLAY_CHARACTER_EXP_GAIN = 0 # 本局不给角色加经验 -> userCharacterList 回传 []
这不是坑,是刻意的默认值 —— 因为「地图走多远」「角色升几级」取决于真实游玩, 纯网络脚本推不出来。想要真机那种 +11000 / +1 级,把这两个配置改回去即可。
★ 唯一要注意的连带影响:validate_before_send() 的「差分节点为空」告警
只在增量非 0 却没产出条目时才触发,所以默认值下不会刷无意义的告警。
七、这个清单是怎么攒出来的
| 阶段 | 手段 | 抓到的坑 |
|---|---|---|
| 初版实现 | 读源码 + 单个真机日志 | 坑 5、6、13、14、18、19、20、23、24 |
| 深度值对比 | 逐字段递归 diff 真机报文 vs 本工程 | 坑 7、8、9、10 |
| 落库失败排查 | 直接 patch 绝对值探测服务端行为 |
坑 1、2、3、4 |
| MelonLoader hook 抓真机 | 在游戏里 hook 网络层抓完整会话 | 坑 11、12、15、16、17、21、22 |
| 真实跑通 | --send + 落库回查 |
坑 25、26、27 |
最有效的一招是「横向比多个真机会话」 —— 坑 11(characterLevel 语义)、 坑 13(loginDate 差 28798/28799)、坑 15(两个时刻分离)都是单看一个会话看不出来、 一横向比就立刻暴露的。
第二有效的是 MelonLoader hook —— 它能看到 HTTP 头(number)、
能抓到 CalcSpecialNum() 的真实返回值,这些在纯日志里根本看不到。