查看源码下载文档
CHAPTER / 14

存档、读档与二进制格式:把一场战争装进文件

存档要恢复的不是一张截图,也不只是每辆坦克的坐标。它必须恢复正在建造的工厂、对象之间的关系、阵营资金、任务状态、地图占据、触发器和随机序列,才能让玩家按下“继续”后仍处于原来的战争中。这个项目采用的核心办法是:把运行中的对象图转换成可还原的编号关系,再大量写入对象的二进制内存表示,加载后重建对象身份、运行时连接和派生数据。

这套设计很贴合当年的同一平台、同一程序版本,却天然依赖编译布局。它不是现代带字段名、独立版本迁移和跨平台类型定义的文档格式。本章分析实际的 Save_Game/Load_Game 及其辅助类;资源包和美术媒体格式由资源章节负责,网络命令录像见上一章

14.1 先区分三种“把游戏写下来”

数据 用途 典型内容 恢复办法
场景与规则 定义一局游戏的起点及玩法 地图、初始单位、触发器、类型参数 初始化世界,按配置创建对象
存档 保存当前已经发展到的局面 实例状态、阵营、地图、对象引用、逻辑帧 还原这一时刻的世界
录像 重演从起点发生的过程 初始配置、种子、命令、部分界面状态 再次运行游戏逻辑

这三者可能都包含“单位编号”,但含义不同:规则中的类型编号回答“造什么”,存档中的对象槽位回答“是哪一个实例”,录像中的目标编号回答“本次命令指向哪个实例”。因此,只有录像中的命令而没有正确初始环境,不能还原战场;只有存档中的状态而没有后续命令,也不能推知玩家接下来怎么操作。

普通 Save_Game(id,...) 生成 SAVEGAME.%03did==-1 则走 SAVEGAME.NET 并额外保存多人配置。文件名是入口区别,真正的读写结构由函数顺序决定。源码:文件选择多人存档名

14.2 文件外壳:可直接浏览的头部与经过处理的主体

Save_Game() 先写一小段直接可读的描述及元数据,再把完整战场数据送入压缩、加密和摘要管道。按源码写入顺序,可以画出下面的逻辑格式;长度依赖 C++ 类型的项目不能不加说明地固定成某个平台的数字。

顺序 内容 长度或来源
1 描述文本,带换行、终止符等处理 DESCRIP_MAX,本快照定义为 44
2 场景编号 sizeof(unsigned)
3 玩家阵营 sizeof(HousesType)
4 存档版本标识 sizeof(unsigned long)
5 主体摘要 20 字节
6 经过压缩与加密的游戏主体 可变长度

将场景和阵营单独放在头部,是为了存档菜单不用恢复整张地图就能展示描述。Get_Savefile_Info() 只读取描述、场景、阵营并检查版本;所以“存档菜单能列出来”并不表示主体已经校验成功或能完整加载。源码:头部写入描述长度存档信息快速读取

主体写入链是:Put_All → LZOPipe(COMPRESS) → BlowPipe(ENCRYPT) → SHAPipe → FilePipe。顺序不能倒过来解释:摘要看见的是已经压缩、已经加密、最终写到磁盘的主体字节,不是原始对象,也不包括先前直接写出的头部。代码先留出 20 字节占位,再在全部数据刷新后回到原位置填写真实摘要。加密使用 FastKey 及 Blowfish 引擎的密钥长度。源码:管道连接与摘要回填SHA 管道处理

这一层适合被理解为对文件体积、误改检测和内容封装的处理。仅凭“有加密、有 SHA”不能推导出现代认证存储的安全保证;这里分析其数据路径,不对算法在今天的安全适用性做未经验证的承诺。

14.3 压缩是按字节分块,并不识别坦克或建筑

存档使用 SAVE_BLOCK_SIZE=4096LZOPipe 缓存输入直到凑满一个块,调用 lzo1x_1_compress(),输出块头和压缩数据。块头的两个 unsigned short 分别记录压缩后字节数和解压后字节数;尾部不足整块时,Flush() 仍把已有字节压成一个合法短块。源码:存档块大小块头整块压缩尾块刷新

因此一个 UnitClass 的字节可以跨越两个压缩块,一个块也可能包含几个不同类型对象的尾部和头部。对象边界属于 Put_All 和各对象序列化函数,压缩器只处理连续字节。反向读取时,LZOStraw::Get() 先取块头及相应压缩字节,解压到缓存,再按上层要求提供数据。这种层次分离让游戏对象不必关心压缩细节。源码:解压缓存读取

读者若自己写存档查看器,正确的拆解路线应是验证外壳、解密主体、还原压缩字节流,再按对应版本对象布局解析。不能在文件中直接搜索“某辆坦克生命值的四个字节”就认为找到了稳定的编辑位置;本快照的二进制写法也没有字段标签供解析器自描述。

14.4 真正的主目录藏在 Put_All 的调用顺序里

Put_All() 没有输出一个 JSON 式对象树,也没有给每个大段附独立名称。加载器依靠写入顺序与事先知道的类型解释后续字节。主要顺序如下。

阶段 保存对象 保留什么关系
场景 Scen 整体 场景状态、计时和随机状态等
地图 Map.Save() theater、地图控制状态、需保存的格子
类型化对象池 Houses、Teams、Triggers、各兵种、建筑、炮弹、工厂等 活跃实例及其原槽位
调度及触发 Logic、地图/逻辑/阵营触发器、各地图层 被处理和被显示对象的成员关系及次序
其他状态 Score、AI Base、Carryover 评分、基地规划、跨关卡携带信息
杂项 玩家、Frame、当前选择、时空漩涡、谭雅标志 当前操作上下文及特殊玩法状态
多人扩展 Session 和相关全局选项 继续联机需要的配置

对象池列表远不止“兵、车、建筑”。子弹、动画和工厂也要恢复,否则炮弹会凭空消失,正在制造的单位可能丢失。逻辑层与显示层则回答另一个问题:这些对象应该在什么集合里被更新、按什么关系被绘制。光把对象实例放回内存,不恢复集合成员关系,游戏仍不能继续。源码:场景及对象池逻辑层、触发器和地图层评分、基地、携带与杂项

要注意 Put_All() 中不少英文注释使用旧容器名称。最终事实以当前调用的 TFixedIHeapClass::Save() 为准,它直接写活跃对象的 sizeof(T) 字节,并不是注释中暗示的逐个对象 Write() 虚函数链。

14.5 最难保存的是“它指向谁”

假设运输车 T 载着步兵 I:T 的 CargoHold 指向 I;I 还可能和队伍成员链相连;地图格保存占据对象指针;逻辑层和玩家当前选择也可能引用 T。把这些地址原封不动写进文件,下次进程分配到不同内存位置就无法继续使用。

解决办法是 Code_All_Pointers()。保存前,地图、对象池、逻辑/地图层及当前选择中的相应指针被原地改写为 TARGET 或等价编号;保存结束后,Decode_All_Pointers() 再把它们改回有效地址。编码阶段修改的是正在运行的对象本身,不是一份完全独立的深拷贝。因此这段期间不能把这些“看起来仍然是指针类型”的值拿去正常解引用。源码:全局编码全局恢复

具体例子很直观:CargoClassCargoHold 改为被载对象的 As_Target()RadioClass 把协作联系对象编码为目标编号;ObjectClass 编码链表的 NextCellClass 编码占据者与重叠对象。读取后分别用 As_Techno()As_Object() 找回地址。此处的 Radio 是游戏对象之间的协作关系,不应误读为联机网络连接。源码:载荷关系对象协作关系对象链地图格引用

但并非所有“指针式成员”都需要这一步。CCPtr<T> 内部本来就存对象池 ID,在使用 -> 时才通过类型对应的 Heap 查出地址;其 NoInitClass 构造也不重置 ID。例如 UnitClass::ClassTechnoClass::House 使用这种包装,因此可直接随对象字节保存。这解释了为什么某些 Code_Pointers() 函数很短,而不代表遗漏了所有归属关系。源码:编号式指针单位类型成员及恢复构造阵营引用

全局编码函数仍保留“House 最后编码”的依赖说明;当前 HouseClass::Code_Pointers() 实际为空,解码时则调用 Init_Data() 重建颜色映射等运行时数据。这里正好能看到架构演进留下的痕迹:老说明讲指针编码的依赖,当前类已经有一部分采用 ID 包装,另一部分改成加载后重建。分析不能只复制旧说明。源码:当前 House 修复函数

14.6 同一个编号,必须回到同一个槽位

TFixedIHeapClass<T>::Save() 先存 ActiveCount,然后对每个活跃对象写“原始槽位 ID + 对象原始字节”。原始槽位不是活跃列表里的第几个:它决定了所有 TARGETCCPtr 应该还原成谁。加载时通过这个 ID 找到固定对象池中的对应位置,置活动标志、增加计数,并按文件顺序重新加入 ActivePointers源码:对象池保存同槽位重建

随后的 new(ptr) T(NoInitClass()) 不是再申请一块新内存,而是在刚读回的那块内存上执行特殊构造。其目的包括恢复当前可执行程序的虚函数表,同时让普通游戏状态保持读入值。正常构造函数会设置默认生命值、任务、位置甚至触发其他初始化,因而这里不能简单替换成 new T()。各类的 NoInitClass 构造沿继承链传递,像 UnitClass 还把它传给类型引用、装填计时器和炮塔朝向成员。源码:原位构造单位的特殊构造

这也说明为什么保存虚函数表指针的旧值不等于可以使用它:它可能随原始对象字节进了文件,但加载器必须通过当前类型构造进行覆盖。对象身份可以稳定,进程地址和代码地址不能被当作稳定身份。

继续运输车的例子,设 T 在 Units 池槽位 17,I 在 Infantry 池槽位 42。保存时 T 的载荷关系变成“步兵类型目标 + 42”,对象池分别记录 17 和 42。加载时先把两个实例放回各自槽位,之后再解码载荷关系。无论本次进程中两个池的基地址如何变化,T 都能重新指向 I。这就是先建节点,再连边的对象图恢复;目标编号具体位布局应依照对象模型章节,而不能直接把教学例中的两个十进制数拼成真实文件字节。

14.7 地图加载为何比想象中复杂

地图全局对象实际通过最派生的 MouseClass::Save/Load() 保存。写入时先存 Theater,再存地图控制对象的 sizeof(*this) 字节,然后只记录 Should_Save() 为真的格子,每格前面附格号。Should_Save() 不是只判断“有无建筑”,而是把整格字节与静态默认格比较,只要不同就保存。源码:默认格比较稀疏格子保存

读档先恢复 Theater,并重新初始化该场景对应的地形与对象美术资源。随后释放旧格子数组,读入地图对象字节、执行特殊构造,再分配新格子数组、把所有格子初始化为空,最后只覆盖文件中记录的格子。这样既避免旧指针被直接继续使用,也让没有写入文件的默认格有确定初值。源码:资源初始化与地图对象重建按格号装回数据

地图还有“已经造好、正在等待放置”的对象。DisplayClass::Decode_Pointers() 先恢复 PendingObjectPtr;全局解码到最后,才通过 Class_Of() 建立 PendingObject 并设置放置光标形状。历史注释把延后的理由解释为对象类型尚未解码,但当前建筑的 Class 已是 CCPtrClass_Of() 直接取其引用。因此可以确认实际的分阶段修复顺序,不能仅凭旧注释断言在当前类型模型下提前读取必然无效。源码:当前建筑类型访问源码:延后解析的历史注释最后修复放置状态

逻辑层和地图层也独立保存成员列表。LayerClass::Save() 看起来逐个写“指针大小”的值,实际上此前已由 Code_Pointers() 替换成目标 ID;加载后再还原。其文件布局仍使用 sizeof(ObjectClass*),所以“数据语义是 ID”并没有自动得到“跨 32/64 位平台可读”。源码:层列表读写及引用恢复

14.8 一次读档的完整流水线

Load_Game() 的第一层防线是文件存在、头部读取长度和版本检查。之后取出记录的摘要,把剩余文件通过 SHAStraw 重新计算,再比较 20 字节结果。只有通过这一步,才回到主体起点,建立解密和解压的 Straw 链,并调用 Clear_Scenario() 清除旧世界。摘要检查发生在破坏旧场景之前,但这不代表后续所有加载失败都具备事务回滚。源码:头部、版本、摘要和清场顺序

之后按保存顺序读取 Scen、地图、各对象池、逻辑层与触发器、地图层、评分、基地和其他状态。Carryover 链不是直接保留旧链表地址:加载其原始条目后执行特殊构造、Zap() 清理链关系,再重新串成列表。源码:主体读取携带列表重建

等所有对象和杂项在位,才执行全局解码,Map.Init_IO() 重建界面关联,并标记重绘。Post_Load_Game() 再从实际世界推导桥梁数量和移动区,某些数据适合重新计算,没必要把每一个缓存都视作绝对权威状态。源码:解码及界面恢复派生地图数据

更细的随机数约束在这里再次出现:多人加载会跳过 Map.Overpass(),代码解释它会调用随机数生成器,若存档帧存在差异就可能使各机失同步。在 FIXIT_MULTI_SAVE 下,Init_Random() 对多人读档直接返回,因为当前随机状态已随 ScenarioClass 加载;回放才从初始 Seed 重新初始化。初始种子和当前随机状态不是同一份意义相同的数据。源码:多人派生修复限制读档与录像随机处理

14.9 多人保存不是把 TCP 或 IPX 连接一起冻住

多人存档操作本身也是 SAVEGAME 事件,经事件队列在约定时刻执行,然后各机调用 Save_Game(-1,...)。这使“存在哪一帧”也处于共同调度之内;它不是某台机器随意把自己的时刻当作所有人的时刻。源码:保存事件

额外数据来自 Save_MPlayer_Values():Session 的选定字段、建造级别、初始种子、特殊选项和游戏选项等。在 FIXIT_MULTI_SAVE 分支中还保存通信协议、MaxAhead、FrameSendRate、DesiredFrameRate。注意 Session 有两个同名 Save 重载:存档的 Pipe& 版只保存部分配置;录像的 CCFileClass& 版还保存 Session 类型及 Players 列表。这两个重载不是同一种二进制布局,编写解析器时不能共用一个未经区分的结构表。源码:多人补充字段存档版 Session录像版 Session

多人读取完成后,Reconcile_Players() 把当前已连接玩家的名字与已加载的阵营名字匹配,重建玩家 ID 对应;原本由人控制但没有对应连接的阵营交给电脑,并调整数量。最终置 Session.LoadGame=true,由网络队列重新握手和初始化同步计数。这里恢复的是游戏身份与状态,不是把旧进程的串口句柄、socket 或确认队列直接复活。源码:玩家重新对应进入联机恢复状态重新同步

Put_All() 的已读主目录中,没有看到把 OutList/DoList 和底层连接收发队列整体写入的步骤,因此也不能把这个格式称为“任何执行瞬间都能无损迁移的进程检查点”。它服务于特定游戏保存边界,后续必须走恢复协议。

14.10 为什么修改规则、换编译器可能破坏旧存档

SAVEGAME_VERSION 并非人工维护的纯版本字符串,而是一个常量基值、描述长度与大量 sizeof(Class) 的求和。FIXIT_CSII 保存时另加一,加载时接受对应的两种值。它可以发现很多对象布局大小改变,但不是完整 schema 指纹:交换两个等宽字段的意义,或者两处尺寸变化相抵,理论上都可能保持这个和不变。这是由表达式直接得出的工程局限。源码:尺寸构成的版本写入扩展版本版本接纳规则

兼容性还取决于指针宽度、整数/枚举尺寸、结构体填充、位域排列、继承与虚表布局、条件编译成员等。由于很多地方直接读写 sizeof(T) 字节,现代化移植时“源码能编译”不等于“旧存档可读取”,更不等于“新存档可回给旧程序”。应把旧格式解析与新运行时结构分开设计,而不是期待换编译器自动维持二进制兼容;这是改造建议,不是原项目已有的实现。

另外,存档并不封装全部规则和素材。加载末尾重新读取场景文件,先恢复 RuleINI 的规则,再按扩展和具体场景应用覆盖,特定多人扩展分支还处理 MPLAYER.INI。因此相同二进制实例在改变过的资源/规则环境下,未必保持原来的游戏语义。档案保存或复现实验应同时固定程序版本与相关资源。源码:规则重载多人扩展覆盖

代码的错误传播也有历史局限:对象池加载检查了数量上限和槽位字段读取,却未逐项把对象内容读取结果一路传播;Load_Game() 对多处 Map.Load()、对象池 Load() 调用没有检查返回值。保存顶层也在恢复指针后直接返回 true。静态阅读可以确认这些检查并不完整,但没有做损坏文件注入实验,不能据此断言某个具体文件必然崩溃。源码自己的注释已经提示:读档失败后整个游戏状态可能不确定,需要重新初始化场景。源码:对象池边界及读取顶层加载调用保存返回失败状态说明

14.11 从这套设计能学到什么

这不是简单的“把内存 fwrite 一下”。它至少解决了对象身份、循环引用、虚函数表、地图资源依赖、集合顺序、界面重建、派生缓存和多人身份重新对应。原始字节拷贝只是其中节省实现成本的一种手段,其成立条件是其他层面已经给出足够稳定的约束。

最适合动手练习的是做一个小型对象图演示:两种固定对象池、若干双向引用、保存槽位与编号、按文件先重建对象再修复引用。接着故意改变加载时池的基地址,验证编号仍然找对对象;最后改变对象布局,观察为什么需要显式版本迁移。这些是建议的独立练习,本次分析没有声称已编译或运行它们。

继续阅读建议:Save_Game → Put_All → TFixedIHeapClass::Save/Load → IOOBJ.CPP → IOMAP.CPP → Decode_All_Pointers → Post_Load_Game。想研究格式管道,再接 LZOPipe/LZOStrawBlowPipe/BlowStrawSHAPipe/SHAStraw;想研究多人恢复,再回到上一章的 Queue_AI_Multiplayer()

尚未验证的部分包括:用原工具链生成真实存档后的精确结构偏移、各发行版本之间的可读性、写入失败后的恢复效果、畸形数据处理,以及多人紧急保存后的实际一致性。本章给出的是可追踪的序列化机制和限制,不把这些未执行实验当作测试通过。

红色警戒初代 · 源码剖析18 章正文 / 5 份附录
输入关键词,检索 24 份完整文档
全文检索 · 点击结果直达对应小节