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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user