CD Code Digest Hub /CS:GO

输入与命令:从按键到 CUserCmd 与命令缓冲

37 分钟阅读 13 个章节 3 张图

本页沿着“物理按键 → 引擎输入事件 → 按键绑定 → 控制台命令 → 客户端按钮状态”这条链解释 CS:GO 的输入管线,并说明所有控制台命令共用的命令缓冲(Cbuf_*)如何按来源做权限检查、客户端发给服务器的字符串命令如何被分派与限流,以及配置文件按什么顺序执行。它是 输入命令与预测 的上游:那一页从 CInput::CreateMove 写好 CUserCmd 开始,本页解释 CUserCmd::buttons 里的位从哪里来。

相关页面:引擎总览 · 主循环与 Tick · 输入命令与预测 · 基础库层(ConVar 注册与标志) · VGUI 与本地化 · 服务器运维面 · 比赛模式(游戏模式 cfg)

相关源码文件#

  • 设备采集:inputsystem/inputsystem.cpp、inputsystem/inputsystem.h
  • 消息泵与事件分发:engine/sys_dll2.cpp、engine/sys_mainwind.cpp、engine/keys.cpp
  • 客户端按钮状态:game/client/in_main.cpp、game/client/kbutton.h、game/client/in_mouse.cpp
  • 命令缓冲与执行:engine/cmd.cpp、engine/cmd.h、tier1/commandbuffer.cpp、public/tier1/convar.h、engine/cvar.cpp
  • 网络字符串命令:engine/sv_client.cpp、engine/baseclient.cpp、engine/baseclientstate.cpp、engine/cdll_engine_int.cpp、engine/vengineserver_impl.cpp、game/server/client.cpp
  • 配置执行:engine/host.cpp、game/shared/multiplay_gamerules.cpp、game/server/gameinterface.cpp、game/server/game.cpp

1. 全景#

证据:窗口过程把消息转成事件 inputsystem/inputsystem.cpp:1600-1605、inputsystem/inputsystem.cpp:588-599;消息泵在 PeekMessageW 之后调用 PollInputState 与 DispatchAllStoredGameMessages engine/sys_dll2.cpp:982-1025;分发函数把按键类事件交给 Key_Event engine/sys_mainwind.cpp:397-409、engine/sys_mainwind.cpp:495-503;过滤链与引擎绑定处理 engine/keys.cpp:1302-1366;绑定文本以 kCommandSrcUserInput 入缓冲 engine/keys.cpp:1153-1170;+attack 注册为 IN_AttackDown game/client/in_main.cpp:1955-1956、game/client/in_main.cpp:738-741;CreateMove 写按钮位 game/client/in_main.cpp:1442-1443。

2. 设备采集:inputsystem#

CInputSystem 是一个 AppSystem(启动器模块表中加载,见 接口工厂与 AppSystem),负责把操作系统输入转成统一的 InputEvent_t 事件流。

环节行为证据
挂接窗口AttachToWindow 用 SetWindowLongPtrW 子类化游戏窗口的窗口过程,并注册原始鼠标输入(Raw Input)inputsystem/inputsystem.cpp:386-419
窗口消息WindowProc 在禁用时直接链回原窗口过程;WM_INPUT 只累加原始鼠标位移到 m_mouseRawAccumX/Yinputsystem/inputsystem.cpp:1600-1605、inputsystem/inputsystem.cpp:1858-1876
入队PostEvent 写入 m_InputState[m_bIsPolling]:泵消息期间写 INPUT_STATE_QUEUED,轮询期间写 INPUT_STATE_CURRENTinputsystem/inputsystem.cpp:588-599、inputsystem/inputsystem.h:216-217
每帧轮询PollInputState 先把排队状态(含事件)复制到当前状态,再 SampleDevices,最后把当前状态(不含事件)复制回排队状态以清空队列inputsystem/inputsystem.cpp:1047-1086、inputsystem/inputsystem.cpp:666-686
手柄与控制器SampleDevices 在 joystick_force_disabled 为假时轮询 XInput 与通用摇杆,并始终轮询 Steam Controllerinputsystem/inputsystem.cpp:1123-1141
读取GetEventCount/GetEventData 返回当前状态的事件数组inputsystem/inputsystem.cpp:1314-1322

原始鼠标位移不走事件流:client.dll 的 m_rawinput(默认 1)开启时,CInput 直接读取 GetRawMouseAccumulators,并跳过基于光标回中的 AccumulateMouse:game/client/in_mouse.cpp:120、game/client/in_mouse.cpp:365-378、game/client/in_mouse.cpp:614-629。

3. 引擎侧:消息泵与事件分发#

客户端主循环每轮先 PumpMessages 再 eng->Frame()(见 客户端启动链):engine/sys_dll2.cpp:1126-1155。PumpMessages 的顺序是:

  1. PeekMessageW 取出全部窗口消息;启用 Scaleform 时,键盘/鼠标按键与 IME 消息先交给 Scaleform 的 IME 预处理,再 TranslateMessage/DispatchMessageW(后者会进入 CInputSystem::WindowProc):engine/sys_dll2.cpp:982-1003。
  2. PollInputState( GetBaseLocalClient().IsActive() ) 采样设备并固化本帧事件:engine/sys_dll2.cpp:1010-1011。
  3. game->DispatchAllStoredGameMessages() 逐个分发事件:engine/sys_dll2.cpp:1020、engine/sys_mainwind.cpp:495-503。

CGame::DispatchInputEvent 按事件类型分流:engine/sys_mainwind.cpp:397-492

事件类型去向(先到先得)
IE_ButtonPressed/DoubleClicked/Released、IE_KeyTyped、IE_KeyCodeTyped全部交给 Key_Event(第 4 节)
IE_AnalogValueChanged(摇杆轴)Scaleform → VGUI surface → client.dll HandleGameUIEvent
IE_AnalogValueChanged(鼠标轴/滚轮)VGUI surface → Scaleform(vgui_panel_active 为真时跳过,避免控制台下方的 Scaleform 抢输入)→ HandleGameUIEvent
其他(窗口激活、关闭、尺寸变化等)VGUI surface → Scaleform → g_GameMessageHandlers 表中的 CGame::HandleMsg_*

由于按键事件在 eng->Frame() 之前分发,而绑定只是把文本放入命令缓冲,+attack 之类的命令要等到本帧 _Host_RunFrame 中的 Cbuf_Execute 才真正执行(推断,依据是上述调用顺序与第 7 节的执行时机)。

4. Key_Event:优先级链与“松开归属”#

引擎为每个按键码维护一份 KeyInfo_t:绑定字符串、按下状态,以及“松开事件应交给谁”(m_nKeyUpTarget):engine/keys.cpp:40-58、engine/keys.cpp:79。

Key_Event 的前置处理:DEDICATED 构建直接返回;按键盘/手柄确定分屏玩家槽;重复的按下/松开被丢弃;按键捕获模式(绑定界面用)优先;VGUI 未初始化时不处理;无论谁消费都先同步 VGUI 的按键状态;加载画面期间忽略 ESC:engine/keys.cpp:1243-1300。随后按下表顺序尝试,任一环节返回“已消费”即停止:

顺序目标处理函数与条件证据
1工具框架HandleToolKey:最上层工具的 TrapKey;不接收字符事件engine/keys.cpp:963-971、engine/keys.cpp:1303-1304
2VGUIHandleVGuiKey → EngineVGui()->Key_Event;ESC 和反引号按下不走这一步engine/keys.cpp:977-988、engine/keys.cpp:1310-1314
3ScaleformHandleScaleformKey(INCLUDE_SCALEFORM);ESC/反引号仅在 CS:GO 聊天框或绑定菜单打开时才先给 Scaleformengine/keys.cpp:1316-1327
4GameUIHandleGameUIKey → g_ClientDLL->HandleGameUIEventengine/keys.cpp:1087-1093、engine/keys.cpp:1331-1333
5客户端HandleClientKey → IN_KeyEvent → CInput::KeyEvent → ClientMode::KeyInput;返回 0 表示吞掉;字符事件不交给客户端engine/keys.cpp:1068-1081、game/client/cdll_client_int.cpp:2127-2130、game/client/in_main.cpp:783-799
6ESC 回退仅 ESC:控制台窗口、服务器浏览器打开或正在播放电影时跳过 Scaleform,再给 VGUIengine/keys.cpp:1341-1363
7引擎绑定HandleEngineKey:查绑定表,生成命令文本engine/keys.cpp:1100-1171、engine/keys.cpp:1365-1366

松开归属:FilterKey 在某个目标消费了按下事件时,把该键的 m_nKeyUpTarget 记为这个目标;之后该键的松开与字符事件只交给同一目标(未被认领时任何目标都可接收),目标收到属于自己的松开事件即视为消费:engine/keys.cpp:1180-1218、engine/keys.cpp:94-99。这保证“按下进了 UI、松开却落到游戏”或反之的情况不会出现,从而 +attack/-attack 总是成对(推断,依据是该归属逻辑)。

HandleEngineKey 生成的命令:

  • 未绑定的手柄键只打印提示;空绑定不处理:engine/keys.cpp:1109-1127。
  • 非 DEDICATED 构建下,未连接服务器时只有 toggleconsole 绑定生效:engine/keys.cpp:1129-1137。
  • 松开:只有以 + 开头的绑定产生 -xxx <键码>;按下:+ 绑定产生 +xxx <键码>,其余绑定原样入队;按住修饰键时吞掉 toggleconsole。所有文本都以 kCommandSrcUserInput 加入当前分屏玩家的缓冲:engine/keys.cpp:1139-1170。

5. 绑定表#

  • Key_SetBinding:手柄键码先映射到当前分屏槽,替换旧字符串,并通知 Scaleform(UpdateBindingForButton)与 client.dll(OnKeyBindingChanged):engine/keys.cpp:114-155。
  • bind <key> [command]:只给键名时打印当前绑定;绑定 ESC 时强制改为 cancelselect:engine/keys.cpp:269-328。
  • Key_WriteBindings 把绑定以 bind "键" "命令" 写入配置缓冲(config.cfg 的来源之一):engine/keys.cpp:530-558。
  • 读取配置后若一个绑定都没有则加载默认绑定,并保证 ESC 为 cancelselect、toggleconsole 至少绑定在反引号:engine/host.cpp:2060-2075。

6. +attack 到 CUserCmd::buttons#

client.dll 用 kbutton_t 表示一个“按钮命令”的状态,每个分屏玩家一份:down[2] 记录最多两个按住它的键码,state 的位 1/2/4 分别表示“按住 / 本帧按下脉冲 / 本帧松开脉冲”:game/client/kbutton.h:16-29、game/client/in_main.cpp:803-822。

步骤行为证据
+attack <键码>KeyDown 记录键码(同一键重复按下被忽略,第三个键被拒绝),未按住时置按住位与按下脉冲(state |= 1+2)game/client/in_main.cpp:424-453
-attack <键码>KeyUp 移除对应键码;两个键都松开才清除按住位并置松开脉冲;无参数时强制全部松开game/client/in_main.cpp:460-490
每 tick 汇总CalcButtonBits:state & 3 非零即置位(因此一帧内“按下又松开”也会被记入);bResetState 时清除按下脉冲,被 ClearInputButton 标记的按钮连按住位一起清除game/client/in_main.cpp:1738-1761
附加位GetButtonBits 还合并蹲/走切换键、IN_CANCEL、HUD 武器位,最后去掉被要求重新按下的位game/client/in_main.cpp:1771-1858
写入命令CreateMove 用 GetButtonBits(true) 写 cmd->buttons(带重置);帧间的 ExtraMouseSample 用 GetButtonBits(false)(不重置)game/client/in_main.cpp:1442-1443、game/client/in_main.cpp:1246、game/client/in_main.cpp:1320

之后的命令编码、发送与服务端执行见 输入命令与预测。

7. 命令缓冲:Cbuf_*#

缓冲划分:每个分屏玩家一个缓冲,外加一个 CBUF_SERVER:engine/cmd.h:149-156。全部缓冲由一个互斥量保护:engine/cmd.cpp:63-66。

命令来源 cmd_source_t 决定后续权限检查:public/tier1/convar.h:85-122

来源典型产生者限制
kCommandSrcCode引擎/游戏代码直接入队基本不限制
kCommandSrcClientCmdclient.dll 调用 engine->ClientCmd(且 m_bRestrictClientCommands 为真)只能执行带 FCVAR_CLIENTCMD_CAN_EXECUTE 的命令
kCommandSrcUserInput控制台输入、按键绑定不限制;转发给服务器时客户端限流 16 条/秒
kCommandSrcNetClient服务器收到的 net_StringCmd见第 9 节
kCommandSrcNetServer客户端收到服务器下发的 net_StringCmd只能执行带 FCVAR_SERVER_CAN_EXECUTE 的命令
kCommandSrcDemoFiledemo 回放注释明确说明未按标志限制

入队与执行:

  • Cbuf_AddText 加锁后追加,溢出时只打印 buffer overflow 并丢弃;Cbuf_InsertText 只允许在缓冲正在处理时调用(别名与 exec 用它把展开内容插到当前位置):engine/cmd.cpp:185-207。
  • Cbuf_Execute:不在主线程时告警;逐个缓冲执行,正在处理中的缓冲被跳过(防重入);为玩家缓冲设置对应分屏槽;每条命令经 Cmd_ExecuteCommand 执行,未带 FCVAR_DONTRECORD 的命令同时写入正在录制的 demo:engine/cmd.cpp:232-246、engine/cmd.cpp:252-304。
  • 执行时机:_Host_RunFrame 每帧(所有构建)执行一次 Cbuf_Execute;非 DEDICATED 构建在每个 tick 的输入阶段(ClientDLL_ProcessInput 之后、CL_Move 之前)再执行一次:engine/host.cpp:4112-4146、engine/host.cpp:3256、engine/host.cpp:3276-3292。此外 RCON、ServerExecute 等路径会就地调用 Cbuf_Execute(例如 engine/vengineserver_impl.cpp:962-965)。

8. Cmd_ExecuteCommand:分派与权限检查#

证据:执行标记与别名 engine/cmd.cpp:935-959;服务器下发命令过滤 engine/cmd.cpp:869-895、engine/cmd.cpp:964-968;ClientCmd 来源过滤 engine/cmd.cpp:898-922、engine/cmd.cpp:971-973;FCVAR_GAMEDLL 命令在监听服务器上设置“命令发起者”、在仅作客户端时转发 engine/cmd.cpp:975-1021;作弊、单人、开发命令检查与分派 engine/cmd.cpp:1024-1062;ConVar 分支与未知命令转发 engine/cmd.cpp:1066-1083。

各 FCVAR_* 标志在哪里生效:

标志ConCommand(Cmd_ExecuteCommand)ConVar(CCvarUtilities::IsCommand)
FCVAR_CHEAT需要 CanCheat()(发行构建即 sv_cheats);服务器以 FCVAR_SERVER_CAN_EXECUTE 下发的除外:engine/cmd.cpp:1024-1044、engine/gl_cvars.h:30-43多人游戏且不能作弊时拒绝;HLTV 客户端与 demo 回放例外:engine/cvar.cpp:437-453
FCVAR_SPONLY多人游戏拒绝:engine/cmd.cpp:1046-1053已连接到多人服务器时拒绝:engine/cvar.cpp:401-415
FCVAR_DEVELOPMENTONLY当作未知命令:engine/cmd.cpp:1055-1059不可见:engine/cvar.cpp:390-392
FCVAR_NOT_CONNECTED—已连接时拒绝,除非是 client.dll 允许修改的 FCVAR_USERINFO:engine/cvar.cpp:417-435
FCVAR_REPLICATED—本机不是服务器但已连接时,客户端控制台不能修改(demo 回放除外):engine/cvar.cpp:455-475

转发到服务器:Cmd_ForwardToServer 去掉 cmd 前缀;demo 回放时交给 client.dll;对 kCommandSrcUserInput 来源按每秒 16 条限流(注释说明是为了避免触发服务器端配额踢人),然后 SendStringCmd:engine/cmd.cpp:1105-1155、engine/cmd.cpp:76-85。

9. 客户端 → 服务器:net_StringCmd 的分派与限制#

证据与细节:

  • 引擎把 net_StringCmd 绑定到 CBaseClient::NETMsg_StringCmd,它调用虚函数 ExecuteStringCommand;基类只处理 demorestart:engine/baseclient.cpp:975、engine/baseclient.cpp:1038-1040、engine/baseclient.cpp:1575-1588。
  • CGameClient::ExecuteStringCommand:以 kCommandSrcNetClient 分词;sv_quota_stringcmdspersecond(默认 40)为 0 时静默拒绝全部字符串命令,超出配额则以 DoS 理由断开客户端:engine/sv_client.cpp:1513-1550、engine/sv_client.cpp:67。
  • 引擎白名单 s_clcommands(status、pause、setpause、unpause、ping、ss_connect 等)直接进入 Cmd_ExecuteCommand( CBUF_SERVER, … ):engine/sv_client.cpp:88-110、engine/sv_client.cpp:1352-1364、engine/sv_client.cpp:1552-1556。
  • 带 FCVAR_GAMEDLL 的 ConCommand:多人且不能作弊时拒绝作弊命令;否则除非 sv_allow_legacy_cmd_execution_from_client(默认 0)开启、命令带 FCVAR_GAMEDLL_FOR_REMOTE_CLIENTS,或发起者是监听服务器的本机玩家,一律忽略;多人拒绝 FCVAR_SPONLY;通过后设置命令发起者并 Cmd_Dispatch:engine/sv_client.cpp:1558-1601、engine/sv_client.cpp:73。例如 say、say_team 就是带 FCVAR_GAMEDLL_FOR_REMOTE_CLIENTS 的 server.dll ConCommand:game/server/client.cpp:914、game/server/client.cpp:934。
  • 其余命令(不是 ConCommand 的游戏命令,例如 CS:GO 玩家类识别的 jointeam、buy,以及客户端误发的 ConVar 名)交给服务器插件,插件未返回 PLUGIN_STOP 时进入 CServerGameClients::ClientCommand → ::ClientCommand → 规则对象 ClientCommand(CCSGameRules 先处理少数规则命令,再交给 CCSPlayer::ClientCommand、基类规则与 Bot 管理器),都不认识时回显 Unknown command:engine/sv_client.cpp:1602-1607、engine/sv_plugin.cpp:556-570、game/server/gameinterface.cpp:3236-3240、game/server/client.cpp:1775-1781、game/server/client.cpp:1854-1868、game/shared/cstrike15/cs_gamerules.cpp:5106-5115、game/server/cstrike15/cs_player.cpp:8962、game/server/cstrike15/cs_player.cpp:9152。因此远程客户端不能通过字符串命令直接设置服务器 ConVar(推断,依据是该分支结构中没有 ConVar 设置路径)。

10. 服务器 → 客户端:下发命令#

  • server.dll 调用 engine->ClientCommand(edict, fmt, …) 时,引擎校验实体编号后构造 CNETMsg_StringCmd 发给该客户端:engine/vengineserver_impl.cpp:977-1002。
  • 客户端 InternalProcessStringCmd:未开启限制(m_bRestrictServerCommands,初始为真)或本机正在运行服务器时以 kCommandSrcCode 入队;否则以 kCommandSrcNetServer 入队,由第 8 节的 FCVAR_SERVER_CAN_EXECUTE 检查把关:engine/baseclientstate.cpp:2368-2389、engine/baseclientstate.cpp:494。
  • client.dll 自己调用的 ClientCmd 在 m_bRestrictClientCommands 为真时使用 kCommandSrcClientCmd;ClientCmd_Unrestricted 按是否来自控制台/按键选择 kCommandSrcUserInput 或 kCommandSrcCode:engine/cdll_engine_int.cpp:847-895。

11. 配置文件执行顺序#

exec <file> [pathID] 的规则:路径拼成 //<pathID>/cfg/<file>(默认 path ID 为 *)并补 .cfg 扩展名;拒绝非法路径、非法扩展名与超过 1 MB 的文件;autoexec.cfg、joystick.cfg、game.cfg 缺失时不报错;文件内容逐行立即执行到当前正在处理的缓冲中(而不是追加到末尾);execwithwhitelist 只执行白名单中的命令:engine/cmd.cpp:500-657、engine/cmd.cpp:659-672。

时机执行内容条件证据
创建窗口之前Host_ReadPreStartupConfiguration 只从 config.cfg 中抽取少数影响启动的 ConVar(声音、UI 等)先找 usrlocal,再找 modengine/sys_dll2.cpp:1341-1346、engine/host.cpp:2191-2240
Host_Init 中Host_ReadConfiguration:可选从 Steam Cloud 拉取,然后 exec config.cfg usrlocal / exec config.cfg mod / exec config_default.cfg(三选一),立即 Cbuf_Execute,再补默认绑定;使用默认配置时立刻回写 config.cfg仅非 DEDICATED 构建,且非专用服务器engine/host.cpp:5709-5712、engine/host.cpp:2003-2006、engine/host.cpp:2023-2058、engine/host.cpp:2079-2088
Host_Init 末尾exec valve.rc;若 //mod/cfg/modsettings.cfg 存在则 exec modsettings.cfg mod所有构建;只入队,下一次 Cbuf_Execute 时执行engine/host.cpp:5742-5756
valve.rc 之后autoexec.cfg、命令行 +xxx 参数(stuffcmds)证据不足:两者都应由 valve.rc 触发,但仓库不含任何 .rc/.cfg 内容;代码侧只有 stuffcmds 命令实现、对缺失 autoexec.cfg 的静默处理,以及“在命令行与 autoexec.cfg 解析后初始化网络”的注释engine/cmd.cpp:357-420、engine/cmd.cpp:570-580、engine/host.cpp:4140-4144
每次 GameInit(换图、重开)exec game.cfg(Xbox/PS3 专用服务器换成对应文件名),随后 ServerExecute—game/server/gameinterface.cpp:1066-1081
规则对象构造专用服务器 exec <servercfgfile>(默认 server.cfg),监听服务器 exec <lservercfgfile>(默认 listenserver.cfg)—game/shared/multiplay_gamerules.cpp:275-311、game/server/game.cpp:31-35
游戏模式切换ExecGameTypeCfg 按 game_type/game_mode 执行模式配置见 比赛模式engine/sv_main.cpp:2847-2850
进程退出Host_WriteConfiguration( -1, "config.cfg" ) 保存绑定与归档 ConVar非 DEDICATEDengine/host.cpp:6437-6440、engine/host.cpp:1546

专用服务器没有按键与绑定:控制台文本由 dedicated 的 ProcessConsoleInput 读出,经 CDedicatedServerAPI::AddConsoleText 进入 CBUF_SERVER:dedicated/sys_common.cpp:103-120、engine/sys_dll2.cpp:2898-2901。

约束与边界#

  • 单线程约束:Cbuf_Execute 只应在主循环线程调用,否则打印告警并在调试器下中断:engine/cmd.cpp:256-260。
  • wait 命令在本快照中很可能不可用(推断):CCommandBuffer 只有在 IsWaitEnabled() 为真时才把 wait 当作延迟处理 tier1/commandbuffer.cpp:235-241;但 m_bWaitEnabled 既不在构造函数中初始化,也没有任何调用者执行 SetWaitEnabled tier1/commandbuffer.cpp:31-40、public/tier1/commandbuffer.h:78-79;sv_allow_wait_command 只被定义和 extern 声明,没有读取点 engine/baseserver.cpp:222、engine/cmd.cpp:48。静态缓冲对象零初始化意味着该开关为假,wait 会落入普通命令查找。
  • demo 命令不受标志限制:kCommandSrcDemoFile 的注释承认 demo 中的命令可能来自不可信来源但尚未加限制:public/tier1/convar.h:113-118。
  • 配额两端不一致:客户端只对用户输入限流 16 条/秒,服务端配额默认 40 条/秒且超出即断开;由代码发出的转发命令不受客户端限流:engine/cmd.cpp:76-85、engine/sv_client.cpp:1529-1550。
  • 条件编译:Key_Event 与 Cmd_ForwardToServer 在 DEDICATED 中为空实现;Scaleform 相关分支依赖 INCLUDE_SCALEFORM;ESC/反引号的 Scaleform 特例依赖 CSTRIKE15:engine/keys.cpp:1245-1247、engine/cmd.cpp:1107-1108、engine/keys.cpp:1322-1329。
  • 本页未展开:手柄/Steam Controller 的轴映射与死区、IME 事件、CInputStackSystem 的输入上下文栈(inputsystem/inputstacksystem.cpp:30)、分屏输入分配、CInput 的鼠标灵敏度与加速、CVerifiedUserCmd。