- 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>
226 lines
7.9 KiB
C++
226 lines
7.9 KiB
C++
// CoopMockGame -- a tiny test "game" used to exercise the capture + audio + hook paths.
|
|
//
|
|
// coop_mock_game.exe [dx9|dx9ex|dx10|dx11|dx12|gl|vk] [seconds] [rate] [channels] [bits] [pcm|float]
|
|
//
|
|
// It opens a normal visible window and renders an animated, frame-numbered pattern (see
|
|
// render_backend.hpp): a moving bar + per-frame background colour make motion obvious, and
|
|
// a top-left block encodes the exact frame number so a capture test can decode it and
|
|
// detect dropped / duplicated / stale frames. With audio args it also plays a configurable
|
|
// WASAPI tone (the same ToneSource coop_tone uses), so it doubles as an A/V source. seconds
|
|
// = 0 runs until the window is closed. Console app so it can print its pid + status.
|
|
#include <atomic>
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
#include <windows.h>
|
|
|
|
#include <xinput.h>
|
|
|
|
#include "render_backend.hpp"
|
|
#include "tone_source.hpp"
|
|
|
|
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)
|
|
{
|
|
PostQuitMessage(0);
|
|
return 0;
|
|
}
|
|
return DefWindowProcW(hwnd, msg, wparam, lparam);
|
|
}
|
|
|
|
// Spawns the audio render loop on its own thread (WASAPI wants its own COM apartment).
|
|
void audio_thread(coop::tone::ToneFormat want)
|
|
{
|
|
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
|
|
{
|
|
return;
|
|
}
|
|
coop::tone::ToneSource tone;
|
|
if (tone.open(want, 440.0))
|
|
{
|
|
std::printf("MOCK_GAME audio: %u Hz %u ch %u-bit %s\n", tone.format().rate, tone.format().channels,
|
|
tone.format().bits, tone.format().is_float ? "float" : "pcm");
|
|
std::fflush(stdout);
|
|
while (g_running.load(std::memory_order_relaxed))
|
|
{
|
|
tone.render_step(200);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
std::printf("MOCK_GAME audio: failed to open requested format\n");
|
|
}
|
|
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;
|
|
coop::tone::ToneFormat audio_fmt;
|
|
if (want_audio)
|
|
{
|
|
audio_fmt.rate = static_cast<unsigned>(std::strtoul(argv[3], nullptr, 10));
|
|
audio_fmt.channels = argc > 4 ? static_cast<unsigned>(std::strtoul(argv[4], nullptr, 10)) : 2;
|
|
audio_fmt.bits = argc > 5 ? static_cast<unsigned>(std::strtoul(argv[5], nullptr, 10)) : 32;
|
|
audio_fmt.is_float = argc > 6 ? (std::string(argv[6]) == "float") : (audio_fmt.bits == 32);
|
|
}
|
|
|
|
constexpr std::uint32_t kW = 640, kH = 480;
|
|
const HINSTANCE inst = GetModuleHandleW(nullptr);
|
|
WNDCLASSEXW wc = {};
|
|
wc.cbSize = sizeof(wc);
|
|
wc.style = CS_OWNDC; // a stable private DC, so the OpenGL backend can keep one GL context
|
|
wc.lpfnWndProc = wnd_proc;
|
|
wc.hInstance = inst;
|
|
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
|
wc.lpszClassName = L"CoopMockGameWindow";
|
|
RegisterClassExW(&wc);
|
|
|
|
RECT r = {0, 0, static_cast<LONG>(kW), static_cast<LONG>(kH)};
|
|
AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE);
|
|
HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"CoopMockGame", WS_OVERLAPPEDWINDOW | WS_VISIBLE,
|
|
CW_USEDEFAULT, CW_USEDEFAULT, r.right - r.left, r.bottom - r.top, nullptr,
|
|
nullptr, inst, nullptr);
|
|
if (hwnd == nullptr)
|
|
{
|
|
std::printf("MOCK_GAME error: CreateWindow failed\n");
|
|
return 1;
|
|
}
|
|
|
|
// Test hook (early-load): Vulkan caches its present pointer at init, so the capture hook must
|
|
// be in place before vkCreateInstance. Under COOP_MOCK_VK_EARLY the mock loads vulkan-1.dll
|
|
// now and waits, giving an already-injected hook time to hook vkGetInstanceProcAddr first.
|
|
if (backend_name == "vk" && GetEnvironmentVariableW(L"COOP_MOCK_VK_EARLY", nullptr, 0) != 0)
|
|
{
|
|
LoadLibraryW(L"vulkan-1.dll");
|
|
Sleep(1500);
|
|
}
|
|
|
|
auto backend = coop::mock::RenderBackend::create(backend_name);
|
|
if (!backend || !backend->init(hwnd, kW, kH))
|
|
{
|
|
std::printf("MOCK_GAME error: backend '%s' unavailable\n", backend_name.c_str());
|
|
return 2;
|
|
}
|
|
|
|
std::printf("MOCK_GAME pid=%lu backend=%s w=%u h=%u audio=%s\n", GetCurrentProcessId(), backend->name(),
|
|
kW, kH, want_audio ? "yes" : "no");
|
|
std::fflush(stdout);
|
|
|
|
std::thread audio;
|
|
if (want_audio)
|
|
{
|
|
audio = std::thread(audio_thread, audio_fmt);
|
|
}
|
|
|
|
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;
|
|
while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
|
|
{
|
|
if (msg.message == WM_QUIT)
|
|
{
|
|
g_running.store(false, std::memory_order_relaxed);
|
|
}
|
|
TranslateMessage(&msg);
|
|
DispatchMessageW(&msg);
|
|
}
|
|
if (!g_running.load(std::memory_order_relaxed))
|
|
{
|
|
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;
|
|
}
|
|
}
|
|
|
|
g_running.store(false, std::memory_order_relaxed);
|
|
if (audio.joinable())
|
|
{
|
|
audio.join();
|
|
}
|
|
std::printf("MOCK_GAME done: %u frames\n", frame);
|
|
return 0;
|
|
}
|