输入命令、服务端执行与客户端预测
玩家的一切操作都被压缩成一串 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:
- 取
sequence_number % MULTIPLAYER_BACKUP槽位并重置,记录命令号与当前 tick:game/client/in_main.cpp:1350-1361。 - 游戏激活时计算视角、侧向/上下/前后移动并按速度键缩放,再叠加鼠标/手柄输入;未激活时只清空鼠标累积,避免恢复时视角跳变:
game/client/in_main.cpp:1365-1412。 - 写入冲量、武器选择、按键位与视角,然后让
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。
CreateMove开头取得与命令同槽位的校验项,在所有修改(包括ClientMode::CreateMove)完成后保存命令副本与GetChecksum():game/client/in_main.cpp:1356、game/client/in_main.cpp:1506-1507。- 校验和覆盖命令号、tick、视角、瞄准方向、三个移动量、按键、冲量、武器选择/子类型、随机种子与鼠标增量;不含
hasbeenpredicted等字段;其他游戏的扩展字段受INFESTED_DLL/PORTAL2等宏控制:game/shared/usercmd.h:206-245。 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. 服务端接收:校验与排队#
- 引擎层:客户端未 active 时忽略;同一服务器 tick 只接受一条
clc_Move(源码注释视为作弊);丢包数按sv_max_dropped_packets_to_process截断;暂停时仍执行但不移动;解码溢出则断开客户端:engine/sv_client.cpp:253-315。 - 游戏层
CServerGameClients::ProcessUsercmds:命令总数越界直接置溢出;逐条解码并修正非法视角、非有限或超出sv_max_usercmd_move_magnitude的移动量;然后交给玩家:game/server/gameinterface.cpp:3565-3651。 - 玩家层
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_INTERVAL | game/server/player_command.cpp:342-346 |
| 应用强制/禁用按键、武器选择、冲量、按键状态、视角 | game/server/player_command.cpp:348-425 |
RunPreThink → RunThink → SetupMove → ProcessMovement → FinishMove | game/server/player_command.cpp:427-450 |
恢复服务器时间后处理碰撞冲击,再切回玩家时间执行 RunPostThink | game/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。
重放#
Update保存全局时间变量,对每个分屏玩家执行_Update,结束后恢复:game/client/prediction.cpp:1948-1985。_Update:未开启cl_predict时只同步视角;否则作废可预测实体的骨骼缓存、移除过期的纯预测实体,收到新世界状态时恢复到网络原始状态,然后PerformPrediction:game/client/prediction.cpp:1990-2055。PerformPrediction从“已确认命令 + i”开始逐条取命令,直到最新命令;超过 150 条或取不到命令视为严重延迟并放弃预测;每条执行RunSimulation、存储预测结果、标记hasbeenpredicted:game/client/prediction.cpp:1786-1938。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的逐字段编码格式、分屏玩家的命令合并发送。