
本文仅用于技术研究、漏洞复盘与软件工程讨论,不提供可用脚本,也不建议尝试任何可能影响游戏公平性、账号安全或服务器数据的操作。
一些名词
Keycard 在客户端内部的资源名为 DraftPick,主要用于小队长招募。
COE(Cycle of Evil,邪恶事件循环)中的恐怖博士、克隆岛、战争工厂等事件,到达一定阶段之前,未通过的阶段可以奖励 Keycard。同时,客户端还存在一套历史 Keycard 补发机制。
问题出在:客户端判断谁有资格补发的状态,和最终把奖励写给谁的对象,并没有始终绑定在一起。
实际上客户端至少存在两套相关标记。这里暂时只讨论针对老玩家的 bug 触发链路。
正常完成当前邪恶事件第 7 阶段时,客户端会检查 Avatar 上的通用状态:
commodity[0x1A2] < baseStage + 7 它用于判断当前阶段的 DraftPick 是否已经结算。
历史补发走的是另一套逻辑。每个 COE 事件记录会从 JSON 中读取:
"coe": {
"ed": [
{
"i": 41000006,
"p": 15,
"d": false,
"hct": 0
}
]
} 对应到内存结构:
record + 0x0C = p
record + 0x10 = hct 其中:
p:该邪恶事件已经推进到的进度;
hct:历史 Keycard 已经补发到的位置;
i:事件数据 ID;
d:事件状态标记。
历史补发函数关注的是 p 与 hct 之间是否存在缺口,而不是先检查自家账号的 0x1A2。
其简化逻辑类似:
lastStage = p + eventBase - 1
if hct < lastStage:
for stage in hct + 1 ... lastStage:
total += DraftPickReward(stage) 因此,即使当前玩家早已领取过自己的补发,只要客户端此刻读取的是另一个玩家较旧的 hct,历史补发检查仍然可能返回正数。
客户端在创建一条 COE event record 时,会把 hct 默认初始化为 0;只有 JSON 中确实包含 hct 时才覆盖它。
这意味着:
较新、持续上线的账号,通常已经完成数据迁移,hct 会跟随进度推进;
长期离线账号可能保留旧快照,出现 p 很高但 hct=0 的状态。
对游戏采样结果如下:
这些 ID 依次对应 Hammerman、Dr. T Tropical/Volcano、Gearheart、Imitation Game 及另一组 Dr. T 事件。 (哈莫曼,小博士大博士,工厂,克隆岛,小博士大博士)
长期离线玩家的 7 条记录全部为:
p > 0
hct = 0 活跃玩家的 7 条记录则全部满足:
hct = p + 6 这正是“历史进度已经完成迁移”的形态。
“找长期离线玩家”可以让 hct 满足 hct = 0 的条件。
当玩家点击邪恶事件时,客户端会先检查是否存在历史 Keycard 可补发。
如果计算结果大于 0,客户端不会立刻发奖,而是创建一个内部类型为 0x2BE 的逻辑命令,放入 LogicCommandManager 队列。
关键代码可以简化成:
amount = CalculateMissedCoeDraftPickRange(currentLevel)
if amount > 0:
enqueue(CommandType 0x2BE) 这个命令非常特殊:它没有保存账号 ID、Avatar ID、补发区间、奖励数量或唯一领取流水号。它真正执行时,会再次从“当前 Level”中读取状态并重新计算奖励。
更关键的是,它的状态许可为:
Home = 允许执行
Visit = 禁止执行
Attack = 禁止执行 这解释了为什么 bug 对操作速度非常敏感,稍慢一点就会卡失败。
旧客户端中,从他人资料点击“眼睛”后,画面可能已经退回海域,但逻辑状态仍短暂保留在 Visit。
此时点击邪恶事件:
客户端读取的是被访问玩家的 COE record;
长期离线玩家的 hct=0 让补发预检查返回正数;
0x2BE 命令被成功放入队列;
但 Visit 状态不允许它执行。
快速点击指南针后,状态切换到 Home,命令获得执行许可。与此同时,奖励接收 Avatar 已经切换为自己的 Primary Avatar,而 COE manager 在旧客户端中仍可能保留被访问玩家的数据。
于是形成了最关键的混合状态:
奖励接收者 = 自己
补发资格 = 长期离线玩家的 p/hct 
如果返回慢了,命令会在 Visit 状态中先到达执行器。客户端中存在一条非常直接的错误文本:
Execute command failed! Command is not allowed in visit state. 这与玩家看到的错误高度吻合。
GetOwningPlayerAvatar(level) 会根据 GameMode 选择不同 Avatar:
Home -> Primary Avatar
Visit -> Secondary Avatar 历史补发执行时的逻辑大致为:
selfAvatar = GetOwningPlayerAvatar(level)
coeManager = level->coeManager
record = coeManager.findRecord(currentEvent)
amount = calculate(record.p, record.hct)
selfAvatar.commodity[0x1A2] = lastStage
record.hct = max(record.hct, lastStage)
selfAvatar.DraftPick += amount 这里出现了一个典型的软件工程问题:
selfAvatar 是根据执行时的 GameMode 动态选择;
coeManager 属于 Level 上另一个独立状态对象;
命令没有把二者绑定为同一个不可变玩家上下文。
这本质上是一个 TOCTOU(检查时与使用时不一致)+ 跨上下文状态污染 问题。
一次补发执行后,客户端会推进当前内存中那条 foreign record 的 hct。
但“查看其他玩家”本质上应该是只读快照。这次本地修改不会替被查看玩家永久更新服务器数据。
当玩家再次访问同一个长期离线账号时,服务器仍然会发回原来的:
p > 0
hct = 0 于是同一批历史奖励又会被判断为“尚未领取”。
只要旧客户端还能重复制造混合上下文,就可以重复触发同一批一次性补发。
从玩家视角看,这是一场突然出现的“福利狂欢”;从运营视角看,它会直接破坏抽卡资源、排行榜和账号价值;从技术视角看,它则是一个非常典型的状态管理事故。对于这坨陈年屎山,海岛奇兵的开发者又将如何应对?