Share the Vulkan swapchain tracking between the hook and the layer

vk_hook.cpp and coop_vk_layer.cpp each carried a verbatim copy of the
swap-chain registry -- the SwapInfo struct, the vector+mutex, find_swap,
the create-time de-dup + LRU cap, and the present-time lookup -- because
they are two independent early-presence paths (inline hook vs implicit
layer). The tracking logic is identical, so lift it into one
VkSwapchainRegistry (hook/src/vk_swapchain_registry.hpp); each module
owns an instance. add() de-dups + LRU-caps, lookup() copies the frame
out under the lock, clear() resets -- same behavior, one definition.

Net -45 lines. mock_game_test exercises both paths (the inline-hook vk
storm and the implicit-layer capture) and passes.
This commit is contained in:
2026-07-12 12:17:30 +02:00
parent b2b8abdc51
commit 0e57863d1f
3 changed files with 98 additions and 143 deletions

View File

@@ -1,9 +1,7 @@
#include "vk_hook.hpp"
#include <algorithm>
#include <atomic>
#include <cstring>
#include <mutex>
#include <vector>
#include <windows.h>
@@ -23,6 +21,7 @@
#include "hook_install.hpp"
#include "hook_registry.hpp"
#include "vk_capture.hpp"
#include "vk_swapchain_registry.hpp"
namespace coop::hook {
@@ -62,25 +61,7 @@ std::uint32_t g_qfam = 0;
VkCapture g_cap; // the shared, off-present-thread read-back (same component the layer uses)
// Tracked swap chains (small; engines have one or two).
struct SwapInfo {
VkSwapchainKHR sc;
VkFormat fmt;
std::uint32_t w;
std::uint32_t h;
std::vector<VkImage> images;
};
std::vector<SwapInfo> g_swaps;
// g_swaps is pushed from the create-swapchain detour and read from the present detour, which can run
// on different game threads (Vulkan external-sync is per-object, not global), and cleared from the
// worker thread on removal. This mutex serializes all of that -- a push_back realloc must not race a
// concurrent find_swap iteration. We don't hook vkDestroySwapchainKHR (failing to forward a destroy
// could break the game); instead create de-dups by handle and an LRU cap bounds growth, so a game
// that recreates its swapchain on every resize can't grow g_swaps without bound or match a recycled
// handle's stale images. The active swapchain is always the most-recently created, so it's never
// evicted.
std::mutex g_swaps_mutex;
constexpr std::size_t kMaxTrackedSwaps = 8; // engines use 1-3; headroom for transient resize overlap
VkSwapchainRegistry g_swaps; // create-swapchain records images here; present maps them back for capture
// --- the real vkGetInstanceProcAddr, via the inline hook's trampoline --------
PFN_vkVoidFunction real_gipa(VkInstance inst, const char* name)
@@ -88,18 +69,6 @@ PFN_vkVoidFunction real_gipa(VkInstance inst, const char* name)
return g_hk_gipa.stdcall<PFN_vkVoidFunction>(inst, name);
}
// Caller must hold g_swaps_mutex; the returned pointer is only valid until the lock is released
// (copy out what you need before unlocking, since another thread can push_back and reallocate).
const SwapInfo* find_swap(VkSwapchainKHR sc)
{
for (const SwapInfo& s : g_swaps) {
if (s.sc == sc) {
return &s;
}
}
return nullptr;
}
VKAPI_ATTR VkResult VKAPI_CALL hk_vkQueuePresentKHR(VkQueue queue, const VkPresentInfoKHR* pPresentInfo)
{
DetourGate::Guard guard(g_gate); // keep the read-back resources alive for this whole detour
@@ -114,27 +83,10 @@ VKAPI_ATTR VkResult VKAPI_CALL hk_vkQueuePresentKHR(VkQueue queue, const VkPrese
// read-back state, even though the game keeps calling this cached detour pointer.
if (g_capture_enabled.load(std::memory_order_acquire) && g_device != VK_NULL_HANDLE && pPresentInfo != nullptr
&& pPresentInfo->swapchainCount == 1) {
// Copy the matched swapchain's fields out under the lock, then capture without holding it (so
// the GPU submit can't block a concurrent create, and the SwapInfo* can't dangle on a realloc).
VkImage image = VK_NULL_HANDLE;
VkFormat fmt = VK_FORMAT_UNDEFINED;
std::uint32_t w = 0, h = 0;
bool matched = false;
{
std::scoped_lock lock(g_swaps_mutex);
const SwapInfo* s = find_swap(pPresentInfo->pSwapchains[0]);
const std::uint32_t idx = pPresentInfo->pImageIndices[0];
if (s != nullptr && idx < s->images.size()) {
image = s->images[idx];
fmt = s->fmt;
w = s->w;
h = s->h;
matched = true;
}
}
if (matched) {
VkSwapchainRegistry::Frame f;
if (g_swaps.lookup(pPresentInfo->pSwapchains[0], pPresentInfo->pImageIndices[0], f)) {
VkSemaphore chained = VK_NULL_HANDLE;
if (g_cap.present(image, fmt, w, h, pPresentInfo->pWaitSemaphores, pPresentInfo->waitSemaphoreCount,
if (g_cap.present(f.image, f.fmt, f.w, f.h, pPresentInfo->pWaitSemaphores, pPresentInfo->waitSemaphoreCount,
chained)) {
// Replace the present's wait with our chained semaphore (our submit consumed the
// originals and signals this one), so the present still orders after rendering.
@@ -154,27 +106,11 @@ VKAPI_ATTR VkResult VKAPI_CALL hk_vkCreateSwapchainKHR(VkDevice device, const Vk
DetourGate::Guard guard(g_gate); // keep g_swaps stable while remove may be clearing it
const VkResult r = g_real_create_swapchain(device, ci, alloc, out);
if (r == VK_SUCCESS && out != nullptr && g_get_swapchain_images != nullptr) {
SwapInfo info{};
info.sc = *out;
info.fmt = ci->imageFormat;
info.w = ci->imageExtent.width;
info.h = ci->imageExtent.height;
std::uint32_t n = 0;
g_get_swapchain_images(device, *out, &n, nullptr);
info.images.resize(n);
g_get_swapchain_images(device, *out, &n, info.images.data());
{
std::scoped_lock lock(g_swaps_mutex);
// De-dup a recycled handle value, then bound growth (drop the oldest; the just-created
// active swapchain is newest and stays).
g_swaps.erase(
std::remove_if(g_swaps.begin(), g_swaps.end(), [&](const SwapInfo& e) { return e.sc == info.sc; }),
g_swaps.end());
g_swaps.push_back(std::move(info));
if (g_swaps.size() > kMaxTrackedSwaps) {
g_swaps.erase(g_swaps.begin());
}
}
std::vector<VkImage> images(n);
g_get_swapchain_images(device, *out, &n, images.data());
g_swaps.add(*out, ci->imageFormat, ci->imageExtent.width, ci->imageExtent.height, std::move(images));
// (Don't log the VkSwapchainKHR handle: it's a uint64_t on x86, not a pointer.)
logf("vk: swapchain %ux%u fmt=%d images=%u", ci->imageExtent.width, ci->imageExtent.height,
static_cast<int>(ci->imageFormat), n);
@@ -359,10 +295,7 @@ void remove_vk_hooks()
g_cap.shutdown(); // joins the reaper, drains the device, frees the read-back resources
hook_set_installed(g_id_present, false);
{
std::scoped_lock lock(g_swaps_mutex); // drained above, but keep all g_swaps access serialized
g_swaps.clear();
}
g_swaps.clear(); // registry locks internally
g_get_swapchain_images = nullptr;
g_device = VK_NULL_HANDLE;
g_instance = VK_NULL_HANDLE;

View File

@@ -0,0 +1,79 @@
// Tracks the swap chains a Vulkan game creates so the present path can map a (swapchain, image
// index) back to the VkImage being presented (and its format/size) for capture. Shared by BOTH
// early-presence paths -- the inline hook (vk_hook.cpp) and the implicit layer
// (vk_layer/coop_vk_layer.cpp) -- which each own one instance.
//
// Thread-safety: add() runs on the create-swapchain path and lookup() on the present path, which can
// be different game threads (Vulkan external-sync is per-object, not global); clear() runs on the
// worker/teardown thread. One mutex serializes all of it -- a push_back realloc must not race a
// concurrent lookup. lookup() copies the fields out under the lock, so the caller never holds a
// pointer that a concurrent add() could dangle.
//
// We deliberately do NOT track swap-chain destruction (forwarding a vkDestroySwapchainKHR wrong could
// break the game): add() de-dups a recycled handle value and an LRU cap bounds growth, so a game that
// recreates its swapchain every resize can neither grow the table without bound nor match a recycled
// handle's stale images. The active swapchain is always the most-recently added, so it is never evicted.
#pragma once
#include <cstdint>
#include <mutex>
#include <vector>
#include <vulkan/vulkan.h>
namespace coop::hook {
class VkSwapchainRegistry {
public:
// Record (or refresh) a swapchain's images. Takes ownership of `images`.
void add(VkSwapchainKHR sc, VkFormat fmt, std::uint32_t w, std::uint32_t h, std::vector<VkImage> images)
{
std::scoped_lock lock(mutex_);
std::erase_if(swaps_, [&](const Entry& e) { return e.sc == sc; }); // drop a recycled handle
swaps_.push_back({sc, fmt, w, h, std::move(images)});
if (swaps_.size() > kMax) {
swaps_.erase(swaps_.begin()); // bound growth; the just-added active swapchain stays
}
}
// The presented frame for (sc, image_index). False if the swapchain isn't tracked or the index is
// out of range.
struct Frame {
VkImage image = VK_NULL_HANDLE;
VkFormat fmt = VK_FORMAT_UNDEFINED;
std::uint32_t w = 0;
std::uint32_t h = 0;
};
bool lookup(VkSwapchainKHR sc, std::uint32_t image_index, Frame& out) const
{
std::scoped_lock lock(mutex_);
for (const Entry& e : swaps_) {
if (e.sc == sc && image_index < e.images.size()) {
out = {e.images[image_index], e.fmt, e.w, e.h};
return true;
}
}
return false;
}
void clear()
{
std::scoped_lock lock(mutex_);
swaps_.clear();
}
private:
struct Entry {
VkSwapchainKHR sc;
VkFormat fmt;
std::uint32_t w;
std::uint32_t h;
std::vector<VkImage> images;
};
static constexpr std::size_t kMax = 8; // engines use 1-3; headroom for transient resize overlap
mutable std::mutex mutex_;
std::vector<Entry> swaps_;
};
} // namespace coop::hook