The FPS readout only measured the host's own render rate, hiding capture stutter. The Video panel now shows a "Pipeline rates" section: - Tool render (host FPS / frametime, as before); - hooked source: Game present (/s, from VideoShare.present_calls deltas), Hook publish (/s, generation deltas), and capture->display latency avg/min/max ms; - WGC source: WGC capture (/s) from a new WindowCapture frame-arrival counter (game present + latency are n/a, since WGC frames aren't game-timestamped). Latency uses a system-wide clock: protocol v11->v12 adds VideoShare.present_qpc, stamped by the hook at publish (publish_video_frame); the host measures now_qpc - present_qpc per newly published frame, windowed to min/avg/max each second. Verified: present_hook_test (x64 + x86) now asserts present_qpc is stamped; full build x64 + x86 clean; ctest x64 9/9, x86 3/3. The live rate/latency numbers need a real game mirroring to read meaningfully; wiring validated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
229 lines
7.4 KiB
C++
229 lines
7.4 KiB
C++
// In-process self-test for the Present-hook video path (hook/src/present_hook.cpp).
|
|
// This process plays both "game" and "host": it installs the Present hook, then
|
|
// creates its own D3D11 swapchain, clears the backbuffer to a known color, and
|
|
// calls Present -- exactly as a game would. With the hook live, that Present must
|
|
// (1) fire the detour, (2) copy the backbuffer into the shared keyed-mutex
|
|
// texture, and (3) publish the descriptor to the IPC video channel. A second
|
|
// device then opens the shared texture by name and reads it back, proving the
|
|
// CreateSharedHandle / OpenSharedResourceByName / keyed-mutex / CopyResource
|
|
// contract both the hook and the host rely on -- with no game and no Steam.
|
|
//
|
|
// Requires a D3D11 device; on a headless / no-GPU machine it reports SKIP and
|
|
// exits 0 (mirrors audio_hook_test).
|
|
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
#include <windows.h>
|
|
|
|
#include <d3d11_1.h>
|
|
#include <dxgi1_2.h>
|
|
|
|
#include "coop/protocol.hpp"
|
|
#include "coop/shared_memory.hpp"
|
|
#include "ipc_client.hpp"
|
|
#include "present_hook.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 <typename T>
|
|
void release(T*& p)
|
|
{
|
|
if (p)
|
|
{
|
|
p->Release();
|
|
p = nullptr;
|
|
}
|
|
}
|
|
|
|
constexpr UINT kW = 256;
|
|
constexpr UINT kH = 256;
|
|
// Distinctive clear color; UNORM bytes are ~ {51, 102, 153, 255}.
|
|
constexpr float kClear[4] = {0.20f, 0.40f, 0.60f, 1.0f};
|
|
|
|
bool near_byte(std::uint8_t got, int expected)
|
|
{
|
|
return std::abs(static_cast<int>(got) - expected) <= 2;
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main()
|
|
{
|
|
// --- Host side: SharedBlock (named by our pid) so the hook's IpcClient connects.
|
|
SharedMemory shm;
|
|
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
|
|
{
|
|
std::printf("FAIL: create shared memory\n");
|
|
return 1;
|
|
}
|
|
auto* block = shm.as<SharedBlock>(); // OS zero-fills the mapping
|
|
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");
|
|
|
|
// --- Install the Present hook (inline-hooks IDXGISwapChain::Present). ---
|
|
if (!hook::install_present_hooks(ipc))
|
|
{
|
|
std::printf("SKIP: could not install the Present hook (no D3D11 device?)\n");
|
|
return 0;
|
|
}
|
|
|
|
// --- Game side: our own swapchain on a hidden window; render + Present. ---
|
|
WNDCLASSEXW wc{};
|
|
wc.cbSize = sizeof(wc);
|
|
wc.lpfnWndProc = DefWindowProcW;
|
|
wc.hInstance = GetModuleHandleW(nullptr);
|
|
wc.lpszClassName = L"coop_present_test";
|
|
RegisterClassExW(&wc);
|
|
HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"", WS_OVERLAPPEDWINDOW, 0, 0, kW, kH, nullptr, nullptr,
|
|
wc.hInstance, nullptr);
|
|
|
|
DXGI_SWAP_CHAIN_DESC scd{};
|
|
scd.BufferCount = 2;
|
|
scd.BufferDesc.Width = kW;
|
|
scd.BufferDesc.Height = kH;
|
|
scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM;
|
|
scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;
|
|
scd.OutputWindow = hwnd;
|
|
scd.SampleDesc.Count = 1;
|
|
scd.Windowed = TRUE;
|
|
scd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD;
|
|
|
|
IDXGISwapChain* swapchain = nullptr;
|
|
ID3D11Device* device = nullptr;
|
|
ID3D11DeviceContext* ctx = nullptr;
|
|
HRESULT hr = D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0,
|
|
D3D11_SDK_VERSION, &scd, &swapchain, &device, nullptr, &ctx);
|
|
if (FAILED(hr) || swapchain == nullptr)
|
|
{
|
|
std::printf("SKIP: could not create a D3D11 swapchain (hr=0x%08lX)\n", static_cast<unsigned long>(hr));
|
|
hook::remove_present_hooks();
|
|
return 0;
|
|
}
|
|
|
|
// Clear the backbuffer to the known color, then Present (fires the detour).
|
|
for (int frame = 0; frame < 3; ++frame)
|
|
{
|
|
ID3D11Texture2D* back = nullptr;
|
|
if (SUCCEEDED(swapchain->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast<void**>(&back))))
|
|
{
|
|
ID3D11RenderTargetView* rtv = nullptr;
|
|
if (SUCCEEDED(device->CreateRenderTargetView(back, nullptr, &rtv)))
|
|
{
|
|
ctx->ClearRenderTargetView(rtv, kClear);
|
|
ctx->Flush();
|
|
rtv->Release();
|
|
}
|
|
back->Release();
|
|
}
|
|
swapchain->Present(0, 0);
|
|
}
|
|
|
|
std::printf("present_calls=%llu frames_shared=%llu video{gen=%u %ux%u fmt=%u}\n",
|
|
static_cast<unsigned long long>(hook::present_calls()),
|
|
static_cast<unsigned long long>(hook::present_frames_shared()),
|
|
block->video.generation.load(), block->video.width, block->video.height, block->video.format);
|
|
|
|
check(hook::present_calls() >= 3, "Present detour fired");
|
|
check(hook::present_frames_shared() > 0, "backbuffer copied into the shared texture");
|
|
check(block->video.generation.load() > 0, "video generation published to IPC");
|
|
check(block->video.present_qpc > 0, "present QPC stamped for the latency metric");
|
|
check(block->video.width == kW && block->video.height == kH, "shared dimensions published");
|
|
check(block->video.format == DXGI_FORMAT_R8G8B8A8_UNORM, "shared format published");
|
|
|
|
// --- Consumer side: open the shared texture by name (second device), copy it
|
|
// into a staging texture, map it, and verify the clear color survived. ---
|
|
{
|
|
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));
|
|
const std::wstring name = video_share_name(GetCurrentProcessId());
|
|
ID3D11Texture2D* sharedB = nullptr;
|
|
IDXGIKeyedMutex* km = nullptr;
|
|
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<const std::uint8_t*>(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");
|
|
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);
|
|
}
|
|
|
|
release(swapchain);
|
|
release(ctx);
|
|
release(device);
|
|
DestroyWindow(hwnd);
|
|
UnregisterClassW(wc.lpszClassName, wc.hInstance);
|
|
hook::remove_present_hooks();
|
|
|
|
std::printf(g_failures == 0 ? "PRESENT HOOK TEST PASS\n" : "PRESENT HOOK TEST FAILED (%d)\n", g_failures);
|
|
return g_failures == 0 ? 0 : 1;
|
|
}
|