Forward mouse + keyboard to DirectInput and Raw Input games

The MKB subsystem covered message-loop (PostMessage) and polling
(GetAsyncKeyState/GetKeyboardState/GetCursorPos) games. Add the two remaining
read paths, both fed from the same synthesized state:

- DirectInput: vtable-swap IDirectInputDevice8::GetDeviceState (a COM method ->
  vtable swap, not inline, per the x86 COM-prologue trap), reading the shared
  vtable from a kept-alive probe device (the game made its devices before we
  injected). Dispatch on cbData: 256 = keyboard BYTE[256] indexed by DIK
  scan-code (map VK->DIK via MapVirtualKey VK_TO_VSC); DIMOUSESTATE = mouse
  buttons + relative deltas from the forwarded cursor.
- Raw Input: games get no WM_INPUT while unfocused, so mkb_pump synthesizes it
  (PostMessage WM_INPUT with lParam = one of our RAWINPUT slots) and the hooked
  GetRawInputData serves that slot back (RID_HEADER + RID_INPUT). Covers keys +
  buttons; relative raw-mouse movement isn't in the position-based MKB stream.

Both detours use the DetourGate safe-unhook guard, and the mock_game_test storm
exercises their install/remove. dinput_hook_test drives the real path end-to-end:
forward a key + mouse button through the ring, then a real DI keyboard/mouse
device's GetDeviceState returns them. The Raw Input round-trip is operator-
validated against a real game (too brittle to assert in-process). vtable_hook.hpp
factors the COM vtable-swap helper out for reuse.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 03:14:59 +02:00
parent 7ada550930
commit 911b543d98
6 changed files with 530 additions and 10 deletions

View File

@@ -25,7 +25,7 @@ and forwards guest controllers back into it.
| --- | --- | --- |
| Receive guest input | XInput (RPT delivers guest pads to the focused window); optional, opt-in Steam Input when built with the Steamworks SDK | `coop_host.exe` |
| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` |
| Forward mouse + keyboard | Opt-in MKB subsystem: host streams its window's clicks/keys, the hook posts the matching window messages and synthesizes `GetAsyncKeyState`/`GetKeyboardState`/`GetCursorPos` for polling games | `coop_hook.dll` + `coop_host.exe` |
| Forward mouse + keyboard | Opt-in MKB subsystem: host streams its window's clicks/keys; the hook posts the matching window messages, synthesizes `GetAsyncKeyState`/`GetKeyboardState`/`GetCursorPos` for polling games, augments `IDirectInputDevice8::GetDeviceState` for **DirectInput** games, and synthesizes `WM_INPUT` + `GetRawInputData` for **Raw Input** games | `coop_hook.dll` + `coop_host.exe` |
| Keep game running unfocused | Hook spoofs focus so the game polls while the host holds OS focus | `coop_hook.dll` |
| Mirror video (default) | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` |
| Mirror video (hooked) | Injected Present / OpenGL hook copies the backbuffer into a shared keyed-mutex texture the host samples (lower latency, no capture border) | `coop_hook.dll` + `coop_host.exe` |
@@ -110,14 +110,6 @@ default** and covers anything the hooked path doesn't.
### Current Tasks
- **Mouse + keyboard forwarding for Raw Input / DirectInput games.** The MKB
subsystem forwards via window messages (`PostMessage`) plus synthesized
`GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos`, which covers message-loop
and polling games. Games that read keyboard/mouse via **Raw Input** (`WM_INPUT` /
`GetRawInputData`, e.g. Trails through Daybreak) or **DirectInput**
(`IDirectInputDevice8::GetDeviceState/GetDeviceData`) don't see it. Add hooks for
those paths to synthesize the forwarded input there too.
- **Validate the Vulkan backend against a real game.** Exercise the Vulkan capture path
end-to-end on a shipping title — **Sphere Spectacle** (Steam appid 1123040,
`start steam://rungameid/1123040`) — not just `coop_mock_game`. Cover **both** early-presence
@@ -192,6 +184,13 @@ ctest --test-dir build -C Debug --output-on-failure
- **`hook_selftest`** — in-process check of the IPC + XInput hook core (no game,
no controller needed).
- **`dinput_hook_test`** — in-process self-test of the **DirectInput** forwarding path. Reuses
`mkb_hook.cpp`, installs the MKB hooks (which vtable-swap `IDirectInputDevice8::GetDeviceState`
via a kept-alive probe device), forwards a synthetic key + mouse button through the MKB ring,
pumps it, then creates a *real* DirectInput keyboard + mouse device and asserts `GetDeviceState`
returns the forwarded input (the key at its DIK scan-code, the left mouse button). Skips cleanly
if DirectInput can't acquire a device. (The Raw Input path is operator-validated against a real
game — an in-process WM_INPUT round-trip is too brittle to assert reliably.)
- **`audio_ring_test`** — unit test of the shared audio ring (lock-free SPSC
push/pop, wrap-around, format handshake, overrun/drop). No device needed.
- **`audio_mix_test`** — unit test of the multi-stream mixer math (decode / sum /
@@ -649,3 +648,17 @@ Non-obvious things that cost time and constrain the design:
in-process XInput interception, which hides controllers from `XInputGetState`
unless they're bound to the running appid's action set — defaulting to it
silently broke forwarding. XInput is primary; Steam Input is opt-in.
- **Forwarding keyboard/mouse means covering three read paths, each differently.** Games read
keyboard/mouse three ways and a forwarder has to satisfy all of them from one synthesized state.
(1) **Message-loop** games get `PostMessage`d `WM_KEYDOWN`/`WM_*BUTTON*`. (2) **Polling** games
get synthesized `GetAsyncKeyState`/`GetKeyboardState`/`GetCursorPos`. (3) **DirectInput** games
call `IDirectInputDevice8::GetDeviceState` — a COM method, so it's hooked by **vtable swap, not
inline** (the x86 COM-prologue trap), reading the vtable from a kept-alive *probe* device we
create (the game made its devices before we injected, but all DI devices share one vtable);
dispatch on `cbData` (256 = keyboard `BYTE[256]` indexed by **DIK scan-code**, not VK — map with
`MapVirtualKey(VK_TO_VSC)`; `sizeof(DIMOUSESTATE)` = mouse). (4) **Raw Input** games read
`WM_INPUT` → `GetRawInputData`, but get **no `WM_INPUT` while unfocused** — so we *synthesize* it:
post `WM_INPUT` with `lParam` = the address of one of our `RAWINPUT` slots, and the hooked
`GetRawInputData` serves that slot's data back (`RID_HEADER` and `RID_INPUT`). Relative raw mouse
*movement* isn't in the position-based MKB event stream, so raw-input forwarding covers keys +
buttons, not free-look — a known limitation.