存档、读档与二进制格式:把一场战争装进文件
存档要恢复的不是一张截图,也不只是每辆坦克的坐标。它必须恢复正在建造的工厂、对象之间的关系、阵营资金、任务状态、地图占据、触发器和随机序列,才能让玩家按下“继续”后仍处于原来的战争中。这个项目采用的核心办法是:把运行中的对象图转换成可还原的编号关系,再大量写入对象的二进制内存表示,加载后重建对象身份、运行时连接和派生数据。
这套设计很贴合当年的同一平台、同一程序版本,却天然依赖编译布局。它不是现代带字段名、独立版本迁移和跨平台类型定义的文档格式。本章分析实际的 Save_Game/Load_Game 及其辅助类;资源包和美术媒体格式由资源章节负责,网络命令录像见上一章。
14.1 先区分三种“把游戏写下来”
| 数据 | 用途 | 典型内容 | 恢复办法 |
|---|---|---|---|
| 场景与规则 | 定义一局游戏的起点及玩法 | 地图、初始单位、触发器、类型参数 | 初始化世界,按配置创建对象 |
| 存档 | 保存当前已经发展到的局面 | 实例状态、阵营、地图、对象引用、逻辑帧 | 还原这一时刻的世界 |
| 录像 | 重演从起点发生的过程 | 初始配置、种子、命令、部分界面状态 | 再次运行游戏逻辑 |
这三者可能都包含“单位编号”,但含义不同:规则中的类型编号回答“造什么”,存档中的对象槽位回答“是哪一个实例”,录像中的目标编号回答“本次命令指向哪个实例”。因此,只有录像中的命令而没有正确初始环境,不能还原战场;只有存档中的状态而没有后续命令,也不能推知玩家接下来怎么操作。
普通 Save_Game(id,...) 生成 SAVEGAME.%03d,id==-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=4096。LZOPipe 缓存输入直到凑满一个块,调用 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() 再把它们改回有效地址。编码阶段修改的是正在运行的对象本身,不是一份完全独立的深拷贝。因此这段期间不能把这些“看起来仍然是指针类型”的值拿去正常解引用。源码:全局编码、全局恢复
具体例子很直观:CargoClass 把 CargoHold 改为被载对象的 As_Target();RadioClass 把协作联系对象编码为目标编号;ObjectClass 编码链表的 Next;CellClass 编码占据者与重叠对象。读取后分别用 As_Techno() 或 As_Object() 找回地址。此处的 Radio 是游戏对象之间的协作关系,不应误读为联机网络连接。源码:载荷关系、对象协作关系、对象链、地图格引用
但并非所有“指针式成员”都需要这一步。CCPtr<T> 内部本来就存对象池 ID,在使用 -> 时才通过类型对应的 Heap 查出地址;其 NoInitClass 构造也不重置 ID。例如 UnitClass::Class、TechnoClass::House 使用这种包装,因此可直接随对象字节保存。这解释了为什么某些 Code_Pointers() 函数很短,而不代表遗漏了所有归属关系。源码:编号式指针、单位类型成员及恢复构造、阵营引用
全局编码函数仍保留“House 最后编码”的依赖说明;当前 HouseClass::Code_Pointers() 实际为空,解码时则调用 Init_Data() 重建颜色映射等运行时数据。这里正好能看到架构演进留下的痕迹:老说明讲指针编码的依赖,当前类已经有一部分采用 ID 包装,另一部分改成加载后重建。分析不能只复制旧说明。源码:当前 House 修复函数
14.6 同一个编号,必须回到同一个槽位
TFixedIHeapClass<T>::Save() 先存 ActiveCount,然后对每个活跃对象写“原始槽位 ID + 对象原始字节”。原始槽位不是活跃列表里的第几个:它决定了所有 TARGET 和 CCPtr 应该还原成谁。加载时通过这个 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 已是 CCPtr,Class_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/LZOStraw、BlowPipe/BlowStraw、SHAPipe/SHAStraw;想研究多人恢复,再回到上一章的 Queue_AI_Multiplayer()。
尚未验证的部分包括:用原工具链生成真实存档后的精确结构偏移、各发行版本之间的可读性、写入失败后的恢复效果、畸形数据处理,以及多人紧急保存后的实际一致性。本章给出的是可追踪的序列化机制和限制,不把这些未执行实验当作测试通过。