diff --git a/README.md b/README.md index 713b1a2..c05823f 100644 --- a/README.md +++ b/README.md @@ -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 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 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 diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 5708a9a..fc15338 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 = 10; +inline constexpr std::uint32_t kProtocolVersion = 11; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -145,6 +145,12 @@ struct HookStatus struct HookControl { std::atomic 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 allow_cursor_clip; }; // Present-hook video channel. When the video subsystem is installed, the hook diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index fadc9be..c658083 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -198,6 +198,7 @@ DWORD WINAPI worker_thread(LPVOID) coop::hook::republish_audio_format(); } 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 g_ipc.heartbeat(); diff --git a/hook/src/focus_spoof.cpp b/hook/src/focus_spoof.cpp index 9abc38e..b38a6c7 100644 --- a/hook/src/focus_spoof.cpp +++ b/hook/src/focus_spoof.cpp @@ -24,6 +24,12 @@ int g_id_foreground = -1; int g_id_active = -1; int g_id_focus = -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 { @@ -125,6 +131,28 @@ HWND WINAPI hk_GetFocus() 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(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(x, y); +} + void hook_export(HMODULE module, const char* name, void* detour, int registry_id) { if (void* target = reinterpret_cast(GetProcAddress(module, name))) @@ -148,6 +176,8 @@ bool install_focus_spoof(IpcClient& ipc) g_id_active = hook_register("GetActiveWindow", HookSubsys_Focus); g_id_focus = hook_register("GetFocus", 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()); if (hwnd == nullptr) @@ -172,6 +202,23 @@ bool install_focus_spoof(IpcClient& ipc) g_id_foreground); hook_export(user32, "GetActiveWindow", reinterpret_cast(&hk_GetActiveWindow), g_id_active); hook_export(user32, "GetFocus", reinterpret_cast(&hk_GetFocus), g_id_focus); + + if (void* clip = reinterpret_cast(GetProcAddress(user32, "ClipCursor"))) + { + g_hk_clipcursor = safetyhook::create_inline(clip, reinterpret_cast(&hk_ClipCursor)); + hook_set_installed(g_id_clipcursor, static_cast(g_hk_clipcursor)); + } + if (void* setpos = reinterpret_cast(GetProcAddress(user32, "SetCursorPos"))) + { + g_hk_setcursorpos = safetyhook::create_inline(setpos, reinterpret_cast(&hk_SetCursorPos)); + hook_set_installed(g_id_setcursorpos, static_cast(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(hwnd)); @@ -212,6 +259,14 @@ void update_input_diagnostics(IpcClient& ipc) 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() { if (g_game_hwnd != nullptr && g_orig_proc != nullptr) @@ -226,10 +281,15 @@ void remove_focus_spoof() } } 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_active, false); hook_set_installed(g_id_focus, 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) { g_focus_ipc->mark_focus_spoof(false, 0); diff --git a/hook/src/focus_spoof.hpp b/hook/src/focus_spoof.hpp index 4d17175..ff4fa32 100644 --- a/hook/src/focus_spoof.hpp +++ b/hook/src/focus_spoof.hpp @@ -20,6 +20,11 @@ bool install_focus_spoof(IpcClient& ipc); // to call each tick. 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. void remove_focus_spoof(); diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index cb3ca35..00f4edd 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -55,6 +55,13 @@ public: 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 // was mid-write for the whole spin window (caller should reuse its cache). bool snapshot(CoopPadState (&out)[kMaxPads], std::uint32_t& count) const diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index e8618ce..e46e5fc 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -181,6 +181,7 @@ void InjectionPanel::inject_selected() server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_); server_.set_subsystem_enabled(HookSubsys_Video, want_video_); 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()); if (result.status == InjectStatus::Ok) @@ -399,6 +400,13 @@ void InjectionPanel::draw_subsystem_controls(const HookStatusView& status) } 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) diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 14d0de5..d244612 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -128,6 +128,21 @@ public: 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: void refresh_targets(); // refresh both the window list and the process list void refresh_processes(); @@ -159,6 +174,7 @@ private: 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 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 // 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 9938813..e3c7dff 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -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&)` // 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 a983853..b906a59 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -184,6 +184,10 @@ int run() 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) { diff --git a/host/src/ui/app_chrome.cpp b/host/src/ui/app_chrome.cpp index 478cfca..5ba26df 100644 --- a/host/src/ui/app_chrome.cpp +++ b/host/src/ui/app_chrome.cpp @@ -104,6 +104,7 @@ float draw_main_menu_bar(UiState& ui, const FrameStats& stats) if (ImGui::BeginMenu("Help")) { ImGui::TextDisabled("F1 hide/show this overlay"); + ImGui::TextDisabled("F2 release/clip the operator cursor"); ImGui::TextDisabled("Esc quit"); ImGui::Separator(); ImGui::TextDisabled("This window is what Remote Play");