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

27 KiB
Raw Blame History

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,而且必须带着

这个坑我踩了两次(先误判、后自我推翻):

  1. hook 日志里显示 Cookie : userId=12345678 —— 这是我 hook 自己拼的假串 (gamehook/Main.cs:196-199 的 _cookie = "userId=" + userId),不是真实 Cookie 头。
  2. 于是我用 Ping 探针去验证,看到响应头里没有 Set-Cookie, 就得出「服务器从不下发 Cookie,无需处理」的结论 —— 这是错的。
  3. 后来跑完整登录链路,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() 的真实返回值,这些在纯日志里根本看不到。