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,6 +12,7 @@ add_executable(coop_host WIN32
src/inject/process_list.cpp
src/inject/window_list.cpp
src/inject/injector.cpp
src/inject/mkb_forward.cpp
src/ipc/ipc_server.cpp
src/capture/frame_renderer.cpp
src/capture/window_capture.cpp

View File

@@ -44,6 +44,20 @@ public:
// Render thread: draw the mirrored frame as the window background.
void render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h);
// Whether the video mirror is on (mouse forwarding is gated on this, since the
// operator can't aim clicks without seeing the game).
[[nodiscard]] bool mirroring() const
{
return enabled_;
}
// True when the active source is the injected Present-hook (client/backbuffer);
// false for WGC (whole-window). Drives the mouse coordinate mapping.
[[nodiscard]] bool source_hooked() const
{
return source_ == Source_Hooked;
}
private:
enum Source : int
{

View File

@@ -0,0 +1,207 @@
#include "inject/mkb_forward.hpp"
#include <imgui.h>
#include "coop/protocol.hpp"
#include "injection_panel.hpp"
#include "inject/mkb_map.hpp"
namespace coop
{
namespace
{
// Map an ImGui key to a Win32 virtual-key. Returns 0 for keys we don't forward.
int imgui_key_to_vk(ImGuiKey k)
{
if (k >= ImGuiKey_A && k <= ImGuiKey_Z)
{
return 'A' + (k - ImGuiKey_A);
}
if (k >= ImGuiKey_0 && k <= ImGuiKey_9)
{
return '0' + (k - ImGuiKey_0);
}
if (k >= ImGuiKey_Keypad0 && k <= ImGuiKey_Keypad9)
{
return VK_NUMPAD0 + (k - ImGuiKey_Keypad0);
}
if (k >= ImGuiKey_F1 && k <= ImGuiKey_F12)
{
return VK_F1 + (k - ImGuiKey_F1);
}
switch (k)
{
case ImGuiKey_Tab: return VK_TAB;
case ImGuiKey_LeftArrow: return VK_LEFT;
case ImGuiKey_RightArrow: return VK_RIGHT;
case ImGuiKey_UpArrow: return VK_UP;
case ImGuiKey_DownArrow: return VK_DOWN;
case ImGuiKey_PageUp: return VK_PRIOR;
case ImGuiKey_PageDown: return VK_NEXT;
case ImGuiKey_Home: return VK_HOME;
case ImGuiKey_End: return VK_END;
case ImGuiKey_Insert: return VK_INSERT;
case ImGuiKey_Delete: return VK_DELETE;
case ImGuiKey_Backspace: return VK_BACK;
case ImGuiKey_Space: return VK_SPACE;
case ImGuiKey_Enter: return VK_RETURN;
case ImGuiKey_Escape: return VK_ESCAPE;
case ImGuiKey_LeftCtrl: return VK_LCONTROL;
case ImGuiKey_LeftShift: return VK_LSHIFT;
case ImGuiKey_LeftAlt: return VK_LMENU;
case ImGuiKey_LeftSuper: return VK_LWIN;
case ImGuiKey_RightCtrl: return VK_RCONTROL;
case ImGuiKey_RightShift: return VK_RSHIFT;
case ImGuiKey_RightAlt: return VK_RMENU;
case ImGuiKey_RightSuper: return VK_RWIN;
case ImGuiKey_Menu: return VK_APPS;
case ImGuiKey_Apostrophe: return VK_OEM_7;
case ImGuiKey_Comma: return VK_OEM_COMMA;
case ImGuiKey_Minus: return VK_OEM_MINUS;
case ImGuiKey_Period: return VK_OEM_PERIOD;
case ImGuiKey_Slash: return VK_OEM_2;
case ImGuiKey_Semicolon: return VK_OEM_1;
case ImGuiKey_Equal: return VK_OEM_PLUS;
case ImGuiKey_LeftBracket: return VK_OEM_4;
case ImGuiKey_Backslash: return VK_OEM_5;
case ImGuiKey_RightBracket: return VK_OEM_6;
case ImGuiKey_GraveAccent: return VK_OEM_3;
case ImGuiKey_CapsLock: return VK_CAPITAL;
case ImGuiKey_ScrollLock: return VK_SCROLL;
case ImGuiKey_NumLock: return VK_NUMLOCK;
case ImGuiKey_PrintScreen: return VK_SNAPSHOT;
case ImGuiKey_Pause: return VK_PAUSE;
case ImGuiKey_KeypadDecimal: return VK_DECIMAL;
case ImGuiKey_KeypadDivide: return VK_DIVIDE;
case ImGuiKey_KeypadMultiply: return VK_MULTIPLY;
case ImGuiKey_KeypadSubtract: return VK_SUBTRACT;
case ImGuiKey_KeypadAdd: return VK_ADD;
case ImGuiKey_KeypadEnter: return VK_RETURN;
default: return 0;
}
}
// Last valid game-client position, so a button release that lands on a letterbox
// bar still goes to the game (no stuck buttons).
int g_last_gx = 0;
int g_last_gy = 0;
} // namespace
void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring, bool source_hooked)
{
if (!injection.mkb_enabled())
{
return;
}
// Under RPT the guest's MKB lands on our (focused) window; only forward then, so
// the operator's own desktop use isn't injected into the game.
if (GetForegroundWindow() != host_hwnd)
{
return;
}
const HWND game = injection.game_hwnd();
if (game == nullptr || !IsWindow(game))
{
return;
}
ImGuiIO& io = ImGui::GetIO();
// --- Keyboard (always, unless ImGui is using it for e.g. a text field) ---
if (!io.WantCaptureKeyboard)
{
for (ImGuiKey k = ImGuiKey_NamedKey_BEGIN; k < ImGuiKey_NamedKey_END; k = static_cast<ImGuiKey>(k + 1))
{
const int vk = imgui_key_to_vk(k);
if (vk == 0)
{
continue;
}
if (ImGui::IsKeyPressed(k, false))
{
injection.push_mkb(MkbEvent{Mkb_KeyDown, static_cast<std::uint32_t>(vk), 0, 0});
}
if (ImGui::IsKeyReleased(k))
{
injection.push_mkb(MkbEvent{Mkb_KeyUp, static_cast<std::uint32_t>(vk), 0, 0});
}
}
for (int i = 0; i < io.InputQueueCharacters.Size; ++i)
{
const ImWchar c = io.InputQueueCharacters[i];
if (c != 0)
{
injection.push_mkb(MkbEvent{Mkb_Char, static_cast<std::uint32_t>(c), 0, 0});
}
}
}
// --- Mouse (clicks + wheel only, and only while mirroring) ---
if (!mirroring || io.WantCaptureMouse)
{
return;
}
RECT host_client{};
GetClientRect(host_hwnd, &host_client);
MkbMapInput m;
m.host_x = static_cast<int>(io.MousePos.x);
m.host_y = static_cast<int>(io.MousePos.y);
m.dst_w = host_client.right;
m.dst_h = host_client.bottom;
if (source_hooked)
{
// Hooked capture mirrors the backbuffer (client area), no decorations.
const VideoShareView v = injection.video_share();
m.src_w = m.client_w = static_cast<int>(v.width);
m.src_h = m.client_h = static_cast<int>(v.height);
}
else
{
// WGC captures the whole window; the client area sits at a decoration offset.
RECT wr{}, cr{};
POINT client_origin{0, 0};
GetWindowRect(game, &wr);
GetClientRect(game, &cr);
ClientToScreen(game, &client_origin);
m.src_w = wr.right - wr.left;
m.src_h = wr.bottom - wr.top;
m.client_w = cr.right;
m.client_h = cr.bottom;
m.client_off_x = client_origin.x - wr.left;
m.client_off_y = client_origin.y - wr.top;
}
int gx = 0, gy = 0;
const bool on_game = map_host_to_game_client(m, gx, gy);
if (on_game)
{
g_last_gx = gx;
g_last_gy = gy;
}
const int mx = on_game ? gx : g_last_gx;
const int my = on_game ? gy : g_last_gy;
for (int button = 0; button < 3; ++button) // 0=left, 1=right, 2=middle
{
if (on_game && ImGui::IsMouseClicked(button))
{
injection.push_mkb(MkbEvent{Mkb_MouseDown, static_cast<std::uint32_t>(button), mx, my});
}
if (ImGui::IsMouseReleased(button)) // send the up even off-game, so nothing sticks
{
injection.push_mkb(MkbEvent{Mkb_MouseUp, static_cast<std::uint32_t>(button), mx, my});
}
}
if (on_game && io.MouseWheel != 0.0f)
{
const int delta = static_cast<int>(io.MouseWheel * WHEEL_DELTA);
injection.push_mkb(MkbEvent{Mkb_Wheel, static_cast<std::uint32_t>(delta), mx, my});
}
}
} // namespace coop

View File

@@ -0,0 +1,19 @@
// Forwards the host window's mouse + keyboard input into the injected game.
//
// Called once per frame after ImGui's NewFrame. It runs only when the MKB subsystem
// is on, the host window is foreground (so this is the guest's input under RPT), and
// ImGui isn't capturing the event (so interacting with the overlay never leaks into
// the game). Mouse is forwarded only while the video mirror is on, and clicks are
// mapped through the letterbox to the game's client coordinates.
#pragma once
#include <windows.h>
namespace coop
{
class InjectionPanel;
void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring, bool source_hooked);
} // namespace coop

View File

@@ -0,0 +1,61 @@
// Maps a mouse position in the host window's client area to the game's client
// coordinates, inverting the letterbox the mirror is drawn with. Pure + header-only
// so it can be unit-tested without a device.
//
// The captured frame differs by capture mode:
// - Hooked / borderless: the frame IS the game's client area, so the client
// offset is (0,0) and client size == frame size.
// - WGC of a decorated window: the frame is the whole window (title bar +
// borders), so the client area sits at a non-zero offset inside the frame and
// clicks on the decorations are rejected.
#pragma once
#include <algorithm>
namespace coop
{
struct MkbMapInput
{
int host_x = 0, host_y = 0; // mouse in host-window client pixels
int dst_w = 0, dst_h = 0; // host window client size
int src_w = 0, src_h = 0; // captured frame size (WGC=window, Hooked=backbuffer)
int client_off_x = 0, client_off_y = 0; // client-area top-left within the frame
int client_w = 0, client_h = 0; // game client size within the frame
};
// Returns true and writes gx,gy (game client px) if the point lands on the game's
// client area; false if it falls on a letterbox bar or the window decorations.
inline bool map_host_to_game_client(const MkbMapInput& in, int& gx, int& gy)
{
if (in.src_w <= 0 || in.src_h <= 0 || in.dst_w <= 0 || in.dst_h <= 0 || in.client_w <= 0 || in.client_h <= 0)
{
return false;
}
// Invert the letterbox: the frame is fit (aspect-preserved) and centered in dst.
const double scale =
std::min(static_cast<double>(in.dst_w) / in.src_w, static_cast<double>(in.dst_h) / in.src_h);
const double ox = (in.dst_w - in.src_w * scale) * 0.5;
const double oy = (in.dst_h - in.src_h * scale) * 0.5;
const double fx = (in.host_x - ox) / scale; // position in captured-frame pixels
const double fy = (in.host_y - oy) / scale;
if (fx < 0.0 || fy < 0.0 || fx >= in.src_w || fy >= in.src_h)
{
return false; // on a letterbox bar
}
const double cx = fx - in.client_off_x; // into client space
const double cy = fy - in.client_off_y;
if (cx < 0.0 || cy < 0.0 || cx >= in.client_w || cy >= in.client_h)
{
return false; // on the window decorations
}
gx = static_cast<int>(cx);
gy = static_cast<int>(cy);
return true;
}
} // namespace coop

View File

@@ -180,6 +180,7 @@ void InjectionPanel::inject_selected()
server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_);
server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_);
server_.set_subsystem_enabled(HookSubsys_Video, want_video_);
server_.set_subsystem_enabled(HookSubsys_Mkb, want_mkb_);
const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
if (result.status == InjectStatus::Ok)
@@ -283,7 +284,7 @@ void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
void InjectionPanel::draw_hook_list(const HookStatusView& status)
{
static const char* kSubsysName[] = {"Input", "Focus", "Audio", "Video"};
static const char* kSubsysName[] = {"Input", "Focus", "Audio", "Video", "MKB"};
const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
if (n == 0)
@@ -372,6 +373,7 @@ void InjectionPanel::draw_subsystem_controls(const HookStatusView& status)
{"Focus spoof", HookSubsys_Focus, &want_focus_, "the game keeps running unfocused"},
{"Audio render-hook", HookSubsys_Audio, &want_audio_, "audio mirror without echo"},
{"Video Present-hook", HookSubsys_Video, &want_video_, "Video mirror can use the hooked source"},
{"Mouse + keyboard forwarding", HookSubsys_Mkb, &want_mkb_, "clicks/keys reach the game"},
};
for (const Row& r : rows)

View File

@@ -113,6 +113,21 @@ public:
return want_video_;
}
// --- Mouse + keyboard forwarding -------------------------------------------
// Whether the operator enabled the MKB-forwarding subsystem (the toggle is the
// hook). The host's MKB forwarder only runs while this is on and a hook is alive.
[[nodiscard]] bool mkb_enabled() const
{
return want_mkb_ && injected_;
}
// Enqueue an MKB event for the hook to forward into the game.
void push_mkb(const MkbEvent& ev)
{
server_.push_mkb(ev);
}
private:
void refresh_targets(); // refresh both the window list and the process list
void refresh_processes();
@@ -143,6 +158,7 @@ private:
bool want_focus_ = true;
bool want_audio_ = true;
bool want_video_ = false; // Present-hook video path: opt-in (WGC is the default)
bool want_mkb_ = false; // mouse+keyboard forwarding: opt-in
bool injected_ = false; // a hook DLL is loaded in the target
// Heartbeat liveness tracking (is the injected DLL responding?).

View File

@@ -70,6 +70,16 @@ public:
// reconciles on its next tick. No-op if not started.
void set_subsystem_enabled(std::uint32_t subsystem, bool enabled);
// Enqueue a mouse/keyboard event for the hook's MKB subsystem to forward. Drops
// silently if not started or the ring is full.
void push_mkb(const MkbEvent& ev)
{
if (block_ != nullptr)
{
push_mkb_event(block_->mkb, ev);
}
}
// Drain new log lines streamed by the hook, calling `emit(const LogRecord&)`
// for each. No-op if not started. Header-only so the callback can stay generic.
template <typename F>

View File

@@ -18,6 +18,7 @@
#include "controllers_panel.hpp"
#include "d3d11_window.hpp"
#include "imgui_layer.hpp"
#include "inject/mkb_forward.hpp"
#include "injection_panel.hpp"
#include "input/xinput_source.hpp"
#include "log_panel.hpp"
@@ -194,6 +195,10 @@ int run()
}
coop::apply_layout_end_frame(); // clear the one-shot "Reset layout" force
// Forward the host window's mouse/keyboard into the game (when the MKB
// subsystem is on, we're focused, and ImGui isn't using the event).
coop::forward_mkb_frame(injection, window.hwnd(), capture.mirroring(), capture.source_hooked());
RECT client = {};
GetClientRect(window.hwnd(), &client);
const auto dst_w = static_cast<std::uint32_t>(client.right - client.left);