Files
CoopAllTheThings/tests/hook_selftest.cpp
BlackMark 5e9b1cde4c Pin the whole SharedBlock layout with static_asserts (ABI tripwire)
The cross-process block's layout is a wire protocol shared by the x64 host and the
x86 hook, but only three front offsets were asserted, and hook_selftest's
dump_layout merely printed the rest. A field reordered/resized inside HookStatus
(which precedes control/video/mkb) would silently shift everything with no
compile-time tripwire and, if the developer forgot to bump kProtocolVersion, ship
a silent host<->DLL mismatch.

- protocol.hpp now static_asserts sizeof(SharedBlock) and every sub-channel offset
  (status/control/video/mkb) plus each sub-struct size (HookStatus/HookControl/
  VideoShare/AudioStreamInfo/HookEntry/MkbRing). protocol.hpp is compiled for both
  arches, so a cross-bitness divergence fails to compile on the one that disagrees.
- hook_selftest's dump_layout now ASSERTS the same numbers instead of only
  printing, so hook_selftest_x86 confirms the x86 layout at runtime too.

Verified: x64 and x86 builds both compile (identical layout) and both selftests
pass; sizeof(SharedBlock)=3936 on both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 01:47:19 +02:00

200 lines
8.5 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));
std::printf("LAYOUT off mkb=%zu MkbRing sizeof=%zu AudioStreamInfo sizeof=%zu HookEntry sizeof=%zu\n",
offsetof(SharedBlock, mkb), sizeof(MkbRing), sizeof(AudioStreamInfo), sizeof(HookEntry));
// Assert the layout, not just print it -- and this runs in the x86 build too (hook_selftest_x86),
// so the same numbers the static_asserts pin at compile time are confirmed at runtime on both
// arches. The reference values match the static_asserts in protocol.hpp.
check(sizeof(SharedBlock) == 3936, "layout: sizeof(SharedBlock)");
check(offsetof(SharedBlock, sequence) == 12 && offsetof(SharedBlock, pads) == 16, "layout: seqlock front");
check(offsetof(SharedBlock, status) == 96 && offsetof(SharedBlock, control) == 1816, "layout: status/control");
check(offsetof(SharedBlock, video) == 1840 && offsetof(SharedBlock, mkb) == 1880, "layout: video/mkb");
check(sizeof(HookStatus) == 1720 && sizeof(HookControl) == 24 && sizeof(VideoShare) == 40,
"layout: sub-struct sizes");
check(sizeof(AudioStreamInfo) == 32 && sizeof(HookEntry) == 56 && sizeof(MkbRing) == 2056,
"layout: more sub-struct sizes");
}
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");
// Round-trip: the hook echoes the state it returned to the game (for the
// Controllers panel's forwarded-vs-read view).
check(block->status.read_state[0].buttons == (kButtonA | kButtonB), "status records game-read state");
hook::remove_xinput_hooks();
std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);
return g_failures == 0 ? 0 : 1;
}