// 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 #include #include #include #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 // Exercises every export the game touches on one loaded xinput DLL, so the inline // hook + trampoline over *that DLL's* real prologue is actually called -- not just // installed. Older games load older variants (e.g. xinput1_3.dll) whose export // prologues differ, which is where a 32-bit trampoline-relocation fault hides. void exercise_dll(const wchar_t* dll_name) { HMODULE m = GetModuleHandleW(dll_name); if (m == nullptr) { return; // not loaded on this machine; nothing to exercise } char tag[96]; using GetState_t = DWORD(WINAPI*)(DWORD, XINPUT_STATE*); using SetState_t = DWORD(WINAPI*)(DWORD, XINPUT_VIBRATION*); using GetCaps_t = DWORD(WINAPI*)(DWORD, DWORD, XINPUT_CAPABILITIES*); auto get_state = reinterpret_cast(GetProcAddress(m, "XInputGetState")); auto get_state_ex = reinterpret_cast(GetProcAddress(m, MAKEINTRESOURCEA(100))); auto get_caps = reinterpret_cast(GetProcAddress(m, "XInputGetCapabilities")); auto set_state = reinterpret_cast(GetProcAddress(m, "XInputSetState")); if (get_state != nullptr) { XINPUT_STATE s = {}; std::snprintf(tag, sizeof(tag), "%ls XInputGetState forwards state", dll_name); check(get_state(0, &s) == ERROR_SUCCESS && s.dwPacketNumber == 7, tag); } if (get_state_ex != nullptr) { XINPUT_STATE s = {}; std::snprintf(tag, sizeof(tag), "%ls XInputGetStateEx (ord 100) forwards state", dll_name); check(get_state_ex(0, &s) == ERROR_SUCCESS && s.dwPacketNumber == 7, tag); } if (get_caps != nullptr) { XINPUT_CAPABILITIES c = {}; std::snprintf(tag, sizeof(tag), "%ls XInputGetCapabilities reports gamepad", dll_name); check(get_caps(0, 0, &c) == ERROR_SUCCESS && c.Type == XINPUT_DEVTYPE_GAMEPAD, tag); } if (set_state != nullptr) { // A game commonly rumbles in response to a button press; this is the call // path "crashes as soon as a button is pressed" pointed at. XINPUT_VIBRATION v = {}; v.wLeftMotorSpeed = 0x8000; v.wRightMotorSpeed = 0x4000; std::snprintf(tag, sizeof(tag), "%ls XInputSetState (rumble) accepted, no crash", dll_name); check(set_state(0, &v) == ERROR_SUCCESS, tag); } } void dump_layout() { std::printf("LAYOUT sizeof(SharedBlock)=%zu CoopPadState=%zu\n", sizeof(SharedBlock), sizeof(CoopPadState)); std::printf("LAYOUT off pads=%zu sequence=%zu status=%zu control=%zu video=%zu\n", offsetof(SharedBlock, pads), offsetof(SharedBlock, sequence), offsetof(SharedBlock, status), offsetof(SharedBlock, control), offsetof(SharedBlock, video)); std::printf("LAYOUT HookStatus sizeof=%zu get_state_calls=%zu attached=%zu audio_streams=%zu hook_entries=%zu\n", sizeof(HookStatus), offsetof(HookStatus, get_state_calls), offsetof(HookStatus, attached), offsetof(HookStatus, audio_streams), offsetof(HookStatus, hook_entries)); std::printf("LAYOUT VideoShare sizeof=%zu present_calls=%zu HookControl sizeof=%zu\n", sizeof(VideoShare), offsetof(VideoShare, present_calls), sizeof(HookControl)); } int main() { dump_layout(); // --- 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(); 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); // Load every xinput variant *before* installing hooks, so install_xinput_hooks // (which only hooks already-loaded modules) covers all of them and the matrix // below exercises each DLL's real export prologue under SafetyHook. A real game // loads exactly one, but which one varies by game age -- and the 32-bit crash // only reproduces over the specific DLL the game uses. const wchar_t* xinput_modules[] = {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll", L"xinputuap.dll"}; for (const wchar_t* name : xinput_modules) { LoadLibraryW(name); // best-effort; absent variants stay unloaded } // --- 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"); // Now drive every loaded variant's full export set (GetState, ordinal-100 // GetStateEx, GetCapabilities, and the rumble SetState a game calls on a button // press) so each DLL's hooked prologue/trampoline is actually run. for (const wchar_t* name : xinput_modules) { exercise_dll(name); } // Status back-channel: the host relies on these to prove the hook is live. check(block->status.attached == 1, "status reports attached"); check(block->status.get_state_calls[0].load(std::memory_order_relaxed) >= 1, "status counts slot 0 GetState"); check(block->status.get_state_calls[1].load(std::memory_order_relaxed) >= 1, "status counts slot 1 GetState"); check(block->status.get_caps_calls[0].load(std::memory_order_relaxed) >= 1, "status counts slot 0 GetCaps"); hook::remove_xinput_hooks(); std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures); return g_failures == 0 ? 0 : 1; }