Move input polling to its own thread; fixed-width fast-changing UI numbers

Input thread: controller polling, pad publishing, and rumble forwarding were
driven by the render loop, so a low/synced frame rate throttled how often guest
input reached the game. New InputWorker owns the InputSource and runs poll +
IPC publish + rumble on a dedicated ~1 kHz thread, independent of rendering. The
UI thread reads a copy-safe InputSnapshot for the Controllers panel and relays the
Steam-Input request/active/failed state to/from the worker (Steam init/shutdown now
happen on the worker thread). IpcServer gained a mutex so the worker's publish() /
hook_status() can't race the UI thread starting/stopping the shared-memory channel
(use-after-unmap); InjectionPanel::test_input_ is now atomic. ControllersPanel::draw
takes an InputSnapshot instead of the live InputSource.

Fixed-width numbers: fast-changing readouts (menu-bar FPS/ms, Video pipeline rates +
latency + graph legend, controller poll rates + round-trip sticks, audio buffered ms
+ frames/s) printed with %.0f etc., so they shifted/blurred as values crossed digit
thresholds (99 -> 100) each frame. Padded them to fixed field widths so they stay put.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 12:51:00 +02:00
parent feb8fc5dae
commit 4121ad4373
14 changed files with 299 additions and 78 deletions

View File

@@ -9,6 +9,7 @@ add_executable(coop_host WIN32
src/log_panel.cpp src/log_panel.cpp
src/ui/app_chrome.cpp src/ui/app_chrome.cpp
src/input/xinput_source.cpp src/input/xinput_source.cpp
src/input/input_worker.cpp
src/inject/process_list.cpp src/inject/process_list.cpp
src/inject/window_list.cpp src/inject/window_list.cpp
src/inject/injector.cpp src/inject/injector.cpp

View File

@@ -77,7 +77,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextColored(hooked ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s", ImGui::TextColored(hooked ? ImVec4(0.4f, 1.0f, 0.4f, 1.0f) : ImVec4(1.0f, 0.8f, 0.3f, 1.0f), "%s",
mirror_.source_name()); mirror_.source_name());
ImGui::Text("Buffered: %u ms", mirror_.buffered_ms()); ImGui::Text("Buffered: %4u ms", mirror_.buffered_ms());
} }
const std::string mirror_status = mirror_.status(); const std::string mirror_status = mirror_.status();
if (!mirror_status.empty()) if (!mirror_status.empty())
@@ -176,7 +176,7 @@ void AudioPanel::draw_ui(const HookStatusView& status, bool debug_details)
{ {
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "live"); ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "live");
ImGui::SameLine(); ImGui::SameLine();
ImGui::TextDisabled("%.0f/s", frames_per_s_[i]); ImGui::TextDisabled("%6.0f/s", frames_per_s_[i]);
} }
else else
{ {

View File

@@ -188,13 +188,15 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
} }
const double now = ImGui::GetTime(); const double now = ImGui::GetTime();
ImGui::SeparatorText("Pipeline rates"); ImGui::SeparatorText("Pipeline rates");
ImGui::Text("Tool render: %.0f FPS (%.2f ms)", stats.fps(), stats.avg_ms()); // Fixed field widths so fast-changing numbers don't jitter as they cross digit
// thresholds (e.g. 99 -> 100) frame to frame.
ImGui::Text("Tool render: %4.0f FPS (%6.2f ms)", stats.fps(), stats.avg_ms());
if (source_ == Source_Hooked) if (source_ == Source_Hooked)
{ {
const VideoShareView v = injection_ != nullptr ? injection_->video_share() : VideoShareView{}; const VideoShareView v = injection_ != nullptr ? injection_->video_share() : VideoShareView{};
ImGui::Text("Game present: %.0f /s", present_rate_.sample(v.present_calls, now)); ImGui::Text("Game present: %5.0f /s", present_rate_.sample(v.present_calls, now));
ImGui::Text("Hook publish: %.0f /s", capture_rate_.sample(v.generation, now)); ImGui::Text("Hook publish: %5.0f /s", capture_rate_.sample(v.generation, now));
// On each newly published frame, measure now - present_qpc (system-wide clock). // On each newly published frame, measure now - present_qpc (system-wide clock).
if (v.generation != last_video_gen_ && v.present_qpc != 0 && qpc_freq_ > 0) if (v.generation != last_video_gen_ && v.present_qpc != 0 && qpc_freq_ > 0)
@@ -234,7 +236,7 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
} }
if (lat_avg_ > 0.0f) if (lat_avg_ > 0.0f)
{ {
ImGui::Text("Capture->display: avg %.1f min %.1f max %.1f ms", lat_avg_, lat_min_, lat_max_); ImGui::Text("Capture->display: avg %6.1f min %6.1f max %6.1f ms", lat_avg_, lat_min_, lat_max_);
} }
else else
{ {
@@ -244,7 +246,7 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats)
else else
{ {
ImGui::TextDisabled("Game present: n/a (WGC has no game frame timing)"); ImGui::TextDisabled("Game present: n/a (WGC has no game frame timing)");
ImGui::Text("WGC capture: %.0f /s", capture_rate_.sample(capture_.frames_arrived(), now)); ImGui::Text("WGC capture: %5.0f /s", capture_rate_.sample(capture_.frames_arrived(), now));
ImGui::TextDisabled("Latency: n/a (WGC frames aren't game-timestamped)"); ImGui::TextDisabled("Latency: n/a (WGC frames aren't game-timestamped)");
} }
} }
@@ -319,7 +321,7 @@ void CapturePanel::draw_perf_graphs(const FrameStats& /*stats*/)
// Legend: a colored label + the latest value of each line, so colors map to series. // Legend: a colored label + the latest value of each line, so colors map to series.
for (int i = 0; i < n; ++i) for (int i = 0; i < n; ++i)
{ {
ImGui::TextColored(cols[i], "%s %.0f", names[i], counts[i] > 0 ? fps[i][counts[i] - 1] : 0.0f); ImGui::TextColored(cols[i], "%-4s %4.0f", names[i], counts[i] > 0 ? fps[i][counts[i] - 1] : 0.0f);
if (i + 1 < n) if (i + 1 < n)
{ {
ImGui::SameLine(); ImGui::SameLine();

View File

@@ -67,12 +67,12 @@ void draw_pad(int index, const PadInfo& pad, bool debug_details)
} // namespace } // namespace
void ControllersPanel::draw(const InputSource& input, const HookStatusView& status, bool debug_details) void ControllersPanel::draw(const InputSnapshot& input, const HookStatusView& status, bool debug_details)
{ {
apply_panel_layout(Panel::Controllers); apply_panel_layout(Panel::Controllers);
ImGui::Begin("Controllers"); ImGui::Begin("Controllers");
ImGui::Text("Input backend: %s", input.name()); ImGui::Text("Input backend: %s", input.backend);
ImGui::TextDisabled("This window is what Remote Play Together captures."); ImGui::TextDisabled("This window is what Remote Play Together captures.");
ImGui::TextDisabled("F1: hide overlay (clean mirror) Esc: quit"); ImGui::TextDisabled("F1: hide overlay (clean mirror) Esc: quit");
@@ -108,7 +108,7 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
// --- Guest pads the host receives from RPT ----------------------------- // --- Guest pads the host receives from RPT -----------------------------
ImGui::SeparatorText("Incoming (host receives)"); ImGui::SeparatorText("Incoming (host receives)");
const auto& pads = input.pads(); const auto& pads = input.pads;
for (int i = 0; i < static_cast<int>(pads.size()); ++i) for (int i = 0; i < static_cast<int>(pads.size()); ++i)
{ {
draw_pad(i, pads[i], debug_details); draw_pad(i, pads[i], debug_details);
@@ -145,7 +145,7 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
} }
if (total_rate > 0.0) if (total_rate > 0.0)
{ {
ImGui::TextColored(kGreen, "Game reading controller: %.0f polls/s", total_rate); ImGui::TextColored(kGreen, "Game reading controller: %5.0f polls/s", total_rate);
} }
else else
{ {
@@ -168,7 +168,7 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
ImGui::TableNextColumn(); ImGui::TableNextColumn();
if (state_rate_[i] > 0.0) if (state_rate_[i] > 0.0)
{ {
ImGui::TextColored(kGreen, "%.0f", state_rate_[i]); ImGui::TextColored(kGreen, "%5.0f", state_rate_[i]);
} }
else else
{ {
@@ -194,7 +194,7 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
ImGui::TableSetupColumn("Forwarded btn / LX,LY"); ImGui::TableSetupColumn("Forwarded btn / LX,LY");
ImGui::TableSetupColumn("Game read btn / LX,LY"); ImGui::TableSetupColumn("Game read btn / LX,LY");
ImGui::TableHeadersRow(); ImGui::TableHeadersRow();
const auto& fwd = input.pads(); const auto& fwd = input.pads;
for (int i = 0; i < static_cast<int>(kMaxPads); ++i) for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{ {
const CoopPadState& f = fwd[i].state; const CoopPadState& f = fwd[i].state;
@@ -203,10 +203,10 @@ void ControllersPanel::draw(const InputSource& input, const HookStatusView& stat
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::Text("%d", i); ImGui::Text("%d", i);
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::Text("0x%04X %d,%d", f.buttons, f.thumb_lx, f.thumb_ly); ImGui::Text("0x%04X %6d,%6d", f.buttons, f.thumb_lx, f.thumb_ly);
ImGui::TableNextColumn(); ImGui::TableNextColumn();
const bool match = f.buttons == r.buttons && f.thumb_lx == r.thumb_lx && f.thumb_ly == r.thumb_ly; const bool match = f.buttons == r.buttons && f.thumb_lx == r.thumb_lx && f.thumb_ly == r.thumb_ly;
ImGui::TextColored(match ? kGreen : kGrey, "0x%04X %d,%d", r.buttons, r.thumb_lx, r.thumb_ly); ImGui::TextColored(match ? kGreen : kGrey, "0x%04X %6d,%6d", r.buttons, r.thumb_lx, r.thumb_ly);
} }
ImGui::EndTable(); ImGui::EndTable();
} }

View File

@@ -17,10 +17,11 @@ namespace coop
class ControllersPanel class ControllersPanel
{ {
public: public:
// `input` is the input worker's latest snapshot (guest pads + active backend);
// `status` is the hook's back-channel (per-slot poll counters); `debug_details` // `status` is the hook's back-channel (per-slot poll counters); `debug_details`
// reveals the raw axis values, the per-slot poll-rate table, and the synthetic // reveals the raw axis values, the per-slot poll-rate table, and the synthetic
// test-input toggle. // test-input toggle.
void draw(const InputSource& input, const HookStatusView& status, bool debug_details); void draw(const InputSnapshot& input, const HookStatusView& status, bool debug_details);
// Whether the operator enabled "Forward synthetic test input" (a controller debug // Whether the operator enabled "Forward synthetic test input" (a controller debug
// aid). The host feeds this to InjectionPanel, which substitutes a synthetic pad. // aid). The host feeds this to InjectionPanel, which substitutes a synthetic pad.

View File

@@ -256,7 +256,7 @@ void InjectionPanel::reattach()
void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads) void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
{ {
if (!test_input_) if (!test_input_.load(std::memory_order_relaxed))
{ {
server_.publish(pads); server_.publish(pads);
return; return;

View File

@@ -3,6 +3,7 @@
#pragma once #pragma once
#include <array> #include <array>
#include <atomic>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -52,7 +53,7 @@ public:
// panel (a controller-debug aid); the host feeds its state here each frame. // panel (a controller-debug aid); the host feeds its state here each frame.
void set_test_input(bool on) void set_test_input(bool on)
{ {
test_input_ = on; test_input_.store(on, std::memory_order_relaxed);
} }
// The injected game's main window, as reported by the hook (null if none). A // The injected game's main window, as reported by the hook (null if none). A
@@ -165,7 +166,9 @@ private:
std::string status_; std::string status_;
ImVec4 status_color_; ImVec4 status_color_;
bool test_input_ = false; // Written by the UI thread (set_test_input), read by the input worker thread
// (publish) -- atomic so that cross-thread read is well-defined.
std::atomic<bool> test_input_{false};
// Host-requested per-subsystem install state (default on). Written to the hook // Host-requested per-subsystem install state (default on). Written to the hook
// over the control channel; the hook reconciles each tick. // over the control channel; the hook reconciles each tick.

View File

@@ -21,6 +21,16 @@ struct PadInfo
std::string source; // human-readable label for the debug overlay std::string source; // human-readable label for the debug overlay
}; };
// A copy-safe snapshot of the input backend's state, published by the input worker
// thread for the UI to display. This decouples the Controllers panel (UI thread) from
// the worker's live polling, so the worker can own its InputSource exclusively.
struct InputSnapshot
{
std::array<PadInfo, kMaxPads> pads{};
const char* backend = "XInput"; // backend name (static string literal; thread-safe to share)
bool steam_active = false;
};
class InputSource class InputSource
{ {
public: public:

View File

@@ -0,0 +1,144 @@
#include "input/input_worker.hpp"
#include <chrono>
#include <cstdint>
#include <memory>
#include "injection_panel.hpp"
#include "input/xinput_source.hpp"
#ifdef COOP_WITH_STEAM
#include "input/steam_input_source.hpp"
#endif
namespace coop
{
InputWorker::~InputWorker()
{
stop();
}
void InputWorker::start(InjectionPanel* injection, std::string steam_manifest)
{
if (running_.load(std::memory_order_acquire))
{
return;
}
injection_ = injection;
steam_manifest_ = std::move(steam_manifest);
running_.store(true, std::memory_order_release);
thread_ = std::thread([this] { run(); });
}
void InputWorker::stop()
{
running_.store(false, std::memory_order_release);
if (thread_.joinable())
{
thread_.join();
}
}
InputSnapshot InputWorker::snapshot() const
{
std::scoped_lock lock(snapshot_mutex_);
return snapshot_;
}
void InputWorker::publish_snapshot(const InputSource& src, bool steam_active)
{
std::scoped_lock lock(snapshot_mutex_);
snapshot_.pads = src.pads();
snapshot_.backend = src.name();
snapshot_.steam_active = steam_active;
}
void InputWorker::run()
{
// The worker owns its input backend(s) exclusively, so no locking is needed around
// polling -- only the published snapshot and the IPC are shared with the UI thread.
XInputSource xinput;
#ifdef COOP_WITH_STEAM
std::unique_ptr<SteamInputSource> steam;
#endif
InputSource* active = &xinput;
// Last rumble forwarded per slot, so we only re-send on change.
std::uint16_t last_rumble_l[kMaxPads] = {};
std::uint16_t last_rumble_r[kMaxPads] = {};
while (running_.load(std::memory_order_relaxed))
{
const bool want_steam = want_steam_.load(std::memory_order_relaxed);
#ifdef COOP_WITH_STEAM
// Reconcile the backend with the UI's request. Initializing Steam Input hijacks
// XInput, so it's strictly opt-in; a failed init falls back to plain XInput and
// flags steam_failed_ so the UI can reset its toggle (and a later retry is
// possible once the request is cleared).
if (want_steam && steam == nullptr && !steam_failed_.load(std::memory_order_relaxed))
{
steam = std::make_unique<SteamInputSource>();
if (steam->init(steam_manifest_))
{
active = steam.get();
}
else
{
steam.reset();
active = &xinput;
steam_failed_.store(true, std::memory_order_relaxed);
}
}
else if (!want_steam && steam != nullptr)
{
steam->shutdown();
steam.reset();
active = &xinput;
}
if (!want_steam)
{
steam_failed_.store(false, std::memory_order_relaxed); // allow a future retry
}
const bool steam_active = steam != nullptr;
#else
(void)want_steam;
const bool steam_active = false;
#endif
active->poll();
if (injection_ != nullptr)
{
// Push the latest pads to the game (publish() substitutes synthetic test input
// itself when that mode is on), then forward any newly requested rumble.
injection_->publish(active->pads());
const HookStatusView hs = injection_->hook_status();
for (int i = 0; i < static_cast<int>(kMaxPads); ++i)
{
if (hs.rumble_left[i] != last_rumble_l[i] || hs.rumble_right[i] != last_rumble_r[i])
{
active->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];
}
}
}
publish_snapshot(*active, steam_active);
// ~1 ms cadence (timeBeginPeriod(1) in wWinMain keeps Sleep granular), so input
// is polled/forwarded at ~1 kHz regardless of the render frame rate.
std::this_thread::sleep_for(std::chrono::milliseconds(1));
}
#ifdef COOP_WITH_STEAM
if (steam != nullptr)
{
steam->shutdown();
}
#endif
}
} // namespace coop

View File

@@ -0,0 +1,68 @@
// Runs controller polling + forwarding on its own thread, decoupled from the render
// loop. The render frame rate (which can drop, especially with frame-sync) must not
// throttle how often guest input is polled and pushed to the game, so this worker
// owns the InputSource, polls it at a fixed high cadence, publishes pads to the
// injected hook over IPC, and forwards game-requested rumble back to the guest pad.
// The UI thread reads a copy-safe snapshot() for the Controllers panel.
#pragma once
#include <atomic>
#include <mutex>
#include <string>
#include <thread>
#include "input/input_source.hpp"
namespace coop
{
class InjectionPanel;
class InputWorker
{
public:
InputWorker() = default;
~InputWorker();
InputWorker(const InputWorker&) = delete;
InputWorker& operator=(const InputWorker&) = delete;
// Start the input thread. `injection` receives published pads and supplies the
// game's rumble requests; it must outlive this worker. `steam_manifest` is the
// Steam Input action-manifest path, used only if/when Steam Input is enabled
// (ignored in non-Steam builds). No-op if already running.
void start(InjectionPanel* injection, std::string steam_manifest);
void stop();
// UI -> worker: request the Steam Input backend (true) or plain XInput (false).
void set_want_steam(bool on)
{
want_steam_.store(on, std::memory_order_relaxed);
}
// worker -> UI: latest snapshot for the Controllers panel (thread-safe copy).
[[nodiscard]] InputSnapshot snapshot() const;
// worker -> UI: Steam Input was requested but failed to start (so the panel can
// reset its toggle and fall back to XInput). Cleared once Steam is not requested.
[[nodiscard]] bool steam_failed() const
{
return steam_failed_.load(std::memory_order_relaxed);
}
private:
void run();
void publish_snapshot(const InputSource& src, bool steam_active);
std::thread thread_;
std::atomic<bool> running_{false};
std::atomic<bool> want_steam_{false};
std::atomic<bool> steam_failed_{false};
InjectionPanel* injection_ = nullptr;
std::string steam_manifest_;
mutable std::mutex snapshot_mutex_;
InputSnapshot snapshot_;
};
} // namespace coop

View File

@@ -5,7 +5,8 @@ namespace coop
bool IpcServer::start(unsigned long target_pid) bool IpcServer::start(unsigned long target_pid)
{ {
stop(); std::scoped_lock lock(mutex_);
stop_locked();
if (!shm_.create(shared_memory_name(target_pid), sizeof(SharedBlock))) if (!shm_.create(shared_memory_name(target_pid), sizeof(SharedBlock)))
{ {
@@ -36,6 +37,7 @@ bool IpcServer::start(unsigned long target_pid)
void IpcServer::publish(const std::array<PadInfo, kMaxPads>& pads) void IpcServer::publish(const std::array<PadInfo, kMaxPads>& pads)
{ {
std::scoped_lock lock(mutex_);
if (block_ == nullptr) if (block_ == nullptr)
{ {
return; return;
@@ -51,6 +53,7 @@ void IpcServer::publish(const std::array<PadInfo, kMaxPads>& pads)
HookStatusView IpcServer::hook_status() const HookStatusView IpcServer::hook_status() const
{ {
std::scoped_lock lock(mutex_);
HookStatusView view; HookStatusView view;
if (block_ == nullptr) if (block_ == nullptr)
{ {
@@ -96,6 +99,7 @@ HookStatusView IpcServer::hook_status() const
VideoShareView IpcServer::video_share() const VideoShareView IpcServer::video_share() const
{ {
std::scoped_lock lock(mutex_);
VideoShareView v; VideoShareView v;
if (block_ == nullptr) if (block_ == nullptr)
{ {
@@ -113,6 +117,7 @@ VideoShareView IpcServer::video_share() const
void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled) void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled)
{ {
std::scoped_lock lock(mutex_);
if (block_ != nullptr && subsystem < HookSubsys_Count) if (block_ != nullptr && subsystem < HookSubsys_Count)
{ {
// 0 = install, 1 = remove. // 0 = install, 1 = remove.
@@ -121,6 +126,12 @@ void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled)
} }
void IpcServer::stop() void IpcServer::stop()
{
std::scoped_lock lock(mutex_);
stop_locked();
}
void IpcServer::stop_locked()
{ {
if (block_ != nullptr) if (block_ != nullptr)
{ {

View File

@@ -4,6 +4,7 @@
#include <array> #include <array>
#include <cstdint> #include <cstdint>
#include <mutex>
#include "coop/log_ring.hpp" #include "coop/log_ring.hpp"
#include "coop/protocol.hpp" #include "coop/protocol.hpp"
@@ -82,6 +83,7 @@ public:
// silently if not started or the ring is full. // silently if not started or the ring is full.
void push_mkb(const MkbEvent& ev) void push_mkb(const MkbEvent& ev)
{ {
std::scoped_lock lock(mutex_);
if (block_ != nullptr) if (block_ != nullptr)
{ {
push_mkb_event(block_->mkb, ev); push_mkb_event(block_->mkb, ev);
@@ -92,6 +94,7 @@ public:
// (false, the default). No-op if not started. // (false, the default). No-op if not started.
void set_cursor_clip_allowed(bool allowed) void set_cursor_clip_allowed(bool allowed)
{ {
std::scoped_lock lock(mutex_);
if (block_ != nullptr) if (block_ != nullptr)
{ {
block_->control.allow_cursor_clip.store(allowed ? 1u : 0u, std::memory_order_release); block_->control.allow_cursor_clip.store(allowed ? 1u : 0u, std::memory_order_release);
@@ -119,6 +122,14 @@ public:
} }
private: private:
void stop_locked(); // tear-down body shared by start()/stop(); caller holds mutex_
// Guards the mapping pointer (block_) and its accesses. The input worker thread
// calls publish()/hook_status() while the UI thread may start()/stop() the channel
// (swapping/unmapping block_), so those must be mutually exclusive to avoid a
// use-after-unmap. Critical sections are tiny (a struct copy / memcpy).
mutable std::mutex mutex_;
SharedMemory shm_; SharedMemory shm_;
SharedBlock* block_ = nullptr; SharedBlock* block_ = nullptr;
unsigned long target_pid_ = 0; unsigned long target_pid_ = 0;

View File

@@ -6,7 +6,6 @@
// this window via Windows Graphics Capture (Video mirror panel). // this window via Windows Graphics Capture (Video mirror panel).
#include <cstdint> #include <cstdint>
#include <memory>
#include <windows.h> #include <windows.h>
@@ -23,14 +22,12 @@
#include "imgui_layer.hpp" #include "imgui_layer.hpp"
#include "inject/mkb_forward.hpp" #include "inject/mkb_forward.hpp"
#include "injection_panel.hpp" #include "injection_panel.hpp"
#include "input/xinput_source.hpp" #include "input/input_worker.hpp"
#include "log_panel.hpp" #include "log_panel.hpp"
#include "ui/app_chrome.hpp" #include "ui/app_chrome.hpp"
#ifdef COOP_WITH_STEAM #ifdef COOP_WITH_STEAM
#include <string> #include <string>
#include "input/steam_input_source.hpp"
#endif #endif
namespace namespace
@@ -125,17 +122,6 @@ int run()
return 1; 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::ControllersPanel controllers;
coop::InjectionPanel injection; coop::InjectionPanel injection;
coop::AudioPanel audio; coop::AudioPanel audio;
@@ -148,6 +134,19 @@ int run()
} }
capture.set_injection(&injection); // for the Present-hook (Hooked) video source capture.set_injection(&injection); // for the Present-hook (Hooked) video source
// 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 // The overlay can be hidden (F1) so the window is a clean mirror for Remote
// Play Together; the pipelines keep running underneath either way. // Play Together; the pipelines keep running underneath either way.
bool show_overlay = true; bool show_overlay = true;
@@ -155,10 +154,6 @@ int run()
coop::UiState ui; coop::UiState ui;
coop::FrameStats stats; 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] = {};
// Frame-sync: the hook generation we last presented (so we wait for the next one). // Frame-sync: the hook generation we last presented (so we wait for the next one).
std::uint32_t last_synced_gen = 0; std::uint32_t last_synced_gen = 0;
@@ -174,51 +169,24 @@ int run()
break; 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 #ifdef COOP_WITH_STEAM
// Switch the active input backend to match the Controllers-panel toggle. input_worker.set_want_steam(controllers.steam_input_requested());
const bool want_steam = controllers.steam_input_requested(); if (input_worker.steam_failed())
if (want_steam && steam == nullptr)
{ {
steam = std::make_unique<coop::SteamInputSource>(); controllers.on_steam_init_failed(); // resets the toggle; worker falls back to XInput
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) else
{ {
steam->shutdown(); controllers.set_steam_active(input_snapshot.steam_active);
steam.reset();
input = &xinput;
controllers.set_steam_active(false);
} }
#endif #endif
input->poll();
injection.set_test_input(controllers.test_input()); // toggle lives in the Controllers panel 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() 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(); const HWND game = injection.game_hwnd();
capture.set_target(game); capture.set_target(game);
audio.set_target(game); audio.set_target(game);
@@ -245,7 +213,7 @@ int run()
coop::draw_main_menu_bar(ui, stats); coop::draw_main_menu_bar(ui, stats);
if (ui.show_controllers) if (ui.show_controllers)
{ {
controllers.draw(*input, injection.hook_status(), ui.debug_details); controllers.draw(input_snapshot, injection.hook_status(), ui.debug_details);
} }
if (ui.show_injection) if (ui.show_injection)
{ {

View File

@@ -145,7 +145,9 @@ float draw_main_menu_bar(UiState& ui, const FrameStats& stats)
// Right-aligned performance readout: stable FPS with the frame-time spread // Right-aligned performance readout: stable FPS with the frame-time spread
// (min/max over the last second) so stutter is visible at a glance. // (min/max over the last second) so stutter is visible at a glance.
char perf[96]; char perf[96];
std::snprintf(perf, sizeof(perf), "%.0f FPS %.2f ms (%.2f-%.2f)", stats.fps(), stats.avg_ms(), // Fixed field widths so the readout doesn't jitter/blur as values cross digit
// thresholds (e.g. 99 -> 100) each frame.
std::snprintf(perf, sizeof(perf), "%4.0f FPS %6.2f ms (%6.2f-%6.2f)", stats.fps(), stats.avg_ms(),
stats.min_ms(), stats.max_ms()); stats.min_ms(), stats.max_ms());
const float text_w = ImGui::CalcTextSize(perf).x; const float text_w = ImGui::CalcTextSize(perf).x;
ImGui::SameLine(ImGui::GetWindowWidth() - text_w - ImGui::GetStyle().FramePadding.x * 2.0f); ImGui::SameLine(ImGui::GetWindowWidth() - text_w - ImGui::GetStyle().FramePadding.x * 2.0f);