- 桌面应用
- 游戏开发
【免费下载链接】HandheldCompanion
A touch optimized GUI to increase your handheld gaming computer experience.
本篇以 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" 章节给出的接入步骤如下,本文逐条保留并补充说明:
- 从 releases 部分下载 .dll 文件;
- 拷贝到项目根目录;
- 在解决方案资源管理器中右键该文件,选择"属性",将"复制到输出目录"(Copy to Output Directory)设置为"始终复制"(Always copy to output dir);
- 确认项目构建目标是 x64 平台。如果不是,进入 Build > Configuration Manager,新增一个 x64 配置;
- 参考下方示例程序获取手柄输入并输出到控制台。
补充两点实操细节:
- 仓库内该库本身是源码工程(
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 中做报文过滤与分发:
- 校验报文头
Buffer[0] == 1 && Buffer[1] == 0,否则丢弃; - 按
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–0x03 | ptype/_a1/_a2/_a3 | 报文类型与事件头 |
| 0x04 | seq | 序列号(UInt32) |
| 0x08–0x0A, 0x0B, 0x0D, 0x0E | buttons0/1/2/3/5/6 | 6 个按钮位域字节 |
| 0x10–0x16 | lpad_x/y,rpad_x/y | 左右触控板坐标(Int16) |
| 0x18–0x1C | accel_x/y/z | 加速度计 |
| 0x1E–0x22 | gpitch/gyaw/groll | 陀螺仪 |
| 0x24–0x2A | q1–q4 | 姿态四元数 |
| 0x2C–0x2E | ltrig/rtrig | L2/R2 扳机(Int16) |
| 0x30–0x36 | lthumb_x/y,rthumb_x/y | 左右摇杆(Int16) |
| 0x38–0x3A | lpad_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/BtnB | 1–4 | BtnL1/BtnR1/BtnL2/BtnR2 | 13–16 |
BtnMenu/BtnOptions/BtnSteam/BtnQuickAccess | 5–8 | BtnL4/BtnR4/BtnL5/BtnR5 | 17–20 |
BtnDpadUp/Left/Right/Down | 9–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。
九、使用限制与注意事项
结合源码与项目配置,使用该库需要满足以下前提:
- 平台:仅 Windows(依赖 hidapi 原生库与
HidD_GetPreparsedData等 Win32 API),且宿主进程必须是 x64; - 框架:.NET Framework 4.8(C# 7.3 语言版本);
- 生命周期:
Open()可能抛异常("Could not open device!"),调用方应捕获;Close()会停止读取线程并等待心跳任务退出,务必成对调用; - 线程模型:输入回调发生在 HID 读取线程内,事件处理器中不应做 UI 更新或阻塞操作;
- Lizard 状态要自觉还原:关闭 Lizard 后手柄不再向系统报告键鼠,程序崩溃时用户手柄"失灵",参考主程序的做法是在退出路径恢复
RequestLizardMode(true); - 报文版本差异:Steam Deck 固件升级可能改变输入报文长度(65/64),跨固件场景应像主程序一样做多长度回退;
- 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.
相关推荐
HandheldCompanion hidapi.net:.NET HID 设备封装库的使用与底层实现解析
HandheldCompanion hidapi.net:.NET HID 设备封装库的使用与底层实现解析 hidapi.net 是 HandheldCompa
桌面应用游戏开发Steam Deck Tools:在 Windows 上驾驭 Steam Deck 的风扇控制、性能悬浮窗、功耗控制与手柄驱动
Steam Deck Tools:在 Windows 上驾驭 Steam Deck 的风扇控制、性能悬浮窗、功耗控制与手柄驱动 Steam Deck Tools
桌面应用系统底层Steam Deck Tools:解锁Windows在Steam Deck上的无限可能
Steam Deck Tools:解锁Windows在Steam Deck上的无限可能 项目介绍 Steam Deck Tools 是一个专为Steam Dec
桌面应用系统底层
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考