前言
在上一篇文章中,笔者介绍了全新的 v4 存档格式,解决了不同架构间存档互通的难题。
然而,存档格式只是数据交换的协议,游戏引擎本身的运行时逻辑也需要适应不同的硬件特性。在 PvZ-Portable 中,笔者还完成了两个重要的底层改造:大端序与小端序存档互通以及2038 年问题(Y2038)的妥善处理。
这两个改动不仅让 PvZ-Portable 能在 PowerPC (如 PS3, Wii U, Xbox 360) 和 s390x 等大端平台上运行,更实现了与 PC 版(小端序)存档的无缝互通,并确保了游戏在遥远的未来依然能正常游玩。
挑战一:大端序适配与存档互通
什么是字节序?
字节序指的是多字节数据(如 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),其相关字段(如 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 年的时间平滑过渡,不会像有符号整数那样计算出负数从而导致逻辑错误。整个过程完全自动,对任何时间节点的溢出都是稳定的,无需任何手动介入或重置操作。 - 天然的时钟篡改免疫:无符号减法同样让代码对系统时间回拨具备天然的抗干扰能力。如果玩家回拨时钟到事件发生之前,
uint32(now) - lastEventTime会回绕为一个远超阈值的大数,所有< threshold的判断自动返回false——效果立即失效,等价于惩罚了玩家。因此,原版游戏中旧的LastTime > NowTime反作弊检查在无符号算术下完全冗余(它只能捕获回拨到事件时间之前的情况,而这恰恰已被无符号回绕自然处理),笔者已将其移除,从而消除了在 2106 回绕瞬间可能导致的误触发重置。 - 哨兵值冲突:这是笔者发现的一个极端的边缘情况。在游戏的数据结构中,
0通常被用作未购买或未初始化的哨兵值(Sentinel Value)。然而,当时间在 2106 年溢出归零的那一秒,time(0)的返回值恰恰就是0。如果这一秒恰好发生了蜗牛点击或者巧克力喂食,直接把这个0存入mPurchases数组,下次读取时,系统就会误认为玩家从未购买过蜗牛,导致蜗牛凭空消失。为此,笔者在代码中增加了这种边界情况的检测:如果当前时间戳恰好为0,我们会将其强制存储为1。这 1 秒的微小误差对游戏体验几乎没有任何影响,却避免了严重的逻辑 Bug。 - 理论上的循环限制:这种机制意味着时间每隔约 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 许可证开源,欢迎学习和贡献。