【XInput】手柄模擬滑鼠運作之 .NET P/Invoke 和 UWP-API 方案

东邪独孤發表於2024-03-02

上一篇中,老周簡單膚淺地介紹了 XInput API 的使用,並模擬了滑鼠移動,左、右鍵單擊和滾輪。本篇,咱們用 .NET 程式碼來完成相同的效果。

說起來也是倒黴,博文寫了一半,電腦忽然斷電了。不知道什麼原因,可能是 UPS 電源出故障。重新開機進來一看,博文沒有自動儲存到草稿箱。我記得以前是有自動儲存這功能的。很無奈,只好重寫了。

在 dll 匯入的時,容易出問題的是 INPUT 結構體,因為這貨有 union 成員。不知各位還記不記得。

typedef struct tagINPUT {
    DWORD   type;

    union
    {
        MOUSEINPUT      mi;
        KEYBDINPUT      ki;
        HARDWAREINPUT   hi;
    } DUMMYUNIONNAME;
} INPUT, *PINPUT, FAR* LPINPUT;

匯入程式碼網上一搜一大把,然而,那些程式碼都是恐龍時代的,在 32 位平臺上是沒問題的,但在 64 位平臺上會無法正常用的。夥伴們可能會說,如果不自定義各種屬性,執行時不是自動處理的嗎?對的,如果應用在欄位成員上的各種特性(如 [StructLayout(LayoutKind.Sequential)])是會自動對齊位元組的。

而 INPUT 結構體特別啊,在 type 後面的三個欄位是共享記憶體的,所以,必須明確設定位元組偏移。這個結構體在 32 位系統中是 4 位元組對齊的,大小為 28;而在 64 位系統上是 8 位元組對齊的,大小是 40 位元組。type 欄位佔 4 位元組,這個不變,但如果 8 位元組對齊,那麼,type 後面還要額外填充 4 個位元組,即 mi、ki 等成員的偏移是從第 9 個位元組開始的,索引是 8。如果你抄網上的程式碼,offset = 4,在 64 位系統上執行,是無效的。

解決這個核心問題,dll 匯入就很順利了。

public enum InputType : uint
{
    INPUT_MOUSE = 0,
    INPUT_KEYBOARD = 1,
    INPUT_HARDWARE = 2
}

 [Flags]
 public enum MouseEventFlags : uint
 {
     MOUSEEVENTF_MOVE = 0x0001,
     MOUSEEVENTF_LEFTDOWN = 0x0002,
     MOUSEEVENTF_LEFTUP = 0x0004,
     MOUSEEVENTF_RIGHTDOWN = 0x0008,
     MOUSEEVENTF_RIGHTUP = 0x0010,
     MOUSEEVENTF_ABSOLUTE = 0x8000
 }

 [Flags]
 public enum KeyboardEventFlags : uint
 {
     KEYEVENTF_KEYDOWN = 0x0000,
     KEYEVENTF_EXTENDEDKEY = 0x0001,
     KEYEVENTF_KEYUP = 0x0002,
     KEYEVENTF_UNICODE = 0x0004,
     KEYEVENTF_SCANCODE = 0x0008
 }

這些在標頭檔案中本來是宏定義的,我全定義為列舉,用起來方便幾個檔次。

[StructLayout(LayoutKind.Sequential)]
public struct MOUSEINPUT
{
    public int dx;
    public int dy;
    public uint MouseData;
    public MouseEventFlags Flags;
    public uint Time;
    public nuint ExtraInfo;
}

[StructLayout(LayoutKind.Sequential)]
public struct KEYBDINPUT
{
    public ushort Vk;
    public ushort Scan;
    public KeyboardEventFlags Flags;
    public uint Time;
    public nuint ExtraInfo;
}

以上兩個結構體無需特殊處理,就按常規就行。但下面的 INPUT 結構體就要注意了。

public enum InputType : uint
{
    INPUT_MOUSE = 0,
    INPUT_KEYBOARD = 1,
    INPUT_HARDWARE = 2
}

[StructLayout(LayoutKind.Explicit)]
public struct INPUT
{
    [FieldOffset(0)]
    public InputType Type;
    [FieldOffset(8)]
    public MOUSEINPUT mi;
    [FieldOffset(8)]
    public KEYBDINPUT ki;
}

StructLayoutAttribute 特性類在應用時,目標結構體的成員排列要設定為 Explicit。即由咱們手動指定各個成員的偏移位元組。記住,在 64 位系統中,偏移量是 8(鑑於現在很多人都用 64 位了,所以我這裡就不設定條件編譯了,如果你要相容,可以設定條件編譯,32 位的偏移量是 4,64位的是 8)。

上面那一大堆東西弄好,SendInput 函式就可以匯入了。

[DllImport("user32.dll")]
public static extern uint SendInput(
    uint Inputs,
    [MarshalAs(UnmanagedType.LPArray)] INPUT[] inputs,
    int size);

然後是 XInput 的函式,這個就按常規方式匯入即可(熟悉的配方,熟悉的味道)。

[Flags]
public enum GamePadButtons : ushort
{
    XINPUT_GAMEPAD_DPAD_UP = 0x0001,
    XINPUT_GAMEPAD_DPAD_DOWN = 0x0002,
    XINPUT_GAMEPAD_DPAD_LEFT = 0x0004,
    XINPUT_GAMEPAD_DPAD_RIGHT = 0x0008,
    XINPUT_GAMEPAD_START = 0x0010,
    XINPUT_GAMEPAD_BACK = 0x0020,
    XINPUT_GAMEPAD_LEFT_THUMB = 0x0040,
    XINPUT_GAMEPAD_RIGHT_THUMB = 0x0080,
    XINPUT_GAMEPAD_LEFT_SHOULDER = 0x0100,
    XINPUT_GAMEPAD_RIGHT_SHOULDER = 0x0200,
    XINPUT_GAMEPAD_A = 0x1000,
    XINPUT_GAMEPAD_B = 0x2000,
    XINPUT_GAMEPAD_X = 0x4000,
    XINPUT_GAMEPAD_Y = 0x8000
}

[StructLayout(LayoutKind.Sequential)]
public struct XINPUT_GAMEPAD
{
    public GamePadButtons Buttons;
    public byte LeftTrigger;
    public byte RightTrigger;
    public short ThumbLX;
    public short ThumbLY;
    public short ThumbRX;
    public short ThumbRY;
}

[StructLayout(LayoutKind.Sequential)]
public struct XINPUT_STATE
{
    public uint PacketNumber;
    public XINPUT_GAMEPAD GamePad;
}

匯入 XInputGetState 函式。

[DllImport("Xinput1_4.dll")]
public static extern uint XInputGetState(
    uint UserIndex,
    ref XINPUT_STATE State);

兩個 API 咱們封裝到一個類中。

 static class WinApi
 {
     [DllImport("user32.dll")]
     public static extern uint SendInput(
         uint Inputs,
         [MarshalAs(UnmanagedType.LPArray)] INPUT[] inputs,
         int size);

     [DllImport("Xinput1_4.dll")]
     public static extern uint XInputGetState(
         uint UserIndex,
         ref XINPUT_STATE State);
 }

好了,API 已經匯入,可以玩了。這一次老周只做了:

1、左邊的搖桿負責控制滑鼠移動;

2、A 鍵表示左鍵單擊,B 鍵表示右鍵單擊。

下面是示例程式碼:

internal class Program
{
    // 記錄序號,如果序號改變,才表示有新的資料
    static uint SerialID = default;

    static void Main(string[] args)
    {
        while (true)
        {
            Thread.Sleep(80);
            // 讀取資料
            XINPUT_STATE state = default;
            if (WinApi.XInputGetState(0, ref state) != 0)
            {
                // 返回值不為0,表示不成功,跳過
                continue;
            }
            // 比較一下序號,看是不是新的資料
            if (SerialID == state.PacketNumber)
            {
                continue;   // 資料是舊的,不處理
            }
            // 儲存新的序號
            SerialID = state.PacketNumber;
            // 要傳送的輸入訊息列表
            List<INPUT> inputList = new();
            // 計算滑鼠移動量
            int dx = state.GamePad.ThumbLX / 1000;
            int dy = -state.GamePad.ThumbLY / 1000;
            INPUT mouseMove = new();
            mouseMove.Type = InputType.INPUT_MOUSE;     // 訊息型別是滑鼠
            // 設定滑鼠事件標誌
            mouseMove.mi.Flags = MouseEventFlags.MOUSEEVENTF_MOVE;
            // 設定移動量
            mouseMove.mi.dx = dx;
            mouseMove.mi.dy = dy;
            inputList.Add(mouseMove);

            // 判斷按鍵
            if ((state.GamePad.Buttons & GamePadButtons.XINPUT_GAMEPAD_A) == GamePadButtons.XINPUT_GAMEPAD_A)
            {
                // 左鍵按下訊息
                INPUT lbpress = new INPUT();
                lbpress.Type = InputType.INPUT_MOUSE;
                lbpress.mi.Flags = MouseEventFlags.MOUSEEVENTF_LEFTDOWN;
                inputList.Add(lbpress);
                // 左鍵釋放
                INPUT lbrelease = new INPUT();
                lbrelease.Type = InputType.INPUT_MOUSE;
                lbrelease.mi.Flags = MouseEventFlags.MOUSEEVENTF_LEFTUP;
                inputList.Add(lbrelease);
            }
            if ((state.GamePad.Buttons & GamePadButtons.XINPUT_GAMEPAD_B) == GamePadButtons.XINPUT_GAMEPAD_B)
            {
                // 右鍵按下
                INPUT rbpress = new();
                rbpress.Type = InputType.INPUT_MOUSE;
                rbpress.mi.Flags = MouseEventFlags.MOUSEEVENTF_RIGHTDOWN;
                inputList.Add(rbpress);
                // 右鍵釋放
                INPUT rbrelease = new INPUT();
                rbrelease.Type = InputType.INPUT_MOUSE;
                rbrelease.mi.Flags = MouseEventFlags.MOUSEEVENTF_RIGHTUP;
                inputList.Add(rbrelease);
            }
            // 傳送訊息
            WinApi.SendInput((uint)inputList.Count, inputList.ToArray(), Marshal.SizeOf<INPUT>());
        }
    }
}

原理和上一篇中所述一樣,先讀取手柄資料,然後傳送滑鼠輸入訊息。

===================================================================================

微軟其實有提供了新的 XInput API,即給 UWP 應用程式使用的,而實際上。.NET 應用專案是可以使用 UWP API 的。畢竟,Win 10/11 是內建了執行庫的。

接下來,咱們就用 UWP 方案,這個不需要 Dll 匯入,用起來方便多了。

1、像平常一樣,建立 .NET 專案。WPF、WinForms 或 UWP App 都無所謂,但不建議控制檯,有可能讀不到資料。API 文件中說要求是可以 Focus 的視窗才能接收輸入;

2、開啟系統 CMD 視窗,或任意終端都行。執行 systeminfo

這裡能看到 build 版本號,比如老周的是 Win 11,只要記住前兩位數字就行了,即 10.0.22000.0。

3、回到開發環境,開啟專案檔案,找到這一行。

<TargetFramework>net8.0</TargetFramework>

預設是 net-<ver>,表明這個控制檯應用是跨平臺的,我們把它改為 Windows 特供的。

<TargetFramework>net8.0-Windows10.0.22000.0</TargetFramework>

儲存,關閉檔案。此時,你的專案可以用 UWP API 了。

注意:要模擬滑鼠動作也是要匯入 Win API 的,和前文一樣,只是讀手柄的API不同罷了。

下面的例子,老周就用一個 System.Threading.Timer 來每 100 ms 讀取一次資料,並顯示在視窗上。視窗的結構如下:

主要用到的是 Windows.Gaming.Input 名稱空間下的 Gamepad 類,這個類的建構函式不是公共的,不能直接例項化,而是訪問它的靜態屬性 Gamepads。這是一個集合,如果連線了多個手柄,裡面會有多個元素。

我在視窗的 Load 事件處理中,開一個 Task 來獲取。

_ = Task.Run(async () =>
{
    while (gamePad == null)
    {
        gamePad = Gamepad.Gamepads.FirstOrDefault();
        await Task.Delay(1000);
    }
});

這裡假設只連線了一個手柄,所以總是獲取集合中的第一個元素。為什麼要這樣獲取呢?因為當應用程式初始化時,訪問 Gamepads 集合不一定能獲取到手柄(有時候會有一兩秒的延時),所以咱們要這樣來獲取。

本示例中,老周用來讀資料的 Timer 是後臺執行緒的。儘量不要用 System.Windows.Forms 下的 Timer,因為那個定時器用的是 UI 執行緒。在 UI 執行緒上讀資料要把獲取資料的一段程式碼放在 lock 裡面,否則讀到的全是 0,或者讀到錯的值。同理,WPF 也不用 DispatcherTimer,那個定時器也是在 UI 執行緒上執行的。

用非 UI 執行緒的定時器,在讀取資料時可以不進行 lock。下面是定時器使用過程:

1、在視窗類中定義 Timer 為私有欄位。

 private Gamepad? gamePad;
 private System.Threading.Timer timer;

gamepad 也是私有欄位,待會兒用於引用 Gamepad 例項。

2、在視窗類的建構函式中,new 一個 Timer 例項,用 Change 方法禁用定時器。

 public MyWindow()
 {
     InitializeComponent();
     Load += OnLoad;
     FormClosing += OnClosing;
     timer = new System.Threading.Timer(OnTick);
     timer.Change(Timeout.Infinite, Timeout.Infinite);
 }

傳給 Timer 建構函式的是一個回撥委託,這裡我繫結的是 OnTick 方法。委託型別接收一個 object 型別的引數,是使用者自定義的狀態資料,不使用的話可以忽略。這個 Timer 沒有 Start、Stop 等方法,用 Change 方法設定超時為永不超時,這樣就等於禁用定時器了。

實現 OnTick 方法,迴圈讀取手柄資料,顯示在視窗上。

private void OnTick(object? state)
{
    if (gamePad == null) return;

    // 讀數
    GamepadReading data = gamePad.GetCurrentReading();
    BeginInvoke(() =>
    {
        // 左搖桿
        txtLeftX.Text = data.LeftThumbstickX.ToString("N4");
        txtLeftY.Text = data.LeftThumbstickY.ToString("N4");

        // 右搖桿
        txtRightX.Text = data.RightThumbstickX.ToString("N4");
        txtRightY.Text = data.RightThumbstickY.ToString("N4");

        // 左右扳機鍵
        txtLeftTrigger.Text = data.LeftTrigger.ToString("N2");
        txtRightTrigger.Text = data.RightTrigger.ToString("N2");

        // 檢查按鍵
        ckbX.Checked = (data.Buttons & GamepadButtons.X) == GamepadButtons.X;
        ckbY.Checked = (data.Buttons & GamepadButtons.Y) == GamepadButtons.Y;
        ckbStart.Checked = (data.Buttons & GamepadButtons.Menu) == GamepadButtons.Menu;
    });
}

呼叫 GetCurrentReading 方法就可以獲取實時讀數了。返回的是 GamepadReading 結構體。注意它和 XInput API 的讀數範圍是不同的。

這個 UWP API 的讀範圍是 -1 到 1,如果搖桿在中間位置(預設位置),那麼讀數是 0。讀出來的值是 -1 到 1 的小數(含-1 和 1)。

GamepadButtons 列舉定義的是手柄的按鍵,這個和 XInput API 差不多。

public enum GamepadButtons : uint
{
    // 未按下任何鍵
    None = 0u,
    // 選單鍵,老周的手柄上是 Start 鍵
    Menu = 1u,
   
    // 這個不知道是什麼
    View = 2u,

    // A、B、X、Y 按鍵
    A = 4u,
    B = 8u,
    X = 0x10u,
    Y = 0x20u,

    // 手柄上的四個方向鍵
    DPadUp = 0x40u,
    DPadDown = 0x80u,
    DPadLeft = 0x100u,
    DPadRight = 0x200u,
  
    // 這兩個是兩個肩膀按鍵
    LeftShoulder = 0x400u,
    RightShoulder = 0x800u,

    // 下面兩個指的是搖桿上的按鍵,搖桿除了可以搖,還可以按下去。
    // 其實搖桿中間是一個輕觸按鈕
    LeftThumbstick = 0x1000u,
    RightThumbstick = 0x2000u,

     // 其他按鍵
}

一起來看看效果。

最後,共享點猛料給大夥伴。AOSP Android 14 原生系統,樹莓派 4 / 5 映象,都是最新版的。

連結:https://pan.baidu.com/s/1q9xnLh4n7pNBl62djxDNnQ?pwd=1981
提取碼:1981
下載後解壓出來,直接寫入記憶體卡就行,就跟安裝官方系統一樣。

把卡插到 Pi 上,第一次執行要用 HDMI 口連顯示器,如果顯示器不能觸控,順便連上鍵盤滑鼠。如果你有 DSI 接的觸控螢幕,需要到 設定 - 系統 - Raspberry Pi 設定中開啟 7 寸觸控屏選項。不一定要官方的螢幕(很貴),某寶上隨便弄的只要是 DSI 排線連線的,多數螢幕是可以用的。DSI 排線要在樹莓派關機斷電後再連線,不要熱插拔。接了觸控屏就不要再接 HDMI 口了。

由於是原生系統,時間伺服器是不能用的,要自動更新網路時間,需要用 adb 改為國內的 NTP 伺服器,方法可以百度,很多教程。

經老周測試,不管是4代還是5代,聲音、觸控、WiFi、藍芽、HDMI 音/影片、GPIO 等功能都可正常使用。但是,自己連線到 i2c 上的 MPU6050(重力加速和陀螺儀)不能用。這個是在設定 - 系統 - Raspberry pi 設定中的感測選項中開啟的,反正老周買的模組無法正常使用。

另外,把 GPIO 21 接低電平,可以觸發電源按鈕功能,就像手機上的電源鍵,可以長按關機/重啟、喚醒鎖屏等,有鍵盤的可以按 F5。

相關文章