一个 char 的符号性差异导致的安卓端无限续命 Bug

引言

在将 Touhou 06(东方红魔乡)的 SDL2 移植版适配到 Android 平台的过程中,遇到了一个非常诡异的 Bug:安卓上永远无法触发 Game Over,最后一条命死亡后游戏会自动续命并继续,就好像续关界面被直接跳过了一样。

这个 Bug 的根因出乎意料——char 类型在 ARM 和 x86 架构上的默认符号性不同。

症状

安卓端测试时发现了以下现象:

  • 命用完了(livesRemaining 降为 0)
  • 掉出了 Full Power 道具(确认触发了最后一条命死亡的逻辑)
  • 有 1UP 音效播放
  • 没有续关界面弹出
  • 游戏像是直接触发了续关函数,玩家直接重生继续游戏

在 Windows 桌面端,完全相同的代码表现正常——最后一条命死亡后,续关界面正确弹出。

背景知识:分数奖残

在东方系列中,”分数奖残”是一个经典机制——当玩家累计分数达到特定阈值时,会自动获得 1 条额外生命(即 1UP/Extend)。在红魔乡中,阈值如下:

第几次续命 分数阈值
第 1 次 1,000 万
第 2 次 2,000 万
第 3 次 4,000 万
第 4 次 6,000 万
第 5 次 19 亿

代码中用 extraLives 变量(i8 类型)作为索引,跟踪玩家已经获得了几次分数奖残,并在每帧将 guiScoreg_ExtraLivesScores[extraLives] 比较。当最后一条命死亡时,extraLives 被设为 255(作为有符号数等于 -1),使得 extraLives >= 0 为 false,从而阻止死亡期间触发分数奖残。

调查

第一关:输入系统

最初怀疑是安卓触屏输入导致续关菜单被自动确认。详细审查了:

  • 触屏虚拟按键系统(TouchVirtualButtons)的按钮屏蔽逻辑
  • TryTouchSelect 的双击确认机制
  • g_TapPending 残留触摸事件的清理时机
  • WAS_PRESSED 宏在续关菜单中的行为

结论:输入系统有完善的防护,isInRetryMenu = 1 时虚拟按键返回 0,残留触摸事件在游戏模式下被正确清理。输入不是问题。

第二关:续关菜单逻辑

添加了诊断日志到 Player.cppisInRetryMenu 触发点)和 AsciiManager.cpp(续关菜单状态机)。

然后,安卓日志中完全没有 [retry] 相关条目——这意味着 isInRetryMenu = 1 从未被执行

第三关:死亡流程追踪

仔细追踪了 Player::OnUpdate() 的死亡流程:

1
2
3
PLAYER_STATE_DEAD → respawnTimer 倒计时 → 道具掉落 → 
invulnerabilityTimer 30帧 → livesRemaining 检查 →
isInRetryMenu 或 respawn

关键分支:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Player.cpp, respawnTimer == 0 时
if (livesRemaining > 0) {
// 掉落普通 Power 道具
} else {
// 掉落 Full Power 道具
g_GameManager.extraLives = 255; // 阻止分数奖残
}

// 30帧后, invulnerabilityTimer >= 30 时
if (livesRemaining <= 0) {
g_GameManager.isInRetryMenu = 1; // Game Over
} else {
livesRemaining--;
goto spawning; // 重生
}

我明确看到了 Full Power 道具掉落,说明在 respawnTimer == 0livesRemaining <= 0extraLives 被设为 255

但 30 帧后检查 livesRemaining 时,它居然变成了 > 0!这意味着在这 30 帧内,有什么东西给了玩家一条命。

第四关:谁增加了 livesRemaining?

搜索所有 livesRemaining++ 的调用点,只有两个来源:

  1. GameManager.cpp:分数奖残 — if (extraLives >= 0 && score >= threshold)livesRemaining++
  2. ItemManager.cpp:收集 1UP 道具 — 但死亡状态下无法收集道具

分数奖残是唯一可能的来源。但 extraLives = 255 应该阻止了它……

STAGE CLEAR

检查 extraLives 的类型定义:

1
2
3
4
5
// GameManager.hpp
i8 extraLives; // 类型是 i8

// inttypes.hpp
typedef char i8; // ← 这就是问题所在!

在 C/C++ 标准中,char 的符号性是平台相关的(implementation-defined)。

平台 char 默认 (char)255 的值 >= 0 结果
x86 (Windows/Linux GCC) signed -1 false
ARM (Android NDK) unsigned 255 true

在 x86 上:

1
2
extraLives = 255 → (signed char)255 = -1
-1 >= 0 → false → 分数奖残被正确阻止 ✅

在 ARM 上:

1
2
extraLives = 255 → (unsigned char)255 = 255
255 >= 0 → true → 分数奖残检查通过! ❌

Bug 的完整触发链

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1. 玩家最后一条命死亡
→ livesRemaining = 0

2. respawnTimer 倒计时结束
→ livesRemaining <= 0 → 掉落 Full Power 道具
→ extraLives = 255

3. 接下来的 30 帧内:
GameManager::OnUpdate() (优先级 4, 先于 Player 执行)
→ extraLives(255) >= 0 在 ARM 上为 TRUE
→ g_ExtraLivesScores[255] ← 数组越界访问!(数组只有 5 个元素)
→ 越界读到的值碰巧 <= guiScore
→ livesRemaining++ (0 → 1)
→ 播放 SOUND_1UP
→ extraLives++ (255 → 0, unsigned char 回绕)

4. invulnerabilityTimer 达到 30 帧
→ livesRemaining(1) > 0 → 正常重生,不触发续关界面

5. 玩家永远无法 Game Over

这不仅是一个逻辑错误,还包含了一个数组越界读取g_ExtraLivesScores[255],数组大小为 5),属于未定义行为。

修复

GameManager.cpp 的分数奖残检查处添加显式的有符号类型转换:

1
2
3
4
5
6
7
// 修复前
if (gameManager->extraLives >= 0 &&
g_ExtraLivesScores[gameManager->extraLives] <= gameManager->guiScore)

// 修复后
if ((int8_t)gameManager->extraLives >= 0 &&
g_ExtraLivesScores[gameManager->extraLives] <= gameManager->guiScore)

int8_t 来自 <cstdint>,保证是有符号 8 位整数,在所有平台上行为一致。

为什么不直接改 typedef

最直觉的修复是将 typedef char i8 改为 typedef signed char i8。但在 C++ 中,charsigned charunsigned char三个不同的类型。项目中有些地方将 i8 用于字符串操作(如 strcpy),改为 signed char 会导致类型不匹配的编译错误。

因此选择了最小侵入的修复方式:只在需要有符号比较的地方添加显式转换。

后日谈

  1. 永远不要假设 char 是有符号的。 C/C++ 标准规定 char 的符号性由实现定义。如果代码依赖符号性,请使用 signed charunsigned char<cstdint> 中的 int8_t/uint8_t

  2. 跨平台移植时,ARM 和 x86 的 char 差异是经典陷阱。 这个问题在嵌入式开发和移动端移植中广为人知,但在实际项目中仍然容易遗漏。

  3. 离奇的症状往往有简单的根因。 “1UP 音效 + Full Power 道具 + 无续关界面”这组症状看起来自相矛盾,但最终归结为一个 typedef 中的一个单词差异。

  4. 数组越界是未定义行为的温床。 这个 Bug 不仅导致了逻辑错误,还触发了数组越界读取。在某些情况下,越界读到的值可能导致崩溃,但在这里碰巧只是让游戏”太友善”了。

  5. 编译器不会为 char 的符号性比较产生警告。 即使开启了 -Wall -Wextrachar >= 0 这样的表达式在 char 为 unsigned 时不会产生 “comparison is always true” 的警告,因为编译器认为这是合法的用法。可以考虑使用 -Wchar-subscripts 或静态分析工具来捕获此类问题。

影响范围

项目中所有使用 i8(即 char)进行有符号比较的地方都可能存在类似问题。建议在跨平台项目中进行全面审查,或考虑将 i8 的定义替换为 int8_t,同时修复所有字符串操作的类型不匹配。