Mock game: drive input/focus/MKB hooks + window title; add DetourGate test

- poll_input() each frame (XInputGetState / GetAsyncKeyState / GetKeyboardState /
  GetForegroundWindow), like a real game, so mock_game_test's hook/unhook storm
  actually exercises removing the input/focus/MKB hooks while their detours are in
  flight -- the coverage gap that let those removal races go untested.
- Window title shows the backend + a once-per-second-smoothed fps.
- A vectored-exception crash logger prints the faulting module+offset (named the
  storm's intermittent crashes during this work; inert otherwise).
- detour_gate_test: fast, deterministic guard for DetourGate -- drain() must block
  while a Guard is in flight and return promptly otherwise, plus a concurrency
  stress that asserts no body runs against freed state. (A synthetic install-race
  unit test was tried but flaked on SafetyHook's own enable/disable atomicity under
  ~30M calls/s, unrelated to our code, so the storm is the install/remove guard.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 11:17:34 +02:00
parent 79582f9fa6
commit 0e58902438
4 changed files with 200 additions and 1 deletions

View File

@@ -13,7 +13,8 @@ add_executable(coop_mock_game
# Reuses the shared ToneSource (also used by coop_tone + the audio hook self-test).
target_include_directories(coop_mock_game PRIVATE ${CMAKE_SOURCE_DIR}/tools/audio_tone)
target_link_libraries(coop_mock_game PRIVATE d3d11 d3d12 d3d10 d3d9 dxgi ole32 opengl32 gdi32 volk)
target_link_libraries(coop_mock_game PRIVATE d3d11 d3d12 d3d10 d3d9 dxgi ole32 opengl32 gdi32 volk
xinput user32) # xinput/user32: poll the input + focus APIs the hook intercepts (storm coverage)
set_target_properties(coop_mock_game PROPERTIES OUTPUT_NAME "coop_mock_game")
# Test fixture -> stage next to the tests (alongside coop_tone), not in the deployable root.

View File

@@ -16,6 +16,8 @@
#include <windows.h>
#include <xinput.h>
#include "render_backend.hpp"
#include "tone_source.hpp"
@@ -23,6 +25,41 @@ namespace
{
std::atomic<bool> g_running{true};
// Diagnostic: on an access violation, log the faulting address and the caller (return address on the
// stack) as module+offset, so the storm's intermittent crashes name the exact hook. Then let it
// crash so the test still detects it.
LONG WINAPI crash_logger(EXCEPTION_POINTERS* ep)
{
if (ep->ExceptionRecord->ExceptionCode != EXCEPTION_ACCESS_VIOLATION)
{
return EXCEPTION_CONTINUE_SEARCH;
}
auto mod = [](void* p, char* out, size_t n) -> unsigned long long {
HMODULE m = nullptr;
if (p != nullptr &&
GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
reinterpret_cast<LPCSTR>(p), &m) &&
m != nullptr)
{
char path[MAX_PATH] = {};
GetModuleFileNameA(m, path, MAX_PATH);
const char* base = std::strrchr(path, '\\');
lstrcpynA(out, base ? base + 1 : path, static_cast<int>(n));
return static_cast<unsigned long long>(reinterpret_cast<uintptr_t>(p) - reinterpret_cast<uintptr_t>(m));
}
lstrcpynA(out, "?", static_cast<int>(n));
return reinterpret_cast<unsigned long long>(p);
};
char m1[64] = {}, m2[64] = {};
void* rip = reinterpret_cast<void*>(ep->ContextRecord->Rip);
void* ret = ep->ContextRecord->Rsp ? *reinterpret_cast<void**>(ep->ContextRecord->Rsp) : nullptr;
const unsigned long long o1 = mod(rip, m1, sizeof(m1));
const unsigned long long o2 = mod(ret, m2, sizeof(m2));
std::printf("MOCK_GAME CRASH: AV rip=%s+0x%llx caller=%s+0x%llx\n", m1, o1, m2, o2);
std::fflush(stdout);
return EXCEPTION_CONTINUE_SEARCH; // still crash so the test detects it
}
LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam)
{
if (msg == WM_DESTROY)
@@ -58,10 +95,25 @@ void audio_thread(coop::tone::ToneFormat want)
tone.close();
CoUninitialize();
}
// Poll the input/focus APIs the hook intercepts, like a real game does each frame. When the
// input / MKB / focus subsystems are hooked (e.g. during mock_game_test's hook/unhook storm) this
// drives their detours -- so removing those hooks while a detour is in-flight is actually exercised
// (the capture path alone never calls them, which is how the input-hook removal race went untested).
void poll_input()
{
XINPUT_STATE xs{};
(void)XInputGetState(0, &xs); // XInput hook (XInputGetState/Ex)
(void)GetAsyncKeyState(VK_SPACE); // MKB hook (GetAsyncKeyState)
BYTE kb[256] = {};
(void)GetKeyboardState(kb); // MKB hook (GetKeyboardState)
(void)GetForegroundWindow(); // focus hook (GetForegroundWindow)
}
} // namespace
int main(int argc, char** argv)
{
AddVectoredExceptionHandler(1, crash_logger); // name the faulting hook on an intermittent storm crash
const std::string backend_name = argc > 1 ? argv[1] : "dx11";
const double seconds = argc > 2 ? std::strtod(argv[2], nullptr) : 0.0;
const bool want_audio = argc > 3;
@@ -124,6 +176,8 @@ int main(int argc, char** argv)
const ULONGLONG start = GetTickCount64();
std::uint32_t frame = 0;
ULONGLONG fps_window_start = start; // window-title fps: frames in the last ~second
std::uint32_t fps_window_frames = 0;
for (;;)
{
MSG msg;
@@ -140,7 +194,21 @@ int main(int argc, char** argv)
{
break;
}
poll_input(); // drive the input/focus/MKB detours each frame, like a real game
backend->render_and_present(frame++);
// Show the backend + a once-per-second-smoothed fps in the title bar.
++fps_window_frames;
const ULONGLONG now = GetTickCount64();
if (now - fps_window_start >= 1000)
{
const double fps = fps_window_frames * 1000.0 / static_cast<double>(now - fps_window_start);
wchar_t title[128];
swprintf(title, 128, L"CoopMockGame [%hs] - %.0f fps", backend->name(), fps);
SetWindowTextW(hwnd, title);
fps_window_start = now;
fps_window_frames = 0;
}
if (seconds > 0.0 && (GetTickCount64() - start) >= static_cast<ULONGLONG>(seconds * 1000.0))
{
break;