The XInput hook used to swallow XInputSetState; now it records the requested left/right motor speeds into the status back-channel (protocol v8->v9: per-slot rumble_left/right in HookStatus). Each frame the host reads them and, only on change, drives the guest's actuator via the active backend: - XInput: XInputSetState on the guest's slot (the open question is whether Steam's RPT virtual pad accepts vibration and routes it to the guest -- needs live RPT); - Steam Input: SteamInput TriggerVibration on the slot's controller handle, with the XInput fallback for slots Steam isn't driving. InputSource gains a set_rumble(slot,left,right) hook (default no-op) implemented by both backends; SteamInputSource now tracks per-slot controller handles + which slots it drives. Verified: hook_selftest (x64 + x86) now asserts the hook records the rumble from XInputSetState into the status; full build x64 + x86 clean; ctest x64 9/9, x86 3/3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
248 lines
7.4 KiB
C++
248 lines
7.4 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;
|
|
|
|
// Last rumble forwarded per slot, so we only re-send on change.
|
|
std::uint16_t last_rumble_l[coop::kMaxPads] = {};
|
|
std::uint16_t last_rumble_r[coop::kMaxPads] = {};
|
|
|
|
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()
|
|
|
|
// Forward the rumble the game requested back to the guest's controller (only
|
|
// when it changes, to avoid spamming XInputSetState / TriggerVibration).
|
|
{
|
|
const coop::HookStatusView hs = injection.hook_status();
|
|
for (int i = 0; i < static_cast<int>(coop::kMaxPads); ++i)
|
|
{
|
|
if (hs.rumble_left[i] != last_rumble_l[i] || hs.rumble_right[i] != last_rumble_r[i])
|
|
{
|
|
input->set_rumble(i, hs.rumble_left[i], hs.rumble_right[i]);
|
|
last_rumble_l[i] = hs.rumble_left[i];
|
|
last_rumble_r[i] = hs.rumble_right[i];
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|