联机、帧同步与录像:几台电脑怎样打同一场战争
红警联机最值得理解的地方是:每台电脑都运行完整战场模拟,网络主要交换“哪位玩家在第几帧命令哪些单位做什么”。坦克逐步移动、子弹飞行、伤害结算和电脑玩家决策由各台机器依照相同规则推演。由此可以在很低的带宽下容纳大量单位,但所有机器必须保持相同的初始状态、命令顺序和随机数调用轨迹。本章把这一设计称为确定性锁步:这是对实际代码的架构归纳,不意味着每一帧都必须同时收到所有人的一个独立包。
本文分析固定提交中的实现,涉及 COMM_PROTOCOL_MULTI_E_COMP、WINSOCK_IPX、FIXIT_MULTI_SAVE 等条件分支;不把这些分支混称为所有发行版本都启用的唯一协议。作为切入点,Queue_AI() 将普通战役及遭遇战送入本地队列处理,将调制解调器、IPX、Internet 等模式送入联机队列,录像回放则优先走独立入口。三者共用事件执行机制。源码:模式分流
13.1 玩家操作如何变成可复制的命令
EventClass 是网络层与游戏规则之间的协议。它拥有事件类型、执行帧 Frame、来源阵营 ID、是否已经执行的标志,以及按类型复用的 Data 联合体。事件不是鼠标屏幕坐标:MEGAMISSION 包含单位、任务、目标和目的地;生产包含对象类别及类型编号;放置建筑包含类别和地图格。这一抽象使不同分辨率、不同观察位置的玩家能够共享同一套战场命令。源码:事件结构与载荷
需要直接读字段而不是只读注释:当前 Frame 是 26 位,尽管附近遗留注释写着 27 位;阵营编号占 5 位,执行标志占 1 位。TARGET/xTargetClass 表示可还原的游戏对象或地图目标标识,不是把一台电脑的裸内存地址发送给另一台电脑。对象身份的基础见对象模型章节。
Queue_Mission() 构造事件并加入 OutList。OutList 是本机产生、等待调度的命令;DoList 是已经获得执行时间、等待执行的命令。单机的 Queue_AI_Normal() 直接搬运,随后可录像、执行并清理;联机必须先给本地命令分配未来帧,装入发送包,同时把同一事件放入本机 DoList。所以本机操作者也受到统一延迟约束,并不会先立即改动坦克、再让远端追赶。源码:入队、单机执行通路、联机本地入执行队列
到达执行时间以后,EventClass::Execute() 才翻译命令为游戏操作。例如 PRODUCE 调用阵营的 Begin_Production();PLACE 调用 Place_Object();移动/攻击的大任务经过 Assign_Mission()、Assign_Target()、Assign_Destination() 等进入单位状态机。发出移动指令不等于立刻写入最终坐标,寻路、转向和每帧推进仍由单位逻辑完成。源码:生产与放置、任务与目标赋值
13.2 用未来执行帧换取共同的现在
最直接的锁步实现可以“发一帧、等一帧”,但那会把每次网络往返都变成停顿。这里用 MaxAhead 允许机器在可控范围内前进:发送时给命令标注未来执行帧,给网络传递留出空间。普通协议使用 Frame + delay;多帧压缩协议还向上取整到 FrameSendRate 的整数倍。每个复合包以 FRAMEINFO 开头,携带该帧的状态校验值、累计命令数和延迟。源码:封包与调度帧
FRAMEINFO 和 FRAMESYNC 必须区分:前者是常规心跳及命令包头,携带运行状态校验;后者用于初始握手或等待中的重新通报,携带场景校验,不附带普通命令。Frame - Delay 用来推回发送者的游戏进度。开始游戏以及载入多人存档时,会清空相关计数、发送同步消息并等待其他机器准备好。普通载入用当前战场校验比较存档;紧急存档分支可把场景校验置零,这是一条明确的历史恢复例外。源码:帧信息定义、握手与载入、同步包
能不能继续由 Can_Advance() 的两个条件共同决定:
所有连接的 已收到命令数 >= 对方宣称已发送命令数
且 当前 Frame < 所有对方进度的最小值 + MaxAhead只比较帧号是不够的。假设对方发送移动命令的可靠包暂时丢失,但后发的无确认心跳先到了,帧号会显示“对方已往前走”。如果本机据此继续,可能错过移动命令的执行时刻。累计命令数让本机知道仍缺数据;Process_Receive_Packet() 从包头更新宣称的数量,从成功拆出的普通事件更新实际收到的数量,并排除心跳本身。源码:推进判定、收到包后的计数
这里的等待并不是回滚预测。代码会等数据、重发帧同步、弹出重连对话框或处理超时;如果普通命令已经错过约定帧,Execute_DoList() 报 TXT_PACKET_TOO_LATE 并返回失败。对所读主通路而言,没有现代 rollback 那种保存最近数帧、猜测输入、再倒退重算的步骤。不要把 FRAMESYNC 名字理解成“下载其他玩家的完整世界来修复”。源码:等待重发、迟到命令处理
13.3 实例:给三辆坦克下达同一移动命令
以下数字是按公式构造的教学例子,不是一次运行测量。设当前逻辑帧为 120,FrameSendRate=3,MaxAhead=9,玩家 A 选中坦克 U1、U2、U3,要求它们去同一地图格 C。
- 输入层为三辆坦克分别产生任务事件,进入 A 的
OutList。此时没有三份完整坦克状态需要传输。 - 本帧符合发送周期,封包将三个事件的执行帧设为
ceil((120+9)/3)×3=129,把 A 的阵营 ID 写入事件,并复制到 A 本机的DoList。 - 如果这三个连续事件都是
MEGAMISSION,且任务、目标、目的地相同,压缩器只保存一次公共任务数据,随后列出其余单位的Whom,并附重复数量。 - B 收到后恢复出三个完整事件,记录 A 的进度及命令数;A、B 都不能因为自己的包先到,就改变公共的执行顺序。
- 双方在允许推进且到达 129 帧时按同样顺序执行事件,提交三辆坦克的任务/目的地。后续逻辑更新持续推进实际移动;某些路径准备可以在目的地赋值时立即发生。
这里还有一个容易忽略的时序细节:本快照的主循环先运行 Logic.AI(),然后运行 Queue_AI(),之后才 Frame++。因而在 129 帧队列阶段赋予的新任务,主要由后续逻辑轮次消费;不能把本例写成“先执行 129 帧命令,再计算同一轮 Logic.AI()”。但这不表示事件执行期间绝不寻路:DriveClass::Assign_Destination() 会在条件满足时立即调用 Start_Of_Move(),后者可以进入 Basic_Path();应区分调用栈内的路径准备与之后持续发生的位移更新。源码:即时移动准备、路径生成条件。详细主循环见运行时章节。源码:主循环两阶段、递增帧
13.4 同一帧也必须有同一顺序
“所有人都收到命令了”仍不等于确定性。如果 A 的坦克开火与 B 的坦克撤退在同一帧到达,按网络到达顺序执行可能导致不同结果。Execute_DoList() 先按阵营顺序遍历,再扫描属于该阵营的事件。代码明确利用各机器共同的 House 顺序来建立执行次序;传输层还要保持同一连接可靠命令的顺序。源码:固定阵营执行次序
由此能看到确定性的三个层次:同一个场景和规则产生相同起点;共同的帧号与阵营次序产生相同外部输入序列;单位、AI、碰撞与随机数计算产生相同后续状态。工程上,这也解释了为什么“只在自己画面上多调用一次游戏随机函数”可能毁掉整局联机,而“只改变本机光标外观”通常不该进入共享战场逻辑。后一句是设计约束的推论,不表示本项目所有视觉路径都已经经过我们运行验证。
在 TIMING_FIX 条件下,代码还处理动态增大 MaxAhead 的危险窗口:时间参数改变前生成的某些命令仍带旧延迟,但机器可能按新延迟放宽推进。实现把落在指定窗口内的普通事件重新安排到窗口末尾,避免迟到。这说明协议演进牵动了事件调度,不能只修改一个全局延迟数字。源码:延迟变更修正
13.5 节省的是结构重复,不是坦克运动精度
压缩协议没有把坦克坐标降低精度来省流量;它省掉事件间重复的协议字段。EventLength[] 描述各类有效载荷长度;公共帧号和玩家 ID 已在包头提供,普通事件只需类型加对应联合体片段。ADDPLAYER 另有变长载荷,不能按照普通固定大小事件处理。源码:按事件长度封装、变长事件
MEGAMISSION 连续重复编码特别适合 RTS:玩家一次框选几十辆坦克,目的地通常相同。第一项存完整任务,后面的项只存不同单位身份。接收方必须重新展开为逐单位事件,保证执行层不需要理解“网络上这次压缩了十辆车”。这里仅对实际出现的 MEGAMISSION 分支作结论,不能凭名字把带编队速度参数的 MEGAMISSION_F 也算进同一重复编码规则。源码:相同任务的比较、写入压缩结果、接收端逐单位展开
发送策略也区分“消息必须到”与“进度通报可以被更新的进度替代”。含命令的包请求 ACK;没有命令的心跳可以不要求 ACK。代码有根据发送积压限制每次命令数的逻辑,但随后明确对 Internet、modem、null-modem 分支覆盖该上限。分析时应保留这个覆盖,不能把上方注释里的队列节流条件宣称为全部联机方式的统一行为。源码:发送容量与确认策略
13.6 网络速度、逻辑帧率和屏幕刷新不是同一个量
Frame 表示模拟的离散步数;连接超时及响应时间使用系统 tick;FrameSendRate 表示每几帧发一次复合包,名称中的 rate 在这里不是每秒多少包。多帧协议下,不符合发送周期时队列处理会提前返回,但主循环仍继续经过各轮逻辑。源码:发送周期分支
每 128 帧,机器报告自身处理耗时;主机角色负责产生统一的 TIMING 事件。它不是负责所有战斗计算的权威服务器。Generate_Real_Timing_Event() 根据最慢参与者所需的系统 tick 和玩家选择的速度,取较低的目标帧率;把往返响应时间换算为单程逻辑帧数,再取整到发送周期,且至少为发送周期的三倍。因此网络变差可能加大命令延迟,机器处理慢可能降低共同节奏。源码:周期调整、目标帧率及 MaxAhead 公式
画面刷新另有工作机会。主循环等待计时的 Sync_Delay() 仍处理输入、渲染和回调,所以不能用“每秒逻辑推进几次”简单等同“显示器每秒刷新几次”。同样,代码中大量 60 tick 换算不能单独证明游戏固定运行在 60 个逻辑帧每秒;速度设置、协议分支和处理能力都会影响实际节奏。源码:等待期仍有界面工作
13.7 从事件协议一路到 UDP、IPX 与串口
| 层次 | 主要职责 | 代码入口 |
|---|---|---|
| 游戏语义 | 任务、生产、部署、出售等命令 | EventClass |
| 确定性调度 | 未来帧、累计命令计数、状态校验 | QUEUE.CPP |
| 连接管理 | 向一个或全部已连接玩家发消息 | IPXManagerClass / NullModemClass |
| 可靠传输 | 包 ID、ACK、重发、重复包处理、顺序交付 | ConnectionClass |
| 平台通道 | UDP/IPX 数据报或串口帧收发 | WSPUDP.CPP / WSPIPX.CPP / NULLCONN.CPP |
多人队列通过 ConnManClass* 选择具体连接管理器,Internet 分支也复用名为 Ipx 的管理对象。IPXManagerClass::Send_Private_Message() 默认向所有连接发送同一包,所以不要从 Ipx 这个遗留变量名推断 Internet 数据必然采用 IPX,也不要把这里的“private”理解成加密。源码:管理器选择、向各连接发送
ConnectionClass 通过 ACK 和超时重发实现可靠性。尤其需要纠正文件头的旧说法:虽然 CONNECT.H 注释曾形容底层不保证顺序,实际 Get_Packet() 对 PACKET_DATA_ACK 只交付 LastReadID+1,而无确认包可以直接取出。可靠命令与不可靠心跳采用不同交付规则,正是帧同步还需要独立累计命令数的原因。源码:实际顺序交付、确认与重发维护
在 WINSOCK_IPX 分支,IPXConnClass::Send_To() 转交 PacketTransport->WriteTo()。Internet 初始化可选 UDPInterfaceClass,其 socket 是 AF_INET/SOCK_DGRAM;IPX 实现则使用 AF_NS/SOCK_DGRAM/NSPROTO_IPX。串口路径把长度、两个 magic 值、数据校验及结束字符包在应用消息外面,然后交给串口写入。这是多套平台通路共享同一游戏协议的实例。源码:传输抽象、Internet/IPX 选择、UDP socket、IPX socket、串口外层帧
WOL 相关代码还承担大厅玩家信息、地址和开局准备:它从玩家信息生成 Session.Players,设置主机地址,然后启动底层收发并调用 Init_Network()。这些服务集成不等于战斗中每个坦克状态都由 WOL 服务器计算。本章确认的是客户端调用与分层,未验证旧在线服务现在能否使用,也未将仓库中的接口定义视为完整服务端源码。源码:WOL 玩家信息与开局
13.8 不同步是怎样发现的:一个很不“纯”的校验函数
Compute_Game_CRC() 组合步兵位置、朝向、任务、目标,车辆和建筑部分字段,舰船字段,阵营资金/电力,以及地图层、逻辑层对象的顺序和身份。它不是对全部内存的完整快照求密码学哈希:很多字段并未逐个纳入,组合算法 Add_CRC() 是移位加法。函数名字包含 CRC,不应据此宣称它使用某种标准 CRC 多项式或能无碰撞证明两局状态完全相同。源码:采样字段、组合算法
最出人意料的是最后的 Add_CRC(&GameCRC, Scen.RandomNumber)。RandomClass::operator int() 会调用取随机数运算符,后者更新 Seed;它消耗一个新的随机数,不是读取 Seed 字段!代码旁被注释掉的旧写法才是直接读取种子。随机数生成器按线性递推更新状态并提取 15 位;区间抽样还可能因为拒绝超范围结果而多次取数。因此调用次数、调用次序和调用参数都可能影响后续战斗。源码:校验取随机数、隐式转换及常量、随机递推与区间抽样
这是阅读源码的一个实用教训:看似“仅用于诊断”的函数,在这个项目里也可能参与模拟状态变化。移植时若擅自删除校验或在某台机器多调用一次,会改变随机序列。网络每帧先计算校验,并在 32 项环形历史中保存;收到 FRAMEINFO 后按对应历史帧比较,且有初始跳过期、恰好在约定帧执行、Delay<32 等条件。不同步时可以输出诊断并提示退出或继续,但“继续”涉及断开连接等处理,并不是自动修复分歧。源码:历史记录、校验条件与不同步处理
13.9 录像重放是再计算,而且还保存了观看视角
项目确实有录像与回放代码,不能说“老红警完全没有录像机制”。但也不能据此承诺发行版提供现代回放浏览器:一处启用 Session.Record/Play 的命令选项位于 CHEAT_KEYS 条件内;Session 默认文件名为 RECORD.BIN,另有 attract 演示逻辑。源码:调试条件内的开关、录像文件名
录像包含三个不同层次:启动头记录 Session、场景、初始随机种子、特殊选项与游戏选项;Do_Record_Playback() 记录/恢复视口位置、当前选择、编组与编队数据;Queue_Record() 在执行前保存本轮待执行事件数量及 EventClass 原始结构。它不是逐帧视频,也不是只有鼠标输入;准确说是“可重建初始环境的配置,加命令流及部分界面状态”。源码:启动头、视角与选择状态、事件记录
回放从记录中恢复事件、重置执行标志、放回 DoList,再调用同一个 Execute_DoList();它会遵循多人首帧和发送周期的特殊节奏,文件读取失败则结束。Init_Random() 的回放分支从记录种子初始化随机状态,真实的伤害与移动仍重新运行,因此规则或执行次序变化可能使旧录像偏离;保存格式直接依赖 sizeof(EventClass),也没有看到跨版本回放迁移层。源码:回放循环、随机初始化
还有一个值得保留的边界:普通单机队列没有在同处调用游戏 CRC,而回放队列会调用;CRC 又会消耗随机数。源码中的记录注释与调试入口多次指向多人用途。因此,“存在战役分支”不足以证明所有单机录像都可确定性正确重放,这一点需实际构建、记录与回放对照后再下结论。存档则保存当前世界并恢复对象,详见下一章。
13.10 继续阅读路线与未验证部分
推荐按 EVENT.H → Queue_Mission → Queue_AI_Multiplayer → Build_Send_Packet → Can_Advance → Execute_DoList → EventClass::Execute 跟踪一条命令,再读 CONNECT.CPP 看可靠交付,最后读 Queue_Record/Queue_Playback 和 Do_Record_Playback 看为什么同一事件边界能支持录像。
本章已据函数体确认事件延迟、顺序、计数门槛、状态校验、传输分层和录放数据流;没有实际完成跨机联机、网络丢包注入、超长局计数回绕或录像兼容性实验。具体发行版本启用了哪些宏、外部在线服务的现状、各平台结构打包差异以及单机录像是否存在上述随机偏差,均不能用静态阅读替代运行结论。