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

@@ -6,7 +6,6 @@
// this window via Windows Graphics Capture (Video mirror panel).
#include <cstdint>
#include <memory>
#include <windows.h>
@@ -23,14 +22,12 @@
#include "imgui_layer.hpp"
#include "inject/mkb_forward.hpp"
#include "injection_panel.hpp"
#include "input/xinput_source.hpp"
#include "input/input_worker.hpp"
#include "log_panel.hpp"
#include "ui/app_chrome.hpp"
#ifdef COOP_WITH_STEAM
#include <string>
#include "input/steam_input_source.hpp"
#endif
namespace
@@ -125,17 +122,6 @@ int run()
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;
@@ -148,6 +134,19 @@ int run()
}
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
// Play Together; the pipelines keep running underneath either way.
bool show_overlay = true;
@@ -155,10 +154,6 @@ int run()
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] = {};
// Frame-sync: the hook generation we last presented (so we wait for the next one).
std::uint32_t last_synced_gen = 0;
@@ -174,51 +169,24 @@ int run()
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
// Switch the active input backend to match the Controllers-panel toggle.
const bool want_steam = controllers.steam_input_requested();
if (want_steam && steam == nullptr)
input_worker.set_want_steam(controllers.steam_input_requested());
if (input_worker.steam_failed())
{
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();
}
controllers.on_steam_init_failed(); // resets the toggle; worker falls back to XInput
}
else if (!want_steam && steam != nullptr)
else
{
steam->shutdown();
steam.reset();
input = &xinput;
controllers.set_steam_active(false);
controllers.set_steam_active(input_snapshot.steam_active);
}
#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);
@@ -245,7 +213,7 @@ int run()
coop::draw_main_menu_bar(ui, stats);
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)
{