M2(Vulkan): too-late detection + red relaunch banner on the mirror window

The hook reports vk_too_late when vulkan-1.dll is loaded and the GPA hook has
been in for >4s but it never caught the app creating its device -- i.e. the app
resolved its Vulkan functions before we hooked (injected too late). Surfaced
through a new HookStatus.vk_too_late field (protocol 15->16) and shown by the
host as a red banner on the mirror window: "Detected a Vulkan game, but the
mirror hook attached too late... enable Auto re-attach (and Set up Vulkan layer
if it persists) and relaunch." WGC keeps mirroring meanwhile.

mock_game_test gains a too-late detection check (late-inject a Vulkan game ->
vk_too_late trips). Verified live: the banner shows over the running tool when a
Vulkan game is injected late with the video subsystem on (added a debug-harness
`video on/off` command to drive it). 15/15 ctest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 12:51:34 +02:00
parent dd976979a1
commit 894285459c
8 changed files with 124 additions and 5 deletions

View File

@@ -12,7 +12,7 @@ namespace coop
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
// refuses to attach to a host with a mismatched version. // refuses to attach to a host with a mismatched version.
inline constexpr std::uint32_t kProtocolVersion = 15; inline constexpr std::uint32_t kProtocolVersion = 16;
// 'COOP' little-endian, used to sanity-check the mapping before trusting it. // 'COOP' little-endian, used to sanity-check the mapping before trusting it.
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
@@ -132,6 +132,11 @@ struct HookStatus
std::uint32_t raw_input_gamepad_sink; // ... and that usage has RIDEV_INPUTSINK (bg delivery) std::uint32_t raw_input_gamepad_sink; // ... and that usage has RIDEV_INPUTSINK (bg delivery)
std::uint32_t dinput_loaded; // dinput8.dll is present in the process std::uint32_t dinput_loaded; // dinput8.dll is present in the process
// Video: set when vulkan-1.dll is loaded but the Vulkan capture hook attached too late to
// catch the game's present (it resolved its present pointer before us). Drives the host's
// "relaunch with Auto-attach / Vulkan layer" red banner.
std::uint32_t vk_too_late;
// Audio render-hook diagnostics. Stream counting runs whenever the DLL is // Audio render-hook diagnostics. Stream counting runs whenever the DLL is
// injected, independent of whether audio mirroring is enabled, so a // injected, independent of whether audio mirroring is enabled, so a
// multi-stream game is visible before/without turning the mirror on. // multi-stream game is visible before/without turning the mirror on.

View File

@@ -227,6 +227,7 @@ DWORD WINAPI worker_thread(LPVOID)
coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change
coop::hook::release_cursor_tick(); // free the operator's mouse if requested (no-op otherwise) coop::hook::release_cursor_tick(); // free the operator's mouse if requested (no-op otherwise)
coop::hook::hook_publish(g_ipc); // installed-hooks list + call counts coop::hook::hook_publish(g_ipc); // installed-hooks list + call counts
g_ipc.set_vk_too_late(coop::hook::vk_injected_too_late()); // Vulkan attached-too-late banner
g_ipc.heartbeat(); g_ipc.heartbeat();
// Reconcile ~4x/s, but drain MKB events far more often (input must be // Reconcile ~4x/s, but drain MKB events far more often (input must be

View File

@@ -147,6 +147,14 @@ public:
} }
} }
void set_vk_too_late(bool too_late)
{
if (block_ != nullptr)
{
block_->status.vk_too_late = too_late ? 1u : 0u;
}
}
// Record the rumble the game requested for a slot (so the host can forward it to // Record the rumble the game requested for a slot (so the host can forward it to
// the guest's controller). Plain stores; the hook is the sole writer. // the guest's controller). Plain stores; the hook is the sole writer.
void note_rumble(std::uint32_t slot, std::uint16_t left, std::uint16_t right) void note_rumble(std::uint32_t slot, std::uint16_t left, std::uint16_t right)

View File

@@ -36,6 +36,7 @@ std::atomic<std::uint64_t> g_presents{0};
std::atomic<std::uint64_t> g_frames_shared{0}; std::atomic<std::uint64_t> g_frames_shared{0};
std::atomic<bool> g_present_captured{false}; // set once we successfully read a present back std::atomic<bool> g_present_captured{false}; // set once we successfully read a present back
bool g_unsupported_logged = false; bool g_unsupported_logged = false;
ULONGLONG g_install_tick = 0; // GetTickCount64 when the GPA hook went in (for the too-late grace)
// Real entry points. g_real_gdpa and below are unhooked exports/results, so they're plain PFNs; // Real entry points. g_real_gdpa and below are unhooked exports/results, so they're plain PFNs;
// the real vkGetInstanceProcAddr is reached through the inline hook's trampoline (real_gipa()). // the real vkGetInstanceProcAddr is reached through the inline hook's trampoline (real_gipa()).
@@ -638,6 +639,7 @@ bool install_vk_hooks(IpcClient& ipc)
} }
g_unsupported_logged = false; g_unsupported_logged = false;
g_hk_gipa = safetyhook::create_inline(gipa, reinterpret_cast<void*>(&hk_vkGetInstanceProcAddr)); g_hk_gipa = safetyhook::create_inline(gipa, reinterpret_cast<void*>(&hk_vkGetInstanceProcAddr));
g_install_tick = GetTickCount64();
hook_set_installed(g_id_present, static_cast<bool>(g_hk_gipa)); hook_set_installed(g_id_present, static_cast<bool>(g_hk_gipa));
logf("install_vk_hooks: vkGetInstanceProcAddr=%p hooked=%d", gipa, static_cast<bool>(g_hk_gipa) ? 1 : 0); logf("install_vk_hooks: vkGetInstanceProcAddr=%p hooked=%d", gipa, static_cast<bool>(g_hk_gipa) ? 1 : 0);
return static_cast<bool>(g_hk_gipa); return static_cast<bool>(g_hk_gipa);
@@ -685,6 +687,7 @@ void remove_vk_hooks()
g_device = VK_NULL_HANDLE; g_device = VK_NULL_HANDLE;
g_instance = VK_NULL_HANDLE; g_instance = VK_NULL_HANDLE;
g_real_gdpa = nullptr; g_real_gdpa = nullptr;
g_install_tick = 0;
g_present_captured.store(false, std::memory_order_relaxed); g_present_captured.store(false, std::memory_order_relaxed);
g_presents.store(0, std::memory_order_relaxed); g_presents.store(0, std::memory_order_relaxed);
g_frames_shared.store(0, std::memory_order_relaxed); g_frames_shared.store(0, std::memory_order_relaxed);
@@ -703,10 +706,16 @@ std::uint64_t vk_frames_shared()
bool vk_injected_too_late() bool vk_injected_too_late()
{ {
// vulkan-1.dll is loaded but we never captured a present -> the app resolved its present // Too late = vulkan-1.dll is loaded and our GPA hook has been in for a few seconds, but we
// pointer before we hooked (or doesn't go through our chain). The host shows the banner. // never even saw the app create its device -> it resolved its Vulkan functions before our
return GetModuleHandleW(L"vulkan-1.dll") != nullptr && g_presents.load(std::memory_order_relaxed) == 0 && // hook (we're not in the chain). A game we hooked early always trips hk_vkCreateDevice
!g_present_captured.load(std::memory_order_relaxed); // (g_device != null) well within the grace window, even before it presents. The host shows
// the "relaunch with Auto-attach / Vulkan layer" banner on this.
if (GetModuleHandleW(L"vulkan-1.dll") == nullptr || g_device != VK_NULL_HANDLE || g_install_tick == 0)
{
return false;
}
return (GetTickCount64() - g_install_tick) > 4000;
} }
} // namespace coop::hook } // namespace coop::hook

View File

@@ -78,6 +78,7 @@ HookStatusView IpcServer::hook_status() const
view.raw_input_gamepad = s.raw_input_gamepad != 0; view.raw_input_gamepad = s.raw_input_gamepad != 0;
view.raw_input_gamepad_sink = s.raw_input_gamepad_sink != 0; view.raw_input_gamepad_sink = s.raw_input_gamepad_sink != 0;
view.dinput_loaded = s.dinput_loaded != 0; view.dinput_loaded = s.dinput_loaded != 0;
view.vk_too_late = s.vk_too_late != 0;
view.audio_streams_seen = s.audio_streams_seen; view.audio_streams_seen = s.audio_streams_seen;
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i) for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
{ {

View File

@@ -29,6 +29,7 @@ struct HookStatusView
bool raw_input_gamepad = false; bool raw_input_gamepad = false;
bool raw_input_gamepad_sink = false; bool raw_input_gamepad_sink = false;
bool dinput_loaded = false; bool dinput_loaded = false;
bool vk_too_late = false; // Vulkan game, but the capture hook attached too late (relaunch banner)
// Audio render-hook diagnostics (for the Audio panel's stream-count view). // Audio render-hook diagnostics (for the Audio panel's stream-count view).
std::uint32_t audio_streams_seen = 0; std::uint32_t audio_streams_seen = 0;

View File

@@ -139,6 +139,12 @@ std::string apply_test_command(const std::string& cmd, coop::UiState& ui, coop::
audio.dev_set_enabled(on); audio.dev_set_enabled(on);
return "ok"; return "ok";
} }
if (v == "video")
{
// Install/remove the hooked video subsystem (Present/GL/D3D9/Vulkan capture hooks).
injection.request_video(arg(1) == "on");
return "ok";
}
if (v == "debug") if (v == "debug")
{ {
ui.debug_details = (arg(1) == "on"); ui.debug_details = (arg(1) == "on");
@@ -219,6 +225,38 @@ std::string steam_manifest_path()
} }
#endif #endif
// Red banner on the mirror window when a Vulkan game was injected too late for the hooked
// capture (Vulkan caches its present pointer at startup, so the hook must be present before
// vkCreateInstance). WGC still mirrors the window; this prompts the operator to relaunch with
// Auto-attach so the hook arms before the game initializes Vulkan. Shown regardless of overlay
// visibility, since it's an actionable alert about the mirror itself.
void draw_vk_too_late_banner()
{
const ImGuiViewport* vp = ImGui::GetMainViewport();
float w = vp->WorkSize.x - 40.0f;
if (w > 760.0f)
{
w = 760.0f;
}
ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + vp->WorkSize.x * 0.5f, vp->WorkPos.y + 16.0f),
ImGuiCond_Always, ImVec2(0.5f, 0.0f));
ImGui::SetNextWindowSize(ImVec2(w, 0.0f));
const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs |
ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing |
ImGuiWindowFlags_NoNav | ImGuiWindowFlags_AlwaysAutoResize;
ImGui::PushStyleColor(ImGuiCol_WindowBg, ImVec4(0.28f, 0.03f, 0.03f, 0.92f));
ImGui::Begin("##vk_too_late", nullptr, flags);
ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.5f, 0.45f, 1.0f));
ImGui::TextWrapped("Detected a Vulkan game, but the mirror hook attached too late to capture it "
"with low latency (Vulkan resolves its present function at startup). The window "
"is mirroring via WGC meanwhile. For the hooked path, enable \"Auto re-attach "
"this game on relaunch\" (and \"Set up Vulkan layer\" if it persists) in the "
"Injection panel, then relaunch the game.");
ImGui::PopStyleColor();
ImGui::End();
ImGui::PopStyleColor();
}
// When the overlay is hidden, briefly show a fading hint so the operator can find // When the overlay is hidden, briefly show a fading hint so the operator can find
// the way back. The window is borderless and non-interactive so it never steals a // the way back. The window is borderless and non-interactive so it never steals a
// click or a frame from the mirror underneath. // click or a frame from the mirror underneath.
@@ -431,6 +469,10 @@ int run()
{ {
draw_overlay_hidden_hint(ImGui::GetTime() - overlay_hidden_at); draw_overlay_hidden_hint(ImGui::GetTime() - overlay_hidden_at);
} }
if (injection.hook_status().vk_too_late) // Vulkan game injected too late -> relaunch prompt
{
draw_vk_too_late_banner();
}
coop::apply_layout_end_frame(); // clear the one-shot "Reset layout" force coop::apply_layout_end_frame(); // clear the one-shot "Reset layout" force
if (ui.request_quit) // File -> Exit if (ui.request_quit) // File -> Exit

View File

@@ -403,6 +403,57 @@ void test_vk_capture(ID3D11Device* device)
cleanup(); cleanup();
} }
// Vulkan too-late detection: launch the vk mock normally (it inits Vulkan immediately), inject
// *late* (the realistic case), and assert the hook reports vk_too_late -- it sees vulkan-1.dll
// loaded but never caught the device, because the app resolved its present pointer first. This is
// what drives the host's relaunch banner.
void test_vk_too_late()
{
std::printf("== vk too-late detection (late inject) ==\n");
MockGame game = MockGame::launch(L"vk 30");
if (!game.ok)
{
check(false, "launch vk mock (too-late)");
return;
}
Sleep(1200); // let it create its instance/device and start presenting
if (!game.alive() && game.exit_code() == 2)
{
std::printf(" Vulkan unavailable on this machine -- skipping\n");
game.kill();
return;
}
SharedMemory shm;
const std::uint32_t disabled = (1u << HookSubsys_Input) | (1u << HookSubsys_Focus) |
(1u << HookSubsys_Audio) | (1u << HookSubsys_Mkb);
SharedBlock* block = make_ipc(shm, game.pid(), disabled);
if (block == nullptr || !inject_retry(game.pid()))
{
if (!game.alive() && game.exit_code() == 2)
{
std::printf(" Vulkan unavailable -- skipping\n");
}
else
{
check(false, "inject vk mock (too-late)");
}
game.kill();
return;
}
bool too_late = false;
for (int i = 0; i < 160 && game.alive(); ++i) // ~8 s (past the hook's 4 s grace)
{
Sleep(50);
if (block->status.vk_too_late != 0)
{
too_late = true;
break;
}
}
check(too_late, "hook reports vk_too_late after a late inject into a Vulkan game");
game.kill();
}
// Launch the mock game rendering audio at `rate`/`channels`/`bits`/`fmt`, inject the audio // Launch the mock game rendering audio at `rate`/`channels`/`bits`/`fmt`, inject the audio
// hook (late, so it's the guessed path), and verify the hook MEASURES the right sample rate // hook (late, so it's the guessed path), and verify the hook MEASURES the right sample rate
// for this variant and captures non-silent audio. (Channels/bit-depth aren't recoverable for // for this variant and captures non-silent audio. (Channels/bit-depth aren't recoverable for
@@ -628,6 +679,7 @@ int main()
// Vulkan: present pointer cached at init -> can't be late-hooked, so we capture via the // Vulkan: present pointer cached at init -> can't be late-hooked, so we capture via the
// early-load path (suspended launch + inject + resume; the mock loads Vulkan and waits). // early-load path (suspended launch + inject + resume; the mock loads Vulkan and waits).
test_vk_capture(device); test_vk_capture(device);
test_vk_too_late();
test_video_capture("gl", device); test_video_capture("gl", device);
test_video_capture("dx9ex", device); test_video_capture("dx9ex", device);
test_video_capture("dx9", device); test_video_capture("dx9", device);