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
|
||||
// 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.
|
||||
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 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
|
||||
// injected, independent of whether audio mirroring is enabled, so a
|
||||
// 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::release_cursor_tick(); // free the operator's mouse if requested (no-op otherwise)
|
||||
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();
|
||||
|
||||
// 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
|
||||
// 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)
|
||||
|
||||
@@ -36,6 +36,7 @@ std::atomic<std::uint64_t> g_presents{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
|
||||
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;
|
||||
// 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_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));
|
||||
logf("install_vk_hooks: vkGetInstanceProcAddr=%p hooked=%d", gipa, static_cast<bool>(g_hk_gipa) ? 1 : 0);
|
||||
return static_cast<bool>(g_hk_gipa);
|
||||
@@ -685,6 +687,7 @@ void remove_vk_hooks()
|
||||
g_device = VK_NULL_HANDLE;
|
||||
g_instance = VK_NULL_HANDLE;
|
||||
g_real_gdpa = nullptr;
|
||||
g_install_tick = 0;
|
||||
g_present_captured.store(false, std::memory_order_relaxed);
|
||||
g_presents.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()
|
||||
{
|
||||
// vulkan-1.dll is loaded but we never captured a present -> the app resolved its present
|
||||
// pointer before we hooked (or doesn't go through our chain). The host shows the banner.
|
||||
return GetModuleHandleW(L"vulkan-1.dll") != nullptr && g_presents.load(std::memory_order_relaxed) == 0 &&
|
||||
!g_present_captured.load(std::memory_order_relaxed);
|
||||
// Too late = vulkan-1.dll is loaded and our GPA hook has been in for a few seconds, but we
|
||||
// never even saw the app create its device -> it resolved its Vulkan functions before our
|
||||
// hook (we're not in the chain). A game we hooked early always trips hk_vkCreateDevice
|
||||
// (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
|
||||
|
||||
@@ -78,6 +78,7 @@ HookStatusView IpcServer::hook_status() const
|
||||
view.raw_input_gamepad = s.raw_input_gamepad != 0;
|
||||
view.raw_input_gamepad_sink = s.raw_input_gamepad_sink != 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;
|
||||
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ struct HookStatusView
|
||||
bool raw_input_gamepad = false;
|
||||
bool raw_input_gamepad_sink = 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).
|
||||
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);
|
||||
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")
|
||||
{
|
||||
ui.debug_details = (arg(1) == "on");
|
||||
@@ -219,6 +225,38 @@ std::string steam_manifest_path()
|
||||
}
|
||||
#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
|
||||
// the way back. The window is borderless and non-interactive so it never steals a
|
||||
// click or a frame from the mirror underneath.
|
||||
@@ -431,6 +469,10 @@ int run()
|
||||
{
|
||||
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
|
||||
|
||||
if (ui.request_quit) // File -> Exit
|
||||
|
||||
@@ -403,6 +403,57 @@ void test_vk_capture(ID3D11Device* device)
|
||||
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
|
||||
// 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
|
||||
@@ -628,6 +679,7 @@ int main()
|
||||
// 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).
|
||||
test_vk_capture(device);
|
||||
test_vk_too_late();
|
||||
test_video_capture("gl", device);
|
||||
test_video_capture("dx9ex", device);
|
||||
test_video_capture("dx9", device);
|
||||
|
||||
Reference in New Issue
Block a user