一个 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 类型)作为索引,跟踪玩家已经获得了几次分数奖残,并在每帧将 guiScore 与 g_ExtraLivesScores[extraLives] 比较。当最后一条命死亡时,extraLives 被设为 255(作为有符号数等于 -1),使得 extraLives >= 0 为 false,从而阻止死亡期间触发分数奖残。
调查
第一关:输入系统
最初怀疑是安卓触屏输入导致续关菜单被自动确认。详细审查了:
- 触屏虚拟按键系统(
TouchVirtualButtons)的按钮屏蔽逻辑 TryTouchSelect的双击确认机制g_TapPending残留触摸事件的清理时机WAS_PRESSED宏在续关菜单中的行为
结论:输入系统有完善的防护,isInRetryMenu = 1 时虚拟按键返回 0,残留触摸事件在游戏模式下被正确清理。输入不是问题。
第二关:续关菜单逻辑
添加了诊断日志到 Player.cpp(isInRetryMenu 触发点)和 AsciiManager.cpp(续关菜单状态机)。
然后,安卓日志中完全没有 [retry] 相关条目——这意味着 isInRetryMenu = 1 从未被执行。
第三关:死亡流程追踪
仔细追踪了 Player::OnUpdate() 的死亡流程:
1 | PLAYER_STATE_DEAD → respawnTimer 倒计时 → 道具掉落 → |
关键分支:
1 | // Player.cpp, respawnTimer == 0 时 |
我明确看到了 Full Power 道具掉落,说明在 respawnTimer == 0 时 livesRemaining <= 0,extraLives 被设为 255。
但 30 帧后检查 livesRemaining 时,它居然变成了 > 0!这意味着在这 30 帧内,有什么东西给了玩家一条命。
第四关:谁增加了 livesRemaining?
搜索所有 livesRemaining++ 的调用点,只有两个来源:
- GameManager.cpp:分数奖残 —
if (extraLives >= 0 && score >= threshold)→livesRemaining++ - ItemManager.cpp:收集 1UP 道具 — 但死亡状态下无法收集道具
分数奖残是唯一可能的来源。但 extraLives = 255 应该阻止了它……
STAGE CLEAR
检查 extraLives 的类型定义:
1 | // GameManager.hpp |
在 C/C++ 标准中,char 的符号性是平台相关的(implementation-defined)。
| 平台 | char 默认 |
(char)255 的值 |
>= 0 结果 |
|---|---|---|---|
| x86 (Windows/Linux GCC) | signed | -1 |
false ✅ |
| ARM (Android NDK) | unsigned | 255 |
true ❌ |
在 x86 上:
1 | extraLives = 255 → (signed char)255 = -1 |
在 ARM 上:
1 | extraLives = 255 → (unsigned char)255 = 255 |
Bug 的完整触发链
1 | 1. 玩家最后一条命死亡 |
这不仅是一个逻辑错误,还包含了一个数组越界读取(g_ExtraLivesScores[255],数组大小为 5),属于未定义行为。
修复
在 GameManager.cpp 的分数奖残检查处添加显式的有符号类型转换:
1 | // 修复前 |
int8_t 来自 <cstdint>,保证是有符号 8 位整数,在所有平台上行为一致。
为什么不直接改 typedef?
最直觉的修复是将 typedef char i8 改为 typedef signed char i8。但在 C++ 中,char、signed char 和 unsigned char 是三个不同的类型。项目中有些地方将 i8 用于字符串操作(如 strcpy),改为 signed char 会导致类型不匹配的编译错误。
因此选择了最小侵入的修复方式:只在需要有符号比较的地方添加显式转换。
后日谈
永远不要假设
char是有符号的。 C/C++ 标准规定char的符号性由实现定义。如果代码依赖符号性,请使用signed char、unsigned char或<cstdint>中的int8_t/uint8_t。跨平台移植时,ARM 和 x86 的
char差异是经典陷阱。 这个问题在嵌入式开发和移动端移植中广为人知,但在实际项目中仍然容易遗漏。离奇的症状往往有简单的根因。 “1UP 音效 + Full Power 道具 + 无续关界面”这组症状看起来自相矛盾,但最终归结为一个 typedef 中的一个单词差异。
数组越界是未定义行为的温床。 这个 Bug 不仅导致了逻辑错误,还触发了数组越界读取。在某些情况下,越界读到的值可能导致崩溃,但在这里碰巧只是让游戏”太友善”了。
编译器不会为
char的符号性比较产生警告。 即使开启了-Wall -Wextra,char >= 0这样的表达式在char为 unsigned 时不会产生 “comparison is always true” 的警告,因为编译器认为这是合法的用法。可以考虑使用-Wchar-subscripts或静态分析工具来捕获此类问题。
影响范围
项目中所有使用 i8(即 char)进行有符号比较的地方都可能存在类似问题。建议在跨平台项目中进行全面审查,或考虑将 i8 的定义替换为 int8_t,同时修复所有字符串操作的类型不匹配。