diff --git a/README.md b/README.md index 19ab618..43db7d2 100644 --- a/README.md +++ b/README.md @@ -113,9 +113,6 @@ default** and covers anything the hooked path doesn't. From an in-depth review pass. Each item is fixed test-first (a failing test, then the fix) and lands as its own commit; "verify" items are confirmed real before any change, and dropped if not. -Test coverage: -- **Dedicated hook tests** — `focus_spoof`, `vk_hook` (present). (`d3d9_hook` done.) - Features: - **Static CRT (`/MT`) for `coop_hook.dll`** (x64 + x86) so it loads in games without the VC++ redist. - **Stale Vulkan-layer registration cleanup** — remove a leftover `HKCU` implicit-layer entry from a diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 9a3ca77..b6ef62b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -268,6 +268,21 @@ target_link_libraries(d3d9_hook_test PRIVATE dxgi) add_test(NAME d3d9_hook_test COMMAND d3d9_hook_test) +# In-process self-test for the focus-spoof subsystem: installs the spoof on the test's own top-level +# window and checks the focus-query APIs report it foreground/active/focused, that removal restores +# them, and that a ClipCursor request is swallowed while cursor release is active. +add_executable(focus_spoof_test + focus_spoof_test.cpp + ${CMAKE_SOURCE_DIR}/hook/src/focus_spoof.cpp + ${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp + ${CMAKE_SOURCE_DIR}/hook/src/hook_registry.cpp) +target_include_directories(focus_spoof_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) +target_link_libraries(focus_spoof_test PRIVATE + coop_common + safetyhook::safetyhook + user32) +add_test(NAME focus_spoof_test COMMAND focus_spoof_test) + # Comprehensive capture/audio/hook stress test against coop_mock_game: launches the # animated, frame-numbered A/V game, injects coop_hook.dll, decodes captured frame # numbers (DX11 + DX12) to assert a monotonic/advancing mirror, checks audio capture, and @@ -386,6 +401,7 @@ coop_output_subdir(tests present_hook_test dx12_present_hook_test d3d9_hook_test + focus_spoof_test opengl_hook_test mock_game_test audio_verify_test diff --git a/tests/focus_spoof_test.cpp b/tests/focus_spoof_test.cpp new file mode 100644 index 0000000..3dd8728 --- /dev/null +++ b/tests/focus_spoof_test.cpp @@ -0,0 +1,113 @@ +// In-process self-test for the focus-spoof subsystem (hook/src/focus_spoof.cpp). It plays game + +// host: creates a top-level window (which find_main_window picks up), installs the spoof, and checks +// that the focus-query APIs now report that window as foreground/active/focused even though the test +// isn't actually the foreground app -- and that removal restores them. Also checks the cursor-release +// gating: with clipping disallowed (the default), a ClipCursor request is swallowed. No game, no Steam. +#include + +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "ipc_client.hpp" +#include "focus_spoof.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what); + if (!ok) + { + ++g_failures; + } +} +void pump() +{ + MSG msg; + while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) + { + TranslateMessage(&msg); + DispatchMessageW(&msg); + } +} +} // namespace + +int main() +{ + SharedMemory shm; + if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock))) + { + std::printf("FAIL: create shared memory\n"); + return 1; + } + auto* block = shm.as(); + block->version = kProtocolVersion; + block->sequence.store(0, std::memory_order_relaxed); + block->magic = kProtocolMagic; // allow_cursor_clip stays 0 (zero-filled) -> release the cursor + + hook::IpcClient ipc; + check(ipc.connect(10, 5), "IPC client connect"); + + // A real top-level window for find_main_window to pick up. + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = DefWindowProcW; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = L"coop_focus_test"; + RegisterClassExW(&wc); + HWND win = CreateWindowExW(0, wc.lpszClassName, L"CoopFocusTest", WS_OVERLAPPEDWINDOW, 0, 0, 320, 240, nullptr, + nullptr, wc.hInstance, nullptr); + ShowWindow(win, SW_SHOW); + pump(); + + // Install, retrying a few times in case the window isn't enumerable yet. + bool installed = false; + for (int i = 0; i < 20 && !installed; ++i) + { + installed = hook::install_focus_spoof(ipc); + if (!installed) + { + pump(); + Sleep(20); + } + } + if (!installed) + { + std::printf("SKIP: focus spoof could not find the test window\n"); + DestroyWindow(win); + UnregisterClassW(wc.lpszClassName, wc.hInstance); + return 0; + } + + // The spoof must report OUR window as foreground/active/focused, even though the test process is + // not the real foreground app (so a coincidental match is unlikely). + check(GetForegroundWindow() == win, "GetForegroundWindow spoofed to the game window"); + check(GetActiveWindow() == win, "GetActiveWindow spoofed to the game window"); + check(GetFocus() == win, "GetFocus spoofed to the game window"); + check(block->status.focus_spoof != 0, "status reports focus spoof active"); + + // Cursor release: with clipping disallowed (default), a ClipCursor request must be swallowed. + RECT want{10, 10, 50, 50}; + ClipCursor(&want); // goes through the hook + RECT got{}; + GetClipCursor(&got); + check(!(got.left == want.left && got.top == want.top && got.right == want.right && got.bottom == want.bottom), + "ClipCursor swallowed while cursor release is active (clip not applied)"); + + // Removal: the spoof is torn down. Check the deterministic signal (the status flag) rather than + // GetForegroundWindow's value -- a shown test window may legitimately BE the real foreground, so + // the un-hooked API can still return it. + hook::remove_focus_spoof(); + check(block->status.focus_spoof == 0, "status reports focus spoof inactive after removal"); + ClipCursor(nullptr); // tidy up + + DestroyWindow(win); + UnregisterClassW(wc.lpszClassName, wc.hInstance); + + std::printf(g_failures == 0 ? "FOCUS SPOOF TEST PASS\n" : "FOCUS SPOOF TEST FAILED (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +}