Phase 1a: input forwarding via DLL injection + XInput hook

The host can now inject coop_hook.dll into a running game and forward
controller state to it over shared memory, so the game reads the host's
(eventually the guest's) input and nothing else.

- hook/: coop_hook.dll. DllMain spawns a worker that opens the shared-memory
  channel (named by the game's pid) and installs SafetyHook inline hooks on
  XInputGetState/GetStateEx/GetCapabilities/SetState. Detours synthesize state
  from shared memory; unmanaged slots report disconnected, hiding physical pads.
- host/: process picker (Toolhelp32), CreateRemoteThread(LoadLibraryW) injector
  with an IsWow64Process2 bitness guard, IPC server publishing pads each frame,
  and an ImGui Injection panel wiring it together.
- tests/: hook_selftest exercises the IPC seqlock + hook detours in-process
  (no game/controller needed); passes.

Build: SafetyHook wired in (COOP_BUILD_HOOK=ON), Zydis via FetchContent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 23:21:20 +02:00
parent cf058aecfa
commit e370c8dcc5
19 changed files with 1038 additions and 11 deletions

View File

@@ -28,9 +28,16 @@ add_subdirectory(third_party)
add_subdirectory(common) add_subdirectory(common)
add_subdirectory(host) add_subdirectory(host)
# The injected hook DLL pulls in SafetyHook (+ Zydis). It is built in Phase 1; # The injected hook DLL pulls in SafetyHook, which fetches Zydis via FetchContent
# enable once the dependency is wired so Phase 0 stays minimal. # at configure time (allowed for transitive deps).
option(COOP_BUILD_HOOK "Build the injected game-side hook DLL" OFF) option(COOP_BUILD_HOOK "Build the injected game-side hook DLL" ON)
if(COOP_BUILD_HOOK) 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) add_subdirectory(hook)
enable_testing()
add_subdirectory(tests)
endif() endif()

View File

@@ -42,14 +42,19 @@ See [`docs`](docs) and the in-repo plan for the full design.
## Status ## Status
**Phase 0 — donor spike (current).** `coop_host.exe` is a borderless D3D11 window **Phase 0 — donor spike. ✅ Validated.** `coop_host.exe` is a borderless D3D11
with an ImGui overlay that lists every controller it can see. It exists to window with an ImGui overlay listing every controller it sees. Confirmed
validate the riskiest assumption before anything else is built: *can Steam RPT end-to-end: Steam RPT streams the window under a donor appid, and guest gamepads
stream an arbitrary window launched under a donor appid, and route a guest's arrive (with correct slot assignment) as XInput.
gamepad into it?*
Later phases (capture, injection, audio) are scoped in the plan and gated on **Phase 1a — input forwarding (current).** The host can inject `coop_hook.dll`
Phase 0 passing. 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 ## 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 **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 (window capture + input injection). If not, we revisit the donor-attribution
approach before building further. 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/<Config>/`).
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`.

15
hook/CMakeLists.txt Normal file
View File

@@ -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.

60
hook/src/dllmain.cpp Normal file
View File

@@ -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 <windows.h>
#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;
}

62
hook/src/ipc_client.hpp Normal file
View File

@@ -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 <cstdint>
#include <windows.h>
#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<SharedBlock>();
if (block->magic == kProtocolMagic && block->version == kProtocolVersion)
{
block_ = block;
return true;
}
shm_.reset(); // present but not a contract we understand; retry
}
Sleep(static_cast<DWORD>(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

192
hook/src/xinput_hook.cpp Normal file
View File

@@ -0,0 +1,192 @@
#include "xinput_hook.hpp"
#include <array>
#include <atomic>
#include <vector>
#include <windows.h>
#include <xinput.h>
#include <safetyhook.hpp>
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<safetyhook::InlineHook> 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<CoopPadState, kMaxPads> 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<SHORT>(0x7FFF);
result.Gamepad.sThumbLY = static_cast<SHORT>(0x7FFF);
result.Gamepad.sThumbRX = static_cast<SHORT>(0x7FFF);
result.Gamepad.sThumbRY = static_cast<SHORT>(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<void*>(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<void*>(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<void*>(&hk_XInputGetState));
hook_ordinal(module, 100, reinterpret_cast<void*>(&hk_XInputGetStateEx));
hook_export(module, "XInputGetCapabilities", reinterpret_cast<void*>(&hk_XInputGetCapabilities));
hook_export(module, "XInputSetState", reinterpret_cast<void*>(&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

18
hook/src/xinput_hook.hpp Normal file
View File

@@ -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

View File

@@ -3,7 +3,11 @@ add_executable(coop_host WIN32
src/d3d11_window.cpp src/d3d11_window.cpp
src/imgui_layer.cpp src/imgui_layer.cpp
src/debug_overlay.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) target_include_directories(coop_host PRIVATE src)

View File

@@ -0,0 +1,130 @@
#include "inject/injector.hpp"
#include <windows.h>
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<LPTHREAD_START_ROUTINE>(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

View File

@@ -0,0 +1,34 @@
// Loads coop_hook.dll into a target process via the classic
// CreateRemoteThread(LoadLibraryW) technique.
#pragma once
#include <string>
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

View File

@@ -0,0 +1,43 @@
#include "inject/process_list.hpp"
#include <algorithm>
#include <windows.h>
#include <tlhelp32.h>
namespace coop
{
std::vector<ProcessEntry> list_processes()
{
std::vector<ProcessEntry> 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

View File

@@ -0,0 +1,20 @@
// Enumerates running processes for the injection target picker.
#pragma once
#include <string>
#include <vector>
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<ProcessEntry> list_processes();
} // namespace coop

View File

@@ -0,0 +1,174 @@
#include "injection_panel.hpp"
#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::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

View File

@@ -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 <array>
#include <string>
#include <vector>
#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<PadInfo, kMaxPads>& pads)
{
server_.publish(pads);
}
private:
void refresh_processes();
void inject_selected();
std::vector<ProcessEntry> processes_;
char filter_[128] = {};
unsigned long selected_pid_ = 0;
std::wstring selected_name_;
IpcServer server_;
std::string status_;
ImVec4 status_color_;
};
} // namespace coop

View File

@@ -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<SharedBlock>();
// 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<PadInfo, kMaxPads>& 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

View File

@@ -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 <array>
#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<PadInfo, kMaxPads>& 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

View File

@@ -14,6 +14,7 @@
#include "d3d11_window.hpp" #include "d3d11_window.hpp"
#include "debug_overlay.hpp" #include "debug_overlay.hpp"
#include "imgui_layer.hpp" #include "imgui_layer.hpp"
#include "injection_panel.hpp"
#include "input/xinput_source.hpp" #include "input/xinput_source.hpp"
namespace namespace
@@ -36,13 +37,16 @@ int run()
} }
auto input = std::make_unique<coop::XInputSource>(); auto input = std::make_unique<coop::XInputSource>();
coop::InjectionPanel injection;
while (window.pump_messages()) while (window.pump_messages())
{ {
input->poll(); input->poll();
injection.publish(input->pads());
imgui.begin_frame(); imgui.begin_frame();
coop::draw_debug_overlay(*input); coop::draw_debug_overlay(*input);
injection.draw();
window.render_frame([&imgui]() { imgui.end_frame(); }); window.render_frame([&imgui]() { imgui.end_frame(); });
} }

14
tests/CMakeLists.txt Normal file
View File

@@ -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)

84
tests/hook_selftest.cpp Normal file
View File

@@ -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 <cstdio>
#include <windows.h>
#include <xinput.h>
#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<SharedBlock>();
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;
}