#include "injection_panel.hpp" #include #include #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(w.size()), nullptr, 0, nullptr, nullptr); std::string out(static_cast(len), '\0'); WideCharToMultiByte(CP_UTF8, 0, w.c_str(), static_cast(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(::tolower(static_cast(c))); } for (char& c : n) { c = static_cast(::tolower(static_cast(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& 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(ms) / 1000.0; std::array synthetic{}; PadInfo& pad = synthetic[0]; pad.connected = true; pad.source = "synthetic test"; pad.state.connected = 1; pad.state.packet = static_cast(ms); pad.state.thumb_lx = static_cast(std::cos(t) * 30000.0); pad.state.thumb_ly = static_cast(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(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(status.focus_calls[coop::FocusApi_Foreground]), static_cast(status.focus_calls[coop::FocusApi_Active]), static_cast(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