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:
@@ -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()
|
||||
|
||||
41
README.md
41
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/<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
15
hook/CMakeLists.txt
Normal 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
60
hook/src/dllmain.cpp
Normal 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
62
hook/src/ipc_client.hpp
Normal 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
192
hook/src/xinput_hook.cpp
Normal 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
18
hook/src/xinput_hook.hpp
Normal 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
|
||||
@@ -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)
|
||||
|
||||
|
||||
130
host/src/inject/injector.cpp
Normal file
130
host/src/inject/injector.cpp
Normal 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
|
||||
34
host/src/inject/injector.hpp
Normal file
34
host/src/inject/injector.hpp
Normal 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
|
||||
43
host/src/inject/process_list.cpp
Normal file
43
host/src/inject/process_list.cpp
Normal 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
|
||||
20
host/src/inject/process_list.hpp
Normal file
20
host/src/inject/process_list.hpp
Normal 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
|
||||
174
host/src/injection_panel.cpp
Normal file
174
host/src/injection_panel.cpp
Normal 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
|
||||
44
host/src/injection_panel.hpp
Normal file
44
host/src/injection_panel.hpp
Normal 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
|
||||
54
host/src/ipc/ipc_server.cpp
Normal file
54
host/src/ipc/ipc_server.cpp
Normal 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
|
||||
41
host/src/ipc/ipc_server.hpp
Normal file
41
host/src/ipc/ipc_server.hpp
Normal 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
|
||||
@@ -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::XInputSource>();
|
||||
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(); });
|
||||
}
|
||||
|
||||
14
tests/CMakeLists.txt
Normal file
14
tests/CMakeLists.txt
Normal 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
84
tests/hook_selftest.cpp
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user