The XInput hook used to swallow XInputSetState; now it records the requested left/right motor speeds into the status back-channel (protocol v8->v9: per-slot rumble_left/right in HookStatus). Each frame the host reads them and, only on change, drives the guest's actuator via the active backend: - XInput: XInputSetState on the guest's slot (the open question is whether Steam's RPT virtual pad accepts vibration and routes it to the guest -- needs live RPT); - Steam Input: SteamInput TriggerVibration on the slot's controller handle, with the XInput fallback for slots Steam isn't driving. InputSource gains a set_rumble(slot,left,right) hook (default no-op) implemented by both backends; SteamInputSource now tracks per-slot controller handles + which slots it drives. Verified: hook_selftest (x64 + x86) now asserts the hook records the rumble from XInputSetState into the status; full build x64 + x86 clean; ctest x64 9/9, x86 3/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
183 lines
7.2 KiB
C++
183 lines
7.2 KiB
C++
// 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 <cstddef>
|
|
#include <cstdio>
|
|
|
|
#include <windows.h>
|
|
#include <xinput.h>
|
|
|
|
#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<GetState_t>(GetProcAddress(m, "XInputGetState"));
|
|
auto get_state_ex = reinterpret_cast<GetState_t>(GetProcAddress(m, MAKEINTRESOURCEA(100)));
|
|
auto get_caps = reinterpret_cast<GetCaps_t>(GetProcAddress(m, "XInputGetCapabilities"));
|
|
auto set_state = reinterpret_cast<SetState_t>(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<SharedBlock>();
|
|
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");
|
|
|
|
// Rumble forwarding: exercise_dll called XInputSetState(0, {0x8000, 0x4000}); the
|
|
// hook should have recorded it into the status for the host to forward to the guest.
|
|
check(block->status.rumble_left[0] == 0x8000 && block->status.rumble_right[0] == 0x4000,
|
|
"status records rumble from XInputSetState");
|
|
|
|
hook::remove_xinput_hooks();
|
|
|
|
std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);
|
|
return g_failures == 0 ? 0 : 1;
|
|
}
|