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

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

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 "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(); });
}