同一个 char Bug 的续集:安卓端物品渲染异常的漫长追踪

引言

上一篇文章记录了 typedef char i8 导致的安卓端无限续命 Bug。当时的修复方式是在分数续命检查处添加 (int8_t) 显式转换,算是一个精准的手术刀式补丁。

我以为这个坑踩完了。然而同一个 char 符号性问题,又在完全不同的地方咬了我一口——而且这次的症状更加隐蔽,追查过程也走了更多弯路。

症状

安卓端第一关,击杀敌人后掉落的物品出现异常:

  • 红色 Power 道具(大小)渲染正常
  • 但本应是蓝色得点物品的位置,显示的是半透明的矩形色块
  • 色块的颜色和纹理看起来像是背景贴图的一部分在渗透到物品的位置上
  • 仅限安卓端,Windows 桌面端完全正常

视觉上的效果就像物品没有被绘制,但 FBO 中残留的背景画面在该位置”透”了出来。

第一关:渲染管线的全面审计

最初的判断是经典的纹理绑定错误。SDL2 移植版有两套渲染器:桌面端用 RendererGL(固定管线),安卓端用 RendererGLES(Shader + FBO)。安卓端的 FBO 系统不清除颜色缓冲(只清深度),背景先填满画面,然后各元素叠加绘制。如果某个元素的绘制被跳过了,该位置就会”透底”显示背景。

于是我开始了一场对整个渲染管线的地毯式排查:

  • 审计了全部 25+ 处 glBindTexture 调用
  • 分析了完整的绘制链和绘制优先级排序
  • 检查了 textureFactor 缓存失效的逻辑
  • DrawInner 函数入口添加了安全重绑(Safety Rebind)

安全重绑的方案实际部署到安卓上后,引入了新的副作用——大尺寸背景精灵变黑。虽然经过精细调整后大图问题修好了,但蓝色物品的渲染异常依然存在。

这条路走了整整两天,最终全部回滚。

回过头看,这段时间的努力当然不是零价值——它排除了”纹理绑定状态错乱”这个最可能的假设,迫使我重新审视问题的本质。

第二关:缩小问题范围

我找到了关键线索:只有蓝色得点物品有问题,红色 Power 物品完全正常。

东方红魔乡的物品系统用一个 itemType 索引来区分不同物品:

itemType 物品 精灵索引
0 小 Power(红) 512
1 Point/得点(蓝) 513
2 大 Power(红) 514
3 Bomb 碎片 515
4 Full Power 516
5 1UP 517
6 子弹消除得点 518

如果问题出在纹理绑定或 Shader 上,所有物品应该同时出问题。只有 type=1 异常,说明问题出在这个特定物品类型的逻辑上,而不是渲染管线。

这个认知转折很重要,但也为后面的调查埋下了一个隐含的假设——我一直认为是”sprite=513 的渲染出了问题”。

第三关:跨平台对照日志

我在 DrawInner 函数中添加了诊断日志,过滤精灵索引 512-520 的所有绘制调用,同时在 Windows 和 Android 上采集数据。

对比结果非常清晰:

1
2
Windows:  sprite=512 (126次), 513 (94次), 514 (42次), 516 (6次), 519 (62次), 520 (36次)
Android: sprite=512 (126次), 514 (90次), 516 (6次), 519 (62次)

Android 上 sprite=513(得点物品)和 sprite=520(得点物品的画面外指示器)完全不存在。

DrawInner 中根本没有这两个精灵的绘制调用。再往上层追溯,DrawNoRotation 会在 isVisible==0 || flag1==0 || color==0 时直接 return,不进入 DrawInner

所以要么:

  1. 得点物品从未被生成(SpawnItem 没被调用)
  2. 得点物品的 ANM 脚本执行失败,isVisible 停留在 0

第四关:误导性的追踪方向

基于”sprite=513 不出现”这个线索,我集中调查了 ANM 脚本加载和执行流程:

  • SetAndExecuteScript 先将 isVisible 设为 0,然后调用 ExecuteScript
  • ExecuteScript 中,AnmOpcode_SetActiveSprite 指令会将 isVisible 设回 1
  • 如果脚本指针为 NULL,ExecuteScript 不会被调用,isVisible 就停在 0

我怀疑是 AnmRawEntry 结构体中的 AnmRawScript 成员含有指针类型 AnmRawInstr*,在 64 位 ARM 上导致结构体布局偏移。仔细分析了 LoadAnm 的脚本加载循环之后发现,代码实际使用 u32* 指针算术直接读取原始文件数据,完全绕开了结构体布局差异。这条路也是死胡同。

又添加了一轮诊断——在 SetAndExecuteScriptIdxSetAndExecuteScript 中记录脚本指针和执行后的 VM 状态。

第五关:真相浮出水面

新一轮诊断日志上来后,关键信息终于出现了:

1
2
3
4
ITEM_SETEXEC idx=533 script_ptr=0xe592b700 isNull=0   ← type=0 (Power), 正常
ITEM_SETEXEC idx=535 script_ptr=0xe592b720 isNull=0 ← type=2 (大Power), 正常
ITEM_SETEXEC idx=537 script_ptr=0xe592b740 isNull=0 ← type=4 (Full Power), 正常
// idx=534 (type=1, 得点物品) ← 完全没有!

同时在 ItemManager::OnDraw 的日志中发现了更诡异的东西:

1
ITEM_ONDRAW type=255 activeSprite=786 isVis=1 flag1=1 color=0xFFFFFFFF unk142=1 posY=127.6 srcFileIdx=4

type=255?!

物品类型只有 0-6,255 是从哪里来的?activeSprite=786 远超物品的精灵范围(512-520),srcFileIdx=4 指向的是背景贴图而不是物品贴图。

这些”幽灵物品”正是屏幕上那些半透明矩形的来源——它们碰巧加载了一段不相关的 ANM 脚本,设置了错误的精灵,把背景贴图的一块区域当作物品渲染了出来。

追到 type=255 物品的来源,代码浮出水面:

1
2
3
4
5
6
7
8
9
10
11
12
// EnemyManager.cpp:762
if (curEnemy->itemDrop >= 0)
{
g_ItemManager.SpawnItem(&curEnemy->position, (ItemType)curEnemy->itemDrop, local_8);
}
else if (curEnemy->itemDrop == ITEM_NO_ITEM)
{
// 随机物品掉落(包含得点物品 ITEM_POINT)
g_ItemManager.SpawnItem(&curEnemy->position,
(ItemType)g_RandomItems[mgr->randomItemTableIndex], local_8);
...
}

Enemy::itemDrop 的类型是 i8——也就是 char

当敌人不掉落物品时,itemDrop 被设为 -1(对应 ITEM_NO_ITEM = 0xFFFFFFFF),截断为 0xFF

在 ARM 上 char 是 unsigned:

平台 (char)0xFF >= 0 结果
x86 -1 false 进入 else if 分支,正确触发随机物品掉落
ARM 255 true 进入 if 分支,生成 type=255 幽灵物品

两个后果:

  1. itemDrop == -1 的敌人在安卓上会掉落 type=255 的幽灵物品(垃圾渲染),而不是被正确跳过
  2. else if 分支——随机物品表(包含 ITEM_POINT 得点物品)——永远无法到达

这就是为什么安卓上没有蓝色得点物品,同时又出现了奇怪的半透明矩形。两个看似不相关的症状,来自同一个根因。

回顾:哪些环节被误导了

整个调查走了不少弯路,复盘下来有几个关键的误判点:

1. 初始假设的方向偏差

“背景渗透到物品”→”纹理绑定错误”→ 全面审计渲染管线。这个因果推断在 99% 的情况下是对的,但这次恰好属于那 1%——物品压根没被绘制。

2. “sprite=513 不出现”掩盖了真实问题

在 DrawInner 中找不到 sprite=513 的调用,很自然地联想到”ANM 脚本加载失败”或”VM 状态未被正确初始化”。但实际上 sprite=513 不出现是因为 type=1 的 SpawnItem 根本没被调用过——不是渲染链路上的问题,而是物品生成链路上的问题。

3. type=255 幽灵物品的迟到发现

直到在 ItemManager::OnDraw 中添加了逐物品的全类型诊断,才发现 type=255 的存在。之前的诊断只关注 DrawInner 中的精灵索引范围 512-520,而 type=255 的精灵是 786,完全不在过滤范围内。如果早一步在 OnDraw 级别做全类型 dump,至少能缩短一天的排查时间。

4. 已知 Bug 的教训没有被及时迁移

上一个 Bug(无限续命)的根因完全相同——typedef char i8 在 ARM 上 unsigned。当时的修复是局部 cast,而不是从根本上解决 char 符号性问题。如果当时直接添加了 -fsigned-char 编译选项,这个 Bug 根本不会出现。

修复

这次不再做局部补丁了。在 CMakeLists.txt 的编译选项中添加 -fsigned-char,从编译器层面统一 char 的行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
# android/app/jni/CMakeLists.txt
target_compile_options(main PRIVATE
-fsigned-char # ARM defaults char to unsigned; match x86 signed behavior
-fpermissive
...
)

# CMakeLists.txt (主项目 GCC/Clang 分支)
target_compile_options(th06 PRIVATE
-m32
-fsigned-char # ARM默认char为unsigned; 统一为signed以匹配x86和游戏逻辑
...
)

-fsigned-char 让 GCC/Clang 把裸 char 当作 signed char 处理,与 MSVC 在 x86 上的默认行为一致。所有使用 i8(即 char)进行有符号比较的代码,不再需要逐个添加显式转换。

这同时也涵盖了上一个 Bug(extraLives >= 0)的修复——之前的 (int8_t) 显式转换仍然保留但已不是必需的,可以视为防御性编程。

后日谈

  1. 局部修复不等于根因修复。 上一个 Bug 用 (int8_t) cast 堵住了一个洞,但同一面墙上还有无数个洞。如果当时就加上了 -fsigned-char,整面墙就不透风了。

  2. 诊断的粒度决定发现速度。 在渲染管线的 DrawInner 层做诊断,看到的是”sprite=513 不出现”;在 ItemManager::OnDraw 层做诊断,看到的是”type=255 幽灵物品”。后者直接指向了根因,前者需要再追好几层。在多层系统中,优先在靠近数据源头的层做诊断。

  3. 跨平台 Bug 的排查要建立对照组。 同一份代码、同一份数据,Windows 和 Android 同时跑诊断,逐行对比日志。很多时候差异本身就是答案。

  4. char 的符号性是跨平台移植的经典地雷。 任何将 char 用作数值类型并参与比较运算的代码都有风险。移植到 ARM 平台时,第一件事就应该检查编译选项中是否有 -fsigned-char