From 7673f186db8c2ca0c5bc0f967a6936e15a0a901d Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sun, 21 Jun 2026 05:06:38 +0200 Subject: [PATCH] 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 --- README.md | 18 +- common/include/coop/protocol.hpp | 75 ++++++- hook/CMakeLists.txt | 1 + hook/src/dllmain.cpp | 34 +++- hook/src/ipc_client.hpp | 9 + hook/src/mkb_hook.cpp | 331 +++++++++++++++++++++++++++++++ hook/src/mkb_hook.hpp | 23 +++ host/CMakeLists.txt | 1 + host/src/capture_panel.hpp | 14 ++ host/src/inject/mkb_forward.cpp | 207 +++++++++++++++++++ host/src/inject/mkb_forward.hpp | 19 ++ host/src/inject/mkb_map.hpp | 61 ++++++ host/src/injection_panel.cpp | 4 +- host/src/injection_panel.hpp | 16 ++ host/src/ipc/ipc_server.hpp | 10 + host/src/main.cpp | 5 + tests/CMakeLists.txt | 13 ++ tests/mkb_map_test.cpp | 93 +++++++++ tests/mkb_ring_test.cpp | 82 ++++++++ tools/audio_probe/main.cpp | 4 +- tools/input_probe/main.cpp | 10 +- 21 files changed, 1003 insertions(+), 27 deletions(-) create mode 100644 hook/src/mkb_hook.cpp create mode 100644 hook/src/mkb_hook.hpp create mode 100644 host/src/inject/mkb_forward.cpp create mode 100644 host/src/inject/mkb_forward.hpp create mode 100644 host/src/inject/mkb_map.hpp create mode 100644 tests/mkb_map_test.cpp create mode 100644 tests/mkb_ring_test.cpp diff --git a/README.md b/README.md index 46dee97..64e5681 100644 --- a/README.md +++ b/README.md @@ -25,6 +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` | | 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` | @@ -75,23 +76,6 @@ is removed from this list once done — so the top item is always next. The self-verifiable tooling / UI / input items come first; the game-pipeline items that need a real game (and Remote Play) to fully validate come last. -- **Mouse & keyboard forwarding (messages + polling-state hooks).** Forward guest - clicks and keystrokes into the unfocused game via a new **MKB hook subsystem** in - `coop_hook.dll` — the toggle *is* the hook (not installed → no forwarding). - *Delivery:* `PostMessage` window-message input (`WM_KEYDOWN`/`WM_KEYUP`/`WM_CHAR`, - `WM_*BUTTONDOWN`/`UP`, `WM_MOUSEWHEEL`) to the game HWND, **plus** hook - `GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos` in the DLL so polling games - see the synthesized keyboard/cursor state (RawInput and DirectInput games are out of - scope for this version). *Keyboard* is always forwarded; *mouse* only while video is - mirrored (otherwise the operator can't see where they click), and only **clicks + - wheel, not movement** (one cursor can't be in two places). *Coordinate mapping* (the - part that must be exact): WGC + decorated windowed → translate by the window - decoration / client-area offset; hooked capture → relative to the mirrored viewport - only (decorations aren't mirrored); borderless → the same under both backends. - *Critical gating:* forward only when the host's main window is focused **and** ImGui - doesn't want the event (`ImGuiIO::WantCaptureMouse` / `WantCaptureKeyboard`), so - interacting with the overlay's own windows never leaks input into the game. The host - sends MKB events to the hook over a new (or extended) IPC region. - **Rumble / haptics forwarding (both backends).** Currently unsupported — the XInput hook swallows `XInputSetState`. Add a reverse path: the hook captures the game's `XInputSetState` (left/right motor) and publishes it over a hook→host channel (the diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 6a7592f..c24aa0e 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -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 head; // producer (host) write position + std::atomic 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::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 diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index d8ef30e..118060a 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(coop_hook SHARED src/audio_hook.cpp src/present_hook.cpp src/opengl_hook.cpp + src/mkb_hook.cpp src/debug_log.cpp src/hook_registry.cpp) diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index 711eabd..fadc9be 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -20,6 +20,7 @@ #include "focus_spoof.hpp" #include "hook_registry.hpp" #include "ipc_client.hpp" +#include "mkb_hook.hpp" #include "opengl_hook.hpp" #include "present_hook.hpp" #include "xinput_hook.hpp" @@ -71,6 +72,7 @@ DWORD WINAPI worker_thread(LPVOID) bool audio_installed = false; bool audio_ring_open = false; bool video_installed = false; + bool mkb_installed = false; // Each tick, reconcile each subsystem with the host's requested state: install // what's wanted but missing (modules / the game window may appear lazily) and @@ -147,6 +149,25 @@ DWORD WINAPI worker_thread(LPVOID) coop::hook::logf("worker_thread: video hooks removed (host request)"); } + // --- Mouse + keyboard forwarding --- + // Opt-in. When on, the host streams MKB events into the shared ring; we post + // them to the game and synthesize polling state. Drained at high rate below. + const bool want_mkb = g_ipc.subsystem_install_requested(coop::HookSubsys_Mkb); + if (want_mkb && !mkb_installed) + { + mkb_installed = coop::hook::install_mkb_hooks(g_ipc); + if (mkb_installed) + { + coop::hook::logf("worker_thread: MKB hooks installed"); + } + } + else if (!want_mkb && mkb_installed) + { + coop::hook::remove_mkb_hooks(); + mkb_installed = false; + coop::hook::logf("worker_thread: MKB hooks removed (host request)"); + } + if (audio_installed && !audio_ring_open) { const std::wstring name = coop::audio_ring_name(GetCurrentProcessId()); @@ -179,7 +200,17 @@ DWORD WINAPI worker_thread(LPVOID) coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change coop::hook::hook_publish(g_ipc); // installed-hooks list + call counts g_ipc.heartbeat(); - Sleep(250); + + // Reconcile ~4x/s, but drain MKB events far more often (input must be + // responsive). 50 slices x 5 ms ~= the old 250 ms reconcile period. + for (int slice = 0; slice < 50 && g_running.load(std::memory_order_relaxed); ++slice) + { + if (mkb_installed) + { + coop::hook::mkb_pump(g_ipc); + } + Sleep(5); + } } if (com_ok) @@ -214,6 +245,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) coop::hook::remove_audio_hooks(); coop::hook::remove_present_hooks(); coop::hook::remove_opengl_hooks(); + coop::hook::remove_mkb_hooks(); coop::hook::hook_registry_reset(); } break; diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index 0fd5e34..8a7e349 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -194,6 +194,15 @@ public: } } + // --- Mouse + keyboard forwarding --------------------------------------- + + // The host's MKB event queue (nullptr if not connected). The MKB subsystem + // drains it; the host is the sole producer. + [[nodiscard]] MkbRing* mkb_ring() + { + return block_ != nullptr ? &block_->mkb : nullptr; + } + // --- Hook registry ----------------------------------------------------- // Publish the installed-hooks table (name / subsystem / installed / calls). diff --git a/hook/src/mkb_hook.cpp b/hook/src/mkb_hook.cpp new file mode 100644 index 0000000..ab8aff6 --- /dev/null +++ b/hook/src/mkb_hook.cpp @@ -0,0 +1,331 @@ +#include "mkb_hook.hpp" + +#include + +#include + +#include + +#include "hook_registry.hpp" + +namespace coop::hook +{ + +namespace +{ + +// Synthesized input state the polling hooks report. Written by the worker thread +// (mkb_pump), read by the game's thread inside the detours -> all atomic. +std::atomic g_active{false}; +std::atomic g_key_down[256]; // by Win32 virtual-key (incl. VK_LBUTTON etc.) +std::atomic g_cursor_x{0}; // last forwarded mouse position (game client px) +std::atomic g_cursor_y{0}; +std::atomic g_have_cursor{false}; // a mouse event has been forwarded at least once +std::atomic g_target{nullptr}; // game main window (HWND), resolved lazily + +safetyhook::InlineHook g_hk_async; +safetyhook::InlineHook g_hk_kbstate; +safetyhook::InlineHook g_hk_cursor; + +int g_id_pump = -1; +int g_id_async = -1; +int g_id_kbstate = -1; +int g_id_cursor = -1; +bool g_installed = false; + +// --- Synthesized-state detours (let polling games see forwarded input) ------ + +SHORT WINAPI hk_GetAsyncKeyState(int vkey) +{ + const SHORT orig = g_hk_async.stdcall(vkey); + if (g_active.load(std::memory_order_relaxed) && vkey >= 0 && vkey < 256 && + g_key_down[vkey].load(std::memory_order_relaxed)) + { + return static_cast(0x8000) | (orig & 0x1); + } + return orig; +} + +BOOL WINAPI hk_GetKeyboardState(PBYTE state) +{ + const BOOL r = g_hk_kbstate.stdcall(state); + if (r && state != nullptr && g_active.load(std::memory_order_relaxed)) + { + for (int vk = 0; vk < 256; ++vk) + { + if (g_key_down[vk].load(std::memory_order_relaxed)) + { + state[vk] |= 0x80; + } + } + } + return r; +} + +BOOL WINAPI hk_GetCursorPos(LPPOINT pt) +{ + const BOOL r = g_hk_cursor.stdcall(pt); + if (g_active.load(std::memory_order_relaxed) && g_have_cursor.load(std::memory_order_relaxed) && pt != nullptr) + { + auto* hwnd = static_cast(g_target.load(std::memory_order_relaxed)); + if (hwnd != nullptr) + { + POINT c{g_cursor_x.load(std::memory_order_relaxed), g_cursor_y.load(std::memory_order_relaxed)}; + ClientToScreen(hwnd, &c); // synth state is game-client; GetCursorPos is screen-space + *pt = c; + return TRUE; + } + } + return r; +} + +// --- Target window + message synthesis -------------------------------------- + +struct FindCtx +{ + DWORD pid; + HWND best; + long area; +}; + +BOOL CALLBACK find_main_proc(HWND hwnd, LPARAM lparam) +{ + auto* c = reinterpret_cast(lparam); + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid != c->pid || !IsWindowVisible(hwnd) || GetWindow(hwnd, GW_OWNER) != nullptr) + { + return TRUE; + } + RECT r{}; + GetWindowRect(hwnd, &r); + const long area = (r.right - r.left) * (r.bottom - r.top); + if (area > c->area) + { + c->area = area; + c->best = hwnd; + } + return TRUE; +} + +HWND find_main_window() +{ + FindCtx c{GetCurrentProcessId(), nullptr, 0}; + EnumWindows(&find_main_proc, reinterpret_cast(&c)); + return c.best; +} + +LPARAM key_lparam(UINT vk, bool key_up) +{ + const UINT scan = MapVirtualKeyW(vk, MAPVK_VK_TO_VSC); + LPARAM lp = 1 | (static_cast(scan) << 16); // repeat count 1 + scan code + if (key_up) + { + lp |= (LPARAM{1} << 30) | (LPARAM{1} << 31); // previous-down + transition (key released) + } + return lp; +} + +void set_key(UINT vk, bool down) +{ + if (vk < 256) + { + g_key_down[vk].store(down, std::memory_order_relaxed); + } +} + +WPARAM mouse_button_wparam() +{ + WPARAM w = 0; + if (g_key_down[VK_LBUTTON].load(std::memory_order_relaxed)) + { + w |= MK_LBUTTON; + } + if (g_key_down[VK_RBUTTON].load(std::memory_order_relaxed)) + { + w |= MK_RBUTTON; + } + if (g_key_down[VK_MBUTTON].load(std::memory_order_relaxed)) + { + w |= MK_MBUTTON; + } + return w; +} + +void handle_mouse(const MkbEvent& ev, bool down, HWND hwnd) +{ + g_cursor_x.store(ev.x, std::memory_order_relaxed); + g_cursor_y.store(ev.y, std::memory_order_relaxed); + g_have_cursor.store(true, std::memory_order_relaxed); + + const UINT vk = ev.code == 0 ? VK_LBUTTON : ev.code == 1 ? VK_RBUTTON : VK_MBUTTON; + set_key(vk, down); + if (hwnd == nullptr) + { + return; + } + UINT msg; + if (ev.code == 0) + { + msg = down ? WM_LBUTTONDOWN : WM_LBUTTONUP; + } + else if (ev.code == 1) + { + msg = down ? WM_RBUTTONDOWN : WM_RBUTTONUP; + } + else + { + msg = down ? WM_MBUTTONDOWN : WM_MBUTTONUP; + } + PostMessageW(hwnd, msg, mouse_button_wparam(), MAKELPARAM(ev.x, ev.y)); +} + +void handle_wheel(const MkbEvent& ev, HWND hwnd) +{ + g_cursor_x.store(ev.x, std::memory_order_relaxed); + g_cursor_y.store(ev.y, std::memory_order_relaxed); + g_have_cursor.store(true, std::memory_order_relaxed); + if (hwnd == nullptr) + { + return; + } + POINT pt{ev.x, ev.y}; + ClientToScreen(hwnd, &pt); // WM_MOUSEWHEEL lParam is screen coordinates + const short delta = static_cast(static_cast(ev.code)); + PostMessageW(hwnd, WM_MOUSEWHEEL, MAKEWPARAM(0, delta), MAKELPARAM(pt.x, pt.y)); +} + +void install_user32_hook(HMODULE user32, const char* name, void* detour, safetyhook::InlineHook& slot, int id) +{ + if (user32 == nullptr) + { + return; + } + if (void* target = reinterpret_cast(GetProcAddress(user32, name))) + { + slot = safetyhook::create_inline(target, detour); + if (slot) + { + hook_set_installed(id, true); + } + } +} + +} // namespace + +bool install_mkb_hooks(IpcClient& ipc) +{ + if (g_installed) + { + return true; + } + + if (g_id_pump < 0) + { + g_id_pump = hook_register("MKB pump (PostMessage)", HookSubsys_Mkb); + g_id_async = hook_register("GetAsyncKeyState", HookSubsys_Mkb); + g_id_kbstate = hook_register("GetKeyboardState", HookSubsys_Mkb); + g_id_cursor = hook_register("GetCursorPos", HookSubsys_Mkb); + } + + // Fresh synthesized state so a previous session leaves no stuck keys. + for (int vk = 0; vk < 256; ++vk) + { + g_key_down[vk].store(false, std::memory_order_relaxed); + } + g_have_cursor.store(false, std::memory_order_relaxed); + g_target.store(nullptr, std::memory_order_relaxed); + + HMODULE user32 = GetModuleHandleW(L"user32.dll"); + install_user32_hook(user32, "GetAsyncKeyState", reinterpret_cast(&hk_GetAsyncKeyState), g_hk_async, + g_id_async); + install_user32_hook(user32, "GetKeyboardState", reinterpret_cast(&hk_GetKeyboardState), g_hk_kbstate, + g_id_kbstate); + install_user32_hook(user32, "GetCursorPos", reinterpret_cast(&hk_GetCursorPos), g_hk_cursor, g_id_cursor); + + g_active.store(true, std::memory_order_release); + hook_set_installed(g_id_pump, true); + g_installed = true; + (void)ipc; + return true; +} + +void remove_mkb_hooks() +{ + if (!g_installed) + { + return; + } + g_active.store(false, std::memory_order_release); + g_hk_async = {}; // InlineHook destructor restores the original bytes + g_hk_kbstate = {}; + g_hk_cursor = {}; + for (int vk = 0; vk < 256; ++vk) + { + g_key_down[vk].store(false, std::memory_order_relaxed); // no stuck keys + } + g_have_cursor.store(false, std::memory_order_relaxed); + hook_set_installed(g_id_pump, false); + hook_set_installed(g_id_async, false); + hook_set_installed(g_id_kbstate, false); + hook_set_installed(g_id_cursor, false); + g_installed = false; +} + +void mkb_pump(IpcClient& ipc) +{ + MkbRing* ring = ipc.mkb_ring(); + if (ring == nullptr || !g_active.load(std::memory_order_relaxed)) + { + return; + } + + HWND hwnd = static_cast(g_target.load(std::memory_order_relaxed)); + if (hwnd == nullptr || !IsWindow(hwnd)) + { + hwnd = find_main_window(); + g_target.store(hwnd, std::memory_order_relaxed); + } + + MkbEvent ev{}; + while (pop_mkb_event(*ring, ev)) + { + hook_note_call(g_id_pump); + switch (ev.type) + { + case Mkb_KeyDown: + set_key(ev.code, true); + if (hwnd != nullptr) + { + PostMessageW(hwnd, WM_KEYDOWN, ev.code, key_lparam(ev.code, false)); + } + break; + case Mkb_KeyUp: + set_key(ev.code, false); + if (hwnd != nullptr) + { + PostMessageW(hwnd, WM_KEYUP, ev.code, key_lparam(ev.code, true)); + } + break; + case Mkb_Char: + if (hwnd != nullptr) + { + PostMessageW(hwnd, WM_CHAR, ev.code, 1); + } + break; + case Mkb_MouseDown: + handle_mouse(ev, true, hwnd); + break; + case Mkb_MouseUp: + handle_mouse(ev, false, hwnd); + break; + case Mkb_Wheel: + handle_wheel(ev, hwnd); + break; + default: + break; + } + } +} + +} // namespace coop::hook diff --git a/hook/src/mkb_hook.hpp b/hook/src/mkb_hook.hpp new file mode 100644 index 0000000..0118b6b --- /dev/null +++ b/hook/src/mkb_hook.hpp @@ -0,0 +1,23 @@ +// Mouse + keyboard forwarding subsystem (HookSubsys_Mkb). +// +// The host captures its own window's MKB input and pushes events into the shared +// MkbRing. This subsystem drains them on the worker thread, posts the matching +// window messages to the game's main window (so message-driven games react), and +// maintains a synthesized input state that hooked GetAsyncKeyState / +// GetKeyboardState / GetCursorPos report (so polling games react too). The toggle +// is the subsystem itself: not installed -> nothing is forwarded. +#pragma once + +#include "ipc_client.hpp" + +namespace coop::hook +{ + +bool install_mkb_hooks(IpcClient& ipc); +void remove_mkb_hooks(); + +// Drain the host's MKB event queue: post window messages + update synthesized +// state. Called frequently from the worker loop while the subsystem is installed. +void mkb_pump(IpcClient& ipc); + +} // namespace coop::hook diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index e4a8017..4d666c8 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -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 diff --git a/host/src/capture_panel.hpp b/host/src/capture_panel.hpp index 00d650b..66b3720 100644 --- a/host/src/capture_panel.hpp +++ b/host/src/capture_panel.hpp @@ -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 { diff --git a/host/src/inject/mkb_forward.cpp b/host/src/inject/mkb_forward.cpp new file mode 100644 index 0000000..eba1775 --- /dev/null +++ b/host/src/inject/mkb_forward.cpp @@ -0,0 +1,207 @@ +#include "inject/mkb_forward.hpp" + +#include + +#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(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(vk), 0, 0}); + } + if (ImGui::IsKeyReleased(k)) + { + injection.push_mkb(MkbEvent{Mkb_KeyUp, static_cast(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(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(io.MousePos.x); + m.host_y = static_cast(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(v.width); + m.src_h = m.client_h = static_cast(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(button), mx, my}); + } + if (ImGui::IsMouseReleased(button)) // send the up even off-game, so nothing sticks + { + injection.push_mkb(MkbEvent{Mkb_MouseUp, static_cast(button), mx, my}); + } + } + if (on_game && io.MouseWheel != 0.0f) + { + const int delta = static_cast(io.MouseWheel * WHEEL_DELTA); + injection.push_mkb(MkbEvent{Mkb_Wheel, static_cast(delta), mx, my}); + } +} + +} // namespace coop diff --git a/host/src/inject/mkb_forward.hpp b/host/src/inject/mkb_forward.hpp new file mode 100644 index 0000000..8be24dc --- /dev/null +++ b/host/src/inject/mkb_forward.hpp @@ -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 + +namespace coop +{ + +class InjectionPanel; + +void forward_mkb_frame(InjectionPanel& injection, HWND host_hwnd, bool mirroring, bool source_hooked); + +} // namespace coop diff --git a/host/src/inject/mkb_map.hpp b/host/src/inject/mkb_map.hpp new file mode 100644 index 0000000..91959de --- /dev/null +++ b/host/src/inject/mkb_map.hpp @@ -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 + +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(in.dst_w) / in.src_w, static_cast(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(cx); + gy = static_cast(cy); + return true; +} + +} // namespace coop diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index 08a9111..e8618ce 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -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& 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) diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 8e7014f..14d0de5 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -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?). diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index fc3f609..2bbe230 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -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 diff --git a/host/src/main.cpp b/host/src/main.cpp index 23ca852..b820923 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -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(client.right - client.left); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 794ee75..a8f0300 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,17 @@ add_executable(audio_ring_test audio_ring_test.cpp) target_link_libraries(audio_ring_test PRIVATE coop_common) add_test(NAME audio_ring_test COMMAND audio_ring_test) +# Unit test for the MKB event ring (SPSC push/pop, wrap-around, full/empty). +add_executable(mkb_ring_test mkb_ring_test.cpp) +target_link_libraries(mkb_ring_test PRIVATE coop_common) +add_test(NAME mkb_ring_test COMMAND mkb_ring_test) + +# Unit test for the host->game mouse coordinate mapping (letterbox inverse + +# decorated-window client offset). Header-only, no device. +add_executable(mkb_map_test mkb_map_test.cpp) +target_include_directories(mkb_map_test PRIVATE ${CMAKE_SOURCE_DIR}/host/src) +add_test(NAME mkb_map_test COMMAND mkb_map_test) + # Integration test for WASAPI process-loopback capture. Reuses the shipping # capture code and captures from coop_tone (a known sine-wave render process). add_executable(audio_loopback_test @@ -114,6 +125,8 @@ add_test(NAME opengl_hook_test COMMAND opengl_hook_test) coop_output_subdir(tests hook_selftest audio_ring_test + mkb_ring_test + mkb_map_test audio_loopback_test audio_hook_test srgb_format_test diff --git a/tests/mkb_map_test.cpp b/tests/mkb_map_test.cpp new file mode 100644 index 0000000..b25a154 --- /dev/null +++ b/tests/mkb_map_test.cpp @@ -0,0 +1,93 @@ +// Unit test for the host->game mouse coordinate mapping (letterbox inverse + +// client-offset handling for WGC-of-decorated-window vs hooked/borderless). +#include + +#include "inject/mkb_map.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool cond, const char* what) +{ + if (!cond) + { + std::printf("FAIL: %s\n", what); + ++g_failures; + } +} +} // namespace + +int main() +{ + // --- Hooked / borderless: frame == client, no offset. dst exactly matches src + // (no letterbox), so the mapping is the identity. + { + MkbMapInput in; + in.dst_w = in.src_w = in.client_w = 1920; + in.dst_h = in.src_h = in.client_h = 1080; + int gx = 0, gy = 0; + check(map_host_to_game_client(in, gx, gy) && gx == 0 && gy == 0, "identity: top-left"); + in.host_x = 960; + in.host_y = 540; + check(map_host_to_game_client(in, gx, gy) && gx == 960 && gy == 540, "identity: center"); + } + + // --- Letterbox: a 1600x900 (16:9) frame fit into a 1000x1000 host window. Scale + // = 1000/1600 = 0.625, vertical bars of (1000 - 562.5)/2 = 218.75 px. + { + MkbMapInput in; + in.dst_w = 1000; + in.dst_h = 1000; + in.src_w = in.client_w = 1600; + in.src_h = in.client_h = 900; + int gx = 0, gy = 0; + // A click in the top letterbox bar is rejected. + in.host_x = 500; + in.host_y = 10; + check(!map_host_to_game_client(in, gx, gy), "letterbox: top bar rejected"); + // Center of the host window maps to center of the game frame. + in.host_x = 500; + in.host_y = 500; + check(map_host_to_game_client(in, gx, gy) && gx == 800 && gy == 450, "letterbox: center maps to game center"); + // Just inside the image at the top edge (y = 218.75 -> 219) maps near client top. + in.host_x = 500; + in.host_y = 219; + check(map_host_to_game_client(in, gx, gy) && gy >= 0 && gy <= 2, "letterbox: top edge maps near 0"); + } + + // --- WGC of a decorated window: frame is the whole window (1608x939) with the + // client area (1600x900) offset by an 8 px left border / 31 px title bar. No + // host letterbox here (dst matches the window frame). + { + MkbMapInput in; + in.dst_w = in.src_w = 1608; + in.dst_h = in.src_h = 939; + in.client_off_x = 8; + in.client_off_y = 31; + in.client_w = 1600; + in.client_h = 900; + int gx = 0, gy = 0; + // A click on the title bar (above the client area) is rejected. + in.host_x = 800; + in.host_y = 10; + check(!map_host_to_game_client(in, gx, gy), "decorated: title bar rejected"); + // Client top-left corner. + in.host_x = 8; + in.host_y = 31; + check(map_host_to_game_client(in, gx, gy) && gx == 0 && gy == 0, "decorated: client origin"); + // A point inside the client area subtracts the decoration offset. + in.host_x = 108; + in.host_y = 131; + check(map_host_to_game_client(in, gx, gy) && gx == 100 && gy == 100, "decorated: client interior"); + } + + if (g_failures == 0) + { + std::printf("PASS: mkb_map_test\n"); + return 0; + } + std::printf("FAIL: %d checks failed\n", g_failures); + return 1; +} diff --git a/tests/mkb_ring_test.cpp b/tests/mkb_ring_test.cpp new file mode 100644 index 0000000..23437f2 --- /dev/null +++ b/tests/mkb_ring_test.cpp @@ -0,0 +1,82 @@ +// Unit test for the MKB event ring (SPSC push/pop, wrap-around, full/empty). +#include + +#include "coop/protocol.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool cond, const char* what) +{ + if (!cond) + { + std::printf("FAIL: %s\n", what); + ++g_failures; + } +} +} // namespace + +int main() +{ + MkbRing ring{}; + + // Empty pop fails. + MkbEvent out{}; + check(!pop_mkb_event(ring, out), "pop on empty ring returns false"); + + // Push then pop returns the same event, FIFO. + for (std::uint32_t i = 0; i < 10; ++i) + { + MkbEvent ev{Mkb_KeyDown, i, static_cast(i) * 2, static_cast(i) * 3}; + check(push_mkb_event(ring, ev), "push succeeds with room"); + } + for (std::uint32_t i = 0; i < 10; ++i) + { + check(pop_mkb_event(ring, out), "pop succeeds with data"); + check(out.code == i && out.x == static_cast(i) * 2 && out.y == static_cast(i) * 3, + "popped event matches pushed (FIFO)"); + } + check(!pop_mkb_event(ring, out), "ring empty again after draining"); + + // Fill to capacity, then one more push is dropped. + for (std::uint32_t i = 0; i < kMkbQueueSize; ++i) + { + check(push_mkb_event(ring, MkbEvent{Mkb_Char, i, 0, 0}), "push fills to capacity"); + } + check(!push_mkb_event(ring, MkbEvent{Mkb_Char, 999, 0, 0}), "push on full ring is dropped"); + + // Drain and verify order survived a full buffer. + for (std::uint32_t i = 0; i < kMkbQueueSize; ++i) + { + check(pop_mkb_event(ring, out) && out.code == i, "full-buffer drain is in order"); + } + + // Wrap-around: indices are free-running, so many cycles must keep working. + std::uint32_t produced = 0, consumed = 0; + for (int cycle = 0; cycle < 1000; ++cycle) + { + for (int k = 0; k < 50; ++k) + { + if (push_mkb_event(ring, MkbEvent{Mkb_MouseDown, produced, 0, 0})) + { + ++produced; + } + } + while (pop_mkb_event(ring, out)) + { + check(out.code == consumed, "wrap-around preserves FIFO order"); + ++consumed; + } + } + check(produced == consumed, "all wrap-around events consumed"); + + if (g_failures == 0) + { + std::printf("PASS: mkb_ring_test\n"); + return 0; + } + std::printf("FAIL: %d checks failed\n", g_failures); + return 1; +} diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp index e624984..db58b5e 100644 --- a/tools/audio_probe/main.cpp +++ b/tools/audio_probe/main.cpp @@ -308,12 +308,12 @@ int wmain(int argc, wchar_t** argv) } // Dump the hook registry so the installed-hooks list can be verified headless. - static const char* kSubsys[] = {"Input", "Focus", "Audio", "Video"}; + static const char* kSubsys[] = {"Input", "Focus", "Audio", "Video", "MKB"}; std::printf("\nInstalled hooks (%u):\n", status.hook_entry_count); for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i) { const coop::HookEntry& e = status.hook_entries[i]; - std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 4 ? kSubsys[e.subsystem] : "?", e.name, + std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 5 ? kSubsys[e.subsystem] : "?", e.name, e.installed ? "ON " : "off", static_cast(e.calls)); } diff --git a/tools/input_probe/main.cpp b/tools/input_probe/main.cpp index 4722c0a..94b1730 100644 --- a/tools/input_probe/main.cpp +++ b/tools/input_probe/main.cpp @@ -161,10 +161,12 @@ int wmain(int argc, wchar_t** argv) // Subsystem isolation: skip installing the ones whose bit is set in disable_mask // (0x1=input 0x2=focus 0x4=audio 0x8=video). Lets us bisect which injected // subsystem freezes a given game. - static const char* kSubsysNames[] = {"input", "focus", "audio", "video"}; + static const char* kSubsysNames[] = {"input", "focus", "audio", "video", "mkb"}; for (std::uint32_t i = 0; i < coop::HookSubsys_Count; ++i) { - const bool disabled = (disable_mask & (1u << i)) != 0; + // MKB forwarding needs the host to stream events, which this probe doesn't, so + // keep it off here regardless of the mask (avoids confounding crash bisection). + const bool disabled = (i == coop::HookSubsys_Mkb) || (disable_mask & (1u << i)) != 0; block->control.subsystem_disabled[i].store(disabled ? 1u : 0u, std::memory_order_release); std::printf("subsystem %-6s %s\n", kSubsysNames[i], disabled ? "DISABLED" : "on"); } @@ -238,11 +240,11 @@ int wmain(int argc, wchar_t** argv) } std::printf("\nInstalled hooks (%u):\n", status.hook_entry_count); - static const char* kSubsys[] = {"Input", "Focus", "Audio", "Video"}; + static const char* kSubsys[] = {"Input", "Focus", "Audio", "Video", "MKB"}; for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i) { const coop::HookEntry& e = status.hook_entries[i]; - std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 4 ? kSubsys[e.subsystem] : "?", e.name, + std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 5 ? kSubsys[e.subsystem] : "?", e.name, e.installed ? "ON " : "off", static_cast(e.calls)); }