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();
}