diff --git a/README.md b/README.md index 13cb074..19ab618 100644 --- a/README.md +++ b/README.md @@ -114,7 +114,7 @@ From an in-depth review pass. Each item is fixed test-first (a failing test, the 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`. +- **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. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 72eecd8..9a3ca77 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -250,6 +250,24 @@ target_link_libraries(dx12_present_hook_test PRIVATE add_test(NAME dx12_present_hook_test COMMAND dx12_present_hook_test) +# In-process self-test for the D3D9 capture path: installs the IDirect3DDevice9::Present hook, drives +# a real D3D9 device (Clear + Present), verifies the GetRenderTargetData -> swizzle -> shared-texture +# upload end to end, and guards the present-thread overhead at a realistic resolution. Skips without +# a D3D9/D3D11 device. +add_executable(d3d9_hook_test + d3d9_hook_test.cpp + ${CMAKE_SOURCE_DIR}/hook/src/d3d9_hook.cpp + ${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp + ${CMAKE_SOURCE_DIR}/hook/src/hook_registry.cpp) +target_include_directories(d3d9_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) +target_link_libraries(d3d9_hook_test PRIVATE + coop_common + safetyhook::safetyhook + d3d9 + d3d11 + dxgi) +add_test(NAME d3d9_hook_test COMMAND d3d9_hook_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 @@ -367,6 +385,7 @@ coop_output_subdir(tests srgb_format_test present_hook_test dx12_present_hook_test + d3d9_hook_test opengl_hook_test mock_game_test audio_verify_test diff --git a/tests/d3d9_hook_test.cpp b/tests/d3d9_hook_test.cpp new file mode 100644 index 0000000..c1bdfbd --- /dev/null +++ b/tests/d3d9_hook_test.cpp @@ -0,0 +1,213 @@ +// In-process self-test for the D3D9 capture path (hook/src/d3d9_hook.cpp). Like present_hook_test but +// for Direct3D 9: install the Present hook (discovered from a probe device's vtable), create our own +// D3D9 device, Clear the backbuffer to a known color and Present -- which must fire the detour, +// GetRenderTargetData the backbuffer, swizzle BGRA->RGBA, and upload it into the shared keyed-mutex +// texture (coop_video_). A D3D11 device then opens that texture by name and verifies the color. +// Also a present-thread overhead guard at a realistic resolution (the D3D9 half of the perf section: +// GetRenderTargetData reads CACHED system memory, so it must stay well under one frame -- it is NOT +// the Vulkan write-combined-memory stall). Reports SKIP and exits 0 without a D3D9/D3D11 device. +#include +#include + +#include + +#include +#include +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "ipc_client.hpp" +#include "d3d9_hook.hpp" +#include "present_overhead.hpp" + +using namespace coop; + +namespace +{ +int g_failures = 0; +void check(bool ok, const char* what) +{ + if (!ok) + { + std::printf(" FAIL: %s\n", what); + ++g_failures; + } +} +template +void release(T*& p) +{ + if (p) + { + p->Release(); + p = nullptr; + } +} +bool near_byte(std::uint8_t got, int expected) +{ + return std::abs(static_cast(got) - expected) <= 2; +} + +constexpr UINT kW = 1280; // realistic resolution so the overhead guard is meaningful (not a 64x64 toy) +constexpr UINT kH = 720; +} // 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; + + hook::IpcClient ipc; + check(ipc.connect(10, 5), "IPC client connect"); + + if (!hook::install_d3d9_hooks(ipc)) + { + std::printf("SKIP: could not install the D3D9 Present hook (no d3d9.dll / device?)\n"); + return 0; + } + + IDirect3D9* d3d = Direct3DCreate9(D3D_SDK_VERSION); + if (d3d == nullptr) + { + std::printf("SKIP: Direct3DCreate9 failed\n"); + hook::remove_d3d9_hooks(); + return 0; + } + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = DefWindowProcW; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = L"coop_d3d9_hooktest"; + RegisterClassExW(&wc); + HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"", WS_OVERLAPPEDWINDOW, 0, 0, kW, kH, nullptr, nullptr, + wc.hInstance, nullptr); + + D3DPRESENT_PARAMETERS pp{}; + pp.BackBufferWidth = kW; + pp.BackBufferHeight = kH; + pp.BackBufferFormat = D3DFMT_X8R8G8B8; + pp.BackBufferCount = 1; + pp.SwapEffect = D3DSWAPEFFECT_DISCARD; + pp.hDeviceWindow = hwnd; + pp.Windowed = TRUE; + IDirect3DDevice9* dev = nullptr; + HRESULT hr = d3d->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd, + D3DCREATE_HARDWARE_VERTEXPROCESSING | D3DCREATE_MULTITHREADED, &pp, &dev); + if (FAILED(hr) || dev == nullptr) + { + std::printf("SKIP: CreateDevice failed (hr=0x%08lX)\n", static_cast(hr)); + release(d3d); + hook::remove_d3d9_hooks(); + return 0; + } + + // Clear to a known color (R=51,G=102,B=153) and Present -> fires the detour. + const D3DCOLOR color = D3DCOLOR_XRGB(51, 102, 153); + for (int frame = 0; frame < 3; ++frame) + { + dev->Clear(0, nullptr, D3DCLEAR_TARGET, color, 1.0f, 0); + dev->Present(nullptr, nullptr, nullptr, nullptr); + } + + std::printf("d3d9_presents=%llu frames_shared=%llu video{gen=%u %ux%u fmt=%u}\n", + static_cast(hook::d3d9_presents()), + static_cast(hook::d3d9_frames_shared()), block->video.generation.load(), + block->video.width, block->video.height, block->video.format); + + check(hook::d3d9_presents() >= 3, "D3D9 Present detour fired"); + check(hook::d3d9_frames_shared() > 0, "backbuffer copied into the shared texture"); + check(block->video.generation.load() > 0, "video generation published to IPC"); + check(block->video.width == kW && block->video.height == kH, "shared dimensions published"); + + // Consumer side: open the shared texture (D3D11) and verify the swizzled color. + { + ID3D11Device* devB = nullptr; + ID3D11DeviceContext* ctxB = nullptr; + if (SUCCEEDED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0, D3D11_SDK_VERSION, + &devB, nullptr, &ctxB))) + { + ID3D11Device1* dev1 = nullptr; + devB->QueryInterface(IID_PPV_ARGS(&dev1)); + ID3D11Texture2D* sharedB = nullptr; + IDXGIKeyedMutex* km = nullptr; + const std::wstring name = video_share_name(GetCurrentProcessId()); + if (dev1 != nullptr && + SUCCEEDED(dev1->OpenSharedResourceByName(name.c_str(), + DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE, + IID_PPV_ARGS(&sharedB)))) + { + sharedB->QueryInterface(IID_PPV_ARGS(&km)); + D3D11_TEXTURE2D_DESC sd{}; + sharedB->GetDesc(&sd); + sd.Usage = D3D11_USAGE_STAGING; + sd.BindFlags = 0; + sd.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + sd.MiscFlags = 0; + ID3D11Texture2D* staging = nullptr; + check(SUCCEEDED(devB->CreateTexture2D(&sd, nullptr, &staging)), "create staging texture"); + if (km != nullptr && staging != nullptr && km->AcquireSync(kVideoMutexKey, 1000) == S_OK) + { + ctxB->CopyResource(staging, sharedB); + km->ReleaseSync(kVideoMutexKey); + D3D11_MAPPED_SUBRESOURCE mapped{}; + if (SUCCEEDED(ctxB->Map(staging, 0, D3D11_MAP_READ, 0, &mapped))) + { + const auto* px = static_cast(mapped.pData); + std::printf("readback pixel0 = {%u,%u,%u,%u}\n", px[0], px[1], px[2], px[3]); + check(near_byte(px[0], 51) && near_byte(px[1], 102) && near_byte(px[2], 153), + "shared texture carries the rendered color (BGRA->RGBA swizzle)"); + ctxB->Unmap(staging, 0); + } + else + { + check(false, "map staging texture"); + } + } + else + { + check(false, "acquire keyed mutex + copy shared texture"); + } + release(staging); + } + else + { + check(false, "open shared texture by name"); + } + release(km); + release(sharedB); + release(dev1); + } + release(ctxB); + release(devB); + } + + // Present-thread overhead guard (the D3D9 perf section): the cached-memory read-back must stay + // well under one frame -- a regression that put a write-combined-class stall here would trip it. + { + auto render = [&] { dev->Clear(0, nullptr, D3DCLEAR_TARGET, color, 1.0f, 0); }; + auto present = [&] { dev->Present(nullptr, nullptr, nullptr, nullptr); }; + const double hooked = cooptest::avg_present_ms(120, render, present); + hook::remove_d3d9_hooks(); + const double base = cooptest::avg_present_ms(120, render, present); + std::printf("present-thread: hooked %.3f ms, unhooked %.3f ms, capture overhead %.3f ms\n", hooked, base, + hooked - base); + check(hooked - base < cooptest::kPresentOverheadBudgetMs, + "D3D9 capture stays off the present thread's critical path (overhead < one 60 Hz frame)"); + } + + release(dev); + release(d3d); + DestroyWindow(hwnd); + UnregisterClassW(wc.lpszClassName, wc.hInstance); + + std::printf(g_failures == 0 ? "D3D9 HOOK TEST PASS\n" : "D3D9 HOOK TEST FAILED (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +}