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 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 18:04:47 +02:00
parent 1b3fa6824c
commit efc16b5eea

View File

@@ -62,6 +62,28 @@ ID3D11Device* g_on12_d3d11 = nullptr;
ID3D11DeviceContext* g_on12_ctx = nullptr; ID3D11DeviceContext* g_on12_ctx = nullptr;
ID3D12CommandQueue* g_on12_queue = nullptr; ID3D12CommandQueue* g_on12_queue = nullptr;
ID3D12Device* g_on12_d3d12 = 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<ID3D12CommandQueue*> g_present_queue{nullptr};
void* vtable_method(void* obj, unsigned index) void* vtable_method(void* obj, unsigned index)
{ {
@@ -188,32 +210,45 @@ void release_on12_locked()
g_on12_d3d12->Release(); g_on12_d3d12->Release();
g_on12_d3d12 = nullptr; g_on12_d3d12 = nullptr;
} }
g_on12_src_queue = nullptr;
} }
// Build the D3D11On12 device for the game's D3D12 `dev` (creating our own DIRECT // Build the D3D11On12 device for the game's D3D12 `dev`, submitting our copy work on
// queue on it). Caller holds g_tex_mutex. Returns true when the bridge is ready. // `game_queue` (the game's present queue, captured via the ExecuteCommandLists hook)
bool ensure_on12_locked(ID3D12Device* dev) // 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; return true;
} }
release_on12_locked(); release_on12_locked();
// Hold a ref on whichever queue the bridge uses, so Release on teardown is uniform.
ID3D12CommandQueue* queue = game_queue;
if (queue != nullptr)
{
queue->AddRef();
}
else
{
D3D12_COMMAND_QUEUE_DESC qd{}; D3D12_COMMAND_QUEUE_DESC qd{};
qd.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; qd.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
ID3D12CommandQueue* queue = nullptr;
HRESULT hr = dev->CreateCommandQueue(&qd, __uuidof(ID3D12CommandQueue), reinterpret_cast<void**>(&queue)); HRESULT hr = dev->CreateCommandQueue(&qd, __uuidof(ID3D12CommandQueue), reinterpret_cast<void**>(&queue));
if (FAILED(hr) || queue == nullptr) if (FAILED(hr) || queue == nullptr)
{ {
logf("present(d3d12): CreateCommandQueue failed hr=0x%08lX", static_cast<unsigned long>(hr)); logf("present(d3d12): CreateCommandQueue failed hr=0x%08lX", static_cast<unsigned long>(hr));
return false; return false;
} }
}
IUnknown* queues[] = {queue}; IUnknown* queues[] = {queue};
ID3D11Device* d11 = nullptr; ID3D11Device* d11 = nullptr;
ID3D11DeviceContext* ctx = 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) if (FAILED(hr) || d11 == nullptr)
{ {
logf("present(d3d12): D3D11On12CreateDevice failed hr=0x%08lX", static_cast<unsigned long>(hr)); logf("present(d3d12): D3D11On12CreateDevice failed hr=0x%08lX", static_cast<unsigned long>(hr));
@@ -237,10 +272,11 @@ bool ensure_on12_locked(ID3D12Device* dev)
g_on12 = on12; g_on12 = on12;
g_on12_d3d11 = d11; g_on12_d3d11 = d11;
g_on12_ctx = ctx; 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; g_on12_d3d12 = dev;
dev->AddRef(); // we hold a reference for the lifetime of the bridge 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; return true;
} }
@@ -282,10 +318,17 @@ void capture_backbuffer_d3d12(IDXGISwapChain* sc)
bool shared = false; bool shared = false;
UINT w = 0, h = 0; UINT w = 0, h = 0;
DXGI_FORMAT fmt = DXGI_FORMAT_UNKNOWN; 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) if (dev != nullptr)
{ {
std::scoped_lock lock(g_tex_mutex); std::scoped_lock lock(g_tex_mutex);
if (ensure_on12_locked(dev)) if (ensure_on12_locked(dev, game_queue))
{ {
D3D11_RESOURCE_FLAGS rf{}; D3D11_RESOURCE_FLAGS rf{};
rf.BindFlags = D3D11_BIND_RENDER_TARGET; rf.BindFlags = D3D11_BIND_RENDER_TARGET;
@@ -413,6 +456,58 @@ void capture_backbuffer(IDXGISwapChain* sc)
backbuf->Release(); 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<void>(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<PFN_D3D12_CREATE_DEVICE>(GetProcAddress(d3d12, "D3D12CreateDevice"));
if (create == nullptr)
{
return nullptr;
}
ID3D12Device* dev = nullptr;
if (FAILED(create(nullptr, D3D_FEATURE_LEVEL_11_0, __uuidof(ID3D12Device), reinterpret_cast<void**>(&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<void**>(&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) HRESULT STDMETHODCALLTYPE hk_Present(IDXGISwapChain* sc, UINT sync_interval, UINT flags)
{ {
hook_note_call(g_id_present); 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_present = hook_register("IDXGISwapChain::Present", HookSubsys_Video);
g_id_present1 = hook_register("IDXGISwapChain1::Present1", HookSubsys_Video); g_id_present1 = hook_register("IDXGISwapChain1::Present1", HookSubsys_Video);
g_id_ecl = hook_register("ID3D12CommandQueue::ExecuteCommandLists", HookSubsys_Video);
void* present1 = nullptr; void* present1 = nullptr;
void* present = grab_present_address(&present1); void* present = grab_present_address(&present1);
@@ -552,6 +648,22 @@ bool install_present_hooks(IpcClient& ipc)
hook_set_installed(g_id_present1, static_cast<bool>(g_hk_present1)); hook_set_installed(g_id_present1, static_cast<bool>(g_hk_present1));
logf("install_present_hooks: present=%p hooked=%d present1=%p hooked=%d", present, logf("install_present_hooks: present=%p hooked=%d present1=%p hooked=%d", present,
static_cast<bool>(g_hk_present) ? 1 : 0, present1, static_cast<bool>(g_hk_present1) ? 1 : 0); static_cast<bool>(g_hk_present) ? 1 : 0, present1, static_cast<bool>(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<void*>(&hk_ExecuteCommandLists));
hook_set_installed(g_id_ecl, static_cast<bool>(g_hk_ecl));
logf("install_present_hooks: d3d12 ExecuteCommandLists=%p hooked=%d", ecl,
static_cast<bool>(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<bool>(g_hk_present); return static_cast<bool>(g_hk_present);
} }
@@ -559,8 +671,11 @@ void remove_present_hooks()
{ {
g_hk_present = {}; g_hk_present = {};
g_hk_present1 = {}; g_hk_present1 = {};
g_hk_ecl = {};
hook_set_installed(g_id_present, false); hook_set_installed(g_id_present, false);
hook_set_installed(g_id_present1, 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); std::scoped_lock lock(g_tex_mutex);
release_shared_locked(); release_shared_locked();