From 67c1940d5251655af1009fc951d57361c30a2c96 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Mon, 22 Jun 2026 11:55:06 +0200 Subject: [PATCH] M2(DX9): D3D9 capture via Present read-back (covers plain D3D9 + D3D9Ex) New d3d9_hook.cpp (own file like opengl_hook): inline-hooks IDirect3DDevice9::Present (vtable index 17, discovered from a throwaway device; clean prologue so inline is safe, stdcall() on x86) and read-backs the backbuffer with GetRenderTargetData into a D3DPOOL_SYSTEMMEM surface, swizzles BGRA->RGBA, and uploads it into the shared keyed-mutex texture on a hook-owned D3D11 device (a D3D9 surface isn't D3D11-shareable). Both plain D3D9 and D3D9Ex call this same Present, so one read-back path serves both -- the GPU shared-surface fast path the plan sketched for D3D9Ex wasn't worth it. Wired into the video subsystem (install/remove in dllmain); hook links d3d9. mock_game_test decodes DX9 + DX9Ex frames through the hook; 15/15 ctest (incl. the x86 sub-build). Roadmap: DX9 milestone done and removed (remaining renumbered); architecture + lessons updated. Co-Authored-By: Claude Opus 4.8 --- README.md | 51 ++--- hook/CMakeLists.txt | 2 + hook/src/d3d9_hook.cpp | 395 +++++++++++++++++++++++++++++++++++++++ hook/src/d3d9_hook.hpp | 30 +++ hook/src/dllmain.cpp | 10 +- tests/mock_game_test.cpp | 2 + 6 files changed, 456 insertions(+), 34 deletions(-) create mode 100644 hook/src/d3d9_hook.cpp create mode 100644 hook/src/d3d9_hook.hpp diff --git a/README.md b/README.md index 19f6000..596f0e9 100644 --- a/README.md +++ b/README.md @@ -32,18 +32,21 @@ and forwards guest controllers back into it. | Mirror audio | Injected render-hook copies each of the game's WASAPI render streams into its own shared ring and silences the game locally (no echo); the host mixes the streams (soft-clipped); WASAPI process loopback is the automatic fallback | `coop_hook.dll` + `coop_host.exe` | | Host ↔ hook IPC | Named shared memory (seqlock for input, status back-channel, video/audio/log shares) | `common/` | -The hooked video path has two producers: **Direct3D (DXGI)** hooks +The hooked video path has three producers: **Direct3D (DXGI)** hooks `IDXGISwapChain::Present` / `Present1` and copies the backbuffer — directly for D3D11 games (the backbuffer is an `ID3D11Texture2D`), via a **D3D11On12 bridge** for D3D12 games (wrap the `ID3D12Resource` backbuffer, `CopyResource` into the shared texture), and via a **D3D10 read-back** for D3D10 games (their backbuffer's D3D11 view is empty and a feature-level-10 device can't host the shared texture, so read it through the game's own D3D10 device and upload it via a hook-owned D3D11 -device); **OpenGL** hooks `SwapBuffers` / `wglSwapBuffers` and reads the +device); **Direct3D 9** inline-hooks `IDirect3DDevice9::Present` (vtable index 17) and +read-backs the backbuffer with `GetRenderTargetData` (a D3D9 surface isn't D3D11-shareable), +swizzling BGRA→RGBA and uploading via a hook-owned D3D11 device — one path covers both plain +D3D9 and D3D9Ex; **OpenGL** hooks `SwapBuffers` / `wglSwapBuffers` and reads the backbuffer with `glReadPixels` (for games that never touch DXGI, e.g. Phantom Brave). The host samples the copy as plain UNORM (`srgb_to_unorm`) so `*_SRGB`-backbuffer games mirror at correct brightness. **WGC remains the default** -and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap). +and covers anything the hooked path doesn't (Vulkan — see Roadmap). ## Limitations @@ -134,34 +137,10 @@ Conventions for every milestone below: panel so it fits (content can be moved between columns / rows — the most detailed case should still fit). Land an automated harness check (drive-to-max → screenshot → assert no overflow) so later milestones that add UI keep it green. *Independent of the backend work; - every milestone below must preserve this test* (M4's red banner and Vulkan-layer checkbox + every milestone below must preserve this test* (M3's red banner and Vulkan-layer checkbox in particular). -- **M2 — DX9 (mock → D3D9Ex capture → plain-D3D9 capture).** - 1. **Mock backend** (`render_dx09.cpp`). `IDirect3DDevice9` / `IDirect3DDevice9Ex` + present; - Windows SDK only (`d3d9`), **no new deps**. `Clear` for the background and `ColorFill` for - the bar + block (D3D9's built-in rect fill — exactly the primitive DX10/11 lack). Top-left - origin. Runs in **two selectable modes** — `dx9ex` (default) and plain `dx9` - (`Direct3DCreate9`) — so both capture paths have a matching game. - 2. **D3D9Ex capture (clean GPU path).** Hook `IDirect3DDevice9::Present` by vtable — discover - the slot from a throwaway `Direct3DCreate9Ex` device (`stdcall()` on x86 per the SafetyHook - trap); the vtable is shared across all devices of the class, so the game's existing device - is caught → **late-attach works**. On Present, `GetBackBuffer(0)` + `StretchRect` (GPU-side) - into a `CreateRenderTarget(..., pSharedHandle=&h)` surface (`D3DPOOL_DEFAULT`); share that - into the hook's own D3D11 device and copy into the standard keyed-mutex texture (host - unchanged, proper keyed-mutex sync). `A8R8G8B8` is **BGRA** → record the swizzle. Re-create - the surface on `Reset` / resize. `d3d9_present_hook_test` decodes the mock's frames (Ex mode). - 3. **Plain (non-Ex) D3D9 capture (slow path).** Plain `Direct3DCreate9` devices **cannot** - produce a D3D11-shareable surface (WGC remains their default mirror until this lands). - Detect via a failed `QueryInterface(IID_IDirect3DDevice9Ex)` (else take the D3D9Ex path). On - Present, `GetRenderTargetData` the backbuffer into a - `CreateOffscreenPlainSurface(..., D3DPOOL_SYSTEMMEM)` surface, `LockRect`, copy out - (respect `Pitch`; BGRA→RGBA), and upload into the standard keyed-mutex texture via the - hook's own D3D11 device (`Map` / `UpdateSubresource`) — so only *how pixels reach the - texture* differs (CPU copy, not GPU). `GetRenderTargetData` is a GPU→sysmem stall, so - **drop / throttle** mirror frames rather than back-pressure the game. Same test, plain mode. - -- **M3 — OpenGL (mock → capture coverage).** +- **M2 — OpenGL (mock → capture coverage).** 1. **Mock backend** (`render_gl.cpp`). Raw WGL context (`wglCreateContextAttribsARB`) with the **glad** loader (new submodule, `Dav1dde/glad`). Background via `glClearColor`/`glClear`; bar + block via `glScissor` + clear (shader-free GL 1.x). GL's framebuffer is @@ -172,7 +151,7 @@ Conventions for every milestone below: ships; add a mock-backed regression that decodes the mock's frames through it (upgrading the synthetic `opengl_hook_test` to a real animated game). Small. -- **M4 — Vulkan (mock → capture).** The largest. +- **M3 — Vulkan (mock → capture).** The largest. 1. **Mock backend** (`render_vk.cpp`). New submodules **Vulkan-Headers** (`KhronosGroup/Vulkan-Headers`, official) + **volk** (`zeux/volk`); raw `vkCreateWin32SurfaceKHR`, swap chain, per-frame acquire → clear → present. Background via @@ -317,7 +296,7 @@ ctest --test-dir build -C Debug --output-on-failure endpoint format). Skips cleanly if the machine has no audio endpoint. - **`mock_game_test`** — comprehensive capture/audio/hook stress test against **`coop_mock_game`** (an animated, frame-numbered A/V test game under - [`tools/mock_game`](tools/mock_game) with selectable **DX10 / DX11 / DX12** backends and a + [`tools/mock_game`](tools/mock_game) with selectable **DX9 / DX9Ex / DX10 / DX11 / DX12** backends and a configurable WASAPI tone). It launches the game, injects `coop_hook.dll`, opens the hook's shared video texture, and **decodes the frame number out of the captured pixels** to assert the mirror sees a *monotonic, advancing* sequence for each backend (the bar @@ -468,6 +447,16 @@ Non-obvious things that cost time and constrain the design: `UpdateSubresource`-ing it into the shared texture on a **hook-owned D3D11 device** (the game has no usable D3D11 device of its own). The staging `Map` blocks until the GPU copy completes, so there's no cross-device race. +- **D3D9 capture: one read-back path covers plain D3D9 *and* D3D9Ex.** D3D9 has no DXGI + swapchain, so inline-hook `IDirect3DDevice9::Present` (vtable index **17**, found from a + throwaway device — clean prologue, so inline is safe; `stdcall()` on x86). A D3D9 surface + isn't D3D11-shareable, so read the backbuffer back with `GetRenderTargetData` into a + `D3DPOOL_SYSTEMMEM` surface (`LockRect` blocks until the copy → no race), **swizzle BGRA→RGBA** + (`X8R8G8B8`/`A8R8G8B8` store as little-endian `0xAARRGGBB` → bytes B,G,R,A) and upload into the + shared texture on a hook-owned D3D11 device. Both plain D3D9 and D3D9Ex call this same Present, + so **one path serves both** — the GPU shared-surface fast path the plan sketched for D3D9Ex + wasn't worth it (D3D9 games are light enough that the read-back cost is fine, and it dodges the + cross-device keyed-mutex-less sync of a legacy shared surface). - **A render client that predates our injection has no knowable format — measure it.** We inject into already-running games, so we usually never see the game's `IAudioClient::Initialize`; the render-hook then assumes the device mix format for that diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index 118060a..738edb6 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -5,6 +5,7 @@ add_library(coop_hook SHARED src/audio_hook.cpp src/present_hook.cpp src/opengl_hook.cpp + src/d3d9_hook.cpp src/mkb_hook.cpp src/debug_log.cpp src/hook_registry.cpp) @@ -22,6 +23,7 @@ target_link_libraries(coop_hook PRIVATE ole32 mmdevapi d3d11 + d3d9 dxgi) # The x86 sub-build (COOP_X86_HELPER_BUILD) stages alongside the x64 DLL in the diff --git a/hook/src/d3d9_hook.cpp b/hook/src/d3d9_hook.cpp new file mode 100644 index 0000000..fb17cc9 --- /dev/null +++ b/hook/src/d3d9_hook.cpp @@ -0,0 +1,395 @@ +#include "d3d9_hook.hpp" + +#include +#include + +#include + +#include +#include +#include + +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "debug_log.hpp" +#include "hook_registry.hpp" + +namespace coop::hook +{ + +namespace +{ + +// IDirect3DDevice9 vtable: IUnknown 0-2, then the device methods. Present is index 17 +// (TestCooperativeLevel 3, GetAvailableTextureMem 4, EvictManagedResources 5, GetDirect3D 6, +// GetDeviceCaps 7, GetDisplayMode 8, GetCreationParameters 9, SetCursorProperties 10, +// SetCursorPosition 11, ShowCursor 12, CreateAdditionalSwapChain 13, GetSwapChain 14, +// GetNumberOfSwapChains 15, Reset 16, Present 17). IDirect3DDevice9Ex games that call the +// inherited Present hit this too (PresentEx is a separate, higher slot -- not needed for the +// common case / the mock). +constexpr unsigned kIdx_IDirect3DDevice9_Present = 17; + +IpcClient* g_ipc = nullptr; +unsigned long g_pid = 0; + +safetyhook::InlineHook g_hk_present9; +int g_id_present9 = -1; + +std::atomic g_presents{0}; +std::atomic g_frames_shared{0}; +bool g_unsupported_logged = false; + +// Our own D3D11 device hosting the shared texture (the D3D9 game has no D3D11 device). +ID3D11Device* g_device = nullptr; +ID3D11DeviceContext* g_ctx = nullptr; +ID3D11Texture2D* g_shared_tex = nullptr; +IDXGIKeyedMutex* g_shared_mutex = nullptr; +HANDLE g_shared_handle = nullptr; +UINT g_share_w = 0; +UINT g_share_h = 0; + +// System-memory read-back surface on the game's D3D9 device (GetRenderTargetData target). +IDirect3DSurface9* g_sysmem = nullptr; +IDirect3DDevice9* g_sysmem_dev = nullptr; +UINT g_sysmem_w = 0; +UINT g_sysmem_h = 0; +D3DFORMAT g_sysmem_fmt = D3DFMT_UNKNOWN; + +std::vector g_rgba; // swizzled RGBA, uploaded to D3D11 + +// Present may be issued re-entrantly by some engines; capture only on the outermost call. +thread_local bool t_in_present = false; + +void* vtable_method(void* obj, unsigned index) +{ + return (*reinterpret_cast(obj))[index]; +} + +bool ensure_device() +{ + if (g_device != nullptr) + { + return true; + } + const HRESULT hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0, + D3D11_SDK_VERSION, &g_device, nullptr, &g_ctx); + if (FAILED(hr) || g_device == nullptr) + { + logf("d3d9: D3D11CreateDevice failed hr=0x%08lX", static_cast(hr)); + return false; + } + return true; +} + +void release_shared() +{ + if (g_shared_mutex != nullptr) + { + g_shared_mutex->Release(); + g_shared_mutex = nullptr; + } + if (g_shared_tex != nullptr) + { + g_shared_tex->Release(); + g_shared_tex = nullptr; + } + if (g_shared_handle != nullptr) + { + CloseHandle(g_shared_handle); + g_shared_handle = nullptr; + } + g_share_w = g_share_h = 0; +} + +void release_sysmem() +{ + if (g_sysmem != nullptr) + { + g_sysmem->Release(); + g_sysmem = nullptr; + } + if (g_sysmem_dev != nullptr) + { + g_sysmem_dev->Release(); + g_sysmem_dev = nullptr; + } + g_sysmem_w = g_sysmem_h = 0; + g_sysmem_fmt = D3DFMT_UNKNOWN; +} + +bool ensure_shared_texture(UINT w, UINT h) +{ + if (g_shared_tex != nullptr && g_share_w == w && g_share_h == h) + { + return true; + } + release_shared(); + + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = w; + desc.Height = h; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; // we swizzle the D3D9 BGRA backbuffer to RGBA + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + desc.MiscFlags = D3D11_RESOURCE_MISC_SHARED_NTHANDLE | D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX; + + if (FAILED(g_device->CreateTexture2D(&desc, nullptr, &g_shared_tex)) || g_shared_tex == nullptr) + { + return false; + } + IDXGIResource1* res = nullptr; + if (FAILED(g_shared_tex->QueryInterface(__uuidof(IDXGIResource1), reinterpret_cast(&res))) || + res == nullptr) + { + release_shared(); + return false; + } + const std::wstring name = video_share_name(g_pid); + const HRESULT hr = res->CreateSharedHandle( + nullptr, DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE, name.c_str(), &g_shared_handle); + res->Release(); + if (FAILED(hr) || g_shared_handle == nullptr) + { + release_shared(); + return false; + } + if (FAILED(g_shared_tex->QueryInterface(__uuidof(IDXGIKeyedMutex), reinterpret_cast(&g_shared_mutex)))) + { + release_shared(); + return false; + } + g_share_w = w; + g_share_h = h; + logf("d3d9: shared texture ready %ux%u name=%ls", w, h, name.c_str()); + return true; +} + +// Read the game's D3D9 backbuffer back to system memory, swizzle BGRA->RGBA, and upload it. +void capture_d3d9(IDirect3DDevice9* dev) +{ + IDirect3DSurface9* back = nullptr; + if (FAILED(dev->GetBackBuffer(0, 0, D3DBACKBUFFER_TYPE_MONO, &back)) || back == nullptr) + { + return; + } + D3DSURFACE_DESC d{}; + back->GetDesc(&d); + const UINT w = d.Width; + const UINT h = d.Height; + // We only handle the standard 32-bit BGRX/BGRA back buffers (the common D3D9 case). + if ((d.Format != D3DFMT_X8R8G8B8 && d.Format != D3DFMT_A8R8G8B8) || w == 0 || h == 0) + { + if (!g_unsupported_logged) + { + logf("d3d9: unsupported backbuffer format=%d (only X8R8G8B8 / A8R8G8B8); idle", static_cast(d.Format)); + g_unsupported_logged = true; + } + back->Release(); + return; + } + + // (Re)create the system-memory read-back surface on the game's device. + if (!(g_sysmem != nullptr && g_sysmem_dev == dev && g_sysmem_w == w && g_sysmem_h == h && g_sysmem_fmt == d.Format)) + { + release_sysmem(); + if (SUCCEEDED(dev->CreateOffscreenPlainSurface(w, h, d.Format, D3DPOOL_SYSTEMMEM, &g_sysmem, nullptr)) && + g_sysmem != nullptr) + { + g_sysmem_dev = dev; + dev->AddRef(); + g_sysmem_w = w; + g_sysmem_h = h; + g_sysmem_fmt = d.Format; + } + } + + bool shared = false; + if (g_sysmem != nullptr && SUCCEEDED(dev->GetRenderTargetData(back, g_sysmem))) // GPU->sysmem, blocks + { + D3DLOCKED_RECT lr{}; + if (SUCCEEDED(g_sysmem->LockRect(&lr, nullptr, D3DLOCK_READONLY)) && lr.pBits != nullptr) + { + const size_t dst_row = static_cast(w) * 4; + if (g_rgba.size() != dst_row * h) + { + g_rgba.resize(dst_row * h); + } + // X8R8G8B8 / A8R8G8B8 store as little-endian 0xAARRGGBB -> bytes B,G,R,A. Swizzle to + // R,G,B,A and force opaque alpha so the host's RGBA decode matches the other backends. + for (UINT y = 0; y < h; ++y) + { + const unsigned char* src = static_cast(lr.pBits) + static_cast(y) * lr.Pitch; + unsigned char* out = g_rgba.data() + static_cast(y) * dst_row; + for (UINT x = 0; x < w; ++x) + { + out[x * 4 + 0] = src[x * 4 + 2]; // R + out[x * 4 + 1] = src[x * 4 + 1]; // G + out[x * 4 + 2] = src[x * 4 + 0]; // B + out[x * 4 + 3] = 255; // A + } + } + g_sysmem->UnlockRect(); + + if (ensure_device() && ensure_shared_texture(w, h) && g_shared_mutex != nullptr && + g_shared_mutex->AcquireSync(kVideoMutexKey, 8) == S_OK) + { + g_ctx->UpdateSubresource(g_shared_tex, 0, nullptr, g_rgba.data(), static_cast(dst_row), 0); + g_ctx->Flush(); + g_shared_mutex->ReleaseSync(kVideoMutexKey); + shared = true; + } + } + } + + if (shared) + { + g_frames_shared.fetch_add(1, std::memory_order_relaxed); + if (g_ipc != nullptr) + { + g_ipc->publish_video_frame(w, h, static_cast(DXGI_FORMAT_R8G8B8A8_UNORM)); + } + } + back->Release(); +} + +HRESULT STDMETHODCALLTYPE hk_Present9(IDirect3DDevice9* dev, const RECT* src, const RECT* dst, HWND wnd, + const RGNDATA* dirty) +{ + hook_note_call(g_id_present9); + g_presents.fetch_add(1, std::memory_order_relaxed); + if (g_ipc != nullptr) + { + g_ipc->note_present(); + } + if (!t_in_present) + { + t_in_present = true; + capture_d3d9(dev); + t_in_present = false; + } + // stdcall(): IDirect3DDevice9::Present is __stdcall; call() would invoke the trampoline as + // __cdecl on x86 -> ESP imbalance -> crash. No-op on x64. (Present has a clean prologue, so + // an inline hook is safe -- unlike the WASAPI COM methods, which need a vtable swap.) + return g_hk_present9.stdcall(dev, src, dst, wnd, dirty); +} + +// Create a throwaway D3D9 device to read IDirect3DDevice9::Present's address, so we can +// inline-hook it (catching the game's existing device regardless of when we injected). Returns +// null when d3d9.dll isn't loaded (not a D3D9 game) or a probe device can't be created. +void* grab_present9_address() +{ + HMODULE d3d9 = GetModuleHandleW(L"d3d9.dll"); + if (d3d9 == nullptr) + { + return nullptr; // not a D3D9 game + } + using PFN_Direct3DCreate9 = IDirect3D9*(WINAPI*)(UINT); + auto create = reinterpret_cast(GetProcAddress(d3d9, "Direct3DCreate9")); + if (create == nullptr) + { + return nullptr; + } + IDirect3D9* d3d = create(D3D_SDK_VERSION); + if (d3d == nullptr) + { + return nullptr; + } + + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = DefWindowProcW; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = L"coop_d3d9_probe"; + RegisterClassExW(&wc); + HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"", WS_OVERLAPPEDWINDOW, 0, 0, 8, 8, nullptr, nullptr, + wc.hInstance, nullptr); + + void* present = nullptr; + if (hwnd != nullptr) + { + D3DPRESENT_PARAMETERS pp{}; + pp.BackBufferWidth = 8; + pp.BackBufferHeight = 8; + pp.BackBufferFormat = D3DFMT_X8R8G8B8; + pp.BackBufferCount = 1; + pp.SwapEffect = D3DSWAPEFFECT_DISCARD; + pp.hDeviceWindow = hwnd; + pp.Windowed = TRUE; + IDirect3DDevice9* dev = nullptr; + if (SUCCEEDED(d3d->CreateDevice(D3DADAPTER_DEFAULT, D3DDEVTYPE_HAL, hwnd, + D3DCREATE_HARDWARE_VERTEXPROCESSING | D3DCREATE_MULTITHREADED, &pp, &dev)) && + dev != nullptr) + { + present = vtable_method(dev, kIdx_IDirect3DDevice9_Present); + dev->Release(); + } + DestroyWindow(hwnd); + } + UnregisterClassW(wc.lpszClassName, wc.hInstance); + d3d->Release(); + return present; +} + +} // namespace + +bool install_d3d9_hooks(IpcClient& ipc) +{ + g_ipc = &ipc; + g_pid = GetCurrentProcessId(); + if (g_hk_present9) + { + return true; // already installed + } + g_id_present9 = hook_register("IDirect3DDevice9::Present", HookSubsys_Video); + g_unsupported_logged = false; + + void* present = grab_present9_address(); + if (present == nullptr) + { + hook_set_installed(g_id_present9, false); // not a D3D9 game (or no probe device) + return false; + } + g_hk_present9 = safetyhook::create_inline(present, reinterpret_cast(&hk_Present9)); + hook_set_installed(g_id_present9, static_cast(g_hk_present9)); + logf("install_d3d9_hooks: Present=%p hooked=%d", present, static_cast(g_hk_present9) ? 1 : 0); + return static_cast(g_hk_present9); +} + +void remove_d3d9_hooks() +{ + g_hk_present9 = {}; + hook_set_installed(g_id_present9, false); + release_shared(); + release_sysmem(); + if (g_ctx != nullptr) + { + g_ctx->Release(); + g_ctx = nullptr; + } + if (g_device != nullptr) + { + g_device->Release(); + g_device = nullptr; + } + g_rgba.clear(); + g_presents.store(0, std::memory_order_relaxed); + g_frames_shared.store(0, std::memory_order_relaxed); + g_ipc = nullptr; +} + +std::uint64_t d3d9_presents() +{ + return g_presents.load(std::memory_order_relaxed); +} + +std::uint64_t d3d9_frames_shared() +{ + return g_frames_shared.load(std::memory_order_relaxed); +} + +} // namespace coop::hook diff --git a/hook/src/d3d9_hook.hpp b/hook/src/d3d9_hook.hpp new file mode 100644 index 0000000..2108e31 --- /dev/null +++ b/hook/src/d3d9_hook.hpp @@ -0,0 +1,30 @@ +// Injected D3D9 capture path: inline-hooks IDirect3DDevice9::Present (discovered from a +// throwaway device's vtable, so it catches the game's existing device regardless of when we +// injected) in Direct3D 9 / 9Ex games, which don't present via IDXGISwapChain. It reads the +// backbuffer back with GetRenderTargetData, swizzles BGRA->RGBA, and uploads it into the same +// shared keyed-mutex texture (coop_video_) the host samples. Part of the video subsystem, +// alongside present_hook and opengl_hook. +// +// A D3D9 backbuffer isn't directly shareable with D3D11, so this is a CPU read-back +// (GetRenderTargetData -> system memory -> upload via a hook-owned D3D11 device), like the +// OpenGL path. Works for both plain D3D9 and D3D9Ex (both call Present here). +#pragma once + +#include "ipc_client.hpp" + +namespace coop::hook +{ + +// Installs the D3D9 Present hook. `ipc` must outlive the hook. Returns true if Present was +// hooked (i.e. d3d9.dll is present and a probe device came up). Safe to call repeatedly. +bool install_d3d9_hooks(IpcClient& ipc); + +// Removes the D3D9 hook and releases the shared texture (best effort). +void remove_d3d9_hooks(); + +// --- Diagnostics ----------------------------------------------------------- + +std::uint64_t d3d9_presents(); // cumulative IDirect3DDevice9::Present detours +std::uint64_t d3d9_frames_shared(); // frames uploaded into the shared texture + +} // namespace coop::hook diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index 0a6b0d7..e79d726 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -21,6 +21,7 @@ #include "hook_registry.hpp" #include "ipc_client.hpp" #include "mkb_hook.hpp" +#include "d3d9_hook.hpp" #include "opengl_hook.hpp" #include "present_hook.hpp" #include "xinput_hook.hpp" @@ -134,17 +135,19 @@ DWORD WINAPI worker_thread(LPVOID) { const bool present_ok = coop::hook::install_present_hooks(g_ipc); const bool gl_ok = coop::hook::install_opengl_hooks(g_ipc); - video_installed = present_ok || gl_ok; + const bool d3d9_ok = coop::hook::install_d3d9_hooks(g_ipc); + video_installed = present_ok || gl_ok || d3d9_ok; if (video_installed) { - coop::hook::logf("worker_thread: video hooks installed (present=%d opengl=%d)", - present_ok ? 1 : 0, gl_ok ? 1 : 0); + coop::hook::logf("worker_thread: video hooks installed (present=%d opengl=%d d3d9=%d)", + present_ok ? 1 : 0, gl_ok ? 1 : 0, d3d9_ok ? 1 : 0); } } else if (!want_video && video_installed) { coop::hook::remove_present_hooks(); coop::hook::remove_opengl_hooks(); + coop::hook::remove_d3d9_hooks(); video_installed = false; coop::hook::logf("worker_thread: video hooks removed (host request)"); } @@ -255,6 +258,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) coop::hook::shutdown_audio_hooks(); // detach: remove detours + release the kept probe coop::hook::remove_present_hooks(); coop::hook::remove_opengl_hooks(); + coop::hook::remove_d3d9_hooks(); coop::hook::remove_mkb_hooks(); coop::hook::hook_registry_reset(); } diff --git a/tests/mock_game_test.cpp b/tests/mock_game_test.cpp index 7c3fecb..3d891cf 100644 --- a/tests/mock_game_test.cpp +++ b/tests/mock_game_test.cpp @@ -522,6 +522,8 @@ int main() return 0; } + test_video_capture("dx9ex", device); + test_video_capture("dx9", device); test_video_capture("dx10", device); test_video_capture("dx11", device); test_video_capture("dx12", device);