CD Code Digest Hub /CS:GO

输入命令、服务端执行与客户端预测

16 分钟阅读 9 个章节 1 张图

玩家的一切操作都被压缩成一串 CUserCmd。客户端每个 tick 生成一条命令并立即在本地模拟(预测),同时把命令发给服务器;服务器按自己的时钟权威地重新执行这些命令;客户端收到权威状态后回滚到服务器状态,再把服务器尚未确认的命令重放一遍。本页沿这条链逐段给出代码证据。

相关页面:网络通道与协议 · 主循环与 Tick · 延迟补偿 · CS:GO 移动

子页面:客户端预测内部机制:预测字段、中间数据与误差处理

相关源码文件#

  • 命令结构:game/shared/usercmd.h、game/client/input.h
  • 客户端生成:game/client/in_main.cpp、engine/cl_main.cpp
  • 服务端接收与执行:engine/sv_client.cpp、game/server/gameinterface.cpp、game/server/player.cpp、game/server/player_command.cpp
  • 客户端预测:engine/cl_pred.cpp、game/client/prediction.cpp
  • 共享移动:game/shared/gamemovement.cpp

1. 端到端时序#

2. CUserCmd#

核心字段:命令号、客户端 tick、视角、方向/前后/侧向/上下移动量、按键位、冲量、武器选择、随机种子与服务端随机种子、鼠标增量、hasbeenpredicted 等:game/shared/usercmd.h:64、game/shared/usercmd.h:250-279。客户端维护一个大小为 MULTIPLAYER_BACKUP(150)的环形缓冲:game/client/c_baseentity.h:111。

3. 客户端生成命令#

CInput::CreateMove:

  1. 取 sequence_number % MULTIPLAYER_BACKUP 槽位并重置,记录命令号与当前 tick:game/client/in_main.cpp:1350-1361。
  2. 游戏激活时计算视角、侧向/上下/前后移动并按速度键缩放,再叠加鼠标/手柄输入;未激活时只清空鼠标累积,避免恢复时视角跳变:game/client/in_main.cpp:1365-1412。
  3. 写入冲量、武器选择、按键位与视角,然后让 ClientMode::CreateMove 有最后修改机会:game/client/in_main.cpp:1414-1480。CS:GO 玩家类也覆写了 CreateMove:game/client/cstrike15/c_cs_player.cpp:5706。

WriteUsercmdDeltaToBuffer 把命令相对上一条(第一条相对全零命令)做差量编码;取不到旧命令时退化为相对空命令:game/client/in_main.cpp:1654-1708。GetUserCmd 通过比较命令号识别已被覆盖的槽位:game/client/in_main.cpp:1715-1727。发送节奏与扣包逻辑见 网络通道与协议。

CVerifiedUserCmd:发送前的本地校验#

客户端为每个分屏玩家额外维护一份同样大小(150)的 CVerifiedUserCmd 环形缓冲,每项是命令副本加 CRC32:game/client/input.h:44-49、game/client/in_main.cpp:2039-2040。

  1. CreateMove 开头取得与命令同槽位的校验项,在所有修改(包括 ClientMode::CreateMove)完成后保存命令副本与 GetChecksum():game/client/in_main.cpp:1356、game/client/in_main.cpp:1506-1507。
  2. 校验和覆盖命令号、tick、视角、瞄准方向、三个移动量、按键、冲量、武器选择/子类型、随机种子与鼠标增量;不含 hasbeenpredicted 等字段;其他游戏的扩展字段受 INFESTED_DLL/PORTAL2 等宏控制:game/shared/usercmd.h:206-245。
  3. WriteUsercmdDeltaToBuffer 在编码前对“基准命令”和“目标命令”都调用 ValidateUserCmd:若当前内容的 CRC 与保存值不同,就用保存的副本覆盖:game/client/in_main.cpp:1676-1692、game/client/in_main.cpp:1638-1646。

因此它防的是“CreateMove 之后、发送之前”命令缓冲在内存中被改写的情况;CRC 本身不写入网络(WriteUsercmd 只写命令字段:game/shared/usercmd.cpp:125),服务端不做对应校验(检索 game/server、engine/sv_client.cpp 未见使用,推断)。

观察:ValidateUserCmd 使用 GetPerUser() 的默认参数(当前活动分屏槽位),而不是调用方传入的 nSlot:game/client/in_main.cpp:1642-1644、game/client/input.h:374。分屏时是否会拿错校验数组未验证。

4. 服务端接收:校验与排队#

  1. 引擎层:客户端未 active 时忽略;同一服务器 tick 只接受一条 clc_Move(源码注释视为作弊);丢包数按 sv_max_dropped_packets_to_process 截断;暂停时仍执行但不移动;解码溢出则断开客户端:engine/sv_client.cpp:253-315。
  2. 游戏层 CServerGameClients::ProcessUsercmds:命令总数越界直接置溢出;逐条解码并修正非法视角、非有限或超出 sv_max_usercmd_move_magnitude 的移动量;然后交给玩家:game/server/gameinterface.cpp:3565-3651。
  3. 玩家层 CBasePlayer::ProcessUsercmds:把命令放入新的 CCommandContext,用 server_random_seed 覆盖随机种子(可选改为基于服务器时间),暂停时清空按键与移动;暂停时立即模拟,否则等到 GameFrame:game/server/player.cpp:3720-3783。

注意:命令不是在收到时执行,而是在下一次 GameFrame → Physics_RunThinkFunctions 中执行(见 主循环与 Tick)。

5. 服务端执行#

CBasePlayer::PhysicsSimulate:

  • 每 tick 只模拟一次;根据排队命令计算需要模拟的 tick 数并调整 m_nTickBase:game/server/player.cpp:3450-3475、game/server/player.cpp:3321、game/server/player.cpp:3354。
  • 汇总所有上下文中的可执行命令:丢包少于 24 时,用“最后已知命令”或备份命令补齐丢失的部分:game/server/player.cpp:3496-3545。
  • 单帧可执行数量受 sv_maxusrcmdprocessticks(默认 16)限制,超出部分滚到下一次;同时维护“可用于处理命令的时间预算”:game/server/player.cpp:129、game/server/player.cpp:3553-3595。
  • 逐条 PlayerRunCommand,期间抑制会被客户端预测的事件(避免重复播放):game/server/player.cpp:3598-3626;没有命令但预算溢出时,用上一条命令执行一次空命令:game/server/player.cpp:3639-3660。

CBasePlayer::PlayerRunCommand 处理冻结与蹲伏开关后调用 CPlayerMove::RunCommand:game/server/player.cpp:3910-3957。RunCommand 的关键行为:

步骤证据
时间预算不足时直接忽略本条命令(Bot/HLTV 除外)并节流告警game/server/player_command.cpp:320-338
把 gpGlobals->curtime 临时改为玩家 m_nTickBase * TICK_INTERVALgame/server/player_command.cpp:342-346
应用强制/禁用按键、武器选择、冲量、按键状态、视角game/server/player_command.cpp:348-425
RunPreThink → RunThink → SetupMove → ProcessMovement → FinishMovegame/server/player_command.cpp:427-450
恢复服务器时间后处理碰撞冲击,再切回玩家时间执行 RunPostThinkgame/server/player_command.cpp:459-470

武器开火发生在 PostThink 阶段:CBasePlayer::ItemPostFrame 调用当前武器的 ItemPostFrame(客户端只对可预测武器调用):game/shared/baseplayer_shared.cpp:341-350。

6. 客户端预测#

触发#

每帧 tick 循环之后,引擎调用 CL_RunPrediction:没有有效快照或 demo 跳转时不预测;否则把“服务器已确认的最后命令号”和“已发出+被扣住的最新命令号”交给 client.dll:engine/host.cpp:4334、engine/cl_pred.cpp:41-75、engine/cl_pred.cpp:27-35。

收到新快照时#

FRAME_NET_UPDATE_POSTDATAUPDATE_END 阶段调用 PostEntityPacketReceived,让可预测实体保存“原始网络状态”:game/client/cdll_client_int.cpp:3496-3499。PostNetworkDataReceived 累加服务器已确认的命令数,并对比预测结果与网络结果以检测预测误差:game/client/prediction.cpp:600-766。

重放#

  1. Update 保存全局时间变量,对每个分屏玩家执行 _Update,结束后恢复:game/client/prediction.cpp:1948-1985。
  2. _Update:未开启 cl_predict 时只同步视角;否则作废可预测实体的骨骼缓存、移除过期的纯预测实体,收到新世界状态时恢复到网络原始状态,然后 PerformPrediction:game/client/prediction.cpp:1990-2055。
  3. PerformPrediction 从“已确认命令 + i”开始逐条取命令,直到最新命令;超过 150 条或取不到命令视为严重延迟并放弃预测;每条执行 RunSimulation、存储预测结果、标记 hasbeenpredicted:game/client/prediction.cpp:1786-1938。
  4. CPrediction::RunCommand 与服务端 CPlayerMove::RunCommand 结构一致(时间设为 tickbase、PreThink、Think、SetupMove、ProcessMovement、FinishMove、PostThink),最后自增 m_nTickBase:game/client/prediction.cpp:1037-1151。

“首次预测”标志 m_bFirstTimePredicted 用于让声音、特效等只在第一次预测某条命令时播放,重放时不重复:game/client/prediction.cpp:1855-1856、game/client/prediction.cpp:2076-2080。

预测数据与误差(概要)#

  • 参与回滚/重放的字段由客户端的预测数据表(BEGIN_PREDICTION_DATA / DEFINE_PRED_FIELD)声明,由 CPredictionCopy 在实体与“原始状态 / 150 个中间槽位”之间拷贝和比较。
  • 收到快照时,服务器已确认命令对应槽位中的预测值与网络值比较,超出容差即记为预测误差;默认 cl_pred_optimize 2 下,若无误差则不重放旧命令,只预测新命令。
  • 本地玩家原点误差会通过 cl_smooth/cl_smoothtime 在摄像机上线性平滑。

以上细节与证据见 客户端预测内部机制。

7. 共享的移动入口#

两端最终都调用 g_pGameMovement->ProcessMovement;CGameMovement::PlayerMove 先检查参数、递减计时器、分类位置、处理蹲伏,再按移动类型分派(行走 FullWalkMove、梯子、飞行、旁观等):game/shared/gamemovement.cpp:4994-5123。CS:GO 的定制见 移动。

约束与边界#

  • 确定性前提:预测正确的前提是两端执行同一份共享代码、相同的 tickbase 时间与相同的随机种子;ProcessUsercmds 中服务端用自己的 server_random_seed 覆盖客户端种子(可配置),任何不对称都会表现为预测误差(推断)。
  • 单 tick 单包:服务器每个 tick 只接受一个 clc_Move:engine/sv_client.cpp:260-266。
  • 重放并非每帧都发生:第 6 节“重放”描述的是有误差或优化关闭时的路径;无误差时的跳过逻辑见 客户端预测内部机制。
  • CVerifiedUserCmd 只是客户端本地的一致性校验,不构成服务端反作弊。
  • 未展开:命令位流 WriteUsercmd/ReadUsercmd 的逐字段编码格式、分屏玩家的命令合并发送。