Phase 1a: focus spoofing + hook observability

Two problems surfaced in testing: (1) no way to tell whether the injected
hook was actually the input source, and (2) the final design needs the tool
window focused for Steam RPT capture, which would pause/silence games that
react to focus loss. Both are addressed here.

- Focus spoofing (hook/focus_spoof): find the game's main window, subclass it
  to rewrite/swallow WM_ACTIVATE/ACTIVATEAPP/NCACTIVATE/KILLFOCUS, and inline-
  hook GetForegroundWindow/GetActiveWindow/GetFocus to always report the game
  as active. The game keeps running and polling while unfocused.
- Status back-channel (protocol v2): the DLL reports attached/focus-spoof
  flags, game pid/hwnd, a heartbeat, and a cumulative XInputGetState counter.
  The host overlay turns the counter into a live poll rate, so "is the hook
  working" is directly observable.
- Synthetic test-input toggle in the host: forwards a known automated pattern
  (stick circle + periodic A) to prove forwarding independent of the physical
  pad.
- hook_selftest extended to assert the status channel; passes.

Documented the windowed/borderless requirement and the new observable test
flow in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 00:13:06 +02:00
parent e370c8dcc5
commit df4325d21b
14 changed files with 447 additions and 34 deletions

View File

@@ -1,5 +1,7 @@
#include "injection_panel.hpp"
#include <cmath>
#include <windows.h>
#include "inject/injector.hpp"
@@ -108,6 +110,75 @@ void InjectionPanel::inject_selected()
}
}
void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
{
if (!test_input_)
{
server_.publish(pads);
return;
}
// Synthetic, unmistakably non-human pattern: left stick sweeps a circle and
// A is pressed every other second. If the game moves on its own to this, the
// forwarding pipeline is proven end-to-end.
const unsigned long long ms = GetTickCount64();
const double t = static_cast<double>(ms) / 1000.0;
std::array<PadInfo, kMaxPads> synthetic{};
PadInfo& pad = synthetic[0];
pad.connected = true;
pad.source = "synthetic test";
pad.state.connected = 1;
pad.state.packet = static_cast<std::uint32_t>(ms);
pad.state.thumb_lx = static_cast<std::int16_t>(std::cos(t) * 30000.0);
pad.state.thumb_ly = static_cast<std::int16_t>(std::sin(t) * 30000.0);
if ((ms / 1000) % 2 == 0)
{
pad.state.buttons |= 0x1000; // XINPUT_GAMEPAD_A
}
server_.publish(synthetic);
}
void InjectionPanel::draw_hook_status()
{
if (!server_.running())
{
return;
}
const HookStatusView status = server_.hook_status();
// Convert the cumulative query counter into a rate every half second.
const double now = ImGui::GetTime();
if (now - last_sample_time_ >= 0.5)
{
const double dt = now - last_sample_time_;
const unsigned long long delta =
status.xinput_queries >= last_query_count_ ? status.xinput_queries - last_query_count_ : 0;
query_rate_ = dt > 0.0 ? static_cast<double>(delta) / dt : 0.0;
last_query_count_ = status.xinput_queries;
last_sample_time_ = now;
}
ImGui::SeparatorText("Hook status");
if (!status.attached)
{
ImGui::TextColored(kGrey, "Waiting for hook to attach in the game...");
return;
}
ImGui::TextColored(kGreen, "Attached (game pid %u, hwnd 0x%llX)", status.game_pid,
static_cast<unsigned long long>(status.game_hwnd));
ImGui::Text("XInput polled: %.0f/s (last slot %u)", query_rate_, status.last_user_index);
if (query_rate_ > 0.0)
{
ImGui::SameLine();
ImGui::TextColored(kGreen, " <- game is reading our input");
}
ImGui::TextColored(status.focus_spoof ? kGreen : kGrey, "Focus spoof: %s",
status.focus_spoof ? "active" : "inactive");
}
void InjectionPanel::draw()
{
ImGui::SetNextWindowPos(ImVec2(24, 360), ImGuiCond_FirstUseEver);
@@ -168,6 +239,15 @@ void InjectionPanel::draw()
ImGui::TextColored(status_color_, "%s", status_.c_str());
}
ImGui::Checkbox("Forward synthetic test input", &test_input_);
if (test_input_)
{
ImGui::SameLine();
ImGui::TextDisabled("(ignores your controller)");
}
draw_hook_status();
ImGui::End();
}

View File

@@ -21,15 +21,14 @@ public:
void draw();
// Forward the latest pad snapshot to the injected hook (if connected).
void publish(const std::array<PadInfo, kMaxPads>& pads)
{
server_.publish(pads);
}
// Forward the latest pad snapshot to the injected hook (if connected). When
// test-input mode is on, a synthetic pattern is sent instead of `pads`.
void publish(const std::array<PadInfo, kMaxPads>& pads);
private:
void refresh_processes();
void inject_selected();
void draw_hook_status();
std::vector<ProcessEntry> processes_;
char filter_[128] = {};
@@ -39,6 +38,13 @@ private:
IpcServer server_;
std::string status_;
ImVec4 status_color_;
bool test_input_ = false;
// Sampled to turn the hook's cumulative query counter into a poll rate.
unsigned long long last_query_count_ = 0;
double last_sample_time_ = 0.0;
double query_rate_ = 0.0;
};
} // namespace coop

View File

@@ -40,6 +40,24 @@ void IpcServer::publish(const std::array<PadInfo, kMaxPads>& pads)
publish_pads(*block_, states, kMaxPads);
}
HookStatusView IpcServer::hook_status() const
{
HookStatusView view;
if (block_ == nullptr)
{
return view;
}
const HookStatus& s = block_->status;
view.attached = s.attached != 0;
view.focus_spoof = s.focus_spoof != 0;
view.heartbeat = s.heartbeat.load(std::memory_order_relaxed);
view.xinput_queries = s.xinput_queries.load(std::memory_order_relaxed);
view.game_pid = s.game_pid;
view.game_hwnd = s.game_hwnd;
view.last_user_index = s.last_user_index;
return view;
}
void IpcServer::stop()
{
if (block_ != nullptr)

View File

@@ -3,6 +3,7 @@
#pragma once
#include <array>
#include <cstdint>
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
@@ -11,6 +12,18 @@
namespace coop
{
// Plain (non-atomic) snapshot of the hook's back-channel for the overlay.
struct HookStatusView
{
bool attached = false; // XInput hooks installed in the game
bool focus_spoof = false; // focus spoofing active
std::uint32_t heartbeat = 0; // DLL liveness counter
std::uint64_t xinput_queries = 0;
std::uint32_t game_pid = 0;
std::uint64_t game_hwnd = 0;
std::uint32_t last_user_index = 0;
};
class IpcServer
{
public:
@@ -23,6 +36,9 @@ public:
void stop();
// Reads the hook's diagnostics back-channel (zeroed if not started).
[[nodiscard]] HookStatusView hook_status() const;
[[nodiscard]] bool running() const
{
return block_ != nullptr;