diff --git a/CMakeLists.txt b/CMakeLists.txt index b3b0e1c..f7df381 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -28,9 +28,16 @@ add_subdirectory(third_party) add_subdirectory(common) add_subdirectory(host) -# The injected hook DLL pulls in SafetyHook (+ Zydis). It is built in Phase 1; -# enable once the dependency is wired so Phase 0 stays minimal. -option(COOP_BUILD_HOOK "Build the injected game-side hook DLL" OFF) +# The injected hook DLL pulls in SafetyHook, which fetches Zydis via FetchContent +# at configure time (allowed for transitive deps). +option(COOP_BUILD_HOOK "Build the injected game-side hook DLL" ON) if(COOP_BUILD_HOOK) + # SafetyHook's own tests/examples/docs are noise for us. + set(SAFETYHOOK_BUILD_TEST OFF CACHE BOOL "" FORCE) + set(SAFETYHOOK_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) + set(SAFETYHOOK_BUILD_DOCS OFF CACHE BOOL "" FORCE) + add_subdirectory(third_party/safetyhook) add_subdirectory(hook) + enable_testing() + add_subdirectory(tests) endif() diff --git a/README.md b/README.md index 4dc72ab..8e9a010 100644 --- a/README.md +++ b/README.md @@ -42,14 +42,19 @@ See [`docs`](docs) and the in-repo plan for the full design. ## Status -**Phase 0 — donor spike (current).** `coop_host.exe` is a borderless D3D11 window -with an ImGui overlay that lists every controller it can see. It exists to -validate the riskiest assumption before anything else is built: *can Steam RPT -stream an arbitrary window launched under a donor appid, and route a guest's -gamepad into it?* +**Phase 0 — donor spike. ✅ Validated.** `coop_host.exe` is a borderless D3D11 +window with an ImGui overlay listing every controller it sees. Confirmed +end-to-end: Steam RPT streams the window under a donor appid, and guest gamepads +arrive (with correct slot assignment) as XInput. -Later phases (capture, injection, audio) are scoped in the plan and gated on -Phase 0 passing. +**Phase 1a — input forwarding (current).** The host can inject `coop_hook.dll` +into a running game; the DLL hooks XInput (via SafetyHook) so the game reads the +controller state the host forwards over shared memory — and *only* that state, so +physical/other controllers are hidden from the game. The in-process +`hook_selftest` validates the IPC + hook core without needing a game. + +Still ahead (scoped in the plan): video mirror (WGC, then a `Present` hook), +audio (WASAPI process loopback), and x86 support. ## Building @@ -107,3 +112,25 @@ a donor game that supports Remote Play Together. **If steps 2 and 4 both work, the core premise holds** and we proceed to Phase 1 (window capture + input injection). If not, we revisit the donor-attribution approach before building further. + +## Phase 1a: testing input forwarding locally + +This needs no RPT, donor, or second account — just the host, the hook, a +controller, and a target game. `coop_host.exe` and `coop_hook.dll` must sit in +the same folder (the build places both in `bin//`). + +1. Start a DRM-free, **non-anti-cheat**, XInput game (e.g. a small controller + sample or a permissive indie title) and get to a screen that reads the pad. +2. Run `bin\Debug\coop_host.exe`. In the **Injection** panel, filter for the + game's `.exe`, select it, and click **Inject & Connect**. The status line + should turn green ("Injected … / Forwarding input to pid …"). +3. Press buttons on your physical controller. The game should respond — its + XInput now comes from the host's forwarded state, not the device directly. + Unplug-test: other controllers/slots are hidden from the game. +4. Click **Stop forwarding** (or quit the host) to tear down the channel. + +> If injection fails with an access error, run the host as administrator. If it +> reports "target is 32-bit", that game needs the x86 hook (a later phase). + +For a quick sanity check of the forwarding core without a game, run +`bin\Debug\hook_selftest.exe` — it should print `SELFTEST PASS`. diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt new file mode 100644 index 0000000..9121159 --- /dev/null +++ b/hook/CMakeLists.txt @@ -0,0 +1,15 @@ +add_library(coop_hook SHARED + src/dllmain.cpp + src/xinput_hook.cpp) + +target_include_directories(coop_hook PRIVATE src) + +target_link_libraries(coop_hook PRIVATE + coop_common + safetyhook::safetyhook) + +set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook") + +# TODO(dist): statically link the VC runtime (/MT) so the DLL loads in games on +# machines without the matching VC redist. Deferred until SafetyHook + Zydis are +# also forced to a static CRT to avoid a /MT-vs-/MD mismatch. diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp new file mode 100644 index 0000000..d2313f2 --- /dev/null +++ b/hook/src/dllmain.cpp @@ -0,0 +1,60 @@ +// coop_hook.dll -- injected into the target game by the host. +// +// On load it opens the host's shared-memory channel (named by this process's +// pid), then hooks XInput so the game reads the forwarded controller state. All +// real work happens on a worker thread; DllMain only kicks it off to stay clear +// of the loader lock. + +#include + +#include "ipc_client.hpp" +#include "xinput_hook.hpp" + +namespace +{ + +coop::hook::IpcClient g_ipc; + +DWORD WINAPI init_thread(LPVOID) +{ + // The host creates the mapping around injection time; give it a few seconds. + if (!g_ipc.connect(/*attempts=*/200, /*delay_ms=*/25)) + { + return 0; + } + + // XInput may not be loaded yet at this point (games often load it lazily on + // first controller use), so keep retrying until a module appears. + for (int i = 0; i < 400 && !coop::hook::install_xinput_hooks(g_ipc); ++i) + { + Sleep(25); + } + return 0; +} + +} // namespace + +BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) +{ + switch (reason) + { + case DLL_PROCESS_ATTACH: + DisableThreadLibraryCalls(module); + if (HANDLE thread = CreateThread(nullptr, 0, &init_thread, nullptr, 0, nullptr)) + { + CloseHandle(thread); + } + break; + case DLL_PROCESS_DETACH: + // Skip cleanup when the process is tearing down (reserved != null): the + // loader is already unwinding and touching other modules is unsafe. + if (reserved == nullptr) + { + coop::hook::remove_xinput_hooks(); + } + break; + default: + break; + } + return TRUE; +} diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp new file mode 100644 index 0000000..50e2a8d --- /dev/null +++ b/hook/src/ipc_client.hpp @@ -0,0 +1,62 @@ +// Hook-side view of the shared-memory IPC channel. The host creates the section +// (named by the target game's pid); we open it from inside the game and read the +// forwarded pad state the host publishes each frame. +#pragma once + +#include + +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" + +namespace coop::hook +{ + +class IpcClient +{ +public: + // Tries to open the section a few times: the host may inject us slightly + // before (or after) it creates the mapping. Returns true once connected. + bool connect(int attempts, int delay_ms) + { + const std::wstring name = shared_memory_name(GetCurrentProcessId()); + for (int i = 0; i < attempts; ++i) + { + if (shm_.open(name, sizeof(SharedBlock))) + { + auto* block = shm_.as(); + if (block->magic == kProtocolMagic && block->version == kProtocolVersion) + { + block_ = block; + return true; + } + shm_.reset(); // present but not a contract we understand; retry + } + Sleep(static_cast(delay_ms)); + } + return false; + } + + [[nodiscard]] bool connected() const + { + return block_ != nullptr; + } + + // Copies a torn-free snapshot of all slots. Returns false only if the host + // was mid-write for the whole spin window (caller should reuse its cache). + bool snapshot(CoopPadState (&out)[kMaxPads], std::uint32_t& count) const + { + if (block_ == nullptr) + { + return false; + } + return read_pads(*block_, out, count); + } + +private: + SharedMemory shm_; + SharedBlock* block_ = nullptr; +}; + +} // namespace coop::hook diff --git a/hook/src/xinput_hook.cpp b/hook/src/xinput_hook.cpp new file mode 100644 index 0000000..b610e38 --- /dev/null +++ b/hook/src/xinput_hook.cpp @@ -0,0 +1,192 @@ +#include "xinput_hook.hpp" + +#include +#include +#include + +#include +#include + +#include + +namespace coop::hook +{ + +namespace +{ + +// XInput guide-button bit, reported only by the undocumented ordinal-100 +// XInputGetStateEx that many games use. Mirrors how Steam/x360ce expose it. +constexpr std::uint16_t kGuideButton = 0x0400; + +const IpcClient* g_ipc = nullptr; +std::vector g_hooks; + +// Last good snapshot, so a momentary failed IPC read (host mid-write) doesn't +// flicker the controller as disconnected inside the game. +std::array g_cache; + +void refresh_cache() +{ + if (g_ipc == nullptr) + { + return; + } + CoopPadState pads[kMaxPads]; + std::uint32_t count = 0; + if (g_ipc->snapshot(pads, count)) + { + for (std::uint32_t i = 0; i < kMaxPads; ++i) + { + g_cache[i] = pads[i]; + } + } +} + +void fill_gamepad(const CoopPadState& pad, XINPUT_GAMEPAD& out) +{ + out.wButtons = pad.buttons; + out.bLeftTrigger = pad.left_trigger; + out.bRightTrigger = pad.right_trigger; + out.sThumbLX = pad.thumb_lx; + out.sThumbLY = pad.thumb_ly; + out.sThumbRX = pad.thumb_rx; + out.sThumbRY = pad.thumb_ry; +} + +// Core of every state query. `keep_guide` drops the guide bit for the plain +// (documented) XInputGetState, which must not report it. +DWORD query_state(DWORD user_index, XINPUT_STATE* state, bool keep_guide) +{ + if (state == nullptr || user_index >= kMaxPads) + { + return ERROR_DEVICE_NOT_CONNECTED; + } + refresh_cache(); + const CoopPadState& pad = g_cache[user_index]; + if (!pad.connected) + { + return ERROR_DEVICE_NOT_CONNECTED; + } + + XINPUT_STATE result = {}; + result.dwPacketNumber = pad.packet; + fill_gamepad(pad, result.Gamepad); + if (!keep_guide) + { + result.Gamepad.wButtons &= ~kGuideButton; + } + *state = result; + return ERROR_SUCCESS; +} + +DWORD WINAPI hk_XInputGetState(DWORD user_index, XINPUT_STATE* state) +{ + return query_state(user_index, state, /*keep_guide=*/false); +} + +DWORD WINAPI hk_XInputGetStateEx(DWORD user_index, XINPUT_STATE* state) +{ + return query_state(user_index, state, /*keep_guide=*/true); +} + +DWORD WINAPI hk_XInputGetCapabilities(DWORD user_index, DWORD /*flags*/, XINPUT_CAPABILITIES* caps) +{ + if (caps == nullptr || user_index >= kMaxPads) + { + return ERROR_DEVICE_NOT_CONNECTED; + } + refresh_cache(); + if (!g_cache[user_index].connected) + { + return ERROR_DEVICE_NOT_CONNECTED; + } + + // Advertise a standard wired Xbox-style gamepad with all controls present. + XINPUT_CAPABILITIES result = {}; + result.Type = XINPUT_DEVTYPE_GAMEPAD; + result.SubType = XINPUT_DEVSUBTYPE_GAMEPAD; + result.Flags = 0; + result.Gamepad.wButtons = 0xF3FF; // all standard buttons reachable + result.Gamepad.bLeftTrigger = 0xFF; + result.Gamepad.bRightTrigger = 0xFF; + result.Gamepad.sThumbLX = static_cast(0x7FFF); + result.Gamepad.sThumbLY = static_cast(0x7FFF); + result.Gamepad.sThumbRX = static_cast(0x7FFF); + result.Gamepad.sThumbRY = static_cast(0x7FFF); + *caps = result; + return ERROR_SUCCESS; +} + +// Swallow rumble: it would otherwise be sent to whatever physical device sits at +// this index on the host machine. Forwarding it back to the guest is a later +// phase; for now report success so the game's logic is happy. +DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* /*vibration*/) +{ + if (user_index >= kMaxPads || !g_cache[user_index].connected) + { + return ERROR_DEVICE_NOT_CONNECTED; + } + return ERROR_SUCCESS; +} + +void hook_export(HMODULE module, const char* name, void* detour) +{ + if (module == nullptr) + { + return; + } + if (void* target = reinterpret_cast(GetProcAddress(module, name))) + { + g_hooks.emplace_back(safetyhook::create_inline(target, detour)); + } +} + +void hook_ordinal(HMODULE module, WORD ordinal, void* detour) +{ + if (module == nullptr) + { + return; + } + if (void* target = reinterpret_cast(GetProcAddress(module, MAKEINTRESOURCEA(ordinal)))) + { + g_hooks.emplace_back(safetyhook::create_inline(target, detour)); + } +} + +} // namespace + +bool install_xinput_hooks(const IpcClient& ipc) +{ + if (!g_hooks.empty()) + { + return true; // already installed + } + g_ipc = &ipc; + refresh_cache(); + + // A process generally loads exactly one of these, but hook every one that is + // present so we don't miss the one the game actually calls. + const wchar_t* modules[] = {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll", L"xinputuap.dll"}; + for (const wchar_t* name : modules) + { + HMODULE module = GetModuleHandleW(name); + if (module == nullptr) + { + continue; + } + hook_export(module, "XInputGetState", reinterpret_cast(&hk_XInputGetState)); + hook_ordinal(module, 100, reinterpret_cast(&hk_XInputGetStateEx)); + hook_export(module, "XInputGetCapabilities", reinterpret_cast(&hk_XInputGetCapabilities)); + hook_export(module, "XInputSetState", reinterpret_cast(&hk_XInputSetState)); + } + return !g_hooks.empty(); +} + +void remove_xinput_hooks() +{ + g_hooks.clear(); // InlineHook destructor restores the original bytes + g_ipc = nullptr; +} + +} // namespace coop::hook diff --git a/hook/src/xinput_hook.hpp b/hook/src/xinput_hook.hpp new file mode 100644 index 0000000..1f2b56e --- /dev/null +++ b/hook/src/xinput_hook.hpp @@ -0,0 +1,18 @@ +// Installs XInput hooks inside the target game so it reads the controller state +// the host forwards over shared memory -- and nothing else. +#pragma once + +#include "ipc_client.hpp" + +namespace coop::hook +{ + +// Locates the loaded XInput module(s) and hooks the state/capability entry +// points. `ipc` must outlive the hooks. Returns true if at least one module was +// hooked. Safe to call repeatedly while waiting for xinput to load. +bool install_xinput_hooks(const IpcClient& ipc); + +// Removes all installed hooks (best effort; used on DLL detach). +void remove_xinput_hooks(); + +} // namespace coop::hook diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 23e3f89..e6b4fed 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -3,7 +3,11 @@ add_executable(coop_host WIN32 src/d3d11_window.cpp src/imgui_layer.cpp src/debug_overlay.cpp - src/input/xinput_source.cpp) + src/injection_panel.cpp + src/input/xinput_source.cpp + src/inject/process_list.cpp + src/inject/injector.cpp + src/ipc/ipc_server.cpp) target_include_directories(coop_host PRIVATE src) diff --git a/host/src/inject/injector.cpp b/host/src/inject/injector.cpp new file mode 100644 index 0000000..5333a18 --- /dev/null +++ b/host/src/inject/injector.cpp @@ -0,0 +1,130 @@ +#include "inject/injector.hpp" + +#include + +namespace coop +{ + +const char* to_string(InjectStatus status) +{ + switch (status) + { + case InjectStatus::Ok: + return "OK"; + case InjectStatus::OpenProcessFailed: + return "OpenProcess failed (try running as administrator)"; + case InjectStatus::BitnessMismatch: + return "target is 32-bit; x86 hook not built yet"; + case InjectStatus::DllNotFound: + return "coop_hook.dll not found next to the host"; + case InjectStatus::AllocFailed: + return "VirtualAllocEx failed"; + case InjectStatus::WriteFailed: + return "WriteProcessMemory failed"; + case InjectStatus::RemoteThreadFailed: + return "CreateRemoteThread failed"; + case InjectStatus::RemoteLoadFailed: + return "LoadLibraryW returned null in the target"; + } + return "unknown"; +} + +namespace +{ + +InjectResult fail(InjectStatus status) +{ + return InjectResult{status, GetLastError()}; +} + +// Returns true if `process` runs as a 32-bit (WOW64) process on this 64-bit host. +bool is_wow64_process(HANDLE process) +{ + USHORT process_machine = IMAGE_FILE_MACHINE_UNKNOWN; + USHORT native_machine = IMAGE_FILE_MACHINE_UNKNOWN; + if (IsWow64Process2(process, &process_machine, &native_machine)) + { + return process_machine != IMAGE_FILE_MACHINE_UNKNOWN; + } + return false; // be permissive if the query is unavailable +} + +} // namespace + +InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path) +{ + if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES) + { + return fail(InjectStatus::DllNotFound); + } + + const DWORD access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | + PROCESS_VM_WRITE | PROCESS_VM_READ; + HANDLE process = OpenProcess(access, FALSE, pid); + if (process == nullptr) + { + return fail(InjectStatus::OpenProcessFailed); + } + + struct HandleGuard + { + HANDLE h; + ~HandleGuard() + { + if (h != nullptr) + { + CloseHandle(h); + } + } + } process_guard{process}; + + if (is_wow64_process(process)) + { + return InjectResult{InjectStatus::BitnessMismatch, 0}; + } + + const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t); + void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + if (remote == nullptr) + { + return fail(InjectStatus::AllocFailed); + } + + InjectResult result{InjectStatus::Ok, 0}; + + if (!WriteProcessMemory(process, remote, dll_path.c_str(), bytes, nullptr)) + { + result = fail(InjectStatus::WriteFailed); + } + else + { + // kernel32 is mapped at the same address in every process, so LoadLibraryW's + // address in this process is valid as the remote thread's start routine. + auto load_library = + reinterpret_cast(GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW")); + HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_library, remote, 0, nullptr); + if (thread == nullptr) + { + result = fail(InjectStatus::RemoteThreadFailed); + } + else + { + WaitForSingleObject(thread, INFINITE); + DWORD exit_code = 0; + GetExitCodeThread(thread, &exit_code); + CloseHandle(thread); + // LoadLibraryW returns the module handle; 0 means it failed to load. + // (The handle is truncated to 32 bits here, but zero vs non-zero is + // all we need to distinguish success from failure.) + if (exit_code == 0) + { + result = InjectResult{InjectStatus::RemoteLoadFailed, 0}; + } + } + } + + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + return result; +} + +} // namespace coop diff --git a/host/src/inject/injector.hpp b/host/src/inject/injector.hpp new file mode 100644 index 0000000..8b8a956 --- /dev/null +++ b/host/src/inject/injector.hpp @@ -0,0 +1,34 @@ +// Loads coop_hook.dll into a target process via the classic +// CreateRemoteThread(LoadLibraryW) technique. +#pragma once + +#include + +namespace coop +{ + +enum class InjectStatus +{ + Ok, + OpenProcessFailed, // insufficient rights (try running the host as admin) + BitnessMismatch, // 32-bit target; needs the x86 hook (later phase) + DllNotFound, + AllocFailed, + WriteFailed, + RemoteThreadFailed, + RemoteLoadFailed, // LoadLibraryW returned null inside the target +}; + +struct InjectResult +{ + InjectStatus status = InjectStatus::OpenProcessFailed; + unsigned long os_error = 0; // GetLastError at the point of failure, if any +}; + +const char* to_string(InjectStatus status); + +// Injects `dll_path` (absolute) into the process with `pid`. The host and DLL +// must match the target's bitness; 32-bit targets are rejected up front. +InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path); + +} // namespace coop diff --git a/host/src/inject/process_list.cpp b/host/src/inject/process_list.cpp new file mode 100644 index 0000000..6436e2f --- /dev/null +++ b/host/src/inject/process_list.cpp @@ -0,0 +1,43 @@ +#include "inject/process_list.hpp" + +#include + +#include +#include + +namespace coop +{ + +std::vector list_processes() +{ + std::vector result; + + HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snapshot == INVALID_HANDLE_VALUE) + { + return result; + } + + PROCESSENTRY32W entry = {}; + entry.dwSize = sizeof(entry); + if (Process32FirstW(snapshot, &entry)) + { + do + { + if (entry.th32ProcessID == 0) + { + continue; + } + result.push_back(ProcessEntry{entry.th32ProcessID, entry.szExeFile}); + } while (Process32NextW(snapshot, &entry)); + } + CloseHandle(snapshot); + + std::sort(result.begin(), result.end(), [](const ProcessEntry& a, const ProcessEntry& b) { + const int cmp = _wcsicmp(a.exe_name.c_str(), b.exe_name.c_str()); + return cmp != 0 ? cmp < 0 : a.pid < b.pid; + }); + return result; +} + +} // namespace coop diff --git a/host/src/inject/process_list.hpp b/host/src/inject/process_list.hpp new file mode 100644 index 0000000..34c034e --- /dev/null +++ b/host/src/inject/process_list.hpp @@ -0,0 +1,20 @@ +// Enumerates running processes for the injection target picker. +#pragma once + +#include +#include + +namespace coop +{ + +struct ProcessEntry +{ + unsigned long pid = 0; + std::wstring exe_name; // image base name, e.g. "game.exe" +}; + +// Snapshot of current processes, sorted by exe name (case-insensitive). System +// idle/0 pids are skipped. +std::vector list_processes(); + +} // namespace coop diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp new file mode 100644 index 0000000..add8083 --- /dev/null +++ b/host/src/injection_panel.cpp @@ -0,0 +1,174 @@ +#include "injection_panel.hpp" + +#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::draw() +{ + 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::End(); +} + +} // namespace coop diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp new file mode 100644 index 0000000..98cc9e8 --- /dev/null +++ b/host/src/injection_panel.hpp @@ -0,0 +1,44 @@ +// ImGui panel that drives the input-forwarding pipeline: pick a target process, +// inject coop_hook.dll, and stream pad state to it over shared memory. +#pragma once + +#include +#include +#include + +#include "coop/protocol.hpp" +#include "imgui.h" +#include "inject/process_list.hpp" +#include "ipc/ipc_server.hpp" + +namespace coop +{ + +class InjectionPanel +{ +public: + InjectionPanel(); + + void draw(); + + // Forward the latest pad snapshot to the injected hook (if connected). + void publish(const std::array& pads) + { + server_.publish(pads); + } + +private: + void refresh_processes(); + void inject_selected(); + + std::vector processes_; + char filter_[128] = {}; + unsigned long selected_pid_ = 0; + std::wstring selected_name_; + + IpcServer server_; + std::string status_; + ImVec4 status_color_; +}; + +} // namespace coop diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp new file mode 100644 index 0000000..efabefc --- /dev/null +++ b/host/src/ipc/ipc_server.cpp @@ -0,0 +1,54 @@ +#include "ipc/ipc_server.hpp" + +namespace coop +{ + +bool IpcServer::start(unsigned long target_pid) +{ + stop(); + + if (!shm_.create(shared_memory_name(target_pid), sizeof(SharedBlock))) + { + return false; + } + + auto* block = shm_.as(); + // Fresh CreateFileMapping pages are zero-filled; set the header before the + // hook (which validates magic/version) has a chance to read it. + block->version = kProtocolVersion; + block->pad_count = 0; + block->sequence.store(0, std::memory_order_relaxed); + block->magic = kProtocolMagic; // publish magic last so a racing reader bails + + block_ = block; + target_pid_ = target_pid; + return true; +} + +void IpcServer::publish(const std::array& pads) +{ + if (block_ == nullptr) + { + return; + } + CoopPadState states[kMaxPads]; + for (std::size_t i = 0; i < pads.size(); ++i) + { + states[i] = pads[i].state; + states[i].connected = pads[i].connected ? 1 : 0; + } + publish_pads(*block_, states, kMaxPads); +} + +void IpcServer::stop() +{ + if (block_ != nullptr) + { + block_->magic = 0; // invalidate so a late hook read won't trust stale data + block_ = nullptr; + } + shm_.reset(); + target_pid_ = 0; +} + +} // namespace coop diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp new file mode 100644 index 0000000..19af758 --- /dev/null +++ b/host/src/ipc/ipc_server.hpp @@ -0,0 +1,41 @@ +// Host side of the IPC channel: creates the shared-memory section (named by the +// target game's pid) and publishes the forwarded controller state each frame. +#pragma once + +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "input/input_source.hpp" + +namespace coop +{ + +class IpcServer +{ +public: + // Creates and initializes the section for `target_pid`. The injected hook + // derives the same name from its own pid and opens it. + bool start(unsigned long target_pid); + + // Pushes the current pad snapshot to the hook. No-op if not started. + void publish(const std::array& pads); + + void stop(); + + [[nodiscard]] bool running() const + { + return block_ != nullptr; + } + [[nodiscard]] unsigned long target_pid() const + { + return target_pid_; + } + +private: + SharedMemory shm_; + SharedBlock* block_ = nullptr; + unsigned long target_pid_ = 0; +}; + +} // namespace coop diff --git a/host/src/main.cpp b/host/src/main.cpp index 78f0f3e..720a5b9 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -14,6 +14,7 @@ #include "d3d11_window.hpp" #include "debug_overlay.hpp" #include "imgui_layer.hpp" +#include "injection_panel.hpp" #include "input/xinput_source.hpp" namespace @@ -36,13 +37,16 @@ int run() } auto input = std::make_unique(); + coop::InjectionPanel injection; while (window.pump_messages()) { input->poll(); + injection.publish(input->pads()); imgui.begin_frame(); coop::draw_debug_overlay(*input); + injection.draw(); window.render_frame([&imgui]() { imgui.end_frame(); }); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt new file mode 100644 index 0000000..b24de83 --- /dev/null +++ b/tests/CMakeLists.txt @@ -0,0 +1,14 @@ +# Self-contained verification of the forwarding core (IPC + XInput hook). +# Reuses the hook's xinput_hook.cpp directly so it exercises the shipping code. +add_executable(hook_selftest + hook_selftest.cpp + ${CMAKE_SOURCE_DIR}/hook/src/xinput_hook.cpp) + +target_include_directories(hook_selftest PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) + +target_link_libraries(hook_selftest PRIVATE + coop_common + safetyhook::safetyhook + xinput) + +add_test(NAME hook_selftest COMMAND hook_selftest) diff --git a/tests/hook_selftest.cpp b/tests/hook_selftest.cpp new file mode 100644 index 0000000..531228a --- /dev/null +++ b/tests/hook_selftest.cpp @@ -0,0 +1,84 @@ +// In-process self-test for the core forwarding logic: IPC publish/read + the +// SafetyHook XInput interception. No injection or physical controller needed -- +// this process plays both host and game. Exits 0 on pass, 1 on failure. + +#include + +#include +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "ipc_client.hpp" +#include "xinput_hook.hpp" + +using namespace coop; + +namespace +{ + +constexpr std::uint16_t kButtonA = 0x1000; +constexpr std::uint16_t kButtonB = 0x2000; + +int g_failures = 0; + +void check(bool ok, const char* what) +{ + if (!ok) + { + std::printf(" FAIL: %s\n", what); + ++g_failures; + } +} + +} // namespace + +int main() +{ + // --- Host side: create the section (named by our pid) and publish a pad. --- + SharedMemory shm; + if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock))) + { + std::printf("FAIL: could not create shared memory\n"); + return 1; + } + auto* block = shm.as(); + block->version = kProtocolVersion; + block->sequence.store(0, std::memory_order_relaxed); + block->magic = kProtocolMagic; + + CoopPadState pads[kMaxPads] = {}; + pads[0].connected = 1; + pads[0].packet = 7; + pads[0].buttons = kButtonA | kButtonB; + pads[0].left_trigger = 128; + pads[0].thumb_lx = 12345; + pads[0].thumb_ry = -4321; + publish_pads(*block, pads, kMaxPads); + + // --- Hook side: connect and install over this process's own xinput. --- + hook::IpcClient ipc; + check(ipc.connect(10, 5), "IPC client connect"); + check(hook::install_xinput_hooks(ipc), "install XInput hooks"); + + // --- Game side: query and verify we get the forwarded synthetic state. --- + XINPUT_STATE state = {}; + check(XInputGetState(0, &state) == ERROR_SUCCESS, "slot 0 reports connected"); + check(state.dwPacketNumber == 7, "packet number forwarded"); + check(state.Gamepad.wButtons == (kButtonA | kButtonB), "buttons forwarded"); + check(state.Gamepad.bLeftTrigger == 128, "left trigger forwarded"); + check(state.Gamepad.sThumbLX == 12345, "left thumb X forwarded"); + check(state.Gamepad.sThumbRY == -4321, "right thumb Y forwarded"); + + XINPUT_STATE other = {}; + check(XInputGetState(1, &other) == ERROR_DEVICE_NOT_CONNECTED, "slot 1 hidden as disconnected"); + + XINPUT_CAPABILITIES caps = {}; + check(XInputGetCapabilities(0, 0, &caps) == ERROR_SUCCESS, "slot 0 capabilities reported"); + check(caps.Type == XINPUT_DEVTYPE_GAMEPAD, "capability device type"); + + hook::remove_xinput_hooks(); + + std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +}