From b1f87833217bd6a87ab4515cea380cdebd054906 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sat, 20 Jun 2026 11:42:42 +0200 Subject: [PATCH] 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 --- CMakeLists.txt | 39 ++++++++++++++ README.md | 30 ++++++++--- hook/CMakeLists.txt | 8 ++- host/src/inject/injector.cpp | 52 ++++++++++++++++++- host/src/inject/injector.hpp | 9 ++-- tools/audio_probe/main.cpp | 51 ++++++++++++++++++- tools/inject_helper/CMakeLists.txt | 5 ++ tools/inject_helper/main.cpp | 82 ++++++++++++++++++++++++++++++ 8 files changed, 261 insertions(+), 15 deletions(-) create mode 100644 tools/inject_helper/CMakeLists.txt create mode 100644 tools/inject_helper/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 976fbba..a505e42 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,6 +29,23 @@ if(MSVC) add_compile_definitions(UNICODE _UNICODE WIN32_LEAN_AND_MEAN NOMINMAX) 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(common) 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(tests) 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 --config $ + INSTALL_COMMAND "" + BUILD_ALWAYS ON + USES_TERMINAL_CONFIGURE ON + USES_TERMINAL_BUILD ON) +endif() diff --git a/README.md b/README.md index b77d148..52292a2 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,12 @@ XInput game becomes Remote-Play-Together-able. co-op/local-multiplayer titles without active anti-cheat. - **XInput only:** the game must read controllers via XInput (the common case). DirectInput-only / RawInput-only games are not handled. -- **x64 only:** the host and hook DLL must match the game's bitness, and only x64 - is built today. 32-bit games need the x86 hook + injector (see Roadmap). +- **32-bit games supported via a helper:** the host is x64, but the build also + 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 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 @@ -143,14 +147,18 @@ Done: (drives a real D3D11 swapchain end-to-end and reads the pixels back through the 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: - **Steam Input:** consume guest input through the Steam Input API directly 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 @@ -166,6 +174,11 @@ cmake --build build --config Debug # 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/`; the Steamworks SDK is vendored manually there when wired. No 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 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" → -> that game needs the x86 hook (see Roadmap). +> Injection access error → run the host as administrator. A 32-bit (WOW64) target +> 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 diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index 38dff76..a19eb7a 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -22,7 +22,13 @@ target_link_libraries(coop_hook PRIVATE d3d11 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/ 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 # machines without the matching VC redist. Deferred until SafetyHook + Zydis are diff --git a/host/src/inject/injector.cpp b/host/src/inject/injector.cpp index 5333a18..8639646 100644 --- a/host/src/inject/injector.cpp +++ b/host/src/inject/injector.cpp @@ -14,7 +14,11 @@ const char* to_string(InjectStatus status) case InjectStatus::OpenProcessFailed: return "OpenProcess failed (try running as administrator)"; 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: return "coop_hook.dll not found next to the host"; case InjectStatus::AllocFailed: @@ -49,6 +53,49 @@ bool is_wow64_process(HANDLE process) 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 "" + 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 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)) { - 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); diff --git a/host/src/inject/injector.hpp b/host/src/inject/injector.hpp index 8b8a956..3e8b3f6 100644 --- a/host/src/inject/injector.hpp +++ b/host/src/inject/injector.hpp @@ -11,12 +11,14 @@ enum class InjectStatus { Ok, 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, AllocFailed, WriteFailed, RemoteThreadFailed, 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 @@ -27,8 +29,9 @@ struct InjectResult 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. +// Injects `dll_path` (absolute, the x64 coop_hook.dll) into `pid`. For a 32-bit +// (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); } // namespace coop diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp index 25d5b95..ea60bdc 100644 --- a/tools/audio_probe/main.cpp +++ b/tools/audio_probe/main.cpp @@ -40,6 +40,47 @@ std::wstring dll_path_next_to_self() 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) { 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()); 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); void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); 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) { 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(e.calls)); } diff --git a/tools/inject_helper/CMakeLists.txt b/tools/inject_helper/CMakeLists.txt new file mode 100644 index 0000000..b9789bc --- /dev/null +++ b/tools/inject_helper/CMakeLists.txt @@ -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") diff --git a/tools/inject_helper/main.cpp b/tools/inject_helper/main.cpp new file mode 100644 index 0000000..8229a89 --- /dev/null +++ b/tools/inject_helper/main.cpp @@ -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 +// +// Exit code 0 = injected, 1 = failure, 2 = bad arguments. + +#include +#include + +#include + +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( + 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 \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]); +}