diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index d3e3cdc..b0e283a 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -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. diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index 138a228..04ec431 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -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 diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index f4490b7..48236d3 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -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) diff --git a/hook/src/vk_hook.cpp b/hook/src/vk_hook.cpp index 652c268..fa09a38 100644 --- a/hook/src/vk_hook.cpp +++ b/hook/src/vk_hook.cpp @@ -36,6 +36,7 @@ std::atomic g_presents{0}; std::atomic g_frames_shared{0}; std::atomic 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(&hk_vkGetInstanceProcAddr)); + g_install_tick = GetTickCount64(); hook_set_installed(g_id_present, static_cast(g_hk_gipa)); logf("install_vk_hooks: vkGetInstanceProcAddr=%p hooked=%d", gipa, static_cast(g_hk_gipa) ? 1 : 0); return static_cast(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 diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 024ba8e..3e1049d 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -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) { diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 34c66ba..6da50d1 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -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; diff --git a/host/src/main.cpp b/host/src/main.cpp index efe7cf8..bea85e7 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -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 diff --git a/tests/mock_game_test.cpp b/tests/mock_game_test.cpp index af95f60..a3fa5c9 100644 --- a/tests/mock_game_test.cpp +++ b/tests/mock_game_test.cpp @@ -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);