Release the operator cursor for cursor-clipping games
Games that ClipCursor / re-center via SetCursorPos while focused trap the operator's mouse (the focus spoof makes them think they're always focused), so the operator can't reach the overlay. The Focus subsystem now inline-hooks ClipCursor and SetCursorPos (stdcall trampolines): while "release" is requested it forces ClipCursor(NULL) and swallows the re-centering SetCursorPos; otherwise it passes them through. It frees any existing clip at install and re-frees each worker tick (covers one-time clippers and a runtime clip->release toggle). Host: protocol v10->v11 adds HookControl::allow_cursor_clip (0 = release, the default). The Injection panel gets a "Release operator cursor" checkbox and an F2 hotkey (InjectionPanel::toggle_cursor_release); default released, since the guest plays via the pad so the game's clip is operator-only. Verified: full build x64 + x86 clean; ctest x64 9/9, x86 3/3. The cursor behavior against a real clipping game (Trails through Daybreak) needs a live injected session to confirm; logic reviewed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -76,15 +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
|
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.
|
need a real game (and Remote Play) to fully validate come last.
|
||||||
|
|
||||||
- **Release the mouse cursor for cursor-clipping games.** Games that confine the
|
|
||||||
cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / per-frame
|
|
||||||
`SetCursorPos` re-centering) trap the operator's mouse permanently, because the
|
|
||||||
focus spoof makes the game believe it's always focused — so the operator can't
|
|
||||||
reach the ImGui overlay. Add a cursor-release capability to the Focus subsystem:
|
|
||||||
hook `ClipCursor` (force `ClipCursor(NULL)` and swallow the game's clip) and the
|
|
||||||
re-centering `SetCursorPos`, gated by a new host→hook flag driven by a host
|
|
||||||
toggle + hotkey. Defaults to released (the guest plays via the pad, so the game's
|
|
||||||
own cursor clip is operator-only), with the option to re-enable clipping per game.
|
|
||||||
- **Real capture metrics + latency stats.** The current FPS readout only measures
|
- **Real capture metrics + latency stats.** The current FPS readout only measures
|
||||||
how fast the host renders its own window, which hides capture stutter. Add a
|
how fast the host renders its own window, which hides capture stutter. Add a
|
||||||
three-line frametime/FPS graph — **game present rate** (from `VideoShare` present
|
three-line frametime/FPS graph — **game present rate** (from `VideoShare` present
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace coop
|
|||||||
|
|
||||||
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
||||||
// refuses to attach to a host with a mismatched version.
|
// refuses to attach to a host with a mismatched version.
|
||||||
inline constexpr std::uint32_t kProtocolVersion = 10;
|
inline constexpr std::uint32_t kProtocolVersion = 11;
|
||||||
|
|
||||||
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
||||||
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
||||||
@@ -145,6 +145,12 @@ struct HookStatus
|
|||||||
struct HookControl
|
struct HookControl
|
||||||
{
|
{
|
||||||
std::atomic<std::uint32_t> subsystem_disabled[HookSubsys_Count];
|
std::atomic<std::uint32_t> subsystem_disabled[HookSubsys_Count];
|
||||||
|
|
||||||
|
// Cursor handling for cursor-clipping games (part of the Focus subsystem).
|
||||||
|
// 0 = release the operator's mouse: the hook frees the game's ClipCursor and
|
||||||
|
// swallows its re-centering SetCursorPos (the default, so the operator can reach
|
||||||
|
// the overlay). 1 = let the game clip / position the cursor as normal.
|
||||||
|
std::atomic<std::uint32_t> allow_cursor_clip;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Present-hook video channel. When the video subsystem is installed, the hook
|
// Present-hook video channel. When the video subsystem is installed, the hook
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ DWORD WINAPI worker_thread(LPVOID)
|
|||||||
coop::hook::republish_audio_format();
|
coop::hook::republish_audio_format();
|
||||||
}
|
}
|
||||||
coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change
|
coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change
|
||||||
|
coop::hook::release_cursor_tick(); // free the operator's mouse if requested (no-op otherwise)
|
||||||
coop::hook::hook_publish(g_ipc); // installed-hooks list + call counts
|
coop::hook::hook_publish(g_ipc); // installed-hooks list + call counts
|
||||||
g_ipc.heartbeat();
|
g_ipc.heartbeat();
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ int g_id_foreground = -1;
|
|||||||
int g_id_active = -1;
|
int g_id_active = -1;
|
||||||
int g_id_focus = -1;
|
int g_id_focus = -1;
|
||||||
int g_id_wndproc = -1;
|
int g_id_wndproc = -1;
|
||||||
|
int g_id_clipcursor = -1;
|
||||||
|
int g_id_setcursorpos = -1;
|
||||||
|
|
||||||
|
// Cursor-release hooks kept separately so their trampolines can be called.
|
||||||
|
safetyhook::InlineHook g_hk_clipcursor;
|
||||||
|
safetyhook::InlineHook g_hk_setcursorpos;
|
||||||
|
|
||||||
struct EnumContext
|
struct EnumContext
|
||||||
{
|
{
|
||||||
@@ -125,6 +131,28 @@ HWND WINAPI hk_GetFocus()
|
|||||||
return g_game_hwnd;
|
return g_game_hwnd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When cursor release is requested (the default), free any clip the game asks for so
|
||||||
|
// the operator's mouse isn't trapped; otherwise honor the game's clip.
|
||||||
|
BOOL WINAPI hk_ClipCursor(const RECT* rect)
|
||||||
|
{
|
||||||
|
hook_note_call(g_id_clipcursor);
|
||||||
|
const bool allow = g_focus_ipc != nullptr && g_focus_ipc->cursor_clip_allowed();
|
||||||
|
return g_hk_clipcursor.stdcall<BOOL>(allow ? rect : nullptr);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swallow the game's per-frame cursor re-centering while releasing, so the operator's
|
||||||
|
// mouse can move freely (e.g. to reach the overlay); pass it through when clipping.
|
||||||
|
BOOL WINAPI hk_SetCursorPos(int x, int y)
|
||||||
|
{
|
||||||
|
hook_note_call(g_id_setcursorpos);
|
||||||
|
const bool allow = g_focus_ipc != nullptr && g_focus_ipc->cursor_clip_allowed();
|
||||||
|
if (!allow)
|
||||||
|
{
|
||||||
|
return TRUE;
|
||||||
|
}
|
||||||
|
return g_hk_setcursorpos.stdcall<BOOL>(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
void hook_export(HMODULE module, const char* name, void* detour, int registry_id)
|
void hook_export(HMODULE module, const char* name, void* detour, int registry_id)
|
||||||
{
|
{
|
||||||
if (void* target = reinterpret_cast<void*>(GetProcAddress(module, name)))
|
if (void* target = reinterpret_cast<void*>(GetProcAddress(module, name)))
|
||||||
@@ -148,6 +176,8 @@ bool install_focus_spoof(IpcClient& ipc)
|
|||||||
g_id_active = hook_register("GetActiveWindow", HookSubsys_Focus);
|
g_id_active = hook_register("GetActiveWindow", HookSubsys_Focus);
|
||||||
g_id_focus = hook_register("GetFocus", HookSubsys_Focus);
|
g_id_focus = hook_register("GetFocus", HookSubsys_Focus);
|
||||||
g_id_wndproc = hook_register("WndProc (deactivation guard)", HookSubsys_Focus);
|
g_id_wndproc = hook_register("WndProc (deactivation guard)", HookSubsys_Focus);
|
||||||
|
g_id_clipcursor = hook_register("ClipCursor (cursor release)", HookSubsys_Focus);
|
||||||
|
g_id_setcursorpos = hook_register("SetCursorPos (cursor release)", HookSubsys_Focus);
|
||||||
|
|
||||||
HWND hwnd = find_main_window(GetCurrentProcessId());
|
HWND hwnd = find_main_window(GetCurrentProcessId());
|
||||||
if (hwnd == nullptr)
|
if (hwnd == nullptr)
|
||||||
@@ -172,6 +202,23 @@ bool install_focus_spoof(IpcClient& ipc)
|
|||||||
g_id_foreground);
|
g_id_foreground);
|
||||||
hook_export(user32, "GetActiveWindow", reinterpret_cast<void*>(&hk_GetActiveWindow), g_id_active);
|
hook_export(user32, "GetActiveWindow", reinterpret_cast<void*>(&hk_GetActiveWindow), g_id_active);
|
||||||
hook_export(user32, "GetFocus", reinterpret_cast<void*>(&hk_GetFocus), g_id_focus);
|
hook_export(user32, "GetFocus", reinterpret_cast<void*>(&hk_GetFocus), g_id_focus);
|
||||||
|
|
||||||
|
if (void* clip = reinterpret_cast<void*>(GetProcAddress(user32, "ClipCursor")))
|
||||||
|
{
|
||||||
|
g_hk_clipcursor = safetyhook::create_inline(clip, reinterpret_cast<void*>(&hk_ClipCursor));
|
||||||
|
hook_set_installed(g_id_clipcursor, static_cast<bool>(g_hk_clipcursor));
|
||||||
|
}
|
||||||
|
if (void* setpos = reinterpret_cast<void*>(GetProcAddress(user32, "SetCursorPos")))
|
||||||
|
{
|
||||||
|
g_hk_setcursorpos = safetyhook::create_inline(setpos, reinterpret_cast<void*>(&hk_SetCursorPos));
|
||||||
|
hook_set_installed(g_id_setcursorpos, static_cast<bool>(g_hk_setcursorpos));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Free any clip the game already set, so release takes effect immediately.
|
||||||
|
if (!ipc.cursor_clip_allowed())
|
||||||
|
{
|
||||||
|
ClipCursor(nullptr);
|
||||||
}
|
}
|
||||||
|
|
||||||
ipc.mark_focus_spoof(true, reinterpret_cast<std::uint64_t>(hwnd));
|
ipc.mark_focus_spoof(true, reinterpret_cast<std::uint64_t>(hwnd));
|
||||||
@@ -212,6 +259,14 @@ void update_input_diagnostics(IpcClient& ipc)
|
|||||||
ipc.set_input_diagnostics(raw_registered, raw_gamepad, raw_gamepad_sink, dinput);
|
ipc.set_input_diagnostics(raw_registered, raw_gamepad, raw_gamepad_sink, dinput);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void release_cursor_tick()
|
||||||
|
{
|
||||||
|
if (g_focus_ipc != nullptr && g_game_hwnd != nullptr && !g_focus_ipc->cursor_clip_allowed())
|
||||||
|
{
|
||||||
|
ClipCursor(nullptr); // routes through hk_ClipCursor -> frees the cursor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void remove_focus_spoof()
|
void remove_focus_spoof()
|
||||||
{
|
{
|
||||||
if (g_game_hwnd != nullptr && g_orig_proc != nullptr)
|
if (g_game_hwnd != nullptr && g_orig_proc != nullptr)
|
||||||
@@ -226,10 +281,15 @@ void remove_focus_spoof()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
g_focus_hooks.clear();
|
g_focus_hooks.clear();
|
||||||
|
g_hk_clipcursor = {}; // restore ClipCursor / SetCursorPos before clearing state
|
||||||
|
g_hk_setcursorpos = {};
|
||||||
|
ClipCursor(nullptr); // leave the cursor free when the spoof is removed
|
||||||
hook_set_installed(g_id_foreground, false);
|
hook_set_installed(g_id_foreground, false);
|
||||||
hook_set_installed(g_id_active, false);
|
hook_set_installed(g_id_active, false);
|
||||||
hook_set_installed(g_id_focus, false);
|
hook_set_installed(g_id_focus, false);
|
||||||
hook_set_installed(g_id_wndproc, false);
|
hook_set_installed(g_id_wndproc, false);
|
||||||
|
hook_set_installed(g_id_clipcursor, false);
|
||||||
|
hook_set_installed(g_id_setcursorpos, false);
|
||||||
if (g_focus_ipc != nullptr)
|
if (g_focus_ipc != nullptr)
|
||||||
{
|
{
|
||||||
g_focus_ipc->mark_focus_spoof(false, 0);
|
g_focus_ipc->mark_focus_spoof(false, 0);
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ bool install_focus_spoof(IpcClient& ipc);
|
|||||||
// to call each tick.
|
// to call each tick.
|
||||||
void update_input_diagnostics(IpcClient& ipc);
|
void update_input_diagnostics(IpcClient& ipc);
|
||||||
|
|
||||||
|
// While the focus subsystem is installed, free the operator's mouse each tick if the
|
||||||
|
// host requested cursor release (covers games that ClipCursor once rather than every
|
||||||
|
// frame, and a runtime toggle from clip -> release). No-op when clipping is allowed.
|
||||||
|
void release_cursor_tick();
|
||||||
|
|
||||||
// Restores the original window procedure and removes the focus API hooks.
|
// Restores the original window procedure and removes the focus API hooks.
|
||||||
void remove_focus_spoof();
|
void remove_focus_spoof();
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ public:
|
|||||||
return block_->control.subsystem_disabled[subsystem].load(std::memory_order_acquire) == 0;
|
return block_->control.subsystem_disabled[subsystem].load(std::memory_order_acquire) == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Whether the game is allowed to clip/position the cursor (false = release it,
|
||||||
|
// the default). Drives the Focus subsystem's ClipCursor/SetCursorPos hooks.
|
||||||
|
[[nodiscard]] bool cursor_clip_allowed() const
|
||||||
|
{
|
||||||
|
return block_ != nullptr && block_->control.allow_cursor_clip.load(std::memory_order_acquire) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Copies a torn-free snapshot of all slots. Returns false only if the host
|
// Copies a torn-free snapshot of all slots. Returns false only if the host
|
||||||
// was mid-write for the whole spin window (caller should reuse its cache).
|
// was mid-write for the whole spin window (caller should reuse its cache).
|
||||||
bool snapshot(CoopPadState (&out)[kMaxPads], std::uint32_t& count) const
|
bool snapshot(CoopPadState (&out)[kMaxPads], std::uint32_t& count) const
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ void InjectionPanel::inject_selected()
|
|||||||
server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_);
|
server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_);
|
||||||
server_.set_subsystem_enabled(HookSubsys_Video, want_video_);
|
server_.set_subsystem_enabled(HookSubsys_Video, want_video_);
|
||||||
server_.set_subsystem_enabled(HookSubsys_Mkb, want_mkb_);
|
server_.set_subsystem_enabled(HookSubsys_Mkb, want_mkb_);
|
||||||
|
server_.set_cursor_clip_allowed(!release_cursor_);
|
||||||
|
|
||||||
const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
|
const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
|
||||||
if (result.status == InjectStatus::Ok)
|
if (result.status == InjectStatus::Ok)
|
||||||
@@ -399,6 +400,13 @@ void InjectionPanel::draw_subsystem_controls(const HookStatusView& status)
|
|||||||
}
|
}
|
||||||
ImGui::PopID();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cursor release is a Focus sub-option for games that clip/recenter the mouse
|
||||||
|
// (e.g. Trails through Daybreak), which would otherwise trap the operator.
|
||||||
|
if (ImGui::Checkbox("Release operator cursor (free the game's clip) [F2]", &release_cursor_))
|
||||||
|
{
|
||||||
|
server_.set_cursor_clip_allowed(!release_cursor_);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void InjectionPanel::draw_hook_status(bool debug_details)
|
void InjectionPanel::draw_hook_status(bool debug_details)
|
||||||
|
|||||||
@@ -128,6 +128,21 @@ public:
|
|||||||
server_.push_mkb(ev);
|
server_.push_mkb(ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Cursor release (for cursor-clipping games) ----------------------------
|
||||||
|
|
||||||
|
// Toggle whether the operator's mouse is released from the game's cursor clip
|
||||||
|
// (bound to a hotkey in the host). Republishes to the hook immediately.
|
||||||
|
void toggle_cursor_release()
|
||||||
|
{
|
||||||
|
release_cursor_ = !release_cursor_;
|
||||||
|
server_.set_cursor_clip_allowed(!release_cursor_);
|
||||||
|
}
|
||||||
|
|
||||||
|
[[nodiscard]] bool cursor_released() const
|
||||||
|
{
|
||||||
|
return release_cursor_;
|
||||||
|
}
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void refresh_targets(); // refresh both the window list and the process list
|
void refresh_targets(); // refresh both the window list and the process list
|
||||||
void refresh_processes();
|
void refresh_processes();
|
||||||
@@ -159,6 +174,7 @@ private:
|
|||||||
bool want_audio_ = true;
|
bool want_audio_ = true;
|
||||||
bool want_video_ = false; // Present-hook video path: opt-in (WGC is the default)
|
bool want_video_ = false; // Present-hook video path: opt-in (WGC is the default)
|
||||||
bool want_mkb_ = false; // mouse+keyboard forwarding: opt-in
|
bool want_mkb_ = false; // mouse+keyboard forwarding: opt-in
|
||||||
|
bool release_cursor_ = true; // free the operator's mouse from the game's clip (default)
|
||||||
bool injected_ = false; // a hook DLL is loaded in the target
|
bool injected_ = false; // a hook DLL is loaded in the target
|
||||||
|
|
||||||
// Heartbeat liveness tracking (is the injected DLL responding?).
|
// Heartbeat liveness tracking (is the injected DLL responding?).
|
||||||
|
|||||||
@@ -87,6 +87,16 @@ public:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Let the game clip/position the cursor (true) or release the operator's mouse
|
||||||
|
// (false, the default). No-op if not started.
|
||||||
|
void set_cursor_clip_allowed(bool allowed)
|
||||||
|
{
|
||||||
|
if (block_ != nullptr)
|
||||||
|
{
|
||||||
|
block_->control.allow_cursor_clip.store(allowed ? 1u : 0u, std::memory_order_release);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drain new log lines streamed by the hook, calling `emit(const LogRecord&)`
|
// 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.
|
// for each. No-op if not started. Header-only so the callback can stay generic.
|
||||||
template <typename F>
|
template <typename F>
|
||||||
|
|||||||
@@ -184,6 +184,10 @@ int run()
|
|||||||
overlay_hidden_at = ImGui::GetTime();
|
overlay_hidden_at = ImGui::GetTime();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (ImGui::IsKeyPressed(ImGuiKey_F2, false))
|
||||||
|
{
|
||||||
|
injection.toggle_cursor_release(); // free/clip the operator's mouse for clipping games
|
||||||
|
}
|
||||||
|
|
||||||
if (show_overlay)
|
if (show_overlay)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ float draw_main_menu_bar(UiState& ui, const FrameStats& stats)
|
|||||||
if (ImGui::BeginMenu("Help"))
|
if (ImGui::BeginMenu("Help"))
|
||||||
{
|
{
|
||||||
ImGui::TextDisabled("F1 hide/show this overlay");
|
ImGui::TextDisabled("F1 hide/show this overlay");
|
||||||
|
ImGui::TextDisabled("F2 release/clip the operator cursor");
|
||||||
ImGui::TextDisabled("Esc quit");
|
ImGui::TextDisabled("Esc quit");
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
ImGui::TextDisabled("This window is what Remote Play");
|
ImGui::TextDisabled("This window is what Remote Play");
|
||||||
|
|||||||
Reference in New Issue
Block a user