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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user