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:
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
|
||||
Reference in New Issue
Block a user