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

@@ -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

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

View File

@@ -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)

View File

@@ -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;

View File

@@ -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).

331
hook/src/mkb_hook.cpp Normal file
View File

@@ -0,0 +1,331 @@
#include "mkb_hook.hpp"
#include <atomic>
#include <windows.h>
#include <safetyhook.hpp>
#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<bool> g_active{false};
std::atomic<bool> g_key_down[256]; // by Win32 virtual-key (incl. VK_LBUTTON etc.)
std::atomic<long> g_cursor_x{0}; // last forwarded mouse position (game client px)
std::atomic<long> g_cursor_y{0};
std::atomic<bool> g_have_cursor{false}; // a mouse event has been forwarded at least once
std::atomic<void*> 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<SHORT>(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<SHORT>(0x8000) | (orig & 0x1);
}
return orig;
}
BOOL WINAPI hk_GetKeyboardState(PBYTE state)
{
const BOOL r = g_hk_kbstate.stdcall<BOOL>(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<BOOL>(pt);
if (g_active.load(std::memory_order_relaxed) && g_have_cursor.load(std::memory_order_relaxed) && pt != nullptr)
{
auto* hwnd = static_cast<HWND>(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<FindCtx*>(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<LPARAM>(&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<LPARAM>(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<short>(static_cast<std::int32_t>(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<void*>(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<void*>(&hk_GetAsyncKeyState), g_hk_async,
g_id_async);
install_user32_hook(user32, "GetKeyboardState", reinterpret_cast<void*>(&hk_GetKeyboardState), g_hk_kbstate,
g_id_kbstate);
install_user32_hook(user32, "GetCursorPos", reinterpret_cast<void*>(&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<HWND>(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

23
hook/src/mkb_hook.hpp Normal file
View File

@@ -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

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);

View File

@@ -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

93
tests/mkb_map_test.cpp Normal file
View File

@@ -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 <cstdio>
#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;
}

82
tests/mkb_ring_test.cpp Normal file
View File

@@ -0,0 +1,82 @@
// Unit test for the MKB event ring (SPSC push/pop, wrap-around, full/empty).
#include <cstdio>
#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<int>(i) * 2, static_cast<int>(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<int>(i) * 2 && out.y == static_cast<int>(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;
}

View File

@@ -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<unsigned long long>(e.calls));
}

View File

@@ -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<unsigned long long>(e.calls));
}