From efc16b5eeae58b30fd9265c5425cf0e71d7bc457 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sun, 21 Jun 2026 18:04:47 +0200 Subject: [PATCH] Run the DX12 mirror copy on the game's own present queue Even copying the correct back buffer, the DX12 path occasionally showed a several-frames-old frame under GPU load (visible when the game window has true focus and the camera is whipped around with the mouse). Cause: the On12 bridge submitted CopyResource on a command queue of our own, which knows nothing about the game's queue. With frames in flight, our copy could race ahead of the game's render of that buffer and capture its previous (rotated) contents. The DX11 path never had this because it copies on the game's immediate context, ordered after the frame. Fix: submit the copy on the game's actual present queue so it's ordered after the frame's rendering, matching the DX11 path. Recover the queue by inline-hooking ID3D12CommandQueue::ExecuteCommandLists (the per-frame method, not swapchain/queue creation) so it works for late injection -- the queue already exists when we attach. Record the last DIRECT queue seen, preferring the one on the render thread (Present and its queue's ExecuteCommandLists share that thread); the atomic is a cross-thread fallback. Thread it into the On12 bridge, rebuilding if the captured queue changes, and fall back to our own queue until it's captured. Resolves D3D12CreateDevice dynamically from an already-loaded d3d12.dll, so it adds no link dependency and no-ops for D3D11 / x86 games. Co-Authored-By: Claude Opus 4.8 --- hook/src/present_hook.cpp | 145 ++++++++++++++++++++++++++++++++++---- 1 file changed, 130 insertions(+), 15 deletions(-) diff --git a/hook/src/present_hook.cpp b/hook/src/present_hook.cpp index 02da887..aea2682 100644 --- a/hook/src/present_hook.cpp +++ b/hook/src/present_hook.cpp @@ -62,6 +62,28 @@ ID3D11Device* g_on12_d3d11 = nullptr; ID3D11DeviceContext* g_on12_ctx = nullptr; ID3D12CommandQueue* g_on12_queue = nullptr; ID3D12Device* g_on12_d3d12 = nullptr; +// The game's own present queue we built the bridge against (null = we fell back to +// creating our own queue because it hadn't been captured yet). When this changes we +// rebuild the bridge so the copy keeps running on the game's queue. +ID3D12CommandQueue* g_on12_src_queue = nullptr; + +// ExecuteCommandLists hook: recovers the game's D3D12 *present* command queue so the +// On12 copy can be submitted on the game's own queue (ordered after the frame's +// rendering, like the D3D11 immediate-context path) instead of an independent queue +// that races it -- the cause of the occasional stale-frame stutter under GPU load. +// We hook the per-frame method rather than swapchain/queue creation, so it works for +// late injection (the queue already exists). ID3D12CommandQueue method index 10: +// IUnknown 0-2, ID3D12Object 3-6, ID3D12DeviceChild 7 (ID3D12Pageable adds none), +// then ID3D12CommandQueue UpdateTileMappings 8, CopyTileMappings 9, ExecuteCommandLists 10. +constexpr unsigned kIdx_ID3D12CommandQueue_ExecuteCommandLists = 10; +safetyhook::InlineHook g_hk_ecl; +int g_id_ecl = -1; +// Last DIRECT (graphics) queue seen executing command lists. Present and its queue's +// ExecuteCommandLists run on the same render thread, so the thread-local is the most +// reliable match on engines with multiple DIRECT queues; the atomic is a cross-thread +// fallback (e.g. the first D3D12 Present beating any ExecuteCommandLists on its thread). +thread_local ID3D12CommandQueue* t_present_queue = nullptr; +std::atomic g_present_queue{nullptr}; void* vtable_method(void* obj, unsigned index) { @@ -188,32 +210,45 @@ void release_on12_locked() g_on12_d3d12->Release(); g_on12_d3d12 = nullptr; } + g_on12_src_queue = nullptr; } -// Build the D3D11On12 device for the game's D3D12 `dev` (creating our own DIRECT -// queue on it). Caller holds g_tex_mutex. Returns true when the bridge is ready. -bool ensure_on12_locked(ID3D12Device* dev) +// Build the D3D11On12 device for the game's D3D12 `dev`, submitting our copy work on +// `game_queue` (the game's present queue, captured via the ExecuteCommandLists hook) +// so it's ordered after the frame's rendering. If `game_queue` is null we fall back to +// a queue of our own (correct frame, but can race the game's render under load -- the +// old behavior) until the real queue is captured. Caller holds g_tex_mutex. Returns +// true when the bridge is ready. +bool ensure_on12_locked(ID3D12Device* dev, ID3D12CommandQueue* game_queue) { - if (g_on12 != nullptr && g_on12_d3d12 == dev) + if (g_on12 != nullptr && g_on12_d3d12 == dev && g_on12_src_queue == game_queue) { return true; } release_on12_locked(); - D3D12_COMMAND_QUEUE_DESC qd{}; - qd.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; - ID3D12CommandQueue* queue = nullptr; - HRESULT hr = dev->CreateCommandQueue(&qd, __uuidof(ID3D12CommandQueue), reinterpret_cast(&queue)); - if (FAILED(hr) || queue == nullptr) + // Hold a ref on whichever queue the bridge uses, so Release on teardown is uniform. + ID3D12CommandQueue* queue = game_queue; + if (queue != nullptr) { - logf("present(d3d12): CreateCommandQueue failed hr=0x%08lX", static_cast(hr)); - return false; + queue->AddRef(); + } + else + { + D3D12_COMMAND_QUEUE_DESC qd{}; + qd.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; + HRESULT hr = dev->CreateCommandQueue(&qd, __uuidof(ID3D12CommandQueue), reinterpret_cast(&queue)); + if (FAILED(hr) || queue == nullptr) + { + logf("present(d3d12): CreateCommandQueue failed hr=0x%08lX", static_cast(hr)); + return false; + } } IUnknown* queues[] = {queue}; ID3D11Device* d11 = nullptr; ID3D11DeviceContext* ctx = nullptr; - hr = D3D11On12CreateDevice(dev, 0, nullptr, 0, queues, 1, 0, &d11, &ctx, nullptr); + HRESULT hr = D3D11On12CreateDevice(dev, 0, nullptr, 0, queues, 1, 0, &d11, &ctx, nullptr); if (FAILED(hr) || d11 == nullptr) { logf("present(d3d12): D3D11On12CreateDevice failed hr=0x%08lX", static_cast(hr)); @@ -237,10 +272,11 @@ bool ensure_on12_locked(ID3D12Device* dev) g_on12 = on12; g_on12_d3d11 = d11; g_on12_ctx = ctx; - g_on12_queue = queue; + g_on12_queue = queue; // we hold a ref (the AddRef'd game queue, or our created one) + g_on12_src_queue = game_queue; // what we built against (null = our own queue) g_on12_d3d12 = dev; dev->AddRef(); // we hold a reference for the lifetime of the bridge - logf("present(d3d12): D3D11On12 bridge ready"); + logf("present(d3d12): D3D11On12 bridge ready (queue=%s)", game_queue != nullptr ? "game" : "own"); return true; } @@ -282,10 +318,17 @@ void capture_backbuffer_d3d12(IDXGISwapChain* sc) bool shared = false; UINT w = 0, h = 0; DXGI_FORMAT fmt = DXGI_FORMAT_UNKNOWN; + // The game's present queue, preferring the one seen on this (the render) thread. + ID3D12CommandQueue* game_queue = t_present_queue; + if (game_queue == nullptr) + { + game_queue = g_present_queue.load(std::memory_order_relaxed); + } + if (dev != nullptr) { std::scoped_lock lock(g_tex_mutex); - if (ensure_on12_locked(dev)) + if (ensure_on12_locked(dev, game_queue)) { D3D11_RESOURCE_FLAGS rf{}; rf.BindFlags = D3D11_BIND_RENDER_TARGET; @@ -413,6 +456,58 @@ void capture_backbuffer(IDXGISwapChain* sc) backbuf->Release(); } +void STDMETHODCALLTYPE hk_ExecuteCommandLists(ID3D12CommandQueue* queue, UINT num_lists, + ID3D12CommandList* const* lists) +{ + // Record the graphics queue; compute/copy queues never present, so skip them and + // keep the last DIRECT one (the present queue on single-graphics-queue engines). + if (queue != nullptr && queue->GetDesc().Type == D3D12_COMMAND_LIST_TYPE_DIRECT) + { + t_present_queue = queue; + g_present_queue.store(queue, std::memory_order_relaxed); + hook_note_call(g_id_ecl); + } + g_hk_ecl.stdcall(queue, num_lists, lists); // __stdcall, see hk_Present +} + +// Create a throwaway D3D12 device + command queue to read the address of +// ID3D12CommandQueue::ExecuteCommandLists, so we can inline-hook it (catching the +// game's pre-existing queues regardless of when we injected). Resolves D3D12CreateDevice +// dynamically: only D3D12 games have d3d12.dll loaded, and we never want to force-load +// it into a D3D11 game. Returns null when D3D12 isn't present. +void* grab_execute_command_lists_address() +{ + HMODULE d3d12 = GetModuleHandleW(L"d3d12.dll"); + if (d3d12 == nullptr) + { + return nullptr; // not a D3D12 game -> nothing to capture + } + using PFN_D3D12_CREATE_DEVICE = HRESULT(WINAPI*)(IUnknown*, D3D_FEATURE_LEVEL, REFIID, void**); + auto create = reinterpret_cast(GetProcAddress(d3d12, "D3D12CreateDevice")); + if (create == nullptr) + { + return nullptr; + } + ID3D12Device* dev = nullptr; + if (FAILED(create(nullptr, D3D_FEATURE_LEVEL_11_0, __uuidof(ID3D12Device), reinterpret_cast(&dev))) || + dev == nullptr) + { + return nullptr; + } + D3D12_COMMAND_QUEUE_DESC qd{}; + qd.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; + ID3D12CommandQueue* queue = nullptr; + void* addr = nullptr; + if (SUCCEEDED(dev->CreateCommandQueue(&qd, __uuidof(ID3D12CommandQueue), reinterpret_cast(&queue))) && + queue != nullptr) + { + addr = vtable_method(queue, kIdx_ID3D12CommandQueue_ExecuteCommandLists); + queue->Release(); + } + dev->Release(); + return addr; +} + HRESULT STDMETHODCALLTYPE hk_Present(IDXGISwapChain* sc, UINT sync_interval, UINT flags) { hook_note_call(g_id_present); @@ -533,6 +628,7 @@ bool install_present_hooks(IpcClient& ipc) g_id_present = hook_register("IDXGISwapChain::Present", HookSubsys_Video); g_id_present1 = hook_register("IDXGISwapChain1::Present1", HookSubsys_Video); + g_id_ecl = hook_register("ID3D12CommandQueue::ExecuteCommandLists", HookSubsys_Video); void* present1 = nullptr; void* present = grab_present_address(&present1); @@ -552,6 +648,22 @@ bool install_present_hooks(IpcClient& ipc) hook_set_installed(g_id_present1, static_cast(g_hk_present1)); logf("install_present_hooks: present=%p hooked=%d present1=%p hooked=%d", present, static_cast(g_hk_present) ? 1 : 0, present1, static_cast(g_hk_present1) ? 1 : 0); + + // Capture the game's D3D12 present queue (D3D12 games only; null otherwise). Done + // here at injection time -- d3d12.dll is already loaded in a running D3D12 game -- + // so the queue is recovered even though we attached after it was created. + void* ecl = grab_execute_command_lists_address(); + if (ecl != nullptr) + { + g_hk_ecl = safetyhook::create_inline(ecl, reinterpret_cast(&hk_ExecuteCommandLists)); + hook_set_installed(g_id_ecl, static_cast(g_hk_ecl)); + logf("install_present_hooks: d3d12 ExecuteCommandLists=%p hooked=%d", ecl, + static_cast(g_hk_ecl) ? 1 : 0); + } + else + { + hook_set_installed(g_id_ecl, false); // not a D3D12 game; On12 path uses its own queue + } return static_cast(g_hk_present); } @@ -559,8 +671,11 @@ void remove_present_hooks() { g_hk_present = {}; g_hk_present1 = {}; + g_hk_ecl = {}; hook_set_installed(g_id_present, false); hook_set_installed(g_id_present1, false); + hook_set_installed(g_id_ecl, false); + g_present_queue.store(nullptr, std::memory_order_relaxed); { std::scoped_lock lock(g_tex_mutex); release_shared_locked();