Files
CoopAllTheThings/host/src/main.cpp
BlackMark 1eee482165 Consolidate the host's UTF-8/wide conversions into util/utf8.hpp
Five files each carried their own copy of the WideCharToMultiByte /
MultiByteToWideChar UTF-8 conversion (injection_panel, audio_overrides,
imgui_layer's to_utf8, main's harness widen, and vk_layer_setup's inline
form). Replace them all with coop::narrow / coop::widen from one header.

audio_panel's image_basename dropped its lossy `c & 0x7F` ASCII mask for
the proper narrow(), so a non-ASCII game exe name is no longer mangled
in log lines.
2026-07-12 09:23:40 +02:00

556 lines
20 KiB
C++

// 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 <cstdint>
#include <cstdio>
#include <string>
#include <windows.h>
#include <mmreg.h>
#include <timeapi.h>
#ifdef COOP_TEST_HARNESS
#include <sstream>
#include <vector>
#endif
#include <winrt/Windows.Foundation.h>
#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<int>(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<int>(file.size()), nullptr, 0,
nullptr, nullptr);
std::string out(static_cast<std::size_t>(n), '\0');
WideCharToMultiByte(CP_UTF8, 0, file.c_str(), static_cast<int>(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<float>(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<std::string> 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<unsigned>(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<std::uint32_t>(WAVE_FORMAT_IEEE_FLOAT)
: static_cast<std::uint32_t>(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<float>(num(1)), static_cast<float>(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<int>(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<float>(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<double>(now.QuadPart - start.QuadPart) * 1000.0 / static_cast<double>(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<std::uint32_t>(client.right - client.left);
const auto dst_h = static_cast<std::uint32_t>(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<unsigned long>(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;
}