Files
CoopAllTheThings/host/src/main.cpp
BlackMark 7673f186db Add mouse + keyboard forwarding (MKB subsystem, opt-in)
Forward the host window's clicks and keystrokes into the injected game so guests
can drive menus / "Press Start" / text entry that a pad can't.

Protocol (v7->v8): new HookSubsys_Mkb and an SPSC MkbRing of MkbEvents in
SharedBlock (host produces, hook consumes); push/pop helpers.

Hook (hook/src/mkb_hook.cpp, new subsystem): a worker-loop pump drains the ring at
~5 ms and PostMessageW's the matching window messages (WM_KEY*/WM_CHAR, mouse
buttons, WM_MOUSEWHEEL) to the game's main window; it also inline-hooks user32
GetAsyncKeyState / GetKeyboardState / GetCursorPos (stdcall trampolines per the x86
rule) to report a synthesized state so polling games react too. Removing the
subsystem clears all synthesized keys (no stuck input).

Host: the Injection panel gets a "Mouse + keyboard forwarding" subsystem toggle
(opt-in, default off -- the toggle is the hook). host/src/inject/mkb_forward.cpp
reads ImGui IO each frame and forwards only when the host window is focused and
ImGui isn't capturing the event; keyboard always, mouse only while mirroring (clicks
+ wheel, not movement). Mouse coords are mapped through the letterbox to game-client
space (host/src/inject/mkb_map.hpp), accounting for WGC-of-decorated-window vs
hooked/borderless. RawInput/DirectInput games are out of scope for this version.

Verified: new mkb_ring_test + mkb_map_test pass; full build x64 + x86 clean; ctest
x64 9/9 and x86 3/3 green (no regression from the protocol bump). The subsystem is
opt-in, so it can't affect existing behavior unless enabled; the end-to-end
click-into-game path needs live Remote Play + a real game to confirm.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 05:06:38 +02:00

228 lines
6.7 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 <memory>
#include <windows.h>
#include <winrt/Windows.Foundation.h>
#include "imgui.h"
#include "audio_panel.hpp"
#include "capture_panel.hpp"
#include "controllers_panel.hpp"
#include "d3d11_window.hpp"
#include "imgui_layer.hpp"
#include "inject/mkb_forward.hpp"
#include "injection_panel.hpp"
#include "input/xinput_source.hpp"
#include "log_panel.hpp"
#include "ui/app_chrome.hpp"
#ifdef COOP_WITH_STEAM
#include <string>
#include "input/steam_input_source.hpp"
#endif
namespace
{
#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
// 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();
}
int run()
{
coop::D3D11Window window;
if (!window.create(L"CoopAllTheThings"))
{
MessageBoxW(nullptr, L"Failed to create the D3D11 window.", L"CoopAllTheThings", MB_ICONERROR);
return 1;
}
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;
}
// XInput is the default guest-input path: Remote Play Together delivers guest
// pads as XInput, and it Just Works. Steam Input is opt-in (Controllers panel) --
// merely initializing it activates Steam's in-process XInput interception, which
// hides controllers from XInput unless they're bound to our action set for this
// app, so making it the default can silently break input. We switch the active
// backend at runtime to match the toggle.
coop::XInputSource xinput;
coop::InputSource* input = &xinput;
#ifdef COOP_WITH_STEAM
std::unique_ptr<coop::SteamInputSource> steam;
#endif
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
// 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;
coop::UiState ui;
coop::FrameStats stats;
while (window.pump_messages())
{
#ifdef COOP_WITH_STEAM
// Switch the active input backend to match the Controllers-panel toggle.
const bool want_steam = controllers.steam_input_requested();
if (want_steam && steam == nullptr)
{
steam = std::make_unique<coop::SteamInputSource>();
if (steam->init(steam_manifest_path()))
{
input = steam.get();
controllers.set_steam_active(true);
}
else
{
steam.reset();
controllers.on_steam_init_failed();
}
}
else if (!want_steam && steam != nullptr)
{
steam->shutdown();
steam.reset();
input = &xinput;
controllers.set_steam_active(false);
}
#endif
input->poll();
injection.set_test_input(controllers.test_input()); // toggle lives in the Controllers panel
injection.publish(input->pads());
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
if (ImGui::IsKeyPressed(ImGuiKey_F1, false))
{
show_overlay = !show_overlay;
if (!show_overlay)
{
overlay_hidden_at = ImGui::GetTime();
}
}
if (show_overlay)
{
coop::draw_main_menu_bar(ui, stats);
if (ui.show_controllers)
{
controllers.draw(*input, 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();
}
}
else
{
draw_overlay_hidden_hint(ImGui::GetTime() - overlay_hidden_at);
}
coop::apply_layout_end_frame(); // clear the one-shot "Reset layout" force
// 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.
window.render_frame([&]() {
capture.render(window.context(), dst_w, dst_h);
imgui.end_frame();
});
}
return 0;
}
} // namespace
int WINAPI wWinMain(HINSTANCE, HINSTANCE, LPWSTR, int)
{
// WGC requires an initialized apartment; multi-threaded suits the
// free-threaded frame pool.
winrt::init_apartment(winrt::apartment_type::multi_threaded);
const int result = run();
winrt::uninit_apartment();
return result;
}