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

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

这套设计很贴合当年的同一平台、同一程序版本，却天然依赖编译布局。它不是现代带字段名、独立版本迁移和跨平台类型定义的文档格式。本章分析实际的 `Save_Game/Load_Game` 及其辅助类；资源包和美术媒体格式由[资源章节](12-assets-audio-and-video.md)负责，网络命令录像见[上一章](13-network-lockstep-and-replay.md)。

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

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

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

普通 `Save_Game(id,...)` 生成 `SAVEGAME.%03d`，`id==-1` 则走 `SAVEGAME.NET` 并额外保存多人配置。文件名是入口区别，真正的读写结构由函数顺序决定。[源码：文件选择](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L318-L342)、[多人存档名](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/DEFINES.H#L475-L479)

## 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()` 只读取描述、场景、阵营并检查版本；所以“存档菜单能列出来”并不表示主体已经校验成功或能完整加载。[源码：头部写入](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L349-L385)、[描述长度](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/DEFINES.H#L2840-L2840)、[存档信息快速读取](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L1366-L1411)

主体写入链是：`Put_All → LZOPipe(COMPRESS) → BlowPipe(ENCRYPT) → SHAPipe → FilePipe`。顺序不能倒过来解释：摘要看见的是**已经压缩、已经加密、最终写到磁盘的主体字节**，不是原始对象，也不包括先前直接写出的头部。代码先留出 20 字节占位，再在全部数据刷新后回到原位置填写真实摘要。加密使用 `FastKey` 及 Blowfish 引擎的密钥长度。[源码：管道连接与摘要回填](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L388-L416)、[SHA 管道处理](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SHAPIPE.CPP#L62-L87)

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

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

存档使用 `SAVE_BLOCK_SIZE=4096`。`LZOPipe` 缓存输入直到凑满一个块，调用 `lzo1x_1_compress()`，输出块头和压缩数据。块头的两个 `unsigned short` 分别记录压缩后字节数和解压后字节数；尾部不足整块时，`Flush()` 仍把已有字节压成一个合法短块。[源码：存档块大小](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L61-L63)、[块头](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/LZOPIPE.H#L91-L97)、[整块压缩](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/LZOPIPE.CPP#L189-L238)、[尾块刷新](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/LZOPIPE.CPP#L301-L319)

因此一个 `UnitClass` 的字节可以跨越两个压缩块，一个块也可能包含几个不同类型对象的尾部和头部。对象边界属于 `Put_All` 和各对象序列化函数，压缩器只处理连续字节。反向读取时，`LZOStraw::Get()` 先取块头及相应压缩字节，解压到缓存，再按上层要求提供数据。这种层次分离让游戏对象不必关心压缩细节。[源码：解压缓存读取](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/LZOSTRAW.CPP#L126-L170)

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

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

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

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

对象池列表远不止“兵、车、建筑”。子弹、动画和工厂也要恢复，否则炮弹会凭空消失，正在制造的单位可能丢失。逻辑层与显示层则回答另一个问题：这些对象应该在什么集合里被更新、按什么关系被绘制。光把对象实例放回内存，不恢复集合成员关系，游戏仍不能继续。[源码：场景及对象池](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L128-L181)、[逻辑层、触发器和地图层](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L185-L215)、[评分、基地、携带与杂项](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L220-L275)

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

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

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

解决办法是 `Code_All_Pointers()`。保存前，地图、对象池、逻辑/地图层及当前选择中的相应指针被原地改写为 `TARGET` 或等价编号；保存结束后，`Decode_All_Pointers()` 再把它们改回有效地址。**编码阶段修改的是正在运行的对象本身**，不是一份完全独立的深拷贝。因此这段期间不能把这些“看起来仍然是指针类型”的值拿去正常解引用。[源码：全局编码](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L1186-L1249)、[全局恢复](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L1265-L1344)

具体例子很直观：`CargoClass` 把 `CargoHold` 改为被载对象的 `As_Target()`；`RadioClass` 把协作联系对象编码为目标编号；`ObjectClass` 编码链表的 `Next`；`CellClass` 编码占据者与重叠对象。读取后分别用 `As_Techno()` 或 `As_Object()` 找回地址。此处的 Radio 是游戏对象之间的协作关系，不应误读为联机网络连接。[源码：载荷关系](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOOBJ.CPP#L805-L841)、[对象协作关系](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOOBJ.CPP#L688-L729)、[对象链](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOOBJ.CPP#L866-L896)、[地图格引用](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOMAP.CPP#L135-L181)

但并非所有“指针式成员”都需要这一步。`CCPtr<T>` 内部本来就存对象池 ID，在使用 `->` 时才通过类型对应的 Heap 查出地址；其 `NoInitClass` 构造也不重置 ID。例如 `UnitClass::Class`、`TechnoClass::House` 使用这种包装，因此可直接随对象字节保存。这解释了为什么某些 `Code_Pointers()` 函数很短，而不代表遗漏了所有归属关系。[源码：编号式指针](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/CCPTR.H#L42-L90)、[单位类型成员及恢复构造](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/UNIT.H#L60-L124)、[阵营引用](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/TECHNO.H#L190-L196)

全局编码函数仍保留“House 最后编码”的依赖说明；当前 `HouseClass::Code_Pointers()` 实际为空，解码时则调用 `Init_Data()` 重建颜色映射等运行时数据。这里正好能看到架构演进留下的痕迹：老说明讲指针编码的依赖，当前类已经有一部分采用 ID 包装，另一部分改成加载后重建。分析不能只复制旧说明。[源码：当前 House 修复函数](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOOBJ.CPP#L531-L562)

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

`TFixedIHeapClass<T>::Save()` 先存 `ActiveCount`，然后对每个活跃对象写“原始槽位 ID + 对象原始字节”。原始槽位不是活跃列表里的第几个：它决定了所有 `TARGET` 和 `CCPtr` 应该还原成谁。加载时通过这个 ID 找到固定对象池中的对应位置，置活动标志、增加计数，并按文件顺序重新加入 `ActivePointers`。[源码：对象池保存](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/HEAP.CPP#L493-L518)、[同槽位重建](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/HEAP.CPP#L535-L578)

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

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

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

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

地图全局对象实际通过最派生的 `MouseClass::Save/Load()` 保存。写入时先存 Theater，再存地图控制对象的 `sizeof(*this)` 字节，然后只记录 `Should_Save()` 为真的格子，每格前面附格号。`Should_Save()` 不是只判断“有无建筑”，而是把整格字节与静态默认格比较，只要不同就保存。[源码：默认格比较](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOMAP.CPP#L69-L74)、[稀疏格子保存](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOMAP.CPP#L322-L364)

读档先恢复 Theater，并重新初始化该场景对应的地形与对象美术资源。随后释放旧格子数组，读入地图对象字节、执行特殊构造，再分配新格子数组、把所有格子初始化为空，最后只覆盖文件中记录的格子。这样既避免旧指针被直接继续使用，也让没有写入文件的默认格有确定初值。[源码：资源初始化与地图对象重建](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOMAP.CPP#L211-L280)、[按格号装回数据](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOMAP.CPP#L282-L305)

地图还有“已经造好、正在等待放置”的对象。`DisplayClass::Decode_Pointers()` 先恢复 `PendingObjectPtr`；全局解码到最后，才通过 `Class_Of()` 建立 `PendingObject` 并设置放置光标形状。历史注释把延后的理由解释为对象类型尚未解码，但当前建筑的 `Class` 已是 `CCPtr`，`Class_Of()` 直接取其引用。因此可以确认实际的分阶段修复顺序，不能仅凭旧注释断言在当前类型模型下提前读取必然无效。[源码：当前建筑类型访问](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/BUILDING.H#L246-L251)、[源码：延后解析的历史注释](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOMAP.CPP#L422-L439)、[最后修复放置状态](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L1330-L1344)

逻辑层和地图层也独立保存成员列表。`LayerClass::Save()` 看起来逐个写“指针大小”的值，实际上此前已由 `Code_Pointers()` 替换成目标 ID；加载后再还原。其文件布局仍使用 `sizeof(ObjectClass*)`，所以“数据语义是 ID”并没有自动得到“跨 32/64 位平台可读”。[源码：层列表读写及引用恢复](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/IOOBJ.CPP#L391-L507)

## 14.8 一次读档的完整流水线

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

之后按保存顺序读取 `Scen`、地图、各对象池、逻辑层与触发器、地图层、评分、基地和其他状态。Carryover 链不是直接保留旧链表地址：加载其原始条目后执行特殊构造、`Zap()` 清理链关系，再重新串成列表。[源码：主体读取](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L641-L722)、[携带列表重建](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L724-L752)

等所有对象和杂项在位，才执行全局解码，`Map.Init_IO()` 重建界面关联，并标记重绘。`Post_Load_Game()` 再从实际世界推导桥梁数量和移动区，某些数据适合重新计算，没必要把每一个缓存都视作绝对权威状态。[源码：解码及界面恢复](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L757-L778)、[派生地图数据](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SCENARIO.CPP#L662-L674)

更细的随机数约束在这里再次出现：多人加载会跳过 `Map.Overpass()`，代码解释它会调用随机数生成器，若存档帧存在差异就可能使各机失同步。在 `FIXIT_MULTI_SAVE` 下，`Init_Random()` 对多人读档直接返回，因为当前随机状态已随 `ScenarioClass` 加载；回放才从初始 `Seed` 重新初始化。**初始种子和当前随机状态不是同一份意义相同的数据。**[源码：多人派生修复限制](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SCENARIO.CPP#L664-L671)、[读档与录像随机处理](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/INIT.CPP#L2437-L2454)

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

多人存档操作本身也是 `SAVEGAME` 事件，经事件队列在约定时刻执行，然后各机调用 `Save_Game(-1,...)`。这使“存在哪一帧”也处于共同调度之内；它不是某台机器随意把自己的时刻当作所有人的时刻。[源码：保存事件](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/EVENT.CPP#L876-L904)

额外数据来自 `Save_MPlayer_Values()`：Session 的选定字段、建造级别、初始种子、特殊选项和游戏选项等。在 `FIXIT_MULTI_SAVE` 分支中还保存通信协议、MaxAhead、FrameSendRate、DesiredFrameRate。注意 Session 有两个同名 Save 重载：存档的 `Pipe&` 版只保存部分配置；录像的 `CCFileClass&` 版还保存 Session 类型及 Players 列表。这两个重载**不是同一种二进制布局**，编写解析器时不能共用一个未经区分的结构表。[源码：多人补充字段](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L1131-L1169)、[存档版 Session](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SESSION.CPP#L533-L599)、[录像版 Session](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SESSION.CPP#L622-L647)

多人读取完成后，`Reconcile_Players()` 把当前已连接玩家的名字与已加载的阵营名字匹配，重建玩家 ID 对应；原本由人控制但没有对应连接的阵营交给电脑，并调整数量。最终置 `Session.LoadGame=true`，由网络队列重新握手和初始化同步计数。这里恢复的是游戏身份与状态，不是把旧进程的串口句柄、socket 或确认队列直接复活。[源码：玩家重新对应](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L1448-L1539)、[进入联机恢复状态](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L867-L874)、[重新同步](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/QUEUE.CPP#L646-L681)

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

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

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

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

另外，存档并不封装全部规则和素材。加载末尾重新读取场景文件，先恢复 `RuleINI` 的规则，再按扩展和具体场景应用覆盖，特定多人扩展分支还处理 `MPLAYER.INI`。因此相同二进制实例在改变过的资源/规则环境下，未必保持原来的游戏语义。档案保存或复现实验应同时固定程序版本与相关资源。[源码：规则重载](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L878-L920)、[多人扩展覆盖](https://github.com/electronicarts/CnC_Red_Alert/blob/0dc09bb1d6188d1ec79ab4765b22a48b75ffee32/CODE/SAVELOAD.CPP#L941-L970)

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

## 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()`。

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