实体复制:快照、SendTable 与差量编码
对于网络化实体,服务器是权威,客户端看到的是服务器状态的“延迟副本”(另有纯客户端实体不在此列)。本页解释这份副本如何声明(游戏代码)、如何在每个发送 tick 被打包与差量编码(引擎服务端)、以及如何被解码并通知客户端实体(引擎客户端 + client.dll)。
相关页面:网络通道与协议 · 主循环与 Tick · 实体系统 · 输入与预测
相关源码文件#
- 声明:
public/networkvar.h、public/server_class.h、public/dt_send.h、public/client_class.h、public/dt_recv.h - 快照:
engine/sv_framesnapshot.cpp、engine/sv_main.cpp - 打包:
engine/sv_packedentities.cpp、engine/dt_send_eng.cpp - 写包:
engine/baseclient.cpp、engine/sv_client.cpp、engine/sv_ents_write.cpp - 读包:
engine/cl_ents_parse.cpp、engine/client.cpp、engine/dt_recv_eng.cpp - 游戏端:
game/server/gameinterface.cpp、game/client/cdll_client_int.cpp
1. 声明:一个网络变量从定义到 HUD#
以玩家金钱 m_iAccount 为例:
| 步骤 | 代码 | 证据 |
|---|---|---|
| 服务端声明 | CNetworkVar( int, m_iAccount ); | game/server/cstrike15/cs_player.h:1253 |
| 赋值即标脏 | CNetworkVarBase::Set 在值变化时调用 NetworkStateChanged();GetForModify 无条件标脏 | public/networkvar.h:334-360、public/networkvar.h:671-673 |
| 发送表 | SendPropInt( SENDINFO( m_iAccount ), 16, SPROP_UNSIGNED )(16 位无符号) | game/server/cstrike15/cs_player.cpp:478 |
| 接收表 | RecvPropInt( RECVINFO( m_iAccount ) ) | game/client/cstrike15/c_cs_player.cpp:1495 |
| 类配对 | IMPLEMENT_SERVERCLASS_ST( CCSPlayer, DT_CSPlayer ) ↔ IMPLEMENT_CLIENTCLASS_DT( C_CSPlayer, DT_CSPlayer, CCSPlayer ) | game/server/cstrike15/cs_player.cpp:444、game/client/cstrike15/c_cs_player.cpp:1480 |
| 使用 | HUD 每帧比较 GetAccount() 与缓存值,变化时驱动 Flash 动画 | game/client/cstrike15/Scaleform/HUD/sfhudmoney.cpp:194-227 |
客户端类以服务器类名字符串登记自己(ClientClass(#serverClassName, ...)),并提供创建函数:public/client_class.h:98-111。服务端宏 IMPLEMENT_SERVERCLASS_ST 同时开始一个 SendTable:public/server_class.h:103-105。
只发给特定接收者的数据#
发送表可以嵌套子表并挂“发送代理”。CCSPlayer 把只属于本地玩家的字段(体力、连射计数、本回合购买次数等)放进 DT_CSLocalPlayerExclusive,坐标在本地/非本地子表中各有一份不同优先级的编码:game/server/cstrike15/cs_player.cpp:415-441;主表通过 SendProxy_SendLocalDataTable/SendProxy_SendNonLocalDataTable 引用它们:game/server/cstrike15/cs_player.cpp:463-465。前者把接收者限定为实体本人,并额外加上 GOTV 客户端:game/server/player.cpp:8214-8230。同时它用 SendPropExclude 排除基类中由客户端动画自行计算的字段:game/server/cstrike15/cs_player.cpp:445-461。
2. 服务器发送流水线#
2.1 选择接收者与拍快照#
SendClientMessages 遍历客户端:不处于 active 状态的只发送可靠数据;active 的加入快照接收列表。若只有 GOTV 主客户端且无人观看、也未录制,则不拍快照:engine/sv_main.cpp:2142-2217。随后 TakeTickSnapshot 为本 tick 记录所有存活实体的 ServerClass 与序列号(未 active 的玩家 edict 被跳过):engine/sv_framesnapshot.cpp:138-198、engine/sv_main.cpp:2219-2229。
2.2 每客户端可见集#
SV_ComputeClientPacks 为每个客户端调用 SetupPackInfo(由游戏 DLL 计算 PVS,分配 CClientFrame 并限制帧历史长度),对需要全量更新的客户端调用 PrepareForFullUpdate,再由 CheckTransmit 填写 transmit_entity 位图:engine/sv_packedentities.cpp:614-641、engine/sv_client.cpp:533-616。游戏端的 CheckTransmit 根据实体标志 FL_EDICT_DONTSEND/ALWAYS/PVSCHECK/FULLCHECK 决定是否发送:game/server/gameinterface.cpp:2784-2846。帧历史默认最多保留 MAX(sv_maxclientframes, MAX_CLIENT_FRAMES)(128),新连接的前若干秒或开启回放时会放宽:engine/sv_client.cpp:568-598、engine/clientframe.h:19。
2.3 打包(只打包一次,所有客户端共享)#
PackEntities_Normal 只要实体对任一客户端可见就把它加入工作列表,然后串行或通过 ParallelProcess 并行打包(sv_parallel_packentities):engine/sv_packedentities.cpp:539-608。
SV_PackEntity 的三条路径:
- 无变化复用。 实体未标脏且上一帧有打包结果时,直接引用上一帧数据:
engine/sv_packedentities.cpp:259-270。 - 快速路径(局部更新)。 有上一帧数据、不是整实体变化、变更列表序列号有效时,
SendTable_BuildDeltaProperties只重编码变化的属性;源码注释称它覆盖约 95% 的实体:engine/sv_packedentities.cpp:284-340。 - 慢速路径。 否则
SendTable_Encode全量编码,再与上一帧做二进制比对SendTable_CalcDelta得到变化属性;若比对后无变化且接收者相同则仍复用旧包:engine/sv_packedentities.cpp:346-380。
最后生成 PackedEntity,记录合并后的“属性变化帧列表”与接收者,并清除实体脏标记:engine/sv_packedentities.cpp:382-396。底层编码函数见 engine/dt_send_eng.cpp:621、engine/dt_send_eng.cpp:937、engine/dt_send_eng.cpp:1274。
2.4 写出:SendSnapshot 与 WriteDeltaEntities#
- 普通客户端:先写视角更新,再走
CBaseClient::SendSnapshot;GOTV 主客户端则把帧加入 HLTV 服务器的增量帧列表并“伪确认”:engine/sv_client.cpp:1612-1692。 CBaseClient::SendSnapshot:同一快照不发两次;若此前发过全量更新且尚未被确认,则只发可靠数据(避免全量刷屏);找不到客户端确认过的 delta frame 时改发全量更新:engine/baseclient.cpp:1412-1444、engine/baseclient.cpp:1692-1708。然后依次写net_Tick、字符串表更新、实体差量、临时实体(多人默认上限由sv_multiplayer_maxtempentities控制)、声音:engine/baseclient.cpp:1451-1512。全量快照溢出直接断开该客户端:engine/baseclient.cpp:1520-1528。InternalWriteDeltaEntities同时遍历新旧两帧的可见实体,按实体号对齐后决定更新类型并写入,最后写显式删除:engine/sv_ents_write.cpp:817-907。更新类型的判定:
| 类型 | 条件 | 证据 |
|---|---|---|
EnterPVS | 新帧有、旧帧无;或需要显式重建 | engine/sv_ents_write.cpp:462-486 |
LeavePVS | 旧帧有、新帧无(必要时带删除标志) | engine/sv_ents_write.cpp:470-475 |
PreserveEnt | 两帧引用同一 PackedEntity(未变化) | engine/sv_ents_write.cpp:491-496 |
DeltaEnt | 其余情况,只写变化属性;若裁剪后没有任何变化位,仍退化为 PreserveEnt | engine/sv_ents_write.cpp:499-585 |
消息头写入 is_delta、delta_from、baseline 等字段;在客户端尚未更新基线且本包含全量属性时,要求客户端把本快照作为新基线(update_baseline):engine/sv_ents_write.cpp:876-933。
发送可串行或并行(sv_parallel_sendsnapshot 且接收者多于 1 个):engine/sv_main.cpp:2238-2260。全部发送完成后调用 server.dll 的 PostClientMessagesSent,让实体清除一次性效果标志:engine/sv_main.cpp:2266-2268、game/server/gameinterface.cpp:3654-3658。
3. 客户端接收流水线#
CL_ProcessPacketEntities 的步骤:
- 分配新的
CClientFrame;若为差量包,找到delta_from对应的旧帧,找不到就丢弃本包(等待全量更新),自引用视为致命错误:engine/cl_ents_parse.cpp:580-611。 - 全量包:删除所有客户端实体、释放基线,并通知
FRAME_NET_FULL_FRAME_UPDATE_ON_REMOVE:engine/cl_ents_parse.cpp:612-628。 - 通知
FRAME_NET_UPDATE_START;若服务器要求更新基线,复制基线并可靠地回送clc_BaselineAck:engine/cl_ents_parse.cpp:631-649。 ReadPacketEntities逐个解析:EnterPVS读类号与序列号并CL_CopyNewEntity(序列号如何与客户端实体句柄保持一致见 实体句柄与实体列表);LeavePVS在带删除标志时删除实体;DeltaEnt调CL_CopyExistingEntity;PreserveEnt原样拷贝位:engine/client.cpp:1490-1580。CL_CopyExistingEntity先PreDataUpdate,再RecvTable_ReadFieldList+RecvTable_Decode把字段写进实体内存,并登记PostDataUpdate调用:engine/cl_ents_parse.cpp:442-476、engine/dt_recv_eng.cpp:624。- 通知
POSTDATAUPDATE_START,对所有改动实体调用PostDataUpdate,通知POSTDATAUPDATE_END;标记出/入 PVS 的实体;删除比delta_from更旧的帧并保存新帧;最后FRAME_NET_UPDATE_END:engine/cl_ents_parse.cpp:672-698。
client.dll 在这些阶段做的事:更新期间禁止重算绝对坐标并抑制空间分区更新,结束后恢复;POSTDATAUPDATE_END 时让预测系统保存“原始”网络状态:game/client/cdll_client_int.cpp:3441-3504。
约束与边界#
- 差量依赖确认。 服务器只能以客户端确认过的帧为基准;任何一方丢失基准帧都会退化为全量更新(服务器侧
engine/baseclient.cpp:1437-1444,客户端侧engine/cl_ents_parse.cpp:604-610)。 - 属性位宽即合法范围。 例如
m_iAccount只有 16 位无符号:game/server/cstrike15/cs_player.cpp:478;超出范围的值在客户端看来会被截断(推断,未查看SendPropInt的溢出处理)。 - 本地后门。 本地客户端可能走
g_pLocalNetworkBackdoor直接写实体,不经过上述编解码:engine/sv_packedentities.cpp:644-696。 - 未逐行分析
dt_send_eng.cpp/dt_encode.cpp中各属性类型的位级编码格式与SPROP_*标志语义。