// CoopAllTheThings host. // // Borderless window that Steam Remote Play Together captures, plus the overlay // that drives the tool: it forwards controller input into an injected game // (Injection panel), spoofs the game's focus, and mirrors the game's window into // this window via Windows Graphics Capture (Video mirror panel). #include #include #include #include #include #include #ifdef COOP_TEST_HARNESS #include #include #endif #include #include "imgui.h" #include "audio_panel.hpp" #include "capture_panel.hpp" #include "controllers_panel.hpp" #include "coop/tool_paths.hpp" #include "d3d11_window.hpp" #include "imgui_layer.hpp" #include "inject/mkb_forward.hpp" #include "injection_panel.hpp" #include "input/input_worker.hpp" #include "log_panel.hpp" #include "test_harness.hpp" #include "ui/app_chrome.hpp" #include "util/utf8.hpp" #include "vk_layer_setup.hpp" namespace { // Timestamped screenshot path next to the exe (e.g. coop_shot_20260622_143501.png). std::wstring screenshot_path() { SYSTEMTIME st{}; GetLocalTime(&st); wchar_t name[64]; swprintf(name, static_cast(std::size(name)), L"coop_shot_%04u%02u%02u_%02u%02u%02u.png", st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond); return coop::exe_directory() + name; } // Just the filename of a path, narrowed to UTF-8 for an ImGui confirmation toast. std::string screenshot_basename(const std::wstring& path) { const std::size_t slash = path.find_last_of(L"\\/"); const std::wstring file = slash == std::wstring::npos ? path : path.substr(slash + 1); if (file.empty()) { return {}; } const int n = WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast(file.size()), nullptr, 0, nullptr, nullptr); std::string out(static_cast(n), '\0'); WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast(file.size()), out.data(), n, nullptr, nullptr); return out; } // Brief fading "saved" confirmation after an F10 screenshot (bottom-left, non-interactive // so it never steals a click). It's drawn the frame *after* the capture, so it never lands // in the shot itself. void draw_screenshot_toast(double seconds_since, const std::string& name) { const float fade = 1.0f - static_cast(seconds_since) / 2.5f; if (fade <= 0.0f || name.empty()) { return; } const ImGuiViewport* vp = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x + 12.0f, vp->WorkPos.y + vp->WorkSize.y - 44.0f)); ImGui::SetNextWindowBgAlpha(0.45f * fade); const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; ImGui::Begin("##shot_toast", nullptr, flags); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.6f, 1.0f, 0.6f, fade)); ImGui::Text("Saved screenshot: %s", name.c_str()); ImGui::PopStyleColor(); ImGui::End(); } #ifdef COOP_TEST_HARNESS using coop::widen; // Run a test-harness command on the main thread, hitting the same code the UI buttons do. // Returns a one-line response the driver reads back. std::string apply_test_command(const std::string& cmd, coop::UiState& ui, coop::InjectionPanel& injection, coop::AudioPanel& audio, coop::D3D11Window& window) { std::vector tok; { std::istringstream is(cmd); std::string t; while (is >> t) { tok.push_back(t); } } if (tok.empty()) { return "empty"; } const std::string& v = tok[0]; auto arg = [&](std::size_t i) -> std::string { return i < tok.size() ? tok[i] : std::string(); }; auto num = [&](std::size_t i) -> unsigned { return static_cast(std::strtoul(arg(i).c_str(), nullptr, 10)); }; if (v == "inject") { const unsigned long pid = injection.dev_inject_by_name(widen(arg(1))); return pid != 0 ? ("ok pid " + std::to_string(pid)) : "fail no-process-or-inject-failed"; } if (v == "audio") { const bool on = arg(1) == "on"; if (on) { audio.dev_set_pid(injection.target_pid()); } 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"); return "ok"; } if (v == "autoattach") { injection.dev_set_auto_reattach(arg(1) == "on"); return "ok"; } if (v == "remeasure") { audio.dev_request_op(num(1), coop::AudioRingOp_Remeasure, 0, 0, 0, 0); return "ok"; } if (v == "override") { const std::uint32_t tag = arg(5) == "float" ? static_cast(WAVE_FORMAT_IEEE_FLOAT) : static_cast(WAVE_FORMAT_PCM); audio.dev_request_op(num(1), coop::AudioRingOp_Override, num(2), num(3), num(4), tag); return "ok"; } if (v == "screenshot") { const std::wstring p = screenshot_path(); window.request_screenshot(p); return "ok"; } if (v == "uisize") { // Force a reference layout size so the UI-fit check is monitor-independent. coop::set_layout_reference(static_cast(num(1)), static_cast(num(2))); return "ok"; } if (v == "uifit") { // Report any panel whose content overflowed its assigned size last frame. char buf[256]; coop::panel_fit_report(buf, sizeof(buf)); return buf; } if (v == "quit") { ui.request_quit = true; return "ok"; } if (v == "status") { const coop::HookStatusView st = injection.hook_status(); const std::string reason = audio.dev_reason(); char buf[512]; std::snprintf(buf, sizeof(buf), "audio_running=%d source=%s rate=%u ch=%u state=%u streams=%u inj_pid=%lu inj_state=%d " "reason=%s", audio.dev_running() ? 1 : 0, audio.dev_source().c_str(), audio.dev_rate(), audio.dev_channels(), st.audio_streams[0].format_state, st.audio_streams_seen, injection.target_pid(), static_cast(injection.target_state()), reason.empty() ? "-" : reason.c_str()); return buf; } return "unknown-command"; } #endif // COOP_TEST_HARNESS #ifdef COOP_WITH_STEAM // Absolute path to the bundled Steam Input action manifest (next to the exe). std::string steam_manifest_path() { char buffer[MAX_PATH] = {}; const DWORD len = GetModuleFileNameA(nullptr, buffer, MAX_PATH); std::string path(buffer, len); const std::size_t slash = path.find_last_of("\\/"); if (slash != std::string::npos) { path.resize(slash + 1); } return path + "steam_input_actions.vdf"; } #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. void draw_overlay_hidden_hint(double seconds_hidden) { const float fade = 1.0f - static_cast(seconds_hidden) / 4.0f; if (fade <= 0.0f) { return; // fully faded -> truly clean window for RPT capture } ImGui::SetNextWindowPos(ImVec2(12.0f, 12.0f)); ImGui::SetNextWindowBgAlpha(0.35f * fade); const ImGuiWindowFlags flags = ImGuiWindowFlags_NoDecoration | ImGuiWindowFlags_NoInputs | ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoSavedSettings | ImGuiWindowFlags_NoFocusOnAppearing | ImGuiWindowFlags_NoNav; ImGui::Begin("##overlay_hint", nullptr, flags); ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 1.0f, 1.0f, fade)); ImGui::TextUnformatted("F1: show overlay"); ImGui::PopStyleColor(); ImGui::End(); } // Frame-sync: block until the injected hook publishes a new frame (its generation // bumps) or a short timeout elapses, pumping window messages so the window stays // responsive while we wait. Updates `last_gen` to the generation we should treat as // just-presented. Returns false only if the app is quitting (WM_QUIT seen mid-wait). bool wait_for_hooked_frame(coop::D3D11Window& window, coop::InjectionPanel& injection, std::uint32_t& last_gen) { LARGE_INTEGER freq{}, start{}; QueryPerformanceFrequency(&freq); QueryPerformanceCounter(&start); constexpr double kTimeoutMs = 200.0; // present anyway if the game stalls / is paused for (;;) { const std::uint32_t gen = injection.video_share().generation; if (gen != last_gen) { last_gen = gen; return true; } LARGE_INTEGER now{}; QueryPerformanceCounter(&now); const double elapsed = static_cast(now.QuadPart - start.QuadPart) * 1000.0 / static_cast(freq.QuadPart); if (elapsed >= kTimeoutMs) { last_gen = gen; return true; } if (!window.pump_messages()) { return false; // WM_QUIT } Sleep(1); // yield ~1 ms (timeBeginPeriod(1) keeps this granular) instead of busy-spinning } } int run() { // Clear any leftover Vulkan-layer registration from a host that crashed while registered, before // it can keep loading our DLL into every Vulkan app this session. coop::cleanup_stale_vk_layer(); coop::D3D11Window window; if (!window.create(L"CoopAllTheThings")) { MessageBoxW(nullptr, L"Failed to create the D3D11 window.", L"CoopAllTheThings", MB_ICONERROR); return 1; } // `ui` must outlive `imgui`: register_ui_settings (below) installs an ImGui settings handler that // holds &ui, and ~ImGuiLayer's DestroyContext flushes the .ini through that handler on shutdown. // Declaring ui first means it's destroyed AFTER imgui, so that final save never reads freed state. coop::UiState ui; coop::ImGuiLayer imgui; if (!imgui.init(window.hwnd(), window.device(), window.context())) { MessageBoxW(nullptr, L"Failed to initialize ImGui.", L"CoopAllTheThings", MB_ICONERROR); return 1; } coop::ControllersPanel controllers; coop::InjectionPanel injection; coop::AudioPanel audio; coop::CapturePanel capture; coop::LogPanel log; if (!capture.init(window.device())) { MessageBoxW(nullptr, L"Failed to initialize the video mirror.", L"CoopAllTheThings", MB_ICONERROR); return 1; } capture.set_injection(&injection); // for the Present-hook (Hooked) video source // Route the audio panel's host-side notices (override-overwrite warnings, applied // saved overrides) into the Log window, color-coded by level. audio.set_logger([&injection](std::uint32_t level, const char* text) { injection.host_log(level, text); }); // Controller polling + forwarding runs on its own thread so the render frame rate // (which can drop, especially with frame-sync) never throttles input. XInput is the // default guest path (RPT delivers guest pads as XInput and it Just Works); Steam // Input is opt-in (Controllers panel) -- merely initializing it hijacks XInput and // hides controllers unless they're bound to our action set, so it can silently break // input. The worker reconciles the active backend with the toggle. coop::InputWorker input_worker; #ifdef COOP_WITH_STEAM input_worker.start(&injection, steam_manifest_path()); #else input_worker.start(&injection, std::string()); #endif // The overlay can be hidden (F1) so the window is a clean mirror for Remote // Play Together; the pipelines keep running underneath either way. bool show_overlay = true; double overlay_hidden_at = 0.0; double last_shot_at = -10.0; // when the last F10 screenshot was saved (for the toast) std::string last_shot_name; // Persist the "Debug details" verbosity in the .ini. Register before the first // begin_frame() below, which is when ImGui loads the .ini and replays our handler. // (ui is declared earlier, before imgui, so it outlives the context -- see the note there.) coop::register_ui_settings(ui); coop::FrameStats stats; coop::TestHarness harness; // debug builds only; a no-op shim otherwise harness.init(); // Frame-sync: the hook generation we last presented (so we wait for the next one). std::uint32_t last_synced_gen = 0; while (window.pump_messages()) { // Rescale the overlay if the window changed DPI (moved monitors, or the display scale changed). // pump_messages() latches the new DPI; apply it here, outside any in-progress ImGui frame. if (unsigned new_dpi = 0; window.take_dpi_change(new_dpi)) { imgui.set_dpi(new_dpi); } // When the operator enabled "Sync flip to game frames" (Hooked source), pace the // whole iteration to the game: wait for the next published frame before rendering, // then present without vsync so the flip lands in lockstep with the game. if (capture.frame_sync_active()) { if (!wait_for_hooked_frame(window, injection, last_synced_gen)) { break; } } // Input polling, pad publishing, and rumble all run on the input worker thread; // here we only relay UI requests to it and read back its snapshot for display. const coop::InputSnapshot input_snapshot = input_worker.snapshot(); #ifdef COOP_WITH_STEAM input_worker.set_want_steam(controllers.steam_input_requested()); if (input_worker.steam_failed()) { controllers.on_steam_init_failed(); // resets the toggle; worker falls back to XInput } else { controllers.set_steam_active(input_snapshot.steam_active); } #endif injection.set_test_input(controllers.test_input()); // toggle lives in the Controllers panel injection.tick(); // refresh target liveness before the mirror panels read game_hwnd() const HWND game = injection.game_hwnd(); capture.set_target(game); audio.set_target(game); imgui.begin_frame(); stats.tick(ImGui::GetIO().DeltaTime * 1000.0f); log.pull(injection); // drain hook log lines even while the Log window is hidden #ifdef COOP_TEST_HARNESS if (std::string tcmd = harness.poll_command(); !tcmd.empty()) { harness.write_response(apply_test_command(tcmd, ui, injection, audio, window)); } #endif if (ImGui::IsKeyPressed(ImGuiKey_F1, false)) { show_overlay = !show_overlay; if (!show_overlay) { overlay_hidden_at = ImGui::GetTime(); } } if (ImGui::IsKeyPressed(ImGuiKey_F2, false)) { injection.toggle_cursor_release(); // free/clip the operator's mouse for clipping games } if (ImGui::IsKeyPressed(ImGuiKey_F10, false)) { window.request_screenshot(screenshot_path()); // captured at Present, overlay included } coop::reset_panel_fit(); // panels record their overflow as they draw (UI-fit check) coop::set_layout_debug(ui.debug_details); // center split adapts to the debug verbosity if (show_overlay) { coop::draw_main_menu_bar(ui, stats); if (ui.show_controllers) { controllers.draw(input_snapshot, injection.hook_status(), ui.debug_details); } if (ui.show_injection) { injection.draw(ui.debug_details); } if (ui.show_audio) { audio.draw_ui(injection.hook_status(), ui.debug_details); } if (ui.show_video) { capture.draw_ui(stats); } if (ui.show_log) { log.draw(); } draw_screenshot_toast(ImGui::GetTime() - last_shot_at, last_shot_name); } else { 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 { break; } // Forward the host window's mouse/keyboard into the game (when the MKB // subsystem is on, we're focused, and ImGui isn't using the event). coop::forward_mkb_frame(injection, window.hwnd(), capture.mirroring(), capture.source_hooked()); RECT client = {}; GetClientRect(window.hwnd(), &client); const auto dst_w = static_cast(client.right - client.left); const auto dst_h = static_cast(client.bottom - client.top); // Mirrored frame first (the window background), ImGui overlay on top. When // frame-syncing, present immediately (interval 0) since the wait above already // paced us to the game; otherwise vsync to the monitor. const UINT sync_interval = capture.frame_sync_active() ? 0u : 1u; window.render_frame( [&]() { capture.render(window.context(), dst_w, dst_h); imgui.end_frame(); }, sync_interval); // A host-side TDR / driver reset / GPU hang surfaces as a lost device on Present. We don't // attempt to recreate the device (it would have to re-init ImGui + the capture pipeline); // surface it and stop cleanly rather than spin forever rendering nothing. if (window.device_lost()) { wchar_t msg[320]; swprintf_s(msg, L"The graphics device was lost (0x%08lX) -- a driver reset, GPU hang, or TDR on " L"this PC.\n\nThe mirror can't continue; please restart CoopAllTheThings.", static_cast(window.device_lost_reason())); MessageBoxW(window.hwnd(), msg, L"CoopAllTheThings -- graphics device lost", MB_ICONERROR | MB_OK); break; } // render_frame saves a pending F10 screenshot just before Present; pick up the // result here so next frame shows the confirmation toast (kept out of the shot). if (std::wstring shot = window.take_screenshot_result(); !shot.empty()) { last_shot_at = ImGui::GetTime(); last_shot_name = screenshot_basename(shot); } } return 0; } } // namespace int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int) { // Per-monitor DPI awareness (v2): make GetSystemMetrics / GetDpiForWindow report true pixels so the // mirror renders at the display's native resolution instead of a virtualized one that Windows would // bitmap-stretch (softening the image). We scale the ImGui overlay ourselves to stay readable. Must // run before any window is created. SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); // WGC requires an initialized apartment; multi-threaded suits the // free-threaded frame pool. winrt::init_apartment(winrt::apartment_type::multi_threaded); // 1 ms timer resolution so the frame-sync wait's Sleep(1) is actually ~1 ms (the // default ~15 ms granularity would cap the synced present rate and add jitter). timeBeginPeriod(1); const int result = run(); timeEndPeriod(1); winrt::uninit_apartment(); return result; }