Add a process-wide hook registry (hook/src/hook_registry) that every hook module registers its hooks with and bumps a counter from each detour. The XInput, focus-spoof, and audio render-hooks now register their individual hooks (XInputGetState/Ex/Caps/SetState; GetForegroundWindow/GetActiveWindow/ GetFocus/WndProc guard; IMMDevice::Activate, IAudioClient::Initialize/ GetService, IAudioRenderClient::GetBuffer/ReleaseBuffer) and count calls. The worker publishes the table to the host each tick over a new HookStatus field (protocol v4 -> v5: HookEntry[] + count). The Injection panel shows it as a collapsible table grouped by subsystem with an installed flag and call count per hook; coop_audio_probe prints the same table headless. Verified against Phantom Brave: 13 hooks listed with live counts (focus APIs polled heavily, GetBuffer/ReleaseBuffer ticking with the audio render loop). All four tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
331 lines
8.5 KiB
C++
331 lines
8.5 KiB
C++
#include "injection_panel.hpp"
|
|
|
|
#include <cmath>
|
|
|
|
#include <windows.h>
|
|
|
|
#include "inject/injector.hpp"
|
|
|
|
namespace coop
|
|
{
|
|
|
|
namespace
|
|
{
|
|
|
|
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
|
|
const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f);
|
|
const ImVec4 kGrey(0.7f, 0.7f, 0.7f, 1.0f);
|
|
|
|
std::string narrow(const std::wstring& w)
|
|
{
|
|
if (w.empty())
|
|
{
|
|
return {};
|
|
}
|
|
const int len = WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast<int>(w.size()), nullptr, 0, nullptr, nullptr);
|
|
std::string out(static_cast<std::size_t>(len), '\0');
|
|
WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast<int>(w.size()), out.data(), len, nullptr, nullptr);
|
|
return out;
|
|
}
|
|
|
|
bool contains_ci(const std::wstring& haystack, const char* needle_utf8)
|
|
{
|
|
if (needle_utf8 == nullptr || needle_utf8[0] == '\0')
|
|
{
|
|
return true;
|
|
}
|
|
const std::string hay = narrow(haystack);
|
|
std::string h = hay, n = needle_utf8;
|
|
for (char& c : h)
|
|
{
|
|
c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
|
|
}
|
|
for (char& c : n)
|
|
{
|
|
c = static_cast<char>(::tolower(static_cast<unsigned char>(c)));
|
|
}
|
|
return h.find(n) != std::string::npos;
|
|
}
|
|
|
|
// Absolute path to coop_hook.dll, assumed to sit next to the host executable.
|
|
std::wstring hook_dll_path()
|
|
{
|
|
wchar_t buffer[MAX_PATH] = {};
|
|
const DWORD len = GetModuleFileNameW(nullptr, buffer, MAX_PATH);
|
|
std::wstring path(buffer, len);
|
|
const std::size_t slash = path.find_last_of(L"\\/");
|
|
if (slash != std::wstring::npos)
|
|
{
|
|
path.resize(slash + 1);
|
|
}
|
|
path += L"coop_hook.dll";
|
|
return path;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
InjectionPanel::InjectionPanel()
|
|
{
|
|
refresh_processes();
|
|
}
|
|
|
|
void InjectionPanel::refresh_processes()
|
|
{
|
|
processes_ = list_processes();
|
|
}
|
|
|
|
void InjectionPanel::inject_selected()
|
|
{
|
|
if (selected_pid_ == 0)
|
|
{
|
|
status_ = "Select a target process first.";
|
|
status_color_ = kRed;
|
|
return;
|
|
}
|
|
|
|
// Bring up the shared-memory channel before injecting so the hook finds it
|
|
// immediately on load.
|
|
if (!server_.start(selected_pid_))
|
|
{
|
|
status_ = "Failed to create shared memory.";
|
|
status_color_ = kRed;
|
|
return;
|
|
}
|
|
|
|
const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
|
|
if (result.status == InjectStatus::Ok)
|
|
{
|
|
status_ = "Injected into " + narrow(selected_name_) + " (pid " + std::to_string(selected_pid_) + ").";
|
|
status_color_ = kGreen;
|
|
}
|
|
else
|
|
{
|
|
server_.stop();
|
|
status_ = std::string("Injection failed: ") + to_string(result.status);
|
|
if (result.os_error != 0)
|
|
{
|
|
status_ += " [err " + std::to_string(result.os_error) + "]";
|
|
}
|
|
status_color_ = kRed;
|
|
}
|
|
}
|
|
|
|
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_list(const HookStatusView& status)
|
|
{
|
|
static const char* kSubsysName[] = {"Input", "Focus", "Audio"};
|
|
|
|
const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
|
|
if (n == 0)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (!ImGui::CollapsingHeader("Installed hooks", ImGuiTreeNodeFlags_DefaultOpen))
|
|
{
|
|
return;
|
|
}
|
|
if (ImGui::BeginTable("hooks", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
|
|
{
|
|
ImGui::TableSetupColumn("Hook");
|
|
ImGui::TableSetupColumn("On", ImGuiTableColumnFlags_WidthFixed);
|
|
ImGui::TableSetupColumn("Calls", ImGuiTableColumnFlags_WidthFixed);
|
|
ImGui::TableHeadersRow();
|
|
|
|
// Group rows by subsystem so related hooks sit together.
|
|
for (std::uint32_t sub = 0; sub < HookSubsys_Count; ++sub)
|
|
{
|
|
bool header_done = false;
|
|
for (std::uint32_t i = 0; i < n; ++i)
|
|
{
|
|
const HookEntry& e = status.hook_entries[i];
|
|
if (e.subsystem != sub)
|
|
{
|
|
continue;
|
|
}
|
|
if (!header_done)
|
|
{
|
|
ImGui::TableNextRow();
|
|
ImGui::TableNextColumn();
|
|
ImGui::TextDisabled("%s", kSubsysName[sub < 3 ? sub : 0]);
|
|
ImGui::TableNextColumn();
|
|
ImGui::TableNextColumn();
|
|
header_done = true;
|
|
}
|
|
ImGui::TableNextRow();
|
|
ImGui::TableNextColumn();
|
|
ImGui::TextUnformatted(e.name);
|
|
ImGui::TableNextColumn();
|
|
if (e.installed)
|
|
{
|
|
ImGui::TextColored(kGreen, "yes");
|
|
}
|
|
else
|
|
{
|
|
ImGui::TextDisabled("no");
|
|
}
|
|
ImGui::TableNextColumn();
|
|
ImGui::Text("%llu", static_cast<unsigned long long>(e.calls));
|
|
}
|
|
}
|
|
ImGui::EndTable();
|
|
}
|
|
}
|
|
|
|
void InjectionPanel::draw_hook_status(bool debug_details)
|
|
{
|
|
if (!server_.running())
|
|
{
|
|
return;
|
|
}
|
|
|
|
const HookStatusView status = server_.hook_status();
|
|
|
|
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)", status.game_pid);
|
|
ImGui::TextColored(status.focus_spoof ? kGreen : kGrey, "Focus spoof: %s",
|
|
status.focus_spoof ? "active" : "inactive");
|
|
ImGui::TextDisabled("Controller poll rates are in the Controllers panel.");
|
|
|
|
draw_hook_list(status);
|
|
|
|
if (!debug_details)
|
|
{
|
|
return; // everything below is diagnostic detail
|
|
}
|
|
|
|
// Focus-API usage: tells us whether the game even consults these (which we
|
|
// spoof) when deciding it lost focus.
|
|
ImGui::Text("Focus API calls FG:%llu Active:%llu Focus:%llu",
|
|
static_cast<unsigned long long>(status.focus_calls[coop::FocusApi_Foreground]),
|
|
static_cast<unsigned long long>(status.focus_calls[coop::FocusApi_Active]),
|
|
static_cast<unsigned long long>(status.focus_calls[coop::FocusApi_Focus]));
|
|
|
|
// Input-path diagnostics: a focus-gated detection path would explain a game
|
|
// that only accepts the controller when it has true focus.
|
|
ImGui::SeparatorText("Input path");
|
|
if (status.raw_input_gamepad)
|
|
{
|
|
ImGui::TextColored(status.raw_input_gamepad_sink ? kGreen : kRed, "Raw Input gamepad: yes (INPUTSINK %s)",
|
|
status.raw_input_gamepad_sink ? "set -> bg ok" : "MISSING -> focus-gated!");
|
|
}
|
|
else if (status.raw_input_registered)
|
|
{
|
|
ImGui::TextColored(kGrey, "Raw Input: registered, but not for a gamepad usage");
|
|
}
|
|
else
|
|
{
|
|
ImGui::TextColored(kGrey, "Raw Input: not registered");
|
|
}
|
|
ImGui::TextColored(status.dinput_loaded ? kRed : kGrey, "DirectInput dll loaded: %s",
|
|
status.dinput_loaded ? "yes (could be foreground-gated)" : "no");
|
|
}
|
|
|
|
void InjectionPanel::draw(bool debug_details)
|
|
{
|
|
ImGui::SetNextWindowPos(ImVec2(24, 360), ImGuiCond_FirstUseEver);
|
|
ImGui::SetNextWindowSize(ImVec2(420, 380), ImGuiCond_FirstUseEver);
|
|
ImGui::Begin("Injection");
|
|
|
|
if (server_.running())
|
|
{
|
|
ImGui::TextColored(kGreen, "Forwarding input to pid %lu", server_.target_pid());
|
|
if (ImGui::Button("Stop forwarding"))
|
|
{
|
|
server_.stop();
|
|
status_ = "Stopped.";
|
|
status_color_ = kGrey;
|
|
}
|
|
ImGui::Separator();
|
|
}
|
|
|
|
ImGui::TextUnformatted("Target process");
|
|
if (ImGui::Button("Refresh"))
|
|
{
|
|
refresh_processes();
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::SetNextItemWidth(-1.0f);
|
|
ImGui::InputTextWithHint("##filter", "filter by name...", filter_, sizeof(filter_));
|
|
|
|
if (ImGui::BeginListBox("##processes", ImVec2(-1.0f, 200.0f)))
|
|
{
|
|
for (const ProcessEntry& entry : processes_)
|
|
{
|
|
if (!contains_ci(entry.exe_name, filter_))
|
|
{
|
|
continue;
|
|
}
|
|
const bool selected = entry.pid == selected_pid_;
|
|
char label[300];
|
|
snprintf(label, sizeof(label), "%-40s %lu", narrow(entry.exe_name).c_str(), entry.pid);
|
|
if (ImGui::Selectable(label, selected))
|
|
{
|
|
selected_pid_ = entry.pid;
|
|
selected_name_ = entry.exe_name;
|
|
}
|
|
}
|
|
ImGui::EndListBox();
|
|
}
|
|
|
|
const bool can_inject = selected_pid_ != 0;
|
|
ImGui::BeginDisabled(!can_inject);
|
|
if (ImGui::Button("Inject & Connect", ImVec2(-1.0f, 0.0f)))
|
|
{
|
|
inject_selected();
|
|
}
|
|
ImGui::EndDisabled();
|
|
|
|
if (!status_.empty())
|
|
{
|
|
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(debug_details);
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
} // namespace coop
|