diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 291b729..dc1f126 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -21,6 +21,7 @@ add_executable(coop_host WIN32 src/audio/audio_loopback.cpp src/audio/audio_overrides.cpp src/audio/process_loopback_capture.cpp + src/vk_layer_setup.cpp src/test_harness.cpp) target_include_directories(coop_host PRIVATE src) diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index 5ddf0bb..a4410c8 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -1,5 +1,7 @@ #include "injection_panel.hpp" +#include "vk_layer_setup.hpp" + #include #include @@ -96,6 +98,10 @@ void InjectionPanel::refresh_targets() InjectionPanel::~InjectionPanel() { + if (vk_layer_enabled_) + { + unregister_vk_layer(); // don't leave the implicit layer registered after the tool closes + } close_target_handle(); } @@ -580,6 +586,27 @@ void InjectionPanel::draw(bool debug_details) ImGui::SameLine(); ImGui::TextColored(kGrey, "(watching for %s...)", narrow(selected_name_).c_str()); } + // Opt-in Vulkan capture layer: for Vulkan games that initialize Vulkan immediately + // (where even auto-attach injects too late -- see the red banner), register a per-user + // implicit layer scoped to this game so the next launch is captured from the first frame. + // Removed when unticked or the host exits. + if (ImGui::Checkbox("Set up Vulkan layer (for immediate-init Vulkan games)", &vk_layer_enabled_)) + { + if (vk_layer_enabled_) + { + vk_layer_enabled_ = register_vk_layer(selected_name_); + } + else + { + unregister_vk_layer(); + } + } + if (ImGui::IsItemHovered()) + { + ImGui::SetTooltip("Registers a per-user (HKCU, no admin) implicit Vulkan layer scoped to\n" + "this game, so a relaunch is captured before Vulkan init. Pair with\n" + "Auto re-attach. Removed when you untick it or close the tool."); + } } ImGui::Separator(); } diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 6313684..ce65129 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -202,6 +202,7 @@ private: // the same image name the moment it relaunches, so a quick kill+relaunch re-attaches // early (catching IAudioClient::Initialize) without the operator picking a target. bool auto_reattach_ = false; + bool vk_layer_enabled_ = false; // opt-in: registered the implicit Vulkan capture layer for this game double last_auto_poll_ = 0.0; // throttle the process-list poll // Heartbeat liveness tracking (is the injected DLL responding?). diff --git a/host/src/vk_layer_setup.cpp b/host/src/vk_layer_setup.cpp new file mode 100644 index 0000000..f6b0d61 --- /dev/null +++ b/host/src/vk_layer_setup.cpp @@ -0,0 +1,79 @@ +#include "vk_layer_setup.hpp" + +#include + +#include "coop/tool_paths.hpp" + +namespace coop +{ +namespace +{ +// The Vulkan loader's per-user implicit-layer registry list. Each value is a manifest path; its +// DWORD data 0 = enabled. +constexpr const wchar_t* kImplicitLayersKey = L"SOFTWARE\\Khronos\\Vulkan\\ImplicitLayers"; + +std::wstring manifest_path() +{ + return exe_directory() + L"coop_vk_layer.json"; // staged next to coop_host.exe +} + +std::wstring scoping_file() +{ + wchar_t dir[MAX_PATH] = {}; + const DWORD n = GetTempPathW(MAX_PATH, dir); + return (n != 0 && n < MAX_PATH) ? std::wstring(dir) + L"coop_vk_target.txt" : std::wstring(); +} +} // namespace + +bool register_vk_layer(const std::wstring& target_image) +{ + // Write the scoping file (target image basename, UTF-8) the layer checks against its own image. + const std::wstring sf = scoping_file(); + if (!sf.empty()) + { + const std::size_t slash = target_image.find_last_of(L"\\/"); + const std::wstring base = slash == std::wstring::npos ? target_image : target_image.substr(slash + 1); + const int n = WideCharToMultiByte(CP_UTF8, 0, base.c_str(), static_cast(base.size()), nullptr, 0, + nullptr, nullptr); + std::string utf8(static_cast(n), '\0'); + WideCharToMultiByte(CP_UTF8, 0, base.c_str(), static_cast(base.size()), utf8.data(), n, nullptr, + nullptr); + HANDLE f = CreateFileW(sf.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (f != INVALID_HANDLE_VALUE) + { + DWORD written = 0; + WriteFile(f, utf8.data(), static_cast(utf8.size()), &written, nullptr); + CloseHandle(f); + } + } + + HKEY key = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, kImplicitLayersKey, 0, nullptr, 0, KEY_SET_VALUE, nullptr, &key, + nullptr) != ERROR_SUCCESS) + { + return false; + } + const std::wstring mp = manifest_path(); + DWORD enabled = 0; // 0 = enabled, per the loader's convention + const LONG r = RegSetValueExW(key, mp.c_str(), 0, REG_DWORD, reinterpret_cast(&enabled), + sizeof(enabled)); + RegCloseKey(key); + return r == ERROR_SUCCESS; +} + +void unregister_vk_layer() +{ + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kImplicitLayersKey, 0, KEY_SET_VALUE, &key) == ERROR_SUCCESS) + { + RegDeleteValueW(key, manifest_path().c_str()); + RegCloseKey(key); + } + const std::wstring sf = scoping_file(); + if (!sf.empty()) + { + DeleteFileW(sf.c_str()); + } +} + +} // namespace coop diff --git a/host/src/vk_layer_setup.hpp b/host/src/vk_layer_setup.hpp new file mode 100644 index 0000000..3ca53e3 --- /dev/null +++ b/host/src/vk_layer_setup.hpp @@ -0,0 +1,24 @@ +// Host-side setup for the opt-in Vulkan capture layer (coop_vk_layer). Registering it as a +// per-user implicit Vulkan layer makes the Vulkan loader insert it at vkCreateInstance -- so it's +// in the chain before a game (that caches its present pointer at init) can resolve +// vkQueuePresentKHR, the reliable early-presence path the inject-after-launch hook can't get. +// +// An implicit layer loads into *every* Vulkan app, so the layer itself only captures the process +// whose image basename matches the scoping file this writes -- keeping it inert for everything but +// the host's target. Registration is HKCU-only (no admin) and fully reversible. +#pragma once + +#include + +namespace coop +{ + +// Register the implicit layer (HKCU) and scope it to `target_image` (the game's exe basename, +// e.g. "game.exe"). Returns true on success. Idempotent. +bool register_vk_layer(const std::wstring& target_image); + +// Remove the registry registration and the scoping file. Safe to call when not registered (e.g. +// on host shutdown as a belt-and-suspenders cleanup). +void unregister_vk_layer(); + +} // namespace coop