Files
CoopAllTheThings/hook/src/ipc_client.hpp
BlackMark 9f5b7c3272 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>
2026-06-21 05:20:17 +02:00

259 lines
7.0 KiB
C++

// Hook-side view of the shared-memory IPC channel. The host creates the section
// (named by the target game's pid); we open it from inside the game and read the
// forwarded pad state the host publishes each frame.
#pragma once
#include <atomic>
#include <cstdint>
#include <windows.h>
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
namespace coop::hook
{
class IpcClient
{
public:
// Tries to open the section a few times: the host may inject us slightly
// before (or after) it creates the mapping. Returns true once connected.
bool connect(int attempts, int delay_ms)
{
const std::wstring name = shared_memory_name(GetCurrentProcessId());
for (int i = 0; i < attempts; ++i)
{
if (shm_.open(name, sizeof(SharedBlock)))
{
auto* block = shm_.as<SharedBlock>();
if (block->magic == kProtocolMagic && block->version == kProtocolVersion)
{
block_ = block;
return true;
}
shm_.reset(); // present but not a contract we understand; retry
}
Sleep(static_cast<DWORD>(delay_ms));
}
return false;
}
[[nodiscard]] bool connected() const
{
return block_ != nullptr;
}
// Host-requested install state for a subsystem (default = install, since the
// mapping is zero-filled and 0 means "disabled flag clear" = install).
[[nodiscard]] bool subsystem_install_requested(std::uint32_t subsystem) const
{
if (block_ == nullptr || subsystem >= HookSubsys_Count)
{
return true;
}
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
{
if (block_ == nullptr)
{
return false;
}
return read_pads(*block_, out, count);
}
// --- Status back-channel (hook -> host diagnostics) --------------------
// Record that the game queried a controller slot via XInputGetState/Ex.
void note_state_query(std::uint32_t user_index)
{
if (block_ != nullptr && user_index < kMaxPads)
{
block_->status.get_state_calls[user_index].fetch_add(1, std::memory_order_relaxed);
}
}
void note_caps_query(std::uint32_t user_index)
{
if (block_ != nullptr && user_index < kMaxPads)
{
block_->status.get_caps_calls[user_index].fetch_add(1, std::memory_order_relaxed);
}
}
void note_focus_query(FocusApi which)
{
if (block_ != nullptr && which < FocusApi_Count)
{
block_->status.focus_query_calls[which].fetch_add(1, std::memory_order_relaxed);
}
}
void mark_attached()
{
if (block_ != nullptr)
{
block_->status.game_pid = GetCurrentProcessId();
block_->status.attached = 1;
}
}
// XInput hooks were removed (host unhooked input): clear the attached flag so
// the Controllers panel stops showing stale poll rates.
void mark_detached()
{
if (block_ != nullptr)
{
block_->status.attached = 0;
}
}
void mark_focus_spoof(bool active, std::uint64_t game_hwnd)
{
if (block_ != nullptr)
{
block_->status.focus_spoof = active ? 1u : 0u;
block_->status.game_hwnd = game_hwnd;
}
}
void set_input_diagnostics(bool raw_registered, bool raw_gamepad, bool raw_gamepad_sink, bool dinput)
{
if (block_ != nullptr)
{
block_->status.raw_input_registered = raw_registered ? 1u : 0u;
block_->status.raw_input_gamepad = raw_gamepad ? 1u : 0u;
block_->status.raw_input_gamepad_sink = raw_gamepad_sink ? 1u : 0u;
block_->status.dinput_loaded = dinput ? 1u : 0u;
}
}
void heartbeat()
{
if (block_ != nullptr)
{
block_->status.heartbeat.fetch_add(1, std::memory_order_relaxed);
}
}
// Record the rumble the game requested for a slot (so the host can forward it to
// the guest's controller). Plain stores; the hook is the sole writer.
void note_rumble(std::uint32_t slot, std::uint16_t left, std::uint16_t right)
{
if (block_ != nullptr && slot < kMaxPads)
{
block_->status.rumble_left[slot] = left;
block_->status.rumble_right[slot] = right;
}
}
// Record the state the hook just returned to the game for a slot (round-trip view).
void note_read_state(std::uint32_t slot, const CoopPadState& state)
{
if (block_ != nullptr && slot < kMaxPads)
{
block_->status.read_state[slot] = state;
}
}
// --- Audio render-hook diagnostics -------------------------------------
// Total distinct render streams the audio hook has observed.
void set_audio_streams_seen(std::uint32_t count)
{
if (block_ != nullptr)
{
block_->status.audio_streams_seen = count;
}
}
// Publish a tracked stream's format/role into its debug slot.
void publish_audio_stream(std::uint32_t slot, const AudioStreamInfo& info)
{
if (block_ != nullptr && slot < kMaxAudioStreams)
{
block_->status.audio_streams[slot] = info;
}
}
// Update a tracked stream's cumulative frame count (host derives live/idle).
void note_audio_frames(std::uint32_t slot, std::uint64_t frames)
{
if (block_ != nullptr && slot < kMaxAudioStreams)
{
block_->status.audio_streams[slot].frames_rendered = frames;
}
}
// --- Present-hook video channel ----------------------------------------
// Record that the game's Present() ran (diagnostic counter, hook is sole writer).
void note_present()
{
if (block_ != nullptr)
{
block_->video.present_calls += 1;
}
}
// Publish that a fresh backbuffer copy is in the shared texture (named
// coop_video_<pid>) with these dimensions/format; bumps the generation the host
// polls. The texture itself is shared out-of-band by name, not through here.
void publish_video_frame(std::uint32_t width, std::uint32_t height, std::uint32_t format)
{
if (block_ != nullptr)
{
block_->video.width = width;
block_->video.height = height;
block_->video.format = format;
block_->video.generation.fetch_add(1, std::memory_order_release);
}
}
// --- 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).
void publish_hook_entries(const HookEntry* entries, std::uint32_t count)
{
if (block_ == nullptr)
{
return;
}
if (count > kMaxHookEntries)
{
count = kMaxHookEntries;
}
for (std::uint32_t i = 0; i < count; ++i)
{
block_->status.hook_entries[i] = entries[i];
}
block_->status.hook_entry_count = count;
}
private:
SharedMemory shm_;
SharedBlock* block_ = nullptr;
};
} // namespace coop::hook