PvZ-Portable:构建可复现的游戏录制与回放系统

从时区、音频时钟与 RNG 流等多个角度,保证录制和回放在任何机器上都能逐 tick 复现

Posted by wszqkzqk on July 22, 2026
本文字数:7310

引言

PvZ-Portable 沿用的 SexyAppFramework 引擎里,留着 PopCap 当年设计的一套 demo 录制/回放机制:把一局游戏录成 .dmo 文件,之后原样放出来。它原本的定位是调试和回归工具:录下一段会话,之后复现出来,配合 DEMO_ASSERT_INT_EQUAL 这类断言命令和 marker 检查点,用来抓崩溃、验证修复。

但是,原版框架的录制从来都不是真正可复现的。它只把输入和时间戳大致记下来,回放时的随机数消耗序列并没有任何保障——简单场景还能大致对上,到了无尽模式这种对象密集、随机数消耗密集的对局,跑不了多久就会跑偏。差一次 RNG 消耗,之后所有随机决策全部错位,失之毫厘谬以千里,拿它做严格复现根本不可靠。

此外,原来的 SexyAppFramework 的录制系统 Windows 时代的实现,并非 SDL 这套机制。而到了 PvZ-Portable 的 SDL 移植里,这套机制剩下的骨架尚未实现完成:输入根本没被录制,回放时真实输入也没人拦,demo buffer 的加载时机还是错的——录出来的文件放回去,游戏停在主菜单一动不动。

所以笔者最近重新构建了一整套基于 SDL 的可复现录制/回放系统,一共三个 PR:#369 重建输入的录制与回放通路,#375 把墙钟时间变成确定性的,#379 消除剩余的不确定性来源,同一份 .dmo 在任何平台、任何时区回放,都能逐 tick 复现录制时的状态——包括无尽模式这种原版录制从来不指望能对上的场景。

现在,这套系统的能力其实已经超出了调试工具的定位:.dmo 里只有输入事件、随机种子和时间信息,不含任何画面数据,体积天然比视频录屏小不知道多少个数量级,而确定性保证让任何人拿到文件都能放出分毫不差的同一局。也就是说,它完全可以当作游戏录像来用——录一局精彩的无尽发给朋友,对方 -play 一下就能看到原汁原味、没有任何转码压缩的完整对局。

Demo 系统的基本结构

先交代一下这套系统长什么样。其中文件格式和命令流的骨架沿用了原框架的设计,输入通路和全部确定性保障则是这次新建的。demo 文件是一个全小端的二进制流,开头依次是文件 ID、DEMO_VERSION、随机种子 mRandSeed(回放时用它 SRand 恢复全局 RNG)、会话开始时间、时区偏移、产品版本字符串、marker 列表,最后是一段 bit 级的命令流

命令流里的每条命令带一个 4 bit 的 tick 增量时间戳,然后是命令本体:鼠标微移用 6 bit 增量压缩,鼠标按键、键盘、注册表读写、文件读写各有命令号。回放时主循环在消息处理前调 ProcessDemo(),按 tick 把流里的输入事件逐条回注给 WidgetManager,跟真人操作走同一条路径。

最关键的设计是 IO 同步RegistryReadWriteBytesToFile 这些调用在录制时会把结果写进流,回放时调用点不认真实注册表和文件系统,而是从流里读回录制时的结果。这样回放就不依赖宿主机器上的存档状态——哪怕换一台没玩过这游戏的电脑,回放看到的”存档”也和录制时一模一样。

整个系统确定性的根基只有一条:游戏全局只有一个 Mersenne Twister 实例,而回放用同一个种子。只要每次运行消耗的随机数个数和顺序完全一致,整局游戏就是逐 tick 确定的。

反过来,MT 是顺序流,任何一个地方多消耗或少消耗一个值,之后所有的随机决策——僵尸出怪、植物行为、掉落物——全部错位。后面要讲的每一处问题,本质都是某个决策点依赖了非确定因素,导致 RNG 消耗次数不稳定。

重建输入的录制与回放通路

#369 解决的是能否工作这一层,问题有四个:

之前的 SDL 输入层里没有任何 demo 相关代码——移植时只保住了 IO 同步命令,鼠标键盘事件从不写入流。修复是在输入层加 RecordDemoEvent,把 SDL 事件翻译成 demo 命令,格式与 ProcessDemo 的读取严格镜像:

case SDL_KEYDOWN:
{
    ...
    theApp->WriteDemoTimingBlock();
    theApp->mDemoBuffer.WriteNumBits(0, 1);
    theApp->mDemoBuffer.WriteNumBits(DEMO_KEY_DOWN, 5);
    theApp->mDemoBuffer.WriteNumBits(static_cast<int>(SDLKeyToKeyCode(theEvent.key.keysym.sym)), 8);
    ...
}

之前的事件循环在回放时照常在分发用户的鼠标键盘事件,动一下鼠标就往游戏里注入了流外输入,确定性当场报废。修复后回放模式下只放行窗口管理事件,其余全部丢弃:

if (mPlayingDemoBuffer)
{
    // Input is replayed from the demo stream; only window-management events are handled
    switch (event.type)
    {
        case SDL_QUIT: CloseRequestAsync(); break;
        case SDL_WINDOWEVENT: ...
    }
    return SDL_HasEvents(SDL_FIRSTEVENT, SDL_LASTEVENT);
}

之前 Init()ReadFromRegistry() 之后才调 ReadDemoBuffer(),而回放模式下注册表读取是 demo-synced 的——它从一个还没加载的空 buffer 里读命令,流一开始就错位了。修复后 buffer 在任何 demo-synced 操作之前加载。

此外,资源加载线程也会走注册表和文件 IO 路径,这些线程上 demo 命令的读写顺序相对主线程是不确定的。修复是把所有 demo-synced IO 用 IsOnPrimaryThread() 门控,非主线程走真实 IO、不进流。

到这里,录制和回放能跑通一个简单会话了。但只要会话稍微长一点、跨一点场景,回放就会在中段悄悄跑偏——剩下的问题全都出在时间上。

把墙钟变成 tick 的函数

游戏里依赖真实时间的地方比想象中多:商店的金盏花每天限购、禅境花园按日历日刷新浇水施肥需求、购买记录的时间戳、Board::mGameID 的生成……这些在录制时是一个值,回放时换成另一个时刻再跑一遍,得到的必然是另一个游戏。

#375 的解法是把所有 demo 可见的时间查询收敛到同一个出口(SexyAppBase.h):

// Demo-synced wall clock: real time normally; session start time advanced by update ticks during demo record/playback
inline time_t			GetNowTime() const
{
    if (IsInDemoMode())
        return static_cast<time_t>(mDemoStartTime) + mUpdateCount / 100;
    return time(nullptr);
}

mDemoStartTime 在录制时取真实时刻并写进 demo 头(v4),回放时从头里恢复。于是 demo 会话里的现在实际上是录制时刻 + 已流逝的 tick 数(引擎 100 tick/秒),完全确定,还跟游戏节奏严格同步。正常游戏不受影响,照样读真实时间。

同一批要处理的还有依赖渲染帧时序的检查。引擎的 update(逻辑)和 draw(渲染)是解耦的,draw 的节奏随机器性能漂移,凡是拿 mDrawCount 当条件的地方都可能会造成无法确定的结果。比如关卡 intro 的预加载门,原来写的是 mDrawCount == 0,改成按一个独立于场景的 mBoardUpdateCounter 判断(CutScene.cpp):

if (mApp->mGameScene != GameScenes::SCENE_LEVEL_INTRO || mBoard->mBoardUpdateCounter <= 1) // the first frame is drawn after the first update tick, so defer one tick deterministically
	return;

原来的写法是同样的位置判断 mBoard->mDrawCount == 0

首帧绘制确定性地落在首个 update 之后,推迟一拍,回放时不再受渲染调度影响。泳池闪光粒子、limbo 页面连点解锁的毫秒间隔(从 SDL_GetTicks() 改成 20 个 update tick 计 200ms)也一并改挂到 tick 上。

清扫剩余导致回放不一致的因素

到这一步回放已经能稳定跑完常规对局了,但 #379 又揪出了一批更隐蔽的不一致来源。

时区:日历日逻辑跨时区不一致

GetNowTime 只解决了”时刻”的确定性,没解决”日历日”的。商店限购和禅境花园的需求刷新比较的是 tm_year / tm_yday,这要求把时刻换算成本地日期——而 localtime回放宿主的时区换算。在另一个时区的机器上回放,同一时刻可能落在不同的日历日,商店补货状态、植物需求状态随之不同,代码路径不同,RNG 消耗次数不同,流就错位了。

修复是录制时把本地时间 − UTC的秒数写进 demo 头,回放时用 gmtime(clock + 偏移) 重建录制者的本地日历日(SexyAppBase.h):

inline tm				GetLocalTime(time_t theTime) const
{
    if (IsInDemoMode())
    {
        time_t aShifted = theTime + static_cast<time_t>(mDemoTimeZoneOffset);
        if (aShifted < 0) // MSVC/UCRT gmtime rejects pre-epoch times
            aShifted = 0; // clamp to no earlier than 1970-01-01
        return *gmtime(&aShifted);
    }
    return *localtime(&theTime);
}

偏移量的计算没有用任何时区数据库,就是录制瞬间 localtime 的分解字段与 UTC 秒数做差,仅包含确定的算术运算;gmtime 前置 0 的钳制是为了照顾 MSVC 对公元前时间的拒绝。禅境花园和商店里所有 localtime 调用点全部改走这个出口,回放从此与宿主时区彻底解耦。

音频时钟:最典型的一条 RNG 错位链

这是整轮修复里笔者觉得最有意思的一处不确定性来源。音效的触发是消耗 RNG 的——TodFoley::PlayFoley 里音高、播放音效的变体选择都要取随机数。

游戏逻辑在决定是否触发一个音效之前,首先检测:这个音效还在播吗?也就是说,下一条音效是否播放,会取决于 SoundInstance::IsPlaying() 的返回值。

旧的 IsPlaying() 读的是 Mix_Playing(mChannel),也就是真实音频硬件的播放进度。这在正常游戏里天经地义,但对回放是个问题:真实世界的播放进度是不确定的——音频线程怎么调度、机器跑多快,两次回放不可能完全一样。于是在同一个 tick 上,每一次运行都可能处于不同的播放状态,得到不同答案,后面的 RNG 消耗次数便会跟着不同,随机数产生便不再与录制时一致了。

笔者的修复则是把还在不在播这个逻辑判定,从真实时钟对齐到确定的 tick 时间轴上。

Play() 是游戏逻辑调用的,它发生的 tick 本身就是确定的;音效的时长也是已知的,通过音频 chunk 长度除以 pitch 就能计算。所以 Play() 时就能算出这个音效理论上应该在哪个 tick 结束,存进 mDemoEndUpdateCount;之后 IsPlaying() 不再问音频硬件,只拿 mUpdateCount 跟这个结束 tick 比较:

bool SDLSoundInstance::IsPlaying()
{
    if (gSexyAppBase->IsInDemoMode()) // see Play(): tick-derived playing state in demo sessions
    {
        if (!mMixChunk || !mHasPlayed || mDemoEndUpdateCount == 0)
            return false;
        if (mDemoLooping)
            return true;
        return gSexyAppBase->mUpdateCount < mDemoEndUpdateCount;
    }
    ...
    return Mix_Playing(mChannel);
}

注意,在这里真实的声音该怎么播还怎么播,用户体验没有任何变化——音频实际结束得比折算的 tick 早几毫秒或晚几毫秒根本不要紧,游戏逻辑不关心,它只认 tick 时间轴上的答案。同一个 tick 在复现中结果永远相同,RNG 消耗序列就稳定了。

排队命令与分歧检测:让回放在出错时停在原地

还有一类结构性问题。文件/注册表命令是游戏逻辑私有的:ProcessDemo 在命令记录的 tick 读到命令头,但配对的 WriteBytesToFile / RegistryRead 调用可能发生在同一 tick 更晚的调用栈里——比如商店购买经模态对话框确认,WaitForResult 退栈之后才写盘,这个写盘不在输入注入的调用栈内。

旧代码里这种命令头掉进 default 分支,payload 没人消费,后续所有命令从 payload 中段开始解码成垃圾,游戏拿着垃圾输入乱点菜单,直到撞上某个被误解码出来的 DEMO_CLOSE 才把进程杀掉——出错了也不告诉你错在哪。

修复是把这类命令排队ProcessDemo 遇到它们时回退 bit 位置和流时钟,自己不消费,把命令留给游戏逻辑的调用点来处理:

case DEMO_REGISTRY_GETSUBKEYS:
case DEMO_REGISTRY_READ:
...
case DEMO_SYNC:
case DEMO_ASSERT_STRING_EQUAL:
case DEMO_ASSERT_INT_EQUAL:
    if (mDemoCommandQueued && mUpdateCount != mDemoQueuedSince) // queued across a tick with no claim: the replay has diverged
    {
        Shutdown();
        return;
    }
    mDemoQueuedSince = mUpdateCount;
    mDemoCommandQueued = true;
    mDemoBuffer.mReadBitPos = mDemoCmdBitPos; // leave queued for the game-logic call site to consume
    mLastDemoUpdateCnt = mDemoCmdUpdateCnt;
    mDemoNeedsCommand = true;
    return;

精确复现的回放里,排队命令一定在同一个 tick 内被它的调用点处理掉;如果它跨过一个完整的 tick 还没被处理,就说明那次调用根本不会来了——游戏状态已经真实偏离了录制。这时直接 Shutdown(),回放停在确切出问题的命令处,而不是带着垃圾数据继续演。配套的改动是把命令处理的 tick 比较从 == 放宽到 >=,游戏时钟超过流时钟时不死锁;Emscripten 下 Dialog::WaitForResult 拿到结果后停止抽取嵌套 update,保证对话框之后的 IO 能在同一 tick 处理掉自己的命令。

同一个排队机制,同 tick 内重复遇到是正常流程(放行等待),跨 tick 仍未被处理就是分歧(立即终止)——一这样的设计既实现了宽容正常时序,又做到了尽早暴露错误。

MTRand 序列化补全

同一 PR 里还把 MTRand 的状态序列化修完整了:状态向量改为 uint32_t、逐字小端编码(原来是 memcpy,字节序相关),并且mti 索引也纳入序列化。旧实现恢复状态时不恢复 mti,下次取随机数会强制重新 twist 整块状态——任何发生在”块中间”的保存/恢复都会错位。目前树内还没有调用点,算是为以后的 RNG 检查点/恢复能力提前做的准备。

结语

现在,PvZ-Portable 有了一套真正可复现的录制/回放系统:跨平台、跨时区逐 tick 确定,包括无尽模式这种原版框架的录制从来没法保证播放一致性的场景。

确定性是一种全局性质,不能漏一个点。 RNG 种子固定只能保证”消耗序列一致时结果一致”,而消耗序列会被任何非确定因素扰动:真实墙钟、宿主时区、渲染帧节奏、音频线程进度、对话框的调用栈时序……每一处都得揪出来锚定到 tick 上。这类工作没有捷径,只能一个一个来源地排查——好在 demo 系统自带断言命令和 marker,验证手段就在工具本身里。

逻辑可见的状态和真实设备的状态要分开。 音频那处的修法很说明问题:不需要让音频线程变得确定,那既不现实也没必要,只需要让游戏逻辑查询到的播放状态从确定性时钟推导。设备性能肯定是不确定的,但逻辑可以照常确定,只需要中间夹一层换算。

谨慎看待容错设计,尽早暴露问题。 调试工具的价值一半在于能复现,另一半就在于复现失败时告诉你断在哪——后者经常被忽视,但真到排查分歧的时候,这就是几小时和几分钟的差别。

从原版框架里一个不可靠的遗留功能,到跨平台逐 tick 确定的录制系统,再到一个可以直接拿来分享对局的录像格式——这套机制现在的样子,大概是当年设计它的人也没想到过的。

⚠️ 版权与说明

PvZ-Portable 严格遵守版权协议。游戏的 IP(植物大战僵尸)属于 PopCap/EA。

本项目仅包含开源重实现的引擎代码,不含任何游戏美术、音效、关卡等受版权保护的资源文件。要研究或使用此项目,你必须拥有正版游戏(如果没有,请在 SteamEA 官网 上购买)。你需要从正版游戏中提取以下文件放到 PvZ-Portable 的程序所在目录中:

  • main.pak
  • properties/ 目录下的资源文件

PvZ-Portable 的源代码以 LGPL-3.0-or-later 许可证开源。


赞赏本文

支付宝 微信支付