回放文件不是录像:它是必须长期维护的版本协议

输入帧经过版本化回放文件重建确定性世界的专业主视觉

先设定一个架构评审场景:一场排位赛结束后,战斗服保存了完整回放;两周后客户端热更新,碰撞半径与逻辑程序集都发生变化。此时玩家从战绩页点击“观看回放”,文件仍能下载,校验和也正确,但若播放器忽略逻辑版本,重演就可能从某一帧开始分叉,最终胜方甚至可能与结算记录相反。

这不是播放器画面出了小误差,而是系统把回放误当成了视频。视频保存的是已经生成的像素,播放器只需理解编码格式;确定性回放保存的是种子、参战者、逐帧输入以及解释这些输入的规则身份。它不是结果本身,而是一份要求未来运行时重新执行旧规则的指令。如果文件没有说明应使用哪套逻辑,或者读取端遇到未知字段仍然勉强播放,“文件可读”与“对局可重建”就会被错误地当成同一件事。

本文依据一个真实跨语言游戏项目的当前实现讨论这条边界。Rust 战斗服在 battle/src/replay.rs 写入回放,C# 客户端由 ReplayReaderReplayDriver 读取并驱动确定性世界。仓库中的 Rust 固定夹具会被 C# 测试直接读取。写作当天重新运行了客户端核心测试,263 项全部通过;其中包括一万帧两次运行逐帧哈希相等、录制后重放最终哈希相等、坏魔数、截断数据、逻辑版本不一致以及解压上限等回放用例。Rust battle 包的五项回放聚焦测试也全部通过。测试证明的是当前格式与当前运行时的契约成立,不代表任意未来版本天然兼容。

先定所有权

回放链路至少有四个所有者。战斗实例拥有正在推进的权威帧和输入集合;回放写入器拥有已经封闭、等待写入文件的帧序列;对象存储或文件系统拥有已提交的不可变回放制品;播放器运行时拥有“是否接受这个制品”的最终门禁。任何一层都不能替下一层猜测。

战斗服负责记录事实,而不是保证所有未来客户端都能解释事实。它写入房间标识、随机种子、开始时间、逻辑版本、胜方、玩家与战绩,并把每帧的紧凑输入编码进帧流。客户端负责验证文件信封和兼容性,再以 header 中的种子构建对应世界,把输入逐帧送入 LockstepWorld.Step。播放器不能修改战绩,也不能把本地重演结果反向覆盖正式结算;回放只是事实副本和诊断制品,不是经济状态所有者。

这条边界导出三个不可破坏的不变量。第一,同一回放只有在逻辑版本与运行时明确兼容时才能执行,不能用“字段大致相同”代替兼容声明。第二,每帧输入的边界必须无歧义,截断、超长或未知编码必须拒绝,不能补零后继续。第三,读取任意外部回放都必须有内存与解压上限;播放器是数据入口,不因为文件来自自家服务器就免于资源防护。

还有一个容易被忽略的不变量:正式结算与回放重演必须单向关联。回放可以证明“在某套规则和输入下得到了什么状态”,却不能单独证明服务器当时没有作弊、没有漏帧,也不能在重演失败时自行重做奖励。若客服或风控需要审计,结算记录应保存回放标识、逻辑版本和内容摘要;处理争议时读取这份关联,而不是让播放器成为第二个结算服务。

文件信封

当前格式的外层结构很小,但每一段都是协议。文件以四字节 TRP1 魔数开头,随后是一个小端 u32 表示 JSON header 长度,再写入 header,最后才是帧流。每帧仍由小端 u32 长度和紧凑帧字节组成。Rust 写入端把帧流以 zstd 压缩,C# 读取端同时识别 zstd 和旧的 raw,因此压缩升级没有偷偷改变帧语义,而是被 header 中的 compression 字段显式描述。

这个设计把兼容性拆成三层。TRP1 是容器世代,决定读取器是否理解外壳;frame_format 是输入编码世代,当前值为 u32_len_prefixed_compact_frameslogic_version 是模拟规则世代,决定相同输入会产生什么世界。三者不能合并成一个模糊的 version。只升级压缩算法时,不应迫使逻辑版本变化;只调整伤害公式时,即使文件字节布局完全没变,也必须改变逻辑身份。

JSON header 允许增加可忽略字段,适合保存玩家、战绩和升级说明等元数据;二进制帧流则适合高频、紧凑且边界明确的数据。这不是“JSON 灵活、二进制高性能”这么简单。真正的价值是把扩展策略分开:header 的未知附加字段可以被旧读取器忽略,影响模拟结果的字段却必须进入兼容判断;帧编码若出现新动作位或新座位语义,必须获得新的 frame_format,不能依赖旧解码器碰巧读出一个整数。

Rust 写入端、TRP1 文件信封与 C# 读取门禁的证据型技术图

正常时序因此很清楚。战斗实例结束并封闭输入帧;写入器根据同一局的元数据构造 header;文件写入 TRP1、header 长度、header 与压缩帧流;制品提交后得到稳定标识;客户端打开文件,先校验信封、逻辑版本、压缩格式、帧格式和长度上限;所有门禁通过后才创建对应世界,从零开始逐帧重演。门禁位于世界创建之前,可以避免“不兼容文件已经产生一半副作用”这种含混状态。

版本不是整数

当前 C# 读取器采用严格相等策略:回放的 logic_version 不等于 LogicVersion.Current 就抛出错误。这是一个保守但诚实的阶段性方案。它保证当前播放器不会拿新逻辑解释旧输入,却也意味着每次破坏确定性的逻辑升级都会让旧回放在最新客户端中不可播放。

看似合理的替代方案是忽略版本,只要反序列化成功就播放。这个方案会制造最危险的失败:没有显式错误,只有悄悄漂移的结果。固定点运算保持不变,并不代表逻辑兼容。系统执行顺序、碰撞遍历顺序、随机数消费次数、配置值、地图阻挡、技能目标选择乃至实体创建顺序中任何一项变化,都可能让同一输入从某一帧开始分叉。播放器仍然能走到末尾,只是播放了另一场不存在的比赛。

另一个方案是规定“版本号差一以内兼容”。整数距离没有语义。版本 41 到 42 可能只增加一个不参与模拟的 header 字段,也可能改变一次随机抽样;版本 42 到 50 反而可能只改 UI。兼容关系应由可验证的规则声明,而不是由数值接近推导。最简单的声明就是当前严格相等;需要跨版本后,再演进为“当前播放器可路由到哪些逻辑运行时”的显式矩阵。

逻辑版本还不等于客户端包版本。一个客户端包可以同时携带主逻辑和若干旧逻辑运行时;多个客户端包也可能复用完全相同的确定性逻辑。把市场版本、资源版本或构建号直接写入 logic_version,会让本来兼容的回放被无谓拒绝,也会让真正影响模拟的配置热更逃出版本管理。逻辑身份应由所有会影响状态推进的代码、配置和地图共同决定。

在规模较小时,递增整数配合严格相等足够清晰。规模扩大后,可以把逻辑制品构建成不可变包,为代码程序集、确定性配置、地图碰撞数据和协议描述计算内容摘要,再由短版本号映射到摘要。版本号便是便于查询的索引,摘要才是不可混淆的身份。回放 header 不必塞入所有文件,但服务端必须能根据它定位完整逻辑包。

拒绝式读取

读取器的职责不是尽可能打开文件,而是在无法证明安全与兼容时尽早拒绝。当前实现先检查最小八字节,再验证 TRP1 魔数和 header 长度是否落在文件范围内。逐帧读取时,如果剩余数据不足四字节长度,或者声明的帧长度越过文件末尾,就抛出截断错误。未知压缩格式和未知帧格式分别触发“不支持”,逻辑版本不等则触发“版本不匹配”。这些错误类别应保留到产品层,不能都折叠成“回放损坏”。

失败时序可以复算:客户端下载一份旧回放,header 合法但 logic_version=17,当前运行时为 18;读取器在构建世界之前拒绝;上层记录回放标识、文件版本、当前版本和拒绝原因;产品可以提示暂不支持,或把请求路由到版本 17 的播放器。整个过程中没有执行一帧旧输入,也没有生成可被截图误认成正式结果的错误画面。

截断文件走另一条恢复路径。若魔数、header 或帧体越界,系统应把制品标记为不完整,检查上传或落盘链路,并尝试从原始节点或对象存储副本重新获取。它不应该被路由到旧运行时,因为版本正确也无法补回丢失字节。未知压缩格式则可能需要升级容器读取器,未知逻辑版本则需要兼容运行时。错误分类直接决定恢复动作。

当前 Rust 写入器还有一个值得明确的边界:后台任务使用 File::create 直接创建目标路径,然后顺序写入 header 和 zstd 帧流。spawn_blocking 避免同步文件 I/O 阻塞异步执行器,但没有建立原子提交。如果进程在写入中途退出,目标路径可能已经存在却内容不完整。读取器会拒绝截断数据,这保护了播放正确性,却不能把残缺文件变完整。下一步应写入同目录临时文件,完成压缩、刷新并按需要同步后,再原子改名到最终路径;只有改名完成,索引层才发布回放可见性。

资源边界

回放文件是可被玩家、管理后台和诊断工具反复打开的数据入口,必须防止压缩炸弹和伪造长度。当前 C# 读取器为 zstd 路径设置了明确上限:最多 216000 帧,每帧按 64 字节预算,另加 1024 字节余量。解压输出上限可写成:

L = 1024 + F × 64

其中 F 是 header 中的帧数;若帧数为零,则使用最大帧数 216000。绝对上限为 1024 + 216000 × 64 = 13,825,024 字节,约 13.18 MiB。这个数字来自当前代码,不是吞吐压测结论。测试夹具专门构造超过上限的 zstd 输出,并断言读取器拒绝它。

216000 帧也能转换成游戏时长约束。若逻辑频率为 30 帧每秒,则上限是 7200 秒,也就是两小时;若实际游戏使用 60 帧每秒,则只有一小时。帧数上限与帧率必须在协议里一起解释,否则调整 TickRate 会在不改读取器的情况下改变可记录时长。当前 header 没有独立 TickRate 字段,说明播放器仍依赖对应逻辑版本知道推进频率,这进一步证明逻辑包是解释回放不可缺少的一部分。

每帧 64 字节同样不是平均大小,而是解压预算。当前紧凑帧在测试中能表示空帧和稀疏座位输入,但未来增加玩家上限、指令位宽或校验附录时,单帧上界可能变化。正确演进不是随手把 64 改成 256,而是先定义新 frame_format 的最大编码长度,再由读取器按照格式选择预算。若所有格式共用一个无限放大的全局上限,一个局部升级会削弱所有旧格式的资源保护。

容量规划还要考虑并发。单份回放解压上限约 13.18 MiB,并不表示服务端可以同时预览任意数量。管理工具若并发打开 C 份,纯解压缓冲理论上就需要 C × L;再加世界状态、地图和索引,实际峰值更高。播放器应限制并发打开数,支持流式读取时逐帧消费,离开页面后及时取消,而不是把“单文件有上限”误当成“进程内存有上限”。

跨语言契约

这套回放最有价值的证据不是 Rust 和 C# 各自能自测,而是固定夹具跨过了语言边界。Rust 侧测试验证 TRP1、header 和 zstd 帧流的写法,并读取仓库中的同一份 rust-zstd.trp;C# 侧测试打开该夹具,核对 header 字段并读出压缩帧。小端整数、JSON 字段名、压缩边界和帧长度前缀因此不是两份相似实现,而是一份被双方共同执行的协议。

当天测试结果需要正确解读。GameCore.Tests 263 项全过,其中一万帧确定性测试把同一输入脚本运行两次并逐帧比较哈希;录制重放测试再把一万帧写入回放,用 header 中的种子重建世界,最终哈希与原运行相等。它证明当前记录的数据足以驱动当前逻辑复现。Rust 的五项回放测试验证日期路径、zstd 往返、文件魔数与压缩、旧 raw 帧流仍可解析,以及固定夹具符合跨语言约定。

但这些测试还没有证明三件事。它们没有覆盖任意历史逻辑版本,因为当前读取器直接拒绝版本不等;没有证明写文件过程崩溃后的原子性;也没有证明两小时上限下的内存峰值和加载时间。文章不能把“功能与契约测试通过”写成“线上容量已经验证”。下一轮更有价值的测试,是在写入 header、压缩中段和改名之前分别注入进程中断,确认索引永远看不到半文件;再对每个仍受支持的逻辑版本保留黄金回放与最终哈希。

固定夹具必须不可变。若一次格式变更顺手重新生成所有旧夹具,测试会全部变绿,却丢失了兼容历史。每个容器世代、帧格式和逻辑运行时都应保留至少一份小型黄金文件,文件名或清单记录生成器版本与预期哈希。新读取器要读取旧夹具;旧读取器应明确拒绝新格式,而不是测试团队只验证最新对最新。

保存旧世界

当产品只要求“当前版本内复盘”,严格版本相等是成本最低、边界最清楚的选择。此时回放保留期不能超过对应逻辑运行时的可用期,战绩页也应在兼容期结束后明确展示不可播放,而不是永远保留一个必然失败的按钮。数据保留承诺必须与运行时保留承诺一致。

当客服申诉、赛事审计或反作弊调查要求跨数月复现,系统就需要保存旧世界。第一种路线是在客户端携带最近 N 个逻辑程序集和确定性配置,根据 header 路由。优点是播放成本仍在客户端,缺点是安装包膨胀、旧代码扩大安全面,而且移动平台未必允许任意加载历史代码。

第二种路线是服务端回放农场:用隔离容器保存历史逻辑镜像,重演后输出状态摘要、事件轨迹或视频。它把旧运行时从用户设备移走,便于审计和访问控制,但增加镜像保留、任务排队和沙箱成本。第三种路线是迁移回放,把旧输入转换成新格式。只有当变化是纯容器或无损编码变化时才能这么做;如果游戏规则已经改变,转换输入无法让新逻辑产生旧结果,所谓迁移只是伪造另一场比赛。

演进触发条件应具体。仍受支持的逻辑版本超过客户端可接受的包体数量;历史回放读取失败率持续上升;客服需要按旧版本复算争议;赛事要求可审计哈希;单份回放接近帧数或解压上限;写入中断产生残缺制品;这些信号分别触发运行时归档、兼容矩阵、服务端重演、容量格式升级和原子提交。不要因为“以后可能有回放”就提前建设完整镜像农场,也不要等第一起申诉发生才发现旧逻辑已经无法构建。

无论采用哪条路线,回放索引都应至少保存容器版本、逻辑身份、帧格式、内容摘要、字节数、帧数和可用状态。删除旧逻辑前先查询仍依赖它的回放与保留策略;删除回放前则遵守结算审计期限。逻辑包、配置、地图与回放形成一组引用关系,不再是几个可以各自清理的文件目录。

架构判断

确定性回放的价值来自“用较小输入重建完整世界”,它的风险也来自同一件事:文件只保存事实的一部分,剩余语义寄存在代码、配置和地图里。因此,回放不能按媒体文件治理,而要按版本协议治理。

当前项目的方向是正确的:TRP1 把容器信封固定下来,header 分离逻辑版本、压缩方式和帧格式,Rust 与 C# 用固定夹具锁定跨语言字节契约,读取器对坏魔数、截断、未知格式、版本不符和超限解压采取拒绝式处理。严格版本相等虽然牺牲旧回放可用性,却避免了静默重演出错误比赛。

下一次架构评审最该追问的不是“回放压缩率还能不能提高”,而是三件更硬的问题:一份回放依赖的旧世界由谁保存;写入中断时半文件如何保持不可见;每个受支持版本是否都有不可变黄金回放和预期状态哈希。只有这三件事有明确答案,回放才不是一段碰巧能播放的字节,而是一份可以被长期兑现的兼容承诺。