Phase 3: x86 (32-bit) game support via injector helper

Drive a nested Win32 sub-build (CMake ExternalProject, re-entrant via
COOP_X86_HELPER_BUILD) from the normal x64 build to produce coop_hook_x86.dll and
a 32-bit coop_inject_x86.exe, staged next to the x64 binaries. The host detects a
WOW64 target with IsWow64Process2 and spawns the helper to load the x86 DLL, since
a 64-bit process can't cleanly inject a 32-bit one. The shared-memory IPC is
fixed-width / bitness-stable, so the x64 host and x86 hook interoperate.

Validated end-to-end against Slaps and Beans (32-bit D3D11): all 15 hooks
installed, heartbeat advancing, the Present hook engaged (shared a 1920x1080
backbuffer -- the real-game video-hook proof Phantom Brave's D3D9 couldn't give),
and status/audio/video/log IPC all crossed the x64<->x86 boundary. coop_audio_probe
now also delegates to the helper for WOW64 targets. All 5 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 11:42:42 +02:00
parent 36b861d167
commit b1f8783321
8 changed files with 261 additions and 15 deletions

View File

@@ -29,6 +29,23 @@ if(MSVC)
add_compile_definitions(UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX) add_compile_definitions(UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX)
endif() endif()
# --- x86 sub-build (internal, re-entrant) ----------------------------------
# When the ExternalProject below reconfigures this same tree with -A Win32 and
# -DCOOP_X86_HELPER_BUILD=ON, build ONLY the 32-bit hook DLL + injector helper
# (staged alongside the x64 binaries) and stop -- no host, tests, or imgui, and
# crucially no recursive ExternalProject.
option(COOP_X86_HELPER_BUILD "Internal: x86 sub-build (hook DLL + injector helper only)" OFF)
if(COOP_X86_HELPER_BUILD)
add_subdirectory(common)
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(tools/inject_helper)
return()
endif()
add_subdirectory(third_party) add_subdirectory(third_party)
add_subdirectory(common) add_subdirectory(common)
add_subdirectory(host) add_subdirectory(host)
@@ -48,3 +65,25 @@ if(COOP_BUILD_HOOK)
add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook
add_subdirectory(tests) add_subdirectory(tests)
endif() endif()
# --- x86 helper artifacts for 32-bit (WOW64) games -------------------------
# A 64-bit process can't cleanly inject a 32-bit one, so build the x86 hook DLL
# (coop_hook_x86.dll) + a 32-bit injector helper (coop_inject_x86.exe) via a
# nested Win32 configure of this same tree, staged next to the x64 binaries. Only
# the multi-config Visual Studio generator is supported here (the Ninja
# build-clangd/ tree is just for compile_commands and skips this).
option(COOP_BUILD_X86_HELPER "Build the x86 hook DLL + injector helper for 32-bit games" ON)
if(COOP_BUILD_HOOK AND COOP_BUILD_X86_HELPER AND CMAKE_GENERATOR MATCHES "Visual Studio")
include(ExternalProject)
ExternalProject_Add(coop_x86
SOURCE_DIR "${CMAKE_SOURCE_DIR}"
BINARY_DIR "${CMAKE_BINARY_DIR}/x86"
CMAKE_GENERATOR "${CMAKE_GENERATOR}"
CMAKE_GENERATOR_PLATFORM "Win32"
CMAKE_ARGS "-DCOOP_X86_HELPER_BUILD=ON"
BUILD_COMMAND "${CMAKE_COMMAND}" --build <BINARY_DIR> --config $<CONFIG>
INSTALL_COMMAND ""
BUILD_ALWAYS ON
USES_TERMINAL_CONFIGURE ON
USES_TERMINAL_BUILD ON)
endif()

View File

@@ -37,8 +37,12 @@ XInput game becomes Remote-Play-Together-able.
co-op/local-multiplayer titles without active anti-cheat. co-op/local-multiplayer titles without active anti-cheat.
- **XInput only:** the game must read controllers via XInput (the common case). - **XInput only:** the game must read controllers via XInput (the common case).
DirectInput-only / RawInput-only games are not handled. DirectInput-only / RawInput-only games are not handled.
- **x64 only:** the host and hook DLL must match the game's bitness, and only x64 - **32-bit games supported via a helper:** the host is x64, but the build also
is built today. 32-bit games need the x86 hook + injector (see Roadmap). produces an x86 hook DLL (`coop_hook_x86.dll`) and a 32-bit injector helper
(`coop_inject_x86.exe`). When the target is a 32-bit (WOW64) process the host
detects it (`IsWow64Process2`) and shells out to the helper to load the x86 DLL
(a 64-bit process can't cleanly inject a 32-bit one). The shared-memory IPC is
fixed-width / bitness-stable, so the x64 host and x86 hook interoperate.
- **Local audio echo (fixed via the hook; falls back otherwise):** when the - **Local audio echo (fixed via the hook; falls back otherwise):** when the
render-hook is active it silences the game's local playback while mirroring it, render-hook is active it silences the game's local playback while mirroring it,
so there is no echo. If the hook can't attach or the game uses an so there is no echo. If the hook can't attach or the game uses an
@@ -143,14 +147,18 @@ Done:
(drives a real D3D11 swapchain end-to-end and reads the pixels back through the (drives a real D3D11 swapchain end-to-end and reads the pixels back through the
shared texture). shared texture).
- **x86 (32-bit) game support. ✅** A nested Win32 sub-build (CMake
`ExternalProject`, driven from the normal x64 build) produces `coop_hook_x86.dll`
and a 32-bit `coop_inject_x86.exe`, staged next to the x64 binaries. The host
detects a WOW64 target with `IsWow64Process2` and spawns the helper to inject
the x86 DLL. Validated end-to-end against Slaps and Beans (a 32-bit D3D11 game):
all subsystems hooked, and the IPC channels (status, audio ring, video share,
log) all flow across the x64↔x86 boundary.
Future work, roughly in priority order: Future work, roughly in priority order:
- **Steam Input:** consume guest input through the Steam Input API directly - **Steam Input:** consume guest input through the Steam Input API directly
rather than XInput. rather than XInput.
- **x86 support:** add an x86 build of `coop_hook.dll` plus an x86 injector
helper the x64 host spawns, so 32-bit games work (a 64-bit process can't
cleanly inject a 32-bit one). Detect target bitness with `IsWow64Process2`.
The shared-memory IPC layout is already fixed-width / bitness-stable.
## Building ## Building
@@ -166,6 +174,11 @@ cmake --build build --config Debug
# output: bin/Debug/coop_host.exe (+ coop_hook.dll, test exes) # output: bin/Debug/coop_host.exe (+ coop_hook.dll, test exes)
``` ```
The x64 build also drives a nested Win32 sub-build (CMake `ExternalProject`,
configured into `build/x86/`) that produces `coop_hook_x86.dll` and
`coop_inject_x86.exe` for 32-bit games, staged next to the x64 binaries. Disable
it with `-DCOOP_BUILD_X86_HELPER=OFF` if you don't need 32-bit support.
Third-party dependencies (Dear ImGui, SafetyHook) are git submodules under Third-party dependencies (Dear ImGui, SafetyHook) are git submodules under
`third_party/`; the Steamworks SDK is vendored manually there when wired. No `third_party/`; the Steamworks SDK is vendored manually there when wired. No
vcpkg / package manager is used. vcpkg / package manager is used.
@@ -259,8 +272,9 @@ Useful checks while developing without RPT: tick **Forward synthetic test input*
in the Injection panel to make the game move on its own (proving forwarding is the in the Injection panel to make the game move on its own (proving forwarding is the
source), and click away from the game to confirm focus spoofing keeps it running. source), and click away from the game to confirm focus spoofing keeps it running.
> Injection access error → run the host as administrator. "Target is 32-bit" → > Injection access error → run the host as administrator. A 32-bit (WOW64) target
> that game needs the x86 hook (see Roadmap). > is injected automatically via `coop_inject_x86.exe` + `coop_hook_x86.dll`; if
> those aren't next to the host, rebuild (the x86 sub-build stages them there).
## Lessons learned ## Lessons learned

View File

@@ -22,7 +22,13 @@ target_link_libraries(coop_hook PRIVATE
d3d11 d3d11
dxgi) dxgi)
set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook") # The x86 sub-build (COOP_X86_HELPER_BUILD) stages alongside the x64 DLL in the
# same bin/<config> dir, so give it a distinct name the host loads for 32-bit games.
if(COOP_X86_HELPER_BUILD)
set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook_x86")
else()
set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook")
endif()
# TODO(dist): statically link the VC runtime (/MT) so the DLL loads in games on # 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 # machines without the matching VC redist. Deferred until SafetyHook + Zydis are

View File

@@ -14,7 +14,11 @@ const char* to_string(InjectStatus status)
case InjectStatus::OpenProcessFailed: case InjectStatus::OpenProcessFailed:
return "OpenProcess failed (try running as administrator)"; return "OpenProcess failed (try running as administrator)";
case InjectStatus::BitnessMismatch: case InjectStatus::BitnessMismatch:
return "target is 32-bit; x86 hook not built yet"; return "target is 32-bit and the x86 hook/helper are unavailable";
case InjectStatus::HelperNotFound:
return "target is 32-bit; coop_inject_x86.exe / coop_hook_x86.dll not found next to the host";
case InjectStatus::HelperFailed:
return "the x86 injector helper failed (run the host as administrator?)";
case InjectStatus::DllNotFound: case InjectStatus::DllNotFound:
return "coop_hook.dll not found next to the host"; return "coop_hook.dll not found next to the host";
case InjectStatus::AllocFailed: case InjectStatus::AllocFailed:
@@ -49,6 +53,49 @@ bool is_wow64_process(HANDLE process)
return false; // be permissive if the query is unavailable return false; // be permissive if the query is unavailable
} }
// Replace the trailing file name of `path` with `name` (same directory).
std::wstring sibling(const std::wstring& path, const wchar_t* name)
{
const std::size_t slash = path.find_last_of(L"\\/");
const std::wstring dir = (slash == std::wstring::npos) ? std::wstring() : path.substr(0, slash + 1);
return dir + name;
}
// Inject into a 32-bit (WOW64) target by spawning the x86 injector helper, which
// loads the x86 hook DLL from the same directory as the x64 dll_path.
InjectResult inject_via_helper(unsigned long pid, const std::wstring& dll_path)
{
const std::wstring helper = sibling(dll_path, L"coop_inject_x86.exe");
const std::wstring x86_dll = sibling(dll_path, L"coop_hook_x86.dll");
if (GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES ||
GetFileAttributesW(x86_dll.c_str()) == INVALID_FILE_ATTRIBUTES)
{
return InjectResult{InjectStatus::HelperNotFound, 0};
}
// coop_inject_x86 <pid> "<x86_dll>"
std::wstring cmd = L"\"" + helper + L"\" " + std::to_wstring(pid) + L" \"" + x86_dll + L"\"";
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(helper.c_str(), cmd.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr,
&si, &pi))
{
return fail(InjectStatus::HelperFailed);
}
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD exit_code = 1;
GetExitCodeProcess(pi.hProcess, &exit_code);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
if (exit_code != 0)
{
return InjectResult{InjectStatus::HelperFailed, exit_code};
}
return InjectResult{InjectStatus::Ok, 0};
}
} // namespace } // namespace
InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path) InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path)
@@ -80,7 +127,8 @@ InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path)
if (is_wow64_process(process)) if (is_wow64_process(process))
{ {
return InjectResult{InjectStatus::BitnessMismatch, 0}; // The x64 host can't inject a 32-bit target directly; delegate to the helper.
return inject_via_helper(pid, dll_path);
} }
const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t); const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t);

View File

@@ -11,12 +11,14 @@ enum class InjectStatus
{ {
Ok, Ok,
OpenProcessFailed, // insufficient rights (try running the host as admin) OpenProcessFailed, // insufficient rights (try running the host as admin)
BitnessMismatch, // 32-bit target; needs the x86 hook (later phase) BitnessMismatch, // 32-bit target; the x86 hook/helper aren't available
DllNotFound, DllNotFound,
AllocFailed, AllocFailed,
WriteFailed, WriteFailed,
RemoteThreadFailed, RemoteThreadFailed,
RemoteLoadFailed, // LoadLibraryW returned null inside the target RemoteLoadFailed, // LoadLibraryW returned null inside the target
HelperNotFound, // 32-bit target but coop_inject_x86.exe / coop_hook_x86.dll missing
HelperFailed, // the x86 injector helper ran but reported failure
}; };
struct InjectResult struct InjectResult
@@ -27,8 +29,9 @@ struct InjectResult
const char* to_string(InjectStatus status); const char* to_string(InjectStatus status);
// Injects `dll_path` (absolute) into the process with `pid`. The host and DLL // Injects `dll_path` (absolute, the x64 coop_hook.dll) into `pid`. For a 32-bit
// must match the target's bitness; 32-bit targets are rejected up front. // (WOW64) target the x64 host can't inject directly, so it spawns the sibling
// coop_inject_x86.exe to load coop_hook_x86.dll (both expected next to dll_path).
InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path); InjectResult inject_dll(unsigned long pid, const std::wstring& dll_path);
} // namespace coop } // namespace coop

View File

@@ -40,6 +40,47 @@ std::wstring dll_path_next_to_self()
return path + L"coop_hook.dll"; return path + L"coop_hook.dll";
} }
std::wstring sibling_of(const std::wstring& path, const wchar_t* name)
{
const size_t slash = path.find_last_of(L"\\/");
return (slash == std::wstring::npos ? std::wstring() : path.substr(0, slash + 1)) + name;
}
// Inject a 32-bit (WOW64) target via the x86 helper, mirroring the host. Lets the
// probe exercise the x86 hook end-to-end (the IPC channels are bitness-agnostic).
bool inject_via_helper(unsigned long pid, const std::wstring& dll_path)
{
const std::wstring helper = sibling_of(dll_path, L"coop_inject_x86.exe");
const std::wstring x86_dll = sibling_of(dll_path, L"coop_hook_x86.dll");
if (GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES ||
GetFileAttributesW(x86_dll.c_str()) == INVALID_FILE_ATTRIBUTES)
{
std::printf("ERROR: x86 helper/dll missing next to the probe.\n");
return false;
}
std::wstring cmd = L"\"" + helper + L"\" " + std::to_wstring(pid) + L" \"" + x86_dll + L"\"";
std::printf("32-bit target: injecting coop_hook_x86.dll via coop_inject_x86.exe ...\n");
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (!CreateProcessW(helper.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si, &pi))
{
std::printf("ERROR: CreateProcess(coop_inject_x86) failed (%lu).\n", GetLastError());
return false;
}
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 1;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
if (code != 0)
{
std::printf("ERROR: coop_inject_x86 reported failure (exit %lu).\n", code);
return false;
}
return true;
}
bool inject(unsigned long pid, const std::wstring& dll_path) bool inject(unsigned long pid, const std::wstring& dll_path)
{ {
if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES) if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES)
@@ -55,6 +96,14 @@ bool inject(unsigned long pid, const std::wstring& dll_path)
std::printf("ERROR: OpenProcess(%lu) failed (%lu). Run as administrator?\n", pid, GetLastError()); std::printf("ERROR: OpenProcess(%lu) failed (%lu). Run as administrator?\n", pid, GetLastError());
return false; return false;
} }
// 32-bit target -> delegate to the x86 helper (a 64-bit process can't inject it).
USHORT proc_machine = IMAGE_FILE_MACHINE_UNKNOWN, native_machine = IMAGE_FILE_MACHINE_UNKNOWN;
if (IsWow64Process2(process, &proc_machine, &native_machine) && proc_machine != IMAGE_FILE_MACHINE_UNKNOWN)
{
CloseHandle(process);
return inject_via_helper(pid, dll_path);
}
const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t); const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t);
void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
bool ok = false; bool ok = false;
@@ -269,7 +318,7 @@ int wmain(int argc, wchar_t** argv)
for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i) for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i)
{ {
const coop::HookEntry& e = status.hook_entries[i]; const coop::HookEntry& e = status.hook_entries[i];
std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 3 ? kSubsys[e.subsystem] : "?", e.name, std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 4 ? kSubsys[e.subsystem] : "?", e.name,
e.installed ? "ON " : "off", static_cast<unsigned long long>(e.calls)); e.installed ? "ON " : "off", static_cast<unsigned long long>(e.calls));
} }

View File

@@ -0,0 +1,5 @@
# 32-bit injector helper. Built only in the x86 sub-build (COOP_X86_HELPER_BUILD);
# the x64 host spawns it to inject the x86 hook DLL into 32-bit (WOW64) games.
add_executable(coop_inject_x86 main.cpp)
set_target_properties(coop_inject_x86 PROPERTIES OUTPUT_NAME "coop_inject_x86")

View File

@@ -0,0 +1,82 @@
// coop_inject_x86 -- a 32-bit injector helper the (x64) host spawns to load the
// x86 hook DLL into a 32-bit (WOW64) game. A 64-bit process can't cleanly
// CreateRemoteThread(LoadLibraryW) into a 32-bit target (its LoadLibraryW lives
// in the 32-bit kernel32 at an address the 64-bit host doesn't have), so the
// host shells out to this same-bitness helper instead.
//
// coop_inject_x86 <pid> <dll_path>
//
// Exit code 0 = injected, 1 = failure, 2 = bad arguments.
#include <cstdio>
#include <string>
#include <windows.h>
namespace
{
int inject(unsigned long pid, const std::wstring& dll_path)
{
if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES)
{
std::fprintf(stderr, "coop_inject_x86: dll not found: %ls\n", dll_path.c_str());
return 1;
}
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)
{
std::fprintf(stderr, "coop_inject_x86: OpenProcess(%lu) failed (%lu)\n", pid, GetLastError());
return 1;
}
int result = 1;
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 && WriteProcessMemory(process, remote, dll_path.c_str(), bytes, nullptr))
{
// In a 32-bit process kernel32 is mapped at the same base as in this 32-bit
// helper, so LoadLibraryW's address here is valid as the remote 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)
{
WaitForSingleObject(thread, INFINITE);
DWORD exit_code = 0;
GetExitCodeThread(thread, &exit_code);
CloseHandle(thread);
result = (exit_code != 0) ? 0 : 1; // LoadLibraryW returns the module handle
}
else
{
std::fprintf(stderr, "coop_inject_x86: CreateRemoteThread failed (%lu)\n", GetLastError());
}
}
if (remote != nullptr)
{
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
}
CloseHandle(process);
return result;
}
} // namespace
int wmain(int argc, wchar_t** argv)
{
if (argc < 3)
{
std::printf("usage: coop_inject_x86 <pid> <dll_path>\n");
return 2;
}
const unsigned long pid = std::wcstoul(argv[1], nullptr, 10);
if (pid == 0)
{
std::fprintf(stderr, "coop_inject_x86: invalid pid\n");
return 2;
}
return inject(pid, argv[2]);
}