ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

HandheldCompanion 之 steam-hidapi.net:.NET 直读 Steam Deck 手柄 HID 报文的完整实现解析

HandheldCompanion 之 steam-hidapi.net:.NET 直读 Steam Deck 手柄 HID 报文的完整实现解析
  • 桌面应用
  • 游戏开发

【免费下载链接】HandheldCompanion

A touch optimized GUI to increase your handheld gaming computer experience.

项目地址:https://gitcode.com/gh_mirrors/ha/HandheldCompanion
点击查看免费下载

本篇以 steam-hidapi.net/README.md 为主体,完整保留其安装步骤与示例程序,并结合仓库源码深入拆解这套 .NET HID 库如何打开 Steam Deck 手柄(代号 Neptune)、解析 64/65 字节输入报文、管理 Lizard 模式与触发线性震动,最后给出 HandheldCompanion 主程序对该库的真实集成方式,帮助读者具备"绕过 Steam 输入栈、直接从 HID 层读取手柄"的完整技术能力。

一、库定位:为 Steam Deck 手柄而生的 HID 封装

README.md 将该项目定义为 "Steam Deck Controller (Neptune) HID Api Library for .Net"。它不是完整的输入重映射引擎,而是一个薄封装层:把 Windows HID 设备上的原始报文解析成结构化的按钮/摇杆状态,并暴露震动、Lizard 模式、序列号等控制接口。

从源码结构看,整个库由三层组成:

  • hidapi.net/HidDevice.cs:底层 HID 封装,负责枚举、打开设备、启动读取线程、发送 feature report;
  • steam-hidapi.net/SteamController.cs:手柄基类,封装打开/关闭、读取序列号、写寄存器、Lizard 模式与震动等公共逻辑;
  • steam-hidapi.net/NeptuneController.cs 与 steam-hidapi.net/GordonController.cs:分别对应 Steam Deck 手柄(Neptune)与 Steam 无线手柄(Gordon)两个具体型号。

项目文件 steam-hidapi.net/steam-hidapi.net.csproj 显示该库目标框架为 .NET Framework 4.8,Debug 与 Release 两个配置均编译为x64平台,这与 README 中"必须 x64"的要求一致。

二、安装与配置步骤(完整继承 README)

README "How to use" 章节给出的接入步骤如下,本文逐条保留并补充说明:

  1. 从 releases 部分下载 .dll 文件;
  2. 拷贝到项目根目录;
  3. 在解决方案资源管理器中右键该文件,选择"属性",将"复制到输出目录"(Copy to Output Directory)设置为"始终复制"(Always copy to output dir);
  4. 确认项目构建目标是 x64 平台。如果不是,进入 Build > Configuration Manager,新增一个 x64 配置;
  5. 参考下方示例程序获取手柄输入并输出到控制台。

补充两点实操细节:

  • 仓库内该库本身是源码工程(steam-hidapi.net目录),因此也可以跳过"下载 dll",直接以项目引用方式接入。主程序 HandheldCompanion/HandheldCompanion.csproj 就是这样做的:<ProjectReference Include="..\steam-hidapi.net\steam-hidapi.net.csproj" />;
  • x64 要求来自 hidapi 原生层:底层通过 P/Invoke 调用hidapi.dll(见 hidapi.net/Native/HidApiNative.cs),x86 进程无法稳定加载 64 位原生依赖。

三、README 示例程序:读取手柄输入并输出到控制台

README 给出的完整示例如下(原文照录,用于说明事件订阅、Lizard 模式、设备打开与序列号读取的基本用法):

using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; namespace neptune_hidapi.net { internal class Program { static void Main(string[] args) { NeptuneController controller = new NeptuneController(); controller.OnControllerInputReceived += Controller_OnControllerInputReceived; controller.LizardModeEnabled = true; //Mouse and Keyboard emulation enabled. controller.Open(); //Open the controller device. Console.WriteLine($"Controller Serial: {controller.SerialNumber}"); // Output the serial number. Only available once device has been opened. Console.ReadLine(); } static DateTime lastUpdate = DateTime.Now; private static void Controller_OnControllerInputReceived(object sender, NeptuneControllerInputEventArgs e) { if ((DateTime.Now - lastUpdate).TotalMilliseconds > 100) { Console.CursorTop = 0; Console.CursorLeft = 0; foreach (var btn in e.State.ButtonState.Buttons) { Console.WriteLine($"{btn}: {e.State.ButtonState[btn]} "); } foreach (var axis in e.State.AxesState.Axes) { Console.WriteLine($"{axis}: {e.State.AxesState[axis]} "); } lastUpdate = DateTime.Now; } } } }

示例的关键点:

  • OnControllerInputReceived委托绑定后,手柄每产生一帧输入报文就会触发一次回调;
  • 回调内用 100ms 节流 + 光标回位的方式,把 28 个按钮(Buttons)与 22 个轴向(Axes)的当前值打印成"仪表盘";
  • SerialNumber只有在Open()成功之后才有值——源码中Open()会先调ReadSerialNumber()再启动读取(见 SteamController.cs)。

示例与当前源码的签名差异(重要):README 示例写作早于代码演进,与当前仓库源码相比有两处出入,按当前源码实际编写时应以 NeptuneController.cs 为准:

  • 构造函数不再无参,而是NeptuneController(ushort vid, ushort pid, ushort inputBufferLen, short index)(另有携带devicePath的重载)。Steam Deck 手柄的 VID/PID 为0x28DE / 0x1205,在 Hid/HidEnums.cs 的SCPid.STEAMDECK中定义;inputBufferLen需匹配固件的输入报文长度,主程序实践中会依次尝试 65 与 64(详见第六节);
  • 事件委托是Action<NeptuneControllerInputEventArgs>(没有sender参数),Lizard 模式的运行期切换接口是RequestLizardMode(bool),而LizardModeEnabled只是只读属性。

按当前源码改写后的最小可用版本大致是:

// Steam Deck: VID 0x28DE, PID 0x1205, 输入报文长度 65(新固件)或 64(旧固件) var controller = new NeptuneController(0x28DE, 0x1205, 65, -1); controller.OnControllerInputReceived += e => { bool A = e.State.ButtonState[NeptuneControllerButton.BtnA]; short lx = e.State.AxesState[NeptuneControllerAxis.LeftStickX]; }; controller.Open(); Console.WriteLine($"Serial: {controller.SerialNumber}"); controller.RequestLizardMode(true); // 重新启用系统级鼠标/键盘仿真

四、输入事件链:从 HID 报文到结构化状态

4.1 读取线程与事件分发

Open()的调用链是:SteamController.Open()→HidDevice.OpenDevice()打开设备 →ReadSerialNumber()→_hidDevice.BeginRead()。BeginRead()在 hidapi.net/HidDevice.cs 中启动一个AboveNormal优先级的后台读取线程ReadLoop,循环调用hid_read_timeout,每收到一帧报文就回调OnInputReceived。

Neptune 子类在 NeptuneController.cs 中做报文过滤与分发:

  1. 校验报文头Buffer[0] == 1 && Buffer[1] == 0,否则丢弃;
  2. 按Buffer[2]的事件类型(SCEventType)分支:
    • INPUT_DATA (0x01):Gordon 手柄的普通输入,Deck 上忽略;
    • DECK_INPUT_DATA (0x09):Steam Deck 专属输入帧——把报文通过ToStructure<NCInput>()转成结构体,构造NeptuneControllerInputState后触发OnControllerInputReceived;
    • CONNECT (0x03)/BATTERY (0x04):预留分支,当前未使用。

事件类型定义见 HidEnums.cs。

4.2 NCInput 报文结构

NCInput是一个Pack = 1的 64 字节顺序布局结构体(HidEnums.cs),字节偏移注释清晰:

偏移字段含义
0x00–0x03ptype/_a1/_a2/_a3报文类型与事件头
0x04seq序列号(UInt32)
0x08–0x0A, 0x0B, 0x0D, 0x0Ebuttons0/1/2/3/5/66 个按钮位域字节
0x10–0x16lpad_x/y,rpad_x/y左右触控板坐标(Int16)
0x18–0x1Caccel_x/y/z加速度计
0x1E–0x22gpitch/gyaw/groll陀螺仪
0x24–0x2Aq1–q4姿态四元数
0x2C–0x2Eltrig/rtrigL2/R2 扳机(Int16)
0x30–0x36lthumb_x/y,rthumb_x/y左右摇杆(Int16)
0x38–0x3Alpad_pressure/rpad_pressure触控板按压压力

ToStructure<T>()是 Util/StructUtils.cs 中的扩展方法,内部用GCHandle.Pinned+Marshal.PtrToStructure零拷贝完成字节数组到结构体的转换,性能开销很低。

4.3 按钮位域解码

按钮不是枚举值,而是分布在 6 个字节里的独立 bit。以NCButton0为例(HidEnums.cs):BTN_A = 0b10000000、BTN_X = 0b01000000、BTN_B = 0b00100000、BTN_Y = 0b00010000、BTN_L1/L2/R1/R2占据低 4 位。解码逻辑在 NeptuneControllerInputState.cs:逐位与掩码比较后写入Dictionary<NeptuneControllerButton, bool>。

完整按钮集合(28 个,见 NeptuneControllerEnums.cs):

枚举值枚举值
BtnX/BtnY/BtnA/BtnB1–4BtnL1/BtnR1/BtnL2/BtnR213–16
BtnMenu/BtnOptions/BtnSteam/BtnQuickAccess5–8BtnL4/BtnR4/BtnL5/BtnR517–20
BtnDpadUp/Left/Right/Down9–12触控板按压/触摸(R/L Pad Press/Touch)21–24
摇杆按压/触摸(R/L Stick Press/Touch)25–28

轴向集合(22 个):左右摇杆 X/Y、左右触控板 X/Y、触控板压力、L2/R2、加速度 XYZ、陀螺仪 Roll/Pitch/Yaw 及四元数 Q1–Q4。

状态对象暴露两种访问方式,正好对应 README 示例的两种遍历:

  • ButtonState[NeptuneControllerButton.BtnA]/AxesState[NeptuneControllerAxis.LeftStickX]:索引器,按钮不存在时返回false,轴向不存在时返回0,取值安全;
  • ButtonState.Buttons/AxesState.Axes:枚举全部键,适合"全量打印"这类调试场景。

五、Lizard 模式:系统级键鼠仿真开关

Lizard 模式是 Valve 为手柄提供的"默认鼠标/键盘仿真":开启后 Windows 会把手柄当作带触控板的设备,触控板可直接移动系统光标;关闭后手柄从系统输入链中退隐,只通过 feature report 与你的程序通信。

5.1 实现原理

SetLizardMode位于 SteamController.cs,两条路径都通过WriteSingleCmd发送 feature report 命令:

  • 开启:发送DEFAULT_MAPPINGS (0x85)+DEFAULT_MOUSE (0x8e),恢复默认按键映射与鼠标行为;
  • 关闭:发送CLEAR_MAPPINGS (0x81),再把LPAD_MODE (0x07)与RPAD_MODE (0x08)两个寄存器写为SCLizardMode.OFF (0x07)。

寄存器与命令码的完整定义在 HidEnums.cs(SCPacketType、SCRegister),注释还标明了各寄存器的用途,如RPAD_MARGIN (0x18)用于消除触控板小幅抖动噪声、LED_INTENSITY (0x2d)取值 0–100。

5.2 心跳循环为什么存在

Neptune 与 Gordon 的一个重要差别:Neptune 在Open()后必须维持一个"心跳"任务(NeptuneController.cs)。ConfigureLoop每 1000ms 醒一次,若_lizardDirty或当前处于关闭态就重新下发SetLizardMode(_lizard)。从源码结构看,这是为了应对 Deck 手柄固件可能随时把映射"抢回"默认态的行为——通过周期性重放配置保证 Lizard 状态与请求值一致。

对外切换入口是RequestLizardMode(bool):它只置脏标记并Set()一个AutoResetEvent,让心跳线程在下一拍执行实际写操作,而不是在调用线程里直接写设备,避免与读取线程抢 HID 句柄。Close()时会等待心跳任务退出,并补发最后一次SetLizardMode(_lizard)(NeptuneController.cs),注释明确说明"防止循环未来得及执行最后一次请求"。

六、Feature Report:序列号、寄存器与震动

6.1 发送/接收机制

所有控制面通信都走 feature report。hidapi.net/HidDevice.cs 的RequestFeatureReport先在请求数据前补 1 字节 report ID,调用hid_send_feature_report发送,再用hid_get_feature_report取回响应,全程持有锁;失败(设备无效、报文超长、原生调用返回负值)统一返回null,调用方据此判空。

序列号读取(SteamController.cs)发送{GET_SERIAL (0xae), 0x15, 0x01},从响应偏移 4 处截取 ASCII 字符串;任何异常都静默降级为"XXXXX",保证Open()流程不会因读序列号失败而中断。

6.2 震动:SetHaptic 与 SetHaptic2

库提供两代震动接口:

  • SetHaptic(byte position, ushort amplitude, ushort period, ushort count, sbyte gain)(SteamController.cs):构造SCHapticPacket(0x8f包),参数为幅度、周期、重复次数,gain单位是分贝(源码注释 -24..+6);
  • SetHaptic2(SCHapticMotor position, NCHapticStyle style, sbyte intensity)(NeptuneController.cs):Neptune 专用(0xea包,len = 0xd),NCHapticStyle取Disabled/Weak/Strong,intensity注释为 -7..5 对应约 -2dB..10dB,并附带两个时间戳字段。源码中也坦诚标注该包结构"需要 USBpcap 抓包验证"。

七、Gordon(Steam 无线手柄)的扩展能力

同一套SteamController基类还支撑 Gordon 手柄(有线 PID0x1102、无线0x1142,见SCPid)。GordonController.cs 在 Neptune 之外多出的能力包括:

  • SetGyroscope(bool):通过GYRO_MODE (0x30)寄存器位域(GCGyroMode:TILT_X/TILT_Y/Q/ACCEL/GYRO)组合开启加速度计 + 陀螺仪;
  • SetIdleTimeout(ushort):写IDLE_TIMEOUT (0x32)寄存器(秒);
  • SetLedIntensity(byte):写LED_INTENSITY,超过 100 自动钳位;
  • TurnOff():发送{OFF (0x9f), 0x04, 0x6f, 0x66, 0x66, 0x21}直接给手柄下电;
  • 电池事件:SCEventType.BATTERY帧中偏移 12 处是电压(UInt16)、偏移 14 处是百分比(GordonController.cs);
  • 断连恢复:CONNECT事件状态字节0x02表示重新连上,此时自动重放此前的 Lizard/陀螺仪/空闲超时配置(GordonController.cs)。

Gordon 的输入报文结构GCInput(HidEnums.cs)与 Neptune 相近,但按钮只有 3 个字节且没有触控板压力字段;其左键鼠/摇杆复用逻辑(LPAD_AND_JOY位)在 GordonControllerInputState.cs 中有专门的合并处理。

八、HandheldCompanion 的真实集成方式

主程序把该库当作"输入源适配层"使用,集成点可以对照阅读:

8.1 设备识别

ControllerManager.cs 按 VID/PID 分发:0x28DE/0x1102(MI==2 时)与0x1142创建GordonController,0x1205(Steam Deck)与0x12f0(SteamOS 掌机手柄)创建NeptuneController。注释还说明 Steam Controller 是三接口复合 HID 设备,Valve 的 feature-report 通道位于接口 2。

8.2 固件兼容与防"错开虚拟设备"

封装类 HandheldCompanion/Controllers/Steam/NeptuneController.cs 展示了两处关键工程细节:

  • 缓冲区长度回退:依次用 65、64 两种inputBufferLen尝试构造并Open(),成功后才继续——注释写明"支持不同固件版本(65 当前,64 旧版)"。而HidDevice.OpenDevice()正是靠比较HidP_GetCaps得到的InputReportByteLength与期望长度来决定是否选中某设备(hidapi.net/HidDevice.cs);
  • devicePath 直开:构造时传入details.devicePath,OpenDevice()会优先hid_open_path,确保打开的是真实物理设备而不是同 VID/PID 的虚拟设备(HidDevice.cs)。

8.3 Lizard 模式的取舍

主程序的策略与"纯监控"场景不同:Open()时RequestLizardMode(false)关闭系统仿真(避免 Deck 触控板同时驱动系统光标),Close()前RequestLizardMode(true)还原,让手柄离开程序管理后恢复默认行为(NeptuneController.cs)。

8.4 输入帧的消费

Tick()每帧把库解析出的状态映射进主程序统一输入模型(NeptuneController.cs):

  • 扳机值L2/R2(Int16)归一化到 0–255,并按Gamepad.TriggerThreshold派生 Soft/Full 两个虚拟按钮;
  • 触控板未触摸时坐标强制清零;点击方向用内部阈值TrackPadInner = 21844判定上/下/左/右四个合成键;
  • 陀螺仪 16 位量程按 ±2000 dps、加速度计按 ±2g 换算成物理量后送入GyroState,再交给GamepadMotion处理;
  • 震动走独立后台线程RumbleThreadLoop:以SteamControllerRumbleInterval(默认 10ms)为周期,把 XInput 风格的大/小马达字节经GetHapticIntensity线性映射到-2..10的NCHapticStyle.Weak强度,调SetHaptic2下发(NeptuneController.cs)。SetHaptic中还按HapticStrength分档映射强度 -7/-1/+5,松开时再衰减 3dB。

九、使用限制与注意事项

结合源码与项目配置,使用该库需要满足以下前提:

  1. 平台:仅 Windows(依赖 hidapi 原生库与HidD_GetPreparsedData等 Win32 API),且宿主进程必须是 x64;
  2. 框架:.NET Framework 4.8(C# 7.3 语言版本);
  3. 生命周期:Open()可能抛异常("Could not open device!"),调用方应捕获;Close()会停止读取线程并等待心跳任务退出,务必成对调用;
  4. 线程模型:输入回调发生在 HID 读取线程内,事件处理器中不应做 UI 更新或阻塞操作;
  5. Lizard 状态要自觉还原:关闭 Lizard 后手柄不再向系统报告键鼠,程序崩溃时用户手柄"失灵",参考主程序的做法是在退出路径恢复RequestLizardMode(true);
  6. 报文版本差异:Steam Deck 固件升级可能改变输入报文长度(65/64),跨固件场景应像主程序一样做多长度回退;
  7. README 示例时效:如第三节所述,README 示例的无参构造与带sender的事件签名属于早期版本写法,集成时以仓库当前源码签名为准。

十、小结

steam-hidapi.net用不到千行代码搭起了一条"Windows HID → Steam 系手柄原始报文 → 结构化输入状态"的完整通道:HidDevice负责设备与读取线程,SteamController基类负责 feature report 控制面(Lizard 模式、序列号、寄存器、震动),NeptuneController/GordonController分别实现两套输入报文的解码与型号专属能力。HandheldCompanion 在其上完成了固件兼容回退、Lizard 模式管理、扳机/触控板/体感换算与线性震动循环等生产级处理,这套"库 + 封装"的组合模式对任何需要绕过游戏层、直接读取 Steam Deck 或 Steam 手柄 HID 报文的 .NET 项目都有直接的参考价值。

  • 桌面应用
  • 游戏开发

【免费下载链接】HandheldCompanion

A touch optimized GUI to increase your handheld gaming computer experience.

项目地址:https://gitcode.com/gh_mirrors/ha/HandheldCompanion
点击查看免费下载
上一篇:终极指南:使用WeChatMsg实现微信聊天记录永久保存与智能分析
下一篇:OBS Studio启动失败终极指南:从崩溃闪退到流畅直播的完整修复方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进