Add mouse + keyboard forwarding (MKB subsystem, opt-in)

Forward the host window's clicks and keystrokes into the injected game so guests
can drive menus / "Press Start" / text entry that a pad can't.

Protocol (v7->v8): new HookSubsys_Mkb and an SPSC MkbRing of MkbEvents in
SharedBlock (host produces, hook consumes); push/pop helpers.

Hook (hook/src/mkb_hook.cpp, new subsystem): a worker-loop pump drains the ring at
~5 ms and PostMessageW's the matching window messages (WM_KEY*/WM_CHAR, mouse
buttons, WM_MOUSEWHEEL) to the game's main window; it also inline-hooks user32
GetAsyncKeyState / GetKeyboardState / GetCursorPos (stdcall trampolines per the x86
rule) to report a synthesized state so polling games react too. Removing the
subsystem clears all synthesized keys (no stuck input).

Host: the Injection panel gets a "Mouse + keyboard forwarding" subsystem toggle
(opt-in, default off -- the toggle is the hook). host/src/inject/mkb_forward.cpp
reads ImGui IO each frame and forwards only when the host window is focused and
ImGui isn't capturing the event; keyboard always, mouse only while mirroring (clicks
+ wheel, not movement). Mouse coords are mapped through the letterbox to game-client
space (host/src/inject/mkb_map.hpp), accounting for WGC-of-decorated-window vs
hooked/borderless. RawInput/DirectInput games are out of scope for this version.

Verified: new mkb_ring_test + mkb_map_test pass; full build x64 + x86 clean; ctest
x64 9/9 and x86 3/3 green (no regression from the protocol bump). The subsystem is
opt-in, so it can't affect existing behavior unless enabled; the end-to-end
click-into-game path needs live Remote Play + a real game to confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 05:06:38 +02:00
parent a32e78ffef
commit 7673f186db
21 changed files with 1003 additions and 27 deletions

View File

@@ -12,7 +12,7 @@ namespace coop
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
// refuses to attach to a host with a mismatched version.
inline constexpr std::uint32_t kProtocolVersion = 7;
inline constexpr std::uint32_t kProtocolVersion = 8;
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
@@ -68,7 +68,8 @@ enum HookSubsystem : std::uint32_t
HookSubsys_Focus = 1, // focus spoof (keep the game running unfocused)
HookSubsys_Audio = 2, // WASAPI render-hook (audio mirror without echo)
HookSubsys_Video = 3, // IDXGISwapChain::Present hook (shared-texture video mirror)
HookSubsys_Count = 4,
HookSubsys_Mkb = 4, // mouse+keyboard forwarding (PostMessage + polling-state hooks)
HookSubsys_Count = 5,
};
// Maximum individual hooks reported in the registry (a few per subsystem).
@@ -152,6 +153,43 @@ struct VideoShare
std::uint64_t present_calls; // cumulative Present() detours (diagnostic)
};
// --- Mouse + keyboard forwarding -------------------------------------------
// The host captures its own window's MKB input (when focused and ImGui doesn't
// want it) and pushes events here; the injected MKB subsystem drains them, posts
// the matching window messages to the game, and maintains a synthesized state the
// GetAsyncKeyState/GetKeyboardState/GetCursorPos hooks report to polling games.
enum MkbEventType : std::uint32_t
{
Mkb_KeyDown = 0, // code = Win32 virtual-key
Mkb_KeyUp = 1, // code = Win32 virtual-key
Mkb_Char = 2, // code = UTF-16 code unit (WM_CHAR)
Mkb_MouseDown = 3, // code = button (0=left,1=right,2=middle); x,y = game client px
Mkb_MouseUp = 4, // code = button; x,y = game client px
Mkb_Wheel = 5, // code = signed wheel delta (WHEEL_DELTA units); x,y = game client px
};
struct MkbEvent
{
std::uint32_t type; // MkbEventType
std::uint32_t code; // see per-type meaning above
std::int32_t x; // game-client x (mouse events)
std::int32_t y; // game-client y (mouse events)
};
static_assert(sizeof(MkbEvent) == 16, "MkbEvent must stay byte-identical across bitness");
// Power-of-two so the free-running indices mask cleanly.
inline constexpr std::uint32_t kMkbQueueSize = 128;
// Lock-free SPSC ring: host produces, hook consumes. Free-running 32-bit indices.
struct MkbRing
{
std::atomic<std::uint32_t> head; // producer (host) write position
std::atomic<std::uint32_t> tail; // consumer (hook) read position
MkbEvent events[kMkbQueueSize];
};
// The shared backbuffer texture is named per target pid, like the audio ring.
inline constexpr wchar_t kVideoSharePrefix[] = L"Local\\coop_video_";
@@ -178,6 +216,9 @@ struct SharedBlock
// Hook -> host Present-hook video channel (shared-texture dimensions/format).
VideoShare video;
// Host -> hook mouse + keyboard event queue (when the MKB subsystem is on).
MkbRing mkb;
};
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
@@ -249,4 +290,34 @@ inline bool read_pads(const SharedBlock& block, CoopPadState (&out)[kMaxPads], s
return false;
}
// --- MKB ring helpers (SPSC: host pushes, hook pops) -----------------------
// Host side: enqueue an MKB event. Returns false (dropped) if the ring is full.
inline bool push_mkb_event(MkbRing& ring, const MkbEvent& ev)
{
const std::uint32_t head = ring.head.load(std::memory_order_relaxed);
const std::uint32_t tail = ring.tail.load(std::memory_order_acquire);
if (head - tail >= kMkbQueueSize)
{
return false; // full -> drop (host should always drain faster than it fills)
}
ring.events[head & (kMkbQueueSize - 1)] = ev;
ring.head.store(head + 1, std::memory_order_release);
return true;
}
// Hook side: dequeue the next MKB event. Returns false if the ring is empty.
inline bool pop_mkb_event(MkbRing& ring, MkbEvent& out)
{
const std::uint32_t tail = ring.tail.load(std::memory_order_relaxed);
const std::uint32_t head = ring.head.load(std::memory_order_acquire);
if (tail == head)
{
return false; // empty
}
out = ring.events[tail & (kMkbQueueSize - 1)];
ring.tail.store(tail + 1, std::memory_order_release);
return true;
}
} // namespace coop