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

68
hook/src/vtable_hook.hpp Normal file
View File

@@ -0,0 +1,68 @@
// Hook a single COM vtable slot by overwriting its function pointer; the original is called
// through the saved pointer. We use this instead of SafetyHook's inline hooks for COM methods
// because, on x86, MMDevApi/AudioSes/DirectInput prologues use dynamic stack alignment
// (`and esp,-8`) with EBP-relative argument access, which SafetyHook's trampoline relocation
// mishandles -> the original runs with garbage args and faults. Swapping the vtable entry leaves
// the original code untouched, so it runs with a pristine stack regardless of prologue shape.
// Every instance of a COM coclass shares one vtable, so a single swap intercepts all of them.
// See the project's stdcall-x86 note. (audio_hook.cpp has its own equivalent; this is the shared
// copy for the input-side COM hooks.)
#pragma once
#include <windows.h>
namespace coop::hook
{
class VtableHook
{
public:
bool install(void* com_object, unsigned index, void* detour)
{
if (m_vtable != nullptr)
{
return true; // already installed (shared vtable covers every instance)
}
auto** vtable = *reinterpret_cast<void***>(com_object);
DWORD old_protect = 0;
if (!VirtualProtect(&vtable[index], sizeof(void*), PAGE_READWRITE, &old_protect))
{
return false;
}
m_original = vtable[index];
vtable[index] = detour; // aligned pointer store -> atomic vs. a concurrent caller
VirtualProtect(&vtable[index], sizeof(void*), old_protect, &old_protect);
m_vtable = vtable;
m_index = index;
return true;
}
void remove()
{
if (m_vtable == nullptr)
{
return;
}
DWORD old_protect = 0;
if (VirtualProtect(&m_vtable[m_index], sizeof(void*), PAGE_READWRITE, &old_protect))
{
m_vtable[m_index] = m_original;
VirtualProtect(&m_vtable[m_index], sizeof(void*), old_protect, &old_protect);
}
m_vtable = nullptr;
// Keep m_original valid: a detour already running on the game's thread may still call
// original() after we restore the slot. The original lives in the loaded module, so the
// pointer stays valid; a re-install re-reads it.
m_index = 0;
}
template <typename Fn> Fn original() const { return reinterpret_cast<Fn>(m_original); }
explicit operator bool() const { return m_vtable != nullptr; }
private:
void** m_vtable = nullptr;
unsigned m_index = 0;
void* m_original = nullptr;
};
} // namespace coop::hook