引言
在上一篇文章中,笔者介绍了全新的 v4 存档格式,解决了不同架构间存档互通的难题。
然而,存档格式只是数据交换的协议,游戏引擎本身的运行时逻辑也需要适应不同的硬件特性。在最近的更新中,笔者完成了两个重要的底层改造:大端序(Big-Endian)与小端序存档互通以及2038 年问题(Y2038)的妥善处理。
这两个改动不仅让 PvZ-Portable 能在 PowerPC (如 PS3, Wii U, Xbox 360) 和 s390x 等大端平台上运行,更实现了与 PC 版(小端序)存档的无缝互通,并确保了游戏在遥远的未来依然能正常游玩。
挑战一:大端序(Big-Endian)适配与存档互通
什么是字节序?
字节序指的是多字节数据(如 int, float)在内存中存储的顺序。
- 小端序 (Little-Endian):低位字节存放在低地址(如 x86/x64, ARM, RISC-V, LoongArch)。
- 大端序 (Big-Endian):高位字节存放在低地址(如 PowerPC, SPARC, s390x)。
原版《植物大战僵尸》是为 x86 Windows 开发的,代码中充斥着对小端序的隐式假设。
遇到的问题
理论上,大端序机器使用自己的大端序存档格式也能正常运行,但这样会导致其产生的存档无法被主流小端序设备读取,反之亦然,形成了数据孤岛。为了实现真正的全平台存档互通,我们选择统一使用小端序作为标准存储格式。
但是,如果在大端序上直接运行未经修改的代码读取来自小端序架构的数据:
- 存档里存储的来自小端序的 32 位数字
1(十六进制0x00 00 00 01)。 - 大端序机器读取内存时,会将其解析为
0x01000000,即 16,777,216。
这会导致完全混乱:植物的坐标飞出天际,血量变成天文数字,颜色红蓝颠倒(ARGB 解析为 BGRA),等等。因此,必须对所有跨存档读写的多字节数据进行字节序转换,这样才能真正实现跨架构的存档无缝兼容。
解决方案
为了解决这个问题,笔者引入了统一的字节序转换层:
-
文件 IO 层强制小端: 无论在什么 CPU 上运行,v4 关卡存档和原版用户全局数据文件始终以小端序写入。
-
运行时转换: 笔者为所有序列化操作引入了
FromLE32/ToLE32系列模板函数。- 在小端序机器(x86/ARM/RISC-V/LoongArch)上:这些函数会被编译为空操作(No-op),零性能损耗。
- 在大端序机器(PowerPC/SPARC/s390x)上:这些函数会编译为
ByteSwap指令(如bswap),在读写时自动反转字节。
// 示例:跨字节序读取 PottedPlant
static inline void PottedPlantFromLE(PottedPlant& p) {
if constexpr (std::endian::native == std::endian::little)
return; // 本机是小端,无需转换
// 逐个字段反转字节
p.mSeedType = static_cast<SeedType>(FromLE32(static_cast<uint32_t>(p.mSeedType)));
p.mX = static_cast<int32_t>(FromLE32(static_cast<uint32_t>(p.mX)));
// ...
}
- 颜色修正:
图像处理中的颜色打包(例如
0xAARRGGBB)也进行了适配,确保在不同架构上Color结构体的位域解析一致。
挑战二:2038 年问题(Y2038)
什么是 2038 年问题?
在许多古老的 C/C++ 程序中,时间戳使用旧的 time_t (通常是 int32_t) 存储,表示从 1970 年 1 月 1 日开始的秒数。这个 32 位有符号整数的最大值是 2147483647,对应的时间是 2038 年 1 月 19 日。超过这个时间,时间戳就会溢出变成负数(1901 年),导致逻辑崩溃。
原版植物大战僵尸游戏诞生于 2009 年,广泛使用了 32 位时间戳来记录禅境花园植物的浇水时间、施肥时间以及蜗牛的喂食时间。
解决方案
在本次重构中,我们不仅要修复这个问题,还要保持对旧的用户全局状态存档格式的兼容性。
核心字段迁移到 64 位
对于禅境花园(Zen Garden)中的植物,我们需要记录它们的上次浇水时间、上次施肥时间等。非常幸运的是,在原版游戏中,PottedPlant 结构体在这些时间戳字段附近存在由于内存对齐(Alignment)产生的填充字节(Padding)。
在原版 32 位环境下,time_t 占用 4 字节,但编译器为了让后续字段(如 DrawVariation)在内存中对齐到 8 字节边界,事实上就在时间戳后面留出了 4 字节的空白。
利用这一点,我们可以直接将这些字段升级为 int64_t(8 字节),正好填满了原本的“数据+填充”空间,而不会造成任何破坏。这意味着我们可以在完全不改变存档文件结构大小和布局的前提下,原地实现 64 位时间戳升级。
- 在 64 位系统(以及在现代的 32 位系统)上,
time(0)本身就是 64 位的,直接赋值即可。 - 此修改彻底消除了这些字段的 2038 年问题限制(有效期延长到了太阳系毁灭)。
特殊情况:蜗牛(Stinky)的时间陷阱
并不是所有对象都这么幸运。对于蜗牛(Stinky),其相关字段(如 mLastStinkyChocolateTime)在内存中是紧凑排列的,前后完全没有可用的 Padding 空间。
如果我们强行将其改为 64 位,就会撑大整个 PlayerInfo 结构体,导致后续所有字段的偏移量错位,这将直接破坏对旧存档的兼容性,导致读取错误。因此,我们不得不保留它的 32 位存储大小。
无符号回绕
为了在不改变 32 位存储结构的前提下解决问题,我们利用了无符号算术(Unsigned Arithmetic)的回绕特性。
我们将蜗牛相关的所有 32 位时间戳修改为 uint32_t。
- 32 位无符号整数的最大值是
0xFFFFFFFF(4294967295),对应 2106 年。这本身就比 2038 年多续了 68 年。 - 回绕特性:即使是时间在 2106 年从最大值溢出变成
0时,根据模运算规则,无符号减法0 (当前) - 0xFFFFFFF0 (过去)的结果依然是16(正确的差值)。
我们在 ZenGarden.cpp 中实现了这一逻辑:
// 修复后的判断逻辑
bool ZenGarden::IsStinkyHighOnChocolate() {
// 使用 uint32_t 进行减法,利用回绕特性处理溢出
// 这将有效期安全地延长到了 2106 年,且在跨越 2106 年瞬间也能正常工作
return static_cast<uint32_t>(time(0)) - mApp->mPlayerInfo->mLastStinkyChocolateTime < 3600;
}
这种基于模运算的数学特性意味着:
- 2038 年:对于
uint32_t来说只是一个普通的数字,毫无波澜。 - 2106 年:核心的时间差计算逻辑依然准确无误。无符号整数溢出后的减法(模 $2^{32}$ 运算)依然能得出精确的时间差。例如
0x00000010(回绕后) -0xFFFFFFF0(回绕前) 的结果在无符号算术下精确等于32。这意味着底层的数学模型已完全能够处理 2106 年的时间平滑过渡,不会像有符号整数那样计算出负数从而导致逻辑错误。 - 防作弊逻辑的特例:代码中有一处防止玩家修改系统时间作弊的逻辑(
LastTime > NowTime),防止用户向前调整时间来控制蜗牛状态。在 2106 年溢出的那一瞬间,由于LastTime是溢出前的大数,NowTime是溢出后的小数,会误触发此逻辑。系统的默认行为是重置蜗牛的状态(让它去睡觉),这对存档没有任何负面影响,仅仅是玩家在那个瞬间需要重新点一下蜗牛或者重新喂巧克力而已。 - 理论上的循环限制:这种机制意味着时间每隔约 136 年($2^{32}$ 秒)会循环一次。只有当玩家关掉游戏,放置了整整 136 年后再打开,系统才可能因为时间循环而出现误判(误以为刚刚点击或者喂过巧克力,导致蜗牛错误地兴奋)。不过这在现实中几乎不可能发生。
如果按照原来的机制,在 2038 年溢出时,时间戳会变成负数(回退到 1901 年)。首先,有符号整数溢出本身就是未定义行为,应该避免,否则可能引发不可预见的后果。即使过了编译器这关,在游戏机制上,这会导致防作弊逻辑持续判定当前时间小于存档时间(因为防作弊重置会将存档时间设为 0,而 0 永远大于溢出后的负数时间戳),从而陷入无限的状态重置循环。此外,游戏中植物生长、商店刷新依赖日历系统,因此这些过程会因为当前年份(1901)远小于上次保存年份(2038)而彻底停摆,导致植物无法再次生长,也无法再向戴夫购买金盏花。结果就是蜗牛将永远无法工作,植物的生长和产币计时也会因为负值运算而彻底乱套,整个禅境花园系统几乎瘫痪。
结语
通过这次的底层重构,PvZ-Portable 不仅拓宽了“空间”上的边界(从小端序到大端序架构),也拓宽了“时间”上的边界。欢迎在项目 GitHub 仓库查看详情。
⚠️ 版权与说明
重要:本项目仅包含代码引擎,不包含任何游戏素材!
PvZ-Portable 严格遵守版权协议。游戏的 IP(植物大战僵尸)属于 PopCap/EA。
要研究或使用此项目,你必须拥有正版游戏(如果没有,请在 Steam 或 EA 官网 上购买)。你需要从正版游戏中提取以下文件放到 PvZ-Portable 的程序所在目录中:
main.pakproperties/目录
本项目仅提供引擎代码,用于技术学习,不包含上述任何游戏资源文件,任何游戏资源均需要用户自行提供正版游戏文件。
本项目的源代码以 LGPL-3.0-or-later 许可证开源,欢迎学习和贡献。